artifacts/internal/pull/pull.go

250 lines
5.8 KiB
Go
Raw Normal View History

2026-09-13 19:10:04 +02:00
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
}