commit ae8d20e174a8706675e6d54ebd3719a2802ea9dd Author: Louis Seubert Date: Sun Sep 13 19:10:04 2026 +0200 feat: artifact push and pull actions 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 new file mode 100644 index 0000000..2dc7cdb --- /dev/null +++ b/.forgejo/workflows/default.yml @@ -0,0 +1,19 @@ +name: default + +on: + push: + branches: [main] + +jobs: + default: + 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 test + run: | + 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/LICENSE b/LICENSE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d773cc --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# artifacts + +Workflow artifact actions, built on `code.geekeey.de/actions/sdk/artifact`. + +## push + +Upload files as a workflow artifact. + +```yaml +- uses: https://code.geekeey.de/actions/artifacts/push@1 + with: + name: my-artifact + path: dist + pattern: | + **/* +``` + +## pull + +Download and extract a workflow artifact. + +```yaml +- uses: https://code.geekeey.de/actions/artifacts/pull@1 + with: + name: my-artifact + path: . +``` + +To download from another run or repository, pass `repository`, `run-id` and a +token with read access to its actions: + +```yaml +- uses: https://code.geekeey.de/actions/artifacts/pull@1 + with: + name: my-artifact + path: . + repository: other-owner/other-repo + run-id: "123" + github-token: ${{ secrets.READ_TOKEN }} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4877ba3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.geekeey.de/actions/artifacts + +go 1.23.2 + +require code.geekeey.de/actions/sdk v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b25cf65 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +code.geekeey.de/actions/sdk v1.5.0 h1:7Ly8QsBniiwDVbv2Gm14l28+ccK1Z9fVUqsPHw9lSMg= +code.geekeey.de/actions/sdk v1.5.0/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..25cb074 --- /dev/null +++ b/internal/pull/pull.go @@ -0,0 +1,250 @@ +package pull + +import ( + "archive/zip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "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 + Repository string + RunID string + Token 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, + Repository: get("repository"), + RunID: get("run-id"), + Token: get("github-token"), + }, nil +} + +func (a *Action) Run(ctx context.Context) error { + cfg, err := parseConfig(a.GetInput) + if err != nil { + return err + } + + // Only use the repository API when the artifact lives in another repository + // or another run. For the current run the results service is used instead. + if useRepositoryAPI(cfg, a.Context()) { + return a.pullFromRepository(ctx, cfg) + } + + 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.SetOutput("download-path", downloadPath(cfg.Path)) + a.Noticef("downloaded artifact %s (%d bytes)", cfg.Name, res.Size) + return nil +} + +// useRepositoryAPI reports whether the configured repository/run differs from +// the current one and therefore has to be resolved through the repository API. +// Empty values mean "current". +func useRepositoryAPI(cfg *config, gh *sdk.GitHubContext) bool { + if cfg.Repository != "" && cfg.Repository != gh.Repository { + return true + } + if cfg.RunID != "" && cfg.RunID != gh.RunID { + return true + } + return false +} + +// pullFromRepository downloads an artifact from a specific repository run using +// the repository-scoped actions API, so it works across runs and repositories. +func (a *Action) pullFromRepository(ctx context.Context, cfg *config) error { + gh := a.Context() + + repository := cfg.Repository + if repository == "" { + repository = gh.Repository + } + owner, repo, ok := strings.Cut(repository, "/") + if !ok || owner == "" || repo == "" { + return fmt.Errorf("input 'repository': %q must be /", repository) + } + + runID := cfg.RunID + if runID == "" { + runID = gh.RunID + } + id, err := strconv.ParseInt(runID, 10, 64) + if err != nil { + return fmt.Errorf("input 'run-id': %q is not a number", runID) + } + + token := cfg.Token + if token == "" { + token = gh.Token + } + + client := artifact.NewRepositoryClient(gh.APIURL, token) + list, err := client.ListRunArtifacts(ctx, owner, repo, id) + if err != nil { + return fmt.Errorf("cannot list artifacts: %w", err) + } + + var found *artifact.RepositoryArtifact + for i := range list.Artifacts { + if list.Artifacts[i].Name == cfg.Name { + found = &list.Artifacts[i] + break + } + } + if found == nil { + return fmt.Errorf("artifact %q not found in %s run %s", cfg.Name, repository, runID) + } + if found.Expired { + return fmt.Errorf("artifact %q is expired", cfg.Name) + } + + body, err := client.DownloadArtifact(ctx, owner, repo, found.Id) + if err != nil { + return fmt.Errorf("cannot download artifact: %w", err) + } + defer body.Close() + + tmp, err := os.CreateTemp("", "artifact-*.zip") + if err != nil { + return fmt.Errorf("cannot create temporary file: %w", err) + } + defer os.Remove(tmp.Name()) + + if _, err := io.Copy(tmp, body); err != nil { + tmp.Close() + return fmt.Errorf("cannot download artifact: %w", err) + } + if err := tmp.Close(); err != nil { + return err + } + + if err := extractArchive(tmp.Name(), cfg.Path); err != nil { + return fmt.Errorf("cannot extract artifact: %w", err) + } + + a.SetOutput("download-path", downloadPath(cfg.Path)) + a.Noticef("downloaded artifact %s from %s run %s", cfg.Name, repository, runID) + return nil +} + +// downloadPath returns the absolute path of the directory the artifact was +// extracted into. +func downloadPath(dest string) string { + abs, err := filepath.Abs(dest) + if err != nil { + return dest + } + return abs +} + +// 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..3f2475f --- /dev/null +++ b/internal/pull/pull_test.go @@ -0,0 +1,173 @@ +package pull + +import ( + "archive/zip" + "os" + "path/filepath" + "reflect" + "testing" + + "code.geekeey.de/actions/sdk" +) + +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: "other repository", + env: map[string]string{ + "name": "foo", "repository": "owner/repo", "run-id": "42", "github-token": "tok", + }, + want: &config{Name: "foo", Path: ".", Repository: "owner/repo", RunID: "42", Token: "tok"}, + }, + { + 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 TestUseRepositoryAPI(t *testing.T) { + t.Parallel() + + gh := &sdk.GitHubContext{Repository: "owner/repo", RunID: "100"} + + cases := []struct { + name string + cfg *config + want bool + }{ + {"current", &config{Repository: "owner/repo", RunID: "100"}, false}, + {"empty means current", &config{}, false}, + {"other repository", &config{Repository: "other/repo", RunID: "100"}, true}, + {"other run", &config{Repository: "owner/repo", RunID: "200"}, true}, + {"repository only matches", &config{Repository: "owner/repo"}, false}, + {"repository only differs", &config{Repository: "other/repo"}, true}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := useRepositoryAPI(tc.cfg, gh); 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 TestDownloadPath(t *testing.T) { + t.Parallel() + + abs := t.TempDir() + if got := downloadPath(abs); got != abs { + t.Errorf("expected %q, got %q", abs, got) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if got, want := downloadPath("out"), filepath.Join(wd, "out"); got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +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..8ffd963 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,159 @@ +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.SetOutput("artifact-id", finish.ArtifactId) + a.SetOutput("artifact-digest", res.SHA256Sum) + if url := artifactURL(a.Context(), finish.ArtifactId); url != "" { + a.SetOutput("artifact-url", url) + } + + a.Noticef("created artifact: %s", finish.ArtifactId) + return nil +} + +// artifactURL builds the URL of an artifact on the server, following the same +// scheme as GitHub's upload-artifact output. +func artifactURL(context *sdk.GitHubContext, artifactID string) string { + if context.ServerURL == "" || context.Repository == "" || context.RunID == "" || artifactID == "" { + return "" + } + return fmt.Sprintf("%s/%s/actions/runs/%s/artifacts/%s", + strings.TrimRight(context.ServerURL, "/"), context.Repository, context.RunID, artifactID) +} + +// 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..e4252bf --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,178 @@ +package push + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "code.geekeey.de/actions/sdk" +) + +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 TestArtifactURL(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + context *sdk.GitHubContext + id string + want string + }{ + { + name: "full", + context: &sdk.GitHubContext{ServerURL: "https://code.geekeey.de", Repository: "actions/test", RunID: "1"}, + id: "1234", + want: "https://code.geekeey.de/actions/test/actions/runs/1/artifacts/1234", + }, + { + name: "trailing slash", + context: &sdk.GitHubContext{ServerURL: "https://code.geekeey.de/", Repository: "actions/test", RunID: "1"}, + id: "1234", + want: "https://code.geekeey.de/actions/test/actions/runs/1/artifacts/1234", + }, + { + name: "missing run id", + context: &sdk.GitHubContext{ServerURL: "https://code.geekeey.de", Repository: "actions/test"}, + id: "1234", + want: "", + }, + { + name: "missing id", + context: &sdk.GitHubContext{ServerURL: "https://code.geekeey.de", Repository: "actions/test", RunID: "1"}, + id: "", + want: "", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := artifactURL(tc.context, tc.id); got != tc.want { + t.Errorf("expected %q, got %q", tc.want, got) + } + }) + } +} + +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..6c9942b --- /dev/null +++ b/pull/action.yml @@ -0,0 +1,39 @@ +# 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: "." + repository: + description: > + The owner and repository name, separated by a slash, to download the + artifact from. + required: false + default: "${{ github.repository }}" + run-id: + description: > + The ID of the workflow run to download the artifact from. + required: false + default: "${{ github.run_id }}" + github-token: + description: > + The token used to access the target repository. Must have read access to + its actions. + required: false + default: "${{ github.token }}" +outputs: + download-path: + description: > + The absolute path of the directory the artifact was extracted into. +runs: + using: "docker" + image: "../Dockerfile" + args: ["pull"] diff --git a/push/action.yml b/push/action.yml new file mode 100644 index 0000000..02ced0a --- /dev/null +++ b/push/action.yml @@ -0,0 +1,33 @@ +# 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: "**/*" +outputs: + artifact-id: + description: > + The ID of the created artifact. + artifact-url: + description: > + URL to download the created artifact. + artifact-digest: + description: > + SHA-256 digest of the created artifact. +runs: + using: "docker" + image: "../Dockerfile" + args: ["push"]