feat: artifact push and pull actions
All checks were successful
default / test artifact actions (push) Successful in 26s

This commit is contained in:
Louis Seubert 2026-09-13 19:10:04 +02:00
commit ae8d20e174
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
15 changed files with 1319 additions and 0 deletions

76
internal/e2e_test.go Normal file
View file

@ -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)
}
}
}

250
internal/pull/pull.go Normal file
View file

@ -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 <owner>/<repo>", 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
}

173
internal/pull/pull_test.go Normal file
View file

@ -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)
}
}

159
internal/push/push.go Normal file
View file

@ -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
}

178
internal/push/push_test.go Normal file
View file

@ -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
}