diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b8710a --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml index 8212505..2dc7cdb 100644 --- a/.forgejo/workflows/default.yml +++ b/.forgejo/workflows/default.yml @@ -6,16 +6,14 @@ on: jobs: default: - name: dotnet-default-workflow + name: test artifact actions runs-on: debian-latest container: docker.io/golang:1.24-alpine steps: - name: checkout uses: https://code.geekeey.de/actions/checkout@1 - - name: go run + - name: go test run: | - export INPUT_NAME=foo - export INPUT_PATH=. - export INPUT_PATTERN=**/* - if=/dev/urandom of=bigfile_rand.bin bs=1M count=10 - go run push/main.go + go mod tidy + go mod verify + go test ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cfa7fd7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM docker.io/golang:1.24-alpine AS compile + +ENV CGO_ENABLED=0 + +WORKDIR /app + +COPY go.mod go.sum /app/ +RUN go mod download + +COPY . /app/ +RUN go build -o app . + +FROM docker.io/alpine:3.22 + +WORKDIR / + +COPY --from=compile /app/app / + +ENTRYPOINT ["/app"] diff --git a/README.md b/README.md index 6f29784..0ea3566 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,27 @@ # artifacts -## artifacts/save +Workflow artifact actions, built on `code.geekeey.de/actions/sdk/artifact`. + +## push + +Upload files as a workflow artifact. ```yaml -uses: actions/artifacts/save +- uses: https://code.geekeey.de/actions/artifacts/push@1 + with: + name: my-artifact + path: dist + pattern: | + **/* ``` -## artifacts/load +## pull + +Download and extract a workflow artifact. ```yaml -uses: actions/artifacts/load -``` \ No newline at end of file +- uses: https://code.geekeey.de/actions/artifacts/pull@1 + with: + name: my-artifact + path: . +``` diff --git a/artifacts.go b/artifacts.go deleted file mode 100644 index a3ca478..0000000 --- a/artifacts.go +++ /dev/null @@ -1,78 +0,0 @@ -package artifacts - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "os" - "strings" -) - -// run, job, err -func JobInfo() (string, string, error) { - rt := os.Getenv("ACTIONS_RUNTIME_TOKEN") - var empty string - if rt == "" { - return empty, empty, fmt.Errorf("env ACTIONS_RUNTIME_TOKEN is only available from the context of an action") - } - jt, err := newJwtDecode(rt) - if err != nil { - return empty, empty, err - } - scp, ok := jt.Payload["scp"].(string) - if !ok { - return empty, empty, fmt.Errorf("no scp in ACTIONS_RUNTIME_TOKEN") - } - for _, scopes := range strings.Split(scp, " ") { - parts := strings.Split(scopes, ":") - if parts[0] != "Actions.Results" { - continue - } - if len(parts) != 3 { - return empty, empty, fmt.Errorf("invalid scp in ACTIONS_RUNTIME_TOKEN") - } - return parts[1], parts[2], nil - } - - return empty, empty, fmt.Errorf("no backend ids found") -} - -type jwt struct { - Header map[string]any - Payload map[string]any - Signature string -} - -func newJwtDecode(token string) (*jwt, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid JWT: expected 3 segments") - } - - decode := func(seg string) ([]byte, error) { - if l := len(seg) % 4; l != 0 { - seg += strings.Repeat("=", 4-l) - } - return base64.URLEncoding.DecodeString(seg) - } - - headerBytes, err := decode(parts[0]) - if err != nil { - return nil, fmt.Errorf("invalid JWT header: %v", err) - } - var header map[string]any - if err := json.Unmarshal(headerBytes, &header); err != nil { - return nil, fmt.Errorf("invalid JWT header json: %v", err) - } - - payloadBytes, err := decode(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid JWT payload: %v", err) - } - var payload map[string]any - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return nil, fmt.Errorf("invalid JWT payload json: %v", err) - } - - return &jwt{Header: header, Payload: payload, Signature: parts[2]}, nil -} diff --git a/client.go b/client.go deleted file mode 100644 index 936a14f..0000000 --- a/client.go +++ /dev/null @@ -1,188 +0,0 @@ -package artifacts - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "os" - "strings" - "time" -) - -type ActionClient struct { - *http.Client - base string - token string -} - -func NewActionClient() *ActionClient { - c := &ActionClient{Client: &http.Client{}} - c.base = os.Getenv("ACTIONS_RESULTS_URL") - c.token = fmt.Sprintf("Bearer %s", os.Getenv("ACTIONS_RUNTIME_TOKEN")) - return c -} - -func (c *ActionClient) Do(req *http.Request) (*http.Response, error) { - req.Header.Set("Authorization", c.token) - if !req.URL.IsAbs() { - u, err := url.Parse(fmt.Sprintf("%s%s", strings.TrimRight(c.base, "/"), req.URL)) - if err != nil { - return nil, err - } - req.URL = u - } - return c.Client.Do(req) -} - -func HasContentType(r *http.Response, mimetype string) bool { - contentType := r.Header.Get("content-type") - if contentType == "" { - return mimetype == "application/octet-stream" - } - - for _, v := range strings.Split(contentType, ",") { - t, _, err := mime.ParseMediaType(v) - if err != nil { - break - } - if t == mimetype { - return true - } - } - return false -} - -type ApiClient interface { - Do(*http.Request) (*http.Response, error) -} - -type GitHub struct { - client ApiClient -} - -func NewGitHub(client ApiClient) *GitHub { - return &GitHub{client: client} -} - -type CreateArtifactRequest struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - Name string `json:"name"` - Version int32 `json:"version"` - - ExpiresAt *time.Time `json:"expiresAt,omitempty"` -} - -type CreateArtifactResponse struct { - Ok bool `json:"ok"` - SignedUploadUrl string `json:"signedUploadUrl"` -} - -type FinalizeArtifactRequest struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - Name string `json:"name"` - Size int64 `json:"size"` - Hash string `json:"hash,omitempty"` -} - -type FinalizeArtifactResponse struct { - Ok bool `json:"ok"` - ArtifactId string `json:"artifactId"` -} - -type ListArtifactsRequest struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - NameFilter string `json:"nameFilter"` - IdFilter string `json:"idFilter"` -} - -type ListArtifactsResponse struct { - Artifacts []struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - Id string `json:"databaseId"` - Name string `json:"name"` - Size string `json:"size"` - CreatedAt *time.Time `json:"createdAt"` - Digest string `json:"digest"` - } `json:"artifacts"` -} - -type GetSignedArtifactURLRequest struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - Name string `json:"name"` -} - -type GetSignedArtifactURLResponse struct { - SignedUrl string `json:"signedUrl"` -} - -type DeleteArtifactRequest struct { - RunID string `json:"workflowRunBackendId"` - JobRunID string `json:"workflowJobRunBackendId"` - Name string `json:"name"` -} - -type DeleteArtifactResponse struct { - Ok bool `json:"ok"` - ArtifactId string `json:"artifactId"` -} - -func (gh *GitHub) CreateArtifact(ctx context.Context, req CreateArtifactRequest) (*CreateArtifactResponse, error) { - endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact" - var result CreateArtifactResponse - if err := gh.twirp(ctx, endpoint, req, &result); err != nil { - return nil, err - } - return &result, nil -} - -func (gh *GitHub) FinalizeArtifact(ctx context.Context, req FinalizeArtifactRequest) (*FinalizeArtifactResponse, error) { - endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact" - var result FinalizeArtifactResponse - if err := gh.twirp(ctx, endpoint, req, &result); err != nil { - return nil, err - } - return &result, nil -} - -func (gh *GitHub) twirp(ctx context.Context, url string, in any, out any) error { - var b bytes.Buffer - - if err := json.NewEncoder(&b).Encode(in); err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &b) - if err != nil { - return err - } - req.Header.Set("content-type", "application/json") - req.Header.Set("accept", "application/json") - - res, err := gh.client.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - if !HasContentType(res, "application/json") { - return fmt.Errorf("content-type: %q should be %q", - res.Header.Get("content-type"), "application/json") - } - dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024)) - - if res.StatusCode >= 200 && res.StatusCode < 300 { - return dec.Decode(out) - } else { - body, _ := io.ReadAll(res.Body) - return fmt.Errorf("twirp call %s failed: %d: %s", url, res.StatusCode, string(body)) - } -} diff --git a/go.mod b/go.mod index ca10462..e327cba 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module code.geekeey.de/actions/artifacts go 1.23.2 -require code.geekeey.de/actions/sdk v1.4.0 +require code.geekeey.de/actions/sdk v1.4.1-0.20260913093346-402ecfd76077 diff --git a/go.sum b/go.sum index 34a8f74..b2f1383 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,5 @@ code.geekeey.de/actions/sdk v1.3.0 h1:F5o5cHG829XeLv/Ec0PiUP6kX4smyL4Xn4vGl0qvp+ code.geekeey.de/actions/sdk v1.3.0/go.mod h1:7iIOi3YUmdDe2BCz9s59wK1t0Ckec34e664q+TOreiE= code.geekeey.de/actions/sdk v1.4.0 h1:u97FwLq80xLpTvOun3SrNSGhX/WlAI52mP1eyjpIw2U= code.geekeey.de/actions/sdk v1.4.0/go.mod h1:7iIOi3YUmdDe2BCz9s59wK1t0Ckec34e664q+TOreiE= +code.geekeey.de/actions/sdk v1.4.1-0.20260913093346-402ecfd76077 h1:jelRPnQpOiPPIq10mROwno2JJYTf44OQGgPswD+6BeA= +code.geekeey.de/actions/sdk v1.4.1-0.20260913093346-402ecfd76077/go.mod h1:7iIOi3YUmdDe2BCz9s59wK1t0Ckec34e664q+TOreiE= diff --git a/internal/e2e_test.go b/internal/e2e_test.go new file mode 100644 index 0000000..74d9b50 --- /dev/null +++ b/internal/e2e_test.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "code.geekeey.de/actions/artifacts/internal/pull" + "code.geekeey.de/actions/artifacts/internal/push" + "code.geekeey.de/actions/sdk" + "code.geekeey.de/actions/sdk/artifact" +) + +// TestE2E_PushPullRoundTrip runs the push and pull actions against the real +// results service. It only runs inside a CI pipeline (GITHUB_ACTIONS=true) and +// is skipped locally. Its purpose is to detect drift in the artifact actions. +func TestE2E_PushPullRoundTrip(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") != "true" { + t.Skip("e2e test only runs in CI") + } + if os.Getenv("ACTIONS_RESULTS_URL") == "" || os.Getenv("ACTIONS_RUNTIME_TOKEN") == "" { + t.Skip("ACTIONS_RESULTS_URL and ACTIONS_RUNTIME_TOKEN are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + name := fmt.Sprintf("e2e-%d-%d", os.Getpid(), time.Now().UnixNano()) + t.Cleanup(func() { + run, job, err := artifact.JobInfo() + if err != nil { + return + } + client := artifact.NewClientFromEnv(os.Getenv) + _, _ = client.DeleteArtifact(context.Background(), artifact.DeleteArtifactRequest{RunID: run, JobRunID: job, Name: name}) + }) + + const want = "hello e2e" + + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "nested", "deep.txt"), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("INPUT_NAME", name) + t.Setenv("INPUT_PATH", src) + t.Setenv("INPUT_PATTERN", "**/*") + if err := push.New(sdk.New()).Run(ctx); err != nil { + t.Fatalf("push: %v", err) + } + + dst := t.TempDir() + t.Setenv("INPUT_PATH", dst) + if err := pull.New(sdk.New()).Run(ctx); err != nil { + t.Fatalf("pull: %v", err) + } + + for _, file := range []string{"file.txt", filepath.Join("nested", "deep.txt")} { + got, err := os.ReadFile(filepath.Join(dst, file)) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("expected %q to contain %q, got %q", file, want, got) + } + } +} diff --git a/internal/pull/pull.go b/internal/pull/pull.go new file mode 100644 index 0000000..5f608c3 --- /dev/null +++ b/internal/pull/pull.go @@ -0,0 +1,133 @@ +package pull + +import ( + "archive/zip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "code.geekeey.de/actions/sdk" + "code.geekeey.de/actions/sdk/artifact" +) + +type Action struct { + *sdk.Action +} + +func New(action *sdk.Action) *Action { + return &Action{Action: action} +} + +type Config struct { + Name string + Path string +} + +func ParseConfig(get func(string) string) (*Config, error) { + name := get("name") + if len(name) == 0 { + return nil, fmt.Errorf("input 'name': is empty") + } + + path := get("path") + if len(path) == 0 { + path = "." + } + return &Config{Name: name, Path: path}, nil +} + +func (a *Action) Run(ctx context.Context) error { + cfg, err := ParseConfig(a.GetInput) + if err != nil { + return err + } + + run, job, err := artifact.JobInfo() + if err != nil { + return fmt.Errorf("unable to get job info: %v", err) + } + + client := artifact.NewClientFromEnv(os.Getenv) + + signed, err := client.GetSignedArtifactURL(ctx, artifact.GetSignedArtifactURLRequest{ + RunID: run, + JobRunID: job, + Name: cfg.Name, + }) + if err != nil { + return fmt.Errorf("cannot get signed artifact URL: %w", err) + } + if signed.SignedUrl == "" { + return fmt.Errorf("no signed URL for artifact %q", cfg.Name) + } + + tmp, err := os.CreateTemp("", "artifact-*.zip") + if err != nil { + return fmt.Errorf("cannot create temporary file: %w", err) + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + res, err := artifact.PullBlob(ctx, nil, tmp, signed.SignedUrl) + if err != nil { + return fmt.Errorf("cannot download artifact: %w", err) + } + + if err := ExtractArchive(tmp.Name(), cfg.Path); err != nil { + return fmt.Errorf("cannot extract artifact: %w", err) + } + + a.Noticef("downloaded artifact %s (%d bytes)", cfg.Name, res.Size) + return nil +} + +// ExtractArchive extracts the zip file at zipPath into dest. Entries that would +// escape dest are rejected. +func ExtractArchive(zipPath, dest string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if err := extractFile(f, dest); err != nil { + return err + } + } + return nil +} + +func extractFile(f *zip.File, dest string) error { + cleanDest := filepath.Clean(dest) + target := filepath.Join(cleanDest, f.Name) + if target != cleanDest && !strings.HasPrefix(target, cleanDest+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path %q", f.Name) + } + + if f.FileInfo().IsDir() { + return os.MkdirAll(target, 0o755) + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, rc) + return err +} diff --git a/internal/pull/pull_test.go b/internal/pull/pull_test.go new file mode 100644 index 0000000..93e9d1c --- /dev/null +++ b/internal/pull/pull_test.go @@ -0,0 +1,117 @@ +package pull + +import ( + "archive/zip" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestParseConfig(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + env map[string]string + want *Config + wantErr bool + }{ + { + name: "default path", + env: map[string]string{"name": "foo"}, + want: &Config{Name: "foo", Path: "."}, + }, + { + name: "explicit path", + env: map[string]string{"name": "foo", "path": "out"}, + want: &Config{Name: "foo", Path: "out"}, + }, + { + name: "missing name", + env: map[string]string{}, + wantErr: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseConfig(func(k string) string { return tc.env[k] }) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + return + } + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("expected %+v, got %+v", tc.want, got) + } + }) + } +} + +func TestExtractArchive(t *testing.T) { + t.Parallel() + + zipPath := filepath.Join(t.TempDir(), "artifact.zip") + writeZip(t, zipPath, map[string]string{ + "a.txt": "a", + "sub/b.txt": "b", + }) + + dest := t.TempDir() + if err := ExtractArchive(zipPath, dest); err != nil { + t.Fatal(err) + } + + for name, want := range map[string]string{"a.txt": "a", "sub/b.txt": "b"} { + got, err := os.ReadFile(filepath.Join(dest, name)) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("expected %q to contain %q, got %q", name, want, got) + } + } +} + +func TestExtractArchive_PathTraversal(t *testing.T) { + t.Parallel() + + zipPath := filepath.Join(t.TempDir(), "artifact.zip") + writeZip(t, zipPath, map[string]string{"../evil.txt": "evil"}) + + if err := ExtractArchive(zipPath, t.TempDir()); err == nil { + t.Fatal("expected error for path traversal entry") + } +} + +func writeZip(t *testing.T, path string, files map[string]string) { + t.Helper() + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + zw := zip.NewWriter(f) + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..6a1b59d --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,143 @@ +package push + +import ( + "archive/zip" + "compress/flate" + "context" + "fmt" + "io" + "os" + "runtime" + "strings" + "time" + + "code.geekeey.de/actions/sdk" + "code.geekeey.de/actions/sdk/artifact" + "code.geekeey.de/actions/sdk/glob" +) + +type Action struct { + *sdk.Action +} + +func New(action *sdk.Action) *Action { + return &Action{Action: action} +} + +type Config struct { + Name string + Path string + Patterns []string +} + +func ParseConfig(get func(string) string) (*Config, error) { + name := get("name") + if len(name) == 0 { + return nil, fmt.Errorf("input 'name': is empty") + } + + path := get("path") + if len(path) == 0 { + return nil, fmt.Errorf("input 'path': is empty") + } + + cfg := &Config{Name: name, Path: path} + for _, pattern := range strings.Split(get("pattern"), "\n") { + if len(pattern) == 0 { + continue + } + cfg.Patterns = append(cfg.Patterns, pattern) + } + return cfg, nil +} + +func (a *Action) Run(ctx context.Context) error { + cfg, err := ParseConfig(a.GetInput) + if err != nil { + return err + } + + run, job, err := artifact.JobInfo() + if err != nil { + return fmt.Errorf("unable to get job info: %v", err) + } + + client := artifact.NewClientFromEnv(os.Getenv) + + expire := time.Now().Add(20 * time.Hour) + create, err := client.CreateArtifact(ctx, artifact.CreateArtifactRequest{ + RunID: run, + JobRunID: job, + Name: cfg.Name, + Version: 4, + ExpiresAt: &expire, + }) + if err != nil { + return fmt.Errorf("cannot create artifact: %w", err) + } + if !create.Ok { + return fmt.Errorf("cannot get pre-signed URL") + } + + rd, err := CreateArchive(cfg.Path, cfg.Patterns) + if err != nil { + return fmt.Errorf("cannot create archive: %w", err) + } + + res, err := artifact.PushBlob(ctx, nil, rd, create.SignedUploadUrl, 1024*1024, runtime.NumCPU()) + if err != nil { + return fmt.Errorf("cannot upload artifact: %w", err) + } + + finish, err := client.FinalizeArtifact(ctx, artifact.FinalizeArtifactRequest{ + RunID: run, + JobRunID: job, + Name: cfg.Name, + Size: res.Size, + Hash: res.SHA256Sum, + }) + if err != nil { + return fmt.Errorf("cannot finish artifact: %w", err) + } + if !finish.Ok { + return fmt.Errorf("cannot finish artifact upload") + } + + a.Noticef("created artifact: %s", finish.ArtifactId) + return nil +} + +// CreateArchive zips the files in path matching any of the given glob patterns +// and returns a reader for the resulting archive. +func CreateArchive(path string, patterns []string) (io.Reader, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("cannot find directory: %w", err) + } + if !stat.IsDir() { + return nil, fmt.Errorf("source path is not a directory") + } + + gfs, err := glob.NewGlobFS(os.DirFS(path), patterns...) + if err != nil { + return nil, fmt.Errorf("cannot apply glob patterns: %w", err) + } + + rd, wr := io.Pipe() + go func() { + defer wr.Close() + + zw := zip.NewWriter(wr) + zw.RegisterCompressor(zip.Deflate, func(w io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(w, flate.BestCompression) + }) + if err := zw.AddFS(gfs); err != nil { + wr.CloseWithError(err) + return + } + if err := zw.Close(); err != nil { + wr.CloseWithError(err) + } + }() + return rd, nil +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..8fadc18 --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,129 @@ +package push + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestParseConfig(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + env map[string]string + want *Config + wantErr bool + }{ + { + name: "minimal", + env: map[string]string{"name": "foo", "path": "dist"}, + want: &Config{Name: "foo", Path: "dist"}, + }, + { + name: "patterns", + env: map[string]string{"name": "foo", "path": "dist", "pattern": "**/*.go\n\n!main.go\n"}, + want: &Config{Name: "foo", Path: "dist", Patterns: []string{"**/*.go", "!main.go"}}, + }, + { + name: "missing name", + env: map[string]string{"path": "dist"}, + wantErr: true, + }, + { + name: "missing path", + env: map[string]string{"name": "foo"}, + wantErr: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseConfig(func(k string) string { return tc.env[k] }) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + return + } + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("expected %+v, got %+v", tc.want, got) + } + }) + } +} + +func TestCreateArchive(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.txt"), "a") + writeFile(t, filepath.Join(dir, "sub", "b.txt"), "b") + writeFile(t, filepath.Join(dir, "sub", "c.log"), "c") + + rd, err := CreateArchive(dir, []string{"**/*.txt"}) + if err != nil { + t.Fatal(err) + } + + entries := zipEntries(t, rd) + if want := []string{"a.txt", "sub/b.txt"}; !reflect.DeepEqual(entries, want) { + t.Errorf("expected entries %v, got %v", want, entries) + } +} + +func TestCreateArchive_NotDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + writeFile(t, path, "x") + + if _, err := CreateArchive(path, []string{"**/*"}); err == nil { + t.Fatal("expected error for non-directory path") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func zipEntries(t *testing.T, r io.Reader) []string { + t.Helper() + + data, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + + var entries []string + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + entries = append(entries, f.Name) + } + sort.Strings(entries) + return entries +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6da66b1 --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "code.geekeey.de/actions/artifacts/internal/pull" + "code.geekeey.de/actions/artifacts/internal/push" + "code.geekeey.de/actions/sdk" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: artifacts ") + os.Exit(1) + } + + action := sdk.New() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + var err error + switch os.Args[1] { + case "push": + err = push.New(action).Run(ctx) + case "pull": + err = pull.New(action).Run(ctx) + default: + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + + if err != nil { + action.Errorf("%s", err) + os.Exit(1) + } +} diff --git a/pull/action.yml b/pull/action.yml new file mode 100644 index 0000000..0be0037 --- /dev/null +++ b/pull/action.yml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Artifact Pull" +description: "Download and extract a workflow artifact" +author: "Louis Seubert" +inputs: + name: + description: > + The name of the artifact to download. + required: true + path: + description: > + The directory to extract the artifact into. + required: false + default: "." +runs: + using: "docker" + image: "../Dockerfile" + args: ["pull"] diff --git a/pull/main.go b/pull/main.go deleted file mode 100644 index 00a783f..0000000 --- a/pull/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "os" - "os/signal" - - "code.geekeey.de/actions/sdk" -) - -func main() { - action := &pullArtifactAction{Action: sdk.New()} - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - if err := action.Run(ctx); err != nil { - action.Errorf("%s", err) - os.Exit(1) - } -} - -type pullArtifactAction struct { - *sdk.Action - name string -} - -func (action *pullArtifactAction) setup() error { - return nil -} - -func (action *pullArtifactAction) Run(ctx context.Context) error { - return nil -} diff --git a/push/action.yml b/push/action.yml new file mode 100644 index 0000000..724a9b1 --- /dev/null +++ b/push/action.yml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Artifact Push" +description: "Upload files as a workflow artifact" +author: "Louis Seubert" +inputs: + name: + description: > + The name of the artifact to upload. + required: true + path: + description: > + The base directory the patterns are resolved against. + required: true + pattern: + description: > + Newline separated list of glob patterns selecting the files to include in + the artifact. + required: false + default: "**/*" +runs: + using: "docker" + image: "../Dockerfile" + args: ["push"] diff --git a/push/main.go b/push/main.go deleted file mode 100644 index 6dbea63..0000000 --- a/push/main.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "archive/zip" - "compress/flate" - "context" - "fmt" - "io" - "os" - "os/signal" - "runtime" - "strings" - "time" - - "code.geekeey.de/actions/artifacts" - "code.geekeey.de/actions/sdk" - "code.geekeey.de/actions/sdk/glob" -) - -func main() { - action := &pushArtifactAction{Action: sdk.New()} - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - if err := action.Run(ctx); err != nil { - action.Errorf("%s", err) - os.Exit(1) - } -} - -type pushArtifactAction struct { - *sdk.Action - name string - path string - patterns []string -} - -func (action *pushArtifactAction) setup() error { - action.name = action.GetInput("name") - if len(action.name) == 0 { - return fmt.Errorf("input 'name': is empty") - } - - action.path = action.GetInput("path") - if len(action.path) == 0 { - return fmt.Errorf("input 'path': is empty") - } - - patterns := strings.Split(action.GetInput("pattern"), "\n") - for _, pattern := range patterns { - if len(pattern) == 0 { - continue - } - action.patterns = append(action.patterns, pattern) - } - return nil -} - -func (action *pushArtifactAction) Run(ctx context.Context) error { - if err := action.setup(); err != nil { - return err - } - - run, job, err := artifacts.JobInfo() - if err != nil { - return fmt.Errorf("unable to get job info: %v", err) - } - - github := artifacts.NewGitHub(artifacts.NewActionClient()) - - expire := time.Now().Add(20 * time.Hour) - create, err := github.CreateArtifact(ctx, artifacts.CreateArtifactRequest{ - RunID: run, - JobRunID: job, - Name: action.name, - Version: 4, - ExpiresAt: &expire, - }) - if err != nil { - return fmt.Errorf("cannot create artifact: %w", err) - } - if !create.Ok { - return fmt.Errorf("cannot get pre-signed URL") - } - - rd, err := action.CreateArtifact(action.path, action.patterns, ctx) - if err != nil { - return fmt.Errorf("cannot create archive: %w", err) - } - - res, err := artifacts.PushBlob(ctx, rd, create.SignedUploadUrl, 1024*1024*1, runtime.NumCPU()) - if err != nil { - return fmt.Errorf("cannot upload artifact: %w", err) - } - - finish, err := github.FinalizeArtifact(ctx, artifacts.FinalizeArtifactRequest{ - RunID: run, - JobRunID: job, - Name: action.name, - Size: res.Size, - Hash: res.SHA256Sum, - }) - if err != nil { - return fmt.Errorf("cannot finish artifact: %w", err) - } - if !create.Ok { - return fmt.Errorf("cannot finish artifact upload") - } - - action.Noticef("created artifact: %s", finish.ArtifactId) - - return nil -} - -func (action *pushArtifactAction) CreateArtifact(path string, inputs []string, ctx context.Context) (io.Reader, error) { - if stat, err := os.Stat(path); err != nil || !stat.IsDir() { - if err != nil { - return nil, fmt.Errorf("cannot find directory: %w", err) - } else { - return nil, fmt.Errorf("source path is not a directory") - } - } - - gfs, err := glob.NewGlobFS(os.DirFS(path), inputs...) - if err != nil { - return nil, fmt.Errorf("cannot apply glob patterns: %w", err) - } - - rd, wr, err := os.Pipe() - go func() { - defer wr.Close() - - a := zip.NewWriter(wr) - a.RegisterCompressor(zip.Deflate, func(w io.Writer) (io.WriteCloser, error) { return flate.NewWriter(w, flate.BestCompression) }) - if err := a.AddFS(gfs); err != nil { - panic(err) - } - if err := a.Close(); err != nil { - panic(err) - } - }() - return rd, err -} diff --git a/storage.go b/storage.go deleted file mode 100644 index 0712b7e..0000000 --- a/storage.go +++ /dev/null @@ -1,155 +0,0 @@ -package artifacts - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/xml" - "fmt" - "io" - "net/http" - "sync" -) - -type PullResult struct { - SHA256Sum string - Size int64 -} -type PushResult struct { - SHA256Sum string - Size int64 -} - -func PushBlob(ctx context.Context, r io.Reader, blobURL string, chunkSize int64, parallelism int) (*PushResult, error) { - if chunkSize <= 0 { - chunkSize = 4 * 1024 * 1024 // 4MB default - } - if parallelism <= 0 { - parallelism = 4 - } - - type chunk struct { - index int - data []byte - } - wrkinp := make(chan chunk) - wrkerr := make(chan error, parallelism) - - var blockMu sync.Mutex - blockIDs := make([]string, 0) - - var wg sync.WaitGroup - - // Worker pool for parallel uploading - for i := 0; i < parallelism; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for c := range wrkinp { - id := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%06d", c.index))) - address := fmt.Sprintf("%s&comp=block&blockid=%s", blobURL, id) - req, err := http.NewRequestWithContext(ctx, "PUT", address, bytes.NewReader(c.data)) - if err != nil { - wrkerr <- err - return - } - req.ContentLength = int64(len(c.data)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - wrkerr <- err - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - wrkerr <- fmt.Errorf("chunk %d upload failed: %s body: %s", c.index, resp.Status, string(body)) - return - } - - blockMu.Lock() - defer blockMu.Unlock() - blockIDs = append(blockIDs, id) - } - }() - } - - // Chunk the data and send to workers - index := 0 - hash := sha256.New() - var size int64 - for { - buf := make([]byte, chunkSize) - n, err := io.ReadFull(r, buf) - if err == io.EOF || err == io.ErrUnexpectedEOF { - if n > 0 { - size += int64(n) - hash.Write(buf[:n]) - wrkinp <- chunk{index: index, data: buf[:n]} - index++ - } - break - } - if err != nil { - close(wrkinp) - wg.Wait() - return nil, err - } - hash.Write(buf) - wrkinp <- chunk{index: index, data: buf} - index++ - } - close(wrkinp) - wg.Wait() - - select { - case err := <-wrkerr: - return nil, err - default: - } - - type Latest struct { - Value string `xml:",chardata"` - } - type BlockList struct { - Latest []Latest `xml:"Latest"` - } - - // Sort block IDs to ensure correct order - blockMu.Lock() - defer blockMu.Unlock() - if len(blockIDs) == 0 { - return nil, fmt.Errorf("no blocks uploaded") - } - blockList := &BlockList{} - for i := 0; i < len(blockIDs); i++ { - blockList.Latest = append(blockList.Latest, Latest{Value: blockIDs[i]}) - } - blockListData, err := xml.Marshal(blockList) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, "PUT", fmt.Sprintf("%s&comp=blocklist", blobURL), bytes.NewReader(blockListData)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/xml") - req.ContentLength = int64(len(blockListData)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("blocklist commit failed: %s body: %s", resp.Status, string(body)) - } - return &PushResult{SHA256Sum: hex.EncodeToString(hash.Sum(nil)), Size: size}, nil -} - -func PullBlob(ctx context.Context, w io.Writer, blobURL string, chunkSize int64, parallelism int) (*PullResult, error) { - return nil, nil -}