artifacts/internal/push/push.go
Louis Seubert ae8d20e174
All checks were successful
default / test artifact actions (push) Successful in 26s
feat: artifact push and pull actions
2026-09-13 19:10:04 +02:00

159 lines
3.7 KiB
Go

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
}