mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 03:43:42 +00:00
enhance: add builtin:upload-artifact (#1343)
Co-authored-by: bircni <[email protected]>
This commit is contained in:
1 parent
8c8950d9ef
commit
02446620df
30 files changed
+1881
-20
No files matched your search
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package uploadartifact
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
)
|
||||
|
||||
const fetchScript = `cd / && if command -v gzip >/dev/null 2>&1; then exec tar -czhf - -- "$@"; fi; exec tar -chf - -- "$@"`
|
||||
|
||||
func readFiles(ctx context.Context, c *action.Context, files []found, visit func(found) error) error {
|
||||
if c.Host() {
|
||||
for _, file := range files {
|
||||
body, err := os.Open(file.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := body.Stat()
|
||||
if err == nil {
|
||||
file.body, file.mode, file.mtime = body, info.Mode(), info.ModTime()
|
||||
err = visit(file)
|
||||
}
|
||||
body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for len(files) > 0 {
|
||||
command := []string{"sh", "-c", fetchScript, "sh"}
|
||||
selected := map[string][]found{}
|
||||
size, count := 0, 0
|
||||
for _, file := range files {
|
||||
name := "./" + strings.TrimPrefix(file.source, "/")
|
||||
if size+len(name)+1 >= 32*1024 {
|
||||
if count == 0 {
|
||||
return fmt.Errorf("artifact path exceeds container command limit: %s", file.path)
|
||||
}
|
||||
break
|
||||
}
|
||||
count++
|
||||
if _, exists := selected[path.Clean(name)]; !exists {
|
||||
command = append(command, name)
|
||||
size += len(name) + 1
|
||||
}
|
||||
selected[path.Clean(name)] = append(selected[path.Clean(name)], file)
|
||||
}
|
||||
output, err := c.Container.ExecOutput(ctx, command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retry, err := readArchive(output, selected, visit)
|
||||
closeErr := output.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
files = append(retry, files[count:]...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readArchive(output io.Reader, selected map[string][]found, visit func(found) error) ([]found, error) {
|
||||
buffered := bufio.NewReader(output)
|
||||
var content io.Reader = buffered
|
||||
if magic, _ := buffered.Peek(2); len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b {
|
||||
compressed, err := gzip.NewReader(buffered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer compressed.Close()
|
||||
compressed.Multistream(false) // bsdtar pads its gzip output with zeros to the block size
|
||||
content = compressed
|
||||
}
|
||||
reader := tar.NewReader(content)
|
||||
var retry []found
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := path.Clean(header.Name)
|
||||
files := selected[name]
|
||||
delete(selected, name)
|
||||
if header.Typeflag == tar.TypeLink {
|
||||
retry = append(retry, files...)
|
||||
continue
|
||||
}
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
retry = append(retry, files[1:]...)
|
||||
file := files[0]
|
||||
file.body, file.mode, file.mtime = reader, header.FileInfo().Mode(), header.ModTime
|
||||
if err := visit(file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
_, err := io.Copy(io.Discard, content)
|
||||
if err == nil {
|
||||
_, err = io.Copy(io.Discard, buffered)
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package uploadartifact
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"mime"
|
||||
"path"
|
||||
)
|
||||
|
||||
func mimeTypeOf(name string) string {
|
||||
return cmp.Or(mime.TypeByExtension(path.Ext(name)), "application/octet-stream")
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package uploadartifact works like actions/upload-artifact v7.
|
||||
package uploadartifact
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"cmp"
|
||||
"compress/flate"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/artifact"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/glob"
|
||||
)
|
||||
|
||||
type inputs struct {
|
||||
name, path, ifNoFilesFound string
|
||||
retentionDays, compressionLevel int
|
||||
overwrite, includeHidden, archiveIt bool
|
||||
}
|
||||
|
||||
func Main(ctx context.Context, c *action.Context) error {
|
||||
c.ApplyDefaults("name", "artifact", "path", "", "if-no-files-found", "warn", "retention-days", "", "compression-level", "6",
|
||||
"overwrite", "false", "include-hidden-files", "false", "archive", "true")
|
||||
in, err := readInputs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, err := glob.Parse(in.path, glob.Options{
|
||||
Workdir: c.Workspace,
|
||||
Home: func() (string, error) { return c.Home(ctx) },
|
||||
Windows: c.Windows(),
|
||||
ImplicitDescendants: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := selectFiles(ctx, c, g, in.includeHidden)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collected := &collected{files: files, root: glob.LeastCommonAncestor(g.SearchPaths(), c.Windows())}
|
||||
if len(files) == 1 && files[0].path == collected.root {
|
||||
collected.root = c.Dir(collected.root)
|
||||
}
|
||||
if len(collected.files) == 0 {
|
||||
message := fmt.Sprintf("No files were found with the provided path: %s. No artifacts will be uploaded.", in.path)
|
||||
switch in.ifNoFilesFound {
|
||||
case "warn":
|
||||
c.Warning("%s", message)
|
||||
case "error":
|
||||
return action.Failf("%s", message)
|
||||
default:
|
||||
c.Infof("%s", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !in.archiveIt && len(collected.files) > 1 {
|
||||
return action.Failf("When 'archive' is set to false, only a single file can be uploaded. Found %d files to upload.", len(collected.files))
|
||||
}
|
||||
return upload(ctx, c, in, collected)
|
||||
}
|
||||
|
||||
func readInputs(c *action.Context) (*inputs, error) {
|
||||
in := &inputs{name: c.Input("name"), path: c.Input("path"), ifNoFilesFound: c.Input("if-no-files-found"), compressionLevel: 6}
|
||||
if in.path == "" {
|
||||
return nil, action.Failf("Input required and not supplied: path")
|
||||
}
|
||||
var err error
|
||||
if in.overwrite, err = c.BoolInput("overwrite"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.includeHidden, err = c.BoolInput("include-hidden-files"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.archiveIt, err = c.BoolInput("archive"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ifNoFilesFound != "warn" && in.ifNoFilesFound != "error" && in.ifNoFilesFound != "ignore" {
|
||||
return nil, action.Failf("Unrecognized if-no-files-found input. Provided: %s. Available options: warn,error,ignore", in.ifNoFilesFound)
|
||||
}
|
||||
if days := c.Input("retention-days"); days != "" {
|
||||
if in.retentionDays, err = action.ParseInt(days); err != nil {
|
||||
return nil, action.Failf("Invalid retention-days")
|
||||
}
|
||||
}
|
||||
if level := c.Input("compression-level"); level != "" {
|
||||
if in.compressionLevel, err = action.ParseInt(level); err != nil {
|
||||
return nil, action.Failf("Invalid compression-level")
|
||||
}
|
||||
if in.compressionLevel < 0 || in.compressionLevel > 9 {
|
||||
return nil, action.Failf("Invalid compression-level. Valid values are 0-9")
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
type collected struct {
|
||||
files []found
|
||||
root string
|
||||
}
|
||||
|
||||
func (got *collected) write(ctx context.Context, c *action.Context, in *inputs, output io.Writer) error {
|
||||
if !in.archiveIt {
|
||||
return readFiles(ctx, c, got.files, func(file found) error {
|
||||
_, err := io.Copy(output, file.body)
|
||||
return err
|
||||
})
|
||||
}
|
||||
deflater, err := flate.NewWriter(nil, in.compressionLevel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer := zip.NewWriter(output)
|
||||
writer.RegisterCompressor(zip.Deflate, func(output io.Writer) (io.WriteCloser, error) {
|
||||
deflater.Reset(output)
|
||||
return deflater, nil
|
||||
})
|
||||
if err := readFiles(ctx, c, got.files, func(file found) error {
|
||||
header := &zip.FileHeader{
|
||||
Name: strings.TrimLeft(strings.ReplaceAll(strings.TrimPrefix(file.path, got.root), `\`, "/"), "/"),
|
||||
Method: zip.Deflate, Modified: file.mtime.UTC(),
|
||||
}
|
||||
if in.compressionLevel == 0 {
|
||||
header.Method = zip.Store
|
||||
}
|
||||
header.SetMode(file.mode & (fs.ModePerm | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky))
|
||||
entry, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(entry, file.body)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Close()
|
||||
}
|
||||
|
||||
const invalidPathChars = "\":<>|*?\r\n"
|
||||
|
||||
func upload(ctx context.Context, c *action.Context, in *inputs, got *collected) error {
|
||||
client, err := artifact.New(c.ResultsURL, c.RuntimeToken, c.Transport)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.Logf = c.Infof
|
||||
if in.overwrite {
|
||||
if id, err := client.Delete(ctx, in.name); err == nil {
|
||||
c.Infof("Artifact '%s' (ID: %d) deleted", in.name, id)
|
||||
} else if !artifact.IsNotFound(err) {
|
||||
c.Infof("Unable to delete artifact: %v", err) // best effort, as the JavaScript action
|
||||
}
|
||||
}
|
||||
name, fileName, mimeType := in.name, in.name+".zip", ""
|
||||
if !in.archiveIt {
|
||||
name, fileName, mimeType = c.Base(got.files[0].path), c.Base(got.files[0].path), mimeTypeOf(got.files[0].path)
|
||||
}
|
||||
if name == "" {
|
||||
return action.Failf("Provided artifact name input during validation is empty")
|
||||
}
|
||||
if strings.ContainsAny(name, invalidPathChars+`\/`) {
|
||||
return action.Failf("The artifact name is not valid: %s", name)
|
||||
}
|
||||
if in.archiveIt {
|
||||
for _, file := range got.files {
|
||||
if rel := strings.TrimPrefix(file.path, got.root); strings.ContainsAny(rel, invalidPathChars) {
|
||||
return action.Failf("The path for one of the files in artifact is not valid: %s", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
signed, err := client.Create(ctx, name, mimeType, expiresAt(c, in.retentionDays))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrency, err := uploadConcurrency(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stall, err := uploadTimeout(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Infof("Uploading artifact: %s", fileName)
|
||||
reader, writer := io.Pipe()
|
||||
defer reader.Close()
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
writer.CloseWithError(got.write(streamCtx, c, in, writer))
|
||||
}()
|
||||
uploaded, err := client.Upload(ctx, signed, cmp.Or(mimeType, "application/zip"), reader, concurrency, stall)
|
||||
reader.CloseWithError(err)
|
||||
cancel()
|
||||
<-done
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := client.Finalize(ctx, name, uploaded.Size, uploaded.Digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Infof("Artifact %s has been successfully uploaded! Final size is %d bytes. Artifact ID is %d", in.name, uploaded.Size, id)
|
||||
ghc := c.Github
|
||||
link := strings.TrimSuffix(ghc.ServerURL, "/") + "/" + ghc.Repository + "/actions/runs/" + ghc.RunID + "/artifacts/" + url.PathEscape(name)
|
||||
if ghc.RunAttempt != "" {
|
||||
link += "?attempt=" + url.QueryEscape(ghc.RunAttempt)
|
||||
}
|
||||
c.Infof("Artifact download URL: %s", link)
|
||||
return c.SetOutputs(ctx, "artifact-id", strconv.FormatInt(id, 10), "artifact-digest", strings.TrimPrefix(uploaded.Digest, "sha256:"), "artifact-url", link)
|
||||
}
|
||||
|
||||
func expiresAt(c *action.Context, days int) *time.Time {
|
||||
if days == 0 {
|
||||
return nil
|
||||
}
|
||||
if limit, err := action.ParseInt(c.Github.RetentionDays); err == nil && limit != 0 && limit < days {
|
||||
c.Warning("Retention days cannot be greater than the maximum allowed retention set within the repository. Using %d instead.", limit)
|
||||
days = limit
|
||||
}
|
||||
expires := time.Now().AddDate(0, 0, days)
|
||||
return &expires
|
||||
}
|
||||
|
||||
func uploadConcurrency(c *action.Context) (int, error) {
|
||||
override := c.Env["ACTIONS_ARTIFACT_UPLOAD_CONCURRENCY"]
|
||||
if override == "" {
|
||||
return 5, nil
|
||||
}
|
||||
concurrency, err := action.ParseInt(override)
|
||||
if err != nil || concurrency < 1 {
|
||||
return 0, action.Failf("Invalid value set for ACTIONS_ARTIFACT_UPLOAD_CONCURRENCY env variable")
|
||||
}
|
||||
return concurrency, nil
|
||||
}
|
||||
|
||||
func uploadTimeout(c *action.Context) (time.Duration, error) {
|
||||
value := c.Env["ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS"]
|
||||
if value == "" {
|
||||
return 5 * time.Minute, nil
|
||||
}
|
||||
ms, err := action.ParseInt(value)
|
||||
if err != nil {
|
||||
return 0, action.Failf("Invalid value set for ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS env variable")
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond, nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package uploadartifact
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifacts"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/artifact"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type localContainer struct {
|
||||
*container.HostEnvironment
|
||||
bin string
|
||||
}
|
||||
|
||||
func (c localContainer) ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
if c.bin != "" {
|
||||
command = append([]string{"env", "PATH=" + c.bin}, command...)
|
||||
}
|
||||
return c.HostEnvironment.ExecOutput(ctx, command)
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
server string
|
||||
client *artifact.Client
|
||||
workspace string
|
||||
env container.ExecutionsEnvironment
|
||||
logs, annotation []string
|
||||
retentionDays string
|
||||
variables map[string]string
|
||||
output string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, env container.ExecutionsEnvironment) *harness {
|
||||
t.Helper()
|
||||
router := http.NewServeMux()
|
||||
artifacts.RegisterV4(router, t.TempDir())
|
||||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
client, err := artifact.New(server.URL, artifacts.LocalRuntimeToken("7", time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
workspace, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
return &harness{t: t, server: server.URL, client: client, workspace: workspace, env: env}
|
||||
}
|
||||
|
||||
func (h *harness) write(rel, content string, mode os.FileMode) {
|
||||
h.t.Helper()
|
||||
p := filepath.Join(h.workspace, filepath.FromSlash(rel))
|
||||
require.NoError(h.t, os.MkdirAll(filepath.Dir(p), 0o755))
|
||||
require.NoError(h.t, os.WriteFile(p, []byte(content), mode))
|
||||
}
|
||||
|
||||
func (h *harness) run(inputs map[string]string) error {
|
||||
h.logs, h.annotation = nil, nil
|
||||
h.output = filepath.Join(h.t.TempDir(), "output")
|
||||
variables := map[string]string{"GITHUB_OUTPUT": h.output}
|
||||
maps.Copy(variables, h.variables)
|
||||
return Main(h.t.Context(), &action.Context{
|
||||
Container: h.env,
|
||||
Github: &model.GithubContext{ServerURL: "https://gitea.example", Repository: "owner/repo", RunID: "7", RunAttempt: "2", RetentionDays: h.retentionDays},
|
||||
Inputs: inputs,
|
||||
Workspace: h.workspace,
|
||||
Env: variables,
|
||||
ResultsURL: h.server,
|
||||
RuntimeToken: artifacts.LocalRuntimeToken("7", time.Hour),
|
||||
Infof: func(format string, args ...any) { h.logs = append(h.logs, fmt.Sprintf(format, args...)) },
|
||||
Warning: func(format string, args ...any) {
|
||||
h.annotation = append(h.annotation, "warning: "+fmt.Sprintf(format, args...))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *harness) fetch(name string) ([]byte, map[string]string) {
|
||||
h.t.Helper()
|
||||
signed, err := h.client.SignedURL(h.t.Context(), name)
|
||||
require.NoError(h.t, err)
|
||||
resp, err := h.client.Download(h.t.Context(), signed, time.Minute)
|
||||
require.NoError(h.t, err)
|
||||
defer resp.Body.Close()
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
require.NoError(h.t, err)
|
||||
reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
return content, nil
|
||||
}
|
||||
entries := map[string]string{}
|
||||
for _, f := range reader.File {
|
||||
body, err := f.Open()
|
||||
require.NoError(h.t, err)
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(h.t, err)
|
||||
require.NoError(h.t, body.Close())
|
||||
entries[f.Name] = string(data)
|
||||
}
|
||||
return content, entries
|
||||
}
|
||||
|
||||
func TestUploadSelectsWhatTheToolkitSelects(t *testing.T) {
|
||||
envs := map[string]container.ExecutionsEnvironment{"host": &container.HostEnvironment{}}
|
||||
if runtime.GOOS != "windows" {
|
||||
envs["container gzip"] = localContainer{HostEnvironment: &container.HostEnvironment{}}
|
||||
bin := t.TempDir()
|
||||
for _, name := range []string{"sh", "find", "readlink", "tar"} {
|
||||
binary, err := exec.LookPath(name)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Symlink(binary, filepath.Join(bin, name)))
|
||||
}
|
||||
envs["container plain"] = localContainer{HostEnvironment: &container.HostEnvironment{}, bin: bin}
|
||||
}
|
||||
for envName, env := range envs {
|
||||
t.Run(envName, func(t *testing.T) {
|
||||
h := newHarness(t, env)
|
||||
h.write("dist/a.txt", "a", 0o644)
|
||||
h.write("dist/run.sh", "#!/bin/sh\n", 0o755)
|
||||
h.write("dist/sub/b.txt", "b", 0o644)
|
||||
h.write("dist/empty", "", 0o644)
|
||||
require.NoError(t, os.Mkdir(filepath.Join(h.workspace, "dist", "emptydir"), 0o755))
|
||||
h.write("dist/.hidden", "h", 0o644)
|
||||
h.write("dist/.git/config", "c", 0o644)
|
||||
h.write("logs/x.log", "x", 0o644)
|
||||
h.write(".store/target.txt", "hidden target", 0o644)
|
||||
if runtime.GOOS != "windows" {
|
||||
require.NoError(t, os.Symlink("a.txt", filepath.Join(h.workspace, "dist", "link.txt")))
|
||||
require.NoError(t, os.Symlink("sub", filepath.Join(h.workspace, "dist", "linkdir")))
|
||||
require.NoError(t, os.Symlink("../.store", filepath.Join(h.workspace, "dist", "alias")))
|
||||
require.NoError(t, os.Symlink("missing", filepath.Join(h.workspace, "dist", "broken")))
|
||||
require.NoError(t, os.Symlink("..", filepath.Join(h.workspace, "dist", "loop")))
|
||||
require.NoError(t, os.Link(filepath.Join(h.workspace, "dist", "a.txt"), filepath.Join(h.workspace, "dist", "hard.txt")))
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
inputs map[string]string
|
||||
expected map[string]string
|
||||
links map[string]string
|
||||
}{
|
||||
{
|
||||
name: "a directory",
|
||||
inputs: map[string]string{"path": "dist"},
|
||||
expected: map[string]string{"a.txt": "a", "run.sh": "#!/bin/sh\n", "sub/b.txt": "b", "empty": ""},
|
||||
links: map[string]string{"link.txt": "a", "hard.txt": "a", "linkdir/b.txt": "b", "loop/logs/x.log": "x", "alias/target.txt": "hidden target"},
|
||||
},
|
||||
{
|
||||
name: "hidden files included",
|
||||
inputs: map[string]string{"path": "dist/", "include-hidden-files": "true"},
|
||||
expected: map[string]string{"a.txt": "a", "run.sh": "#!/bin/sh\n", "sub/b.txt": "b", ".hidden": "h", ".git/config": "c", "empty": ""},
|
||||
links: map[string]string{"link.txt": "a", "hard.txt": "a", "linkdir/b.txt": "b", "loop/logs/x.log": "x", "loop/.store/target.txt": "hidden target", "alias/target.txt": "hidden target"},
|
||||
},
|
||||
{
|
||||
name: "only exclusions and comments",
|
||||
inputs: map[string]string{"path": "!dist\n# dist"},
|
||||
expected: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "several patterns share their common ancestor as root",
|
||||
inputs: map[string]string{"path": "dist/**/*.txt\n!dist/sub/**\n# a comment\nlogs/x.log"},
|
||||
expected: map[string]string{"dist/a.txt": "a", "logs/x.log": "x"},
|
||||
links: map[string]string{"dist/link.txt": "a", "dist/hard.txt": "a", "dist/linkdir/b.txt": "b", "dist/alias/target.txt": "hidden target"},
|
||||
},
|
||||
{
|
||||
name: "hidden root skipped",
|
||||
inputs: map[string]string{"path": ".store"},
|
||||
expected: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "a single file keeps no directory",
|
||||
inputs: map[string]string{"path": h.workspace + "/dist/sub/b.txt"},
|
||||
expected: map[string]string{"b.txt": "b"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
inputs := map[string]string{"name": "art", "overwrite": "true"}
|
||||
maps.Copy(inputs, test.inputs)
|
||||
require.NoError(t, h.run(inputs))
|
||||
expected := test.expected
|
||||
if runtime.GOOS != "windows" {
|
||||
maps.Copy(expected, test.links)
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
assert.Contains(t, h.annotation, "warning: No files were found with the provided path: "+test.inputs["path"]+". No artifacts will be uploaded.")
|
||||
return
|
||||
}
|
||||
_, entries := h.fetch("art")
|
||||
assert.Equal(t, expected, entries)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadOptions(t *testing.T) {
|
||||
h := newHarness(t, &container.HostEnvironment{})
|
||||
h.write("out/run.sh", "#!/bin/sh\n", 0o755)
|
||||
h.write("out/data.txt", strings.Repeat("data", 1000), 0o644)
|
||||
h.variables = map[string]string{"ACTIONS_ARTIFACT_UPLOAD_CONCURRENCY": " 2 workers", "ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS": " 60000ms"}
|
||||
h.retentionDays = "5"
|
||||
require.NoError(t, h.run(map[string]string{"name": "packed", "path": "out", "compression-level": "0", "retention-days": "10 days"}))
|
||||
assert.Equal(t, []string{"warning: Retention days cannot be greater than the maximum allowed retention set within the repository. Using 5 instead."}, h.annotation)
|
||||
|
||||
content, _ := h.fetch("packed")
|
||||
reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
require.NoError(t, err)
|
||||
for _, f := range reader.File {
|
||||
assert.Equal(t, zip.Store, f.Method, f.Name)
|
||||
if runtime.GOOS != "windows" && f.Name == "run.sh" {
|
||||
assert.Equal(t, os.FileMode(0o755), f.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
list, err := h.client.List(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
outputs, err := os.ReadFile(h.output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("artifact-id=%d\nartifact-digest=%s\nartifact-url=https://gitea.example/owner/repo/actions/runs/7/artifacts/packed?attempt=2\n", list[0].ID, strings.TrimPrefix(list[0].Digest, "sha256:")), string(outputs))
|
||||
|
||||
h.write("report.html", "<html></html>", 0o644)
|
||||
|
||||
require.NoError(t, h.run(map[string]string{"name": "packed", "path": "report.html", "archive": "false", "overwrite": "true"}))
|
||||
list, err = h.client.List(t.Context(), "packed")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list)
|
||||
content, entries := h.fetch("report.html")
|
||||
assert.Nil(t, entries)
|
||||
assert.Equal(t, "<html></html>", string(content))
|
||||
assert.Contains(t, h.logs, "Uploading artifact: report.html")
|
||||
assert.Contains(t, strings.Join(h.logs, "\n"), "Artifact packed has been successfully uploaded!")
|
||||
require.ErrorContains(t, h.run(map[string]string{"path": "out", "archive": "false"}), "When 'archive' is set to false, only a single file can be uploaded. Found 2 files to upload.")
|
||||
|
||||
require.NoError(t, h.run(map[string]string{"path": "nothing/here"}))
|
||||
assert.Equal(t, []string{"warning: No files were found with the provided path: nothing/here. No artifacts will be uploaded."}, h.annotation)
|
||||
require.NoError(t, h.run(map[string]string{"path": "nothing", "if-no-files-found": "ignore"}))
|
||||
assert.Contains(t, h.logs, "No files were found with the provided path: nothing. No artifacts will be uploaded.")
|
||||
require.ErrorContains(t, h.run(map[string]string{"path": "nothing", "if-no-files-found": "error"}), "No files were found")
|
||||
|
||||
for _, test := range []struct {
|
||||
inputs map[string]string
|
||||
want string
|
||||
}{
|
||||
{map[string]string{"name": "a/b", "path": "out/data.txt"}, "The artifact name is not valid: a/b"},
|
||||
{map[string]string{}, "Input required and not supplied: path"},
|
||||
{map[string]string{"name": "", "path": "out/data.txt"}, "Provided artifact name input during validation is empty"},
|
||||
{map[string]string{"path": "out/data.txt", "overwrite": ""}, "Core Schema"},
|
||||
{map[string]string{"path": "out/data.txt", "compression-level": "10"}, "Invalid compression-level. Valid values are 0-9"},
|
||||
{map[string]string{"path": "out/data.txt", "compression-level": "+-1"}, "Invalid compression-level"},
|
||||
{map[string]string{"path": "out/data.txt", "if-no-files-found": "fail"}, "Unrecognized if-no-files-found input"},
|
||||
} {
|
||||
require.ErrorContains(t, h.run(test.inputs), test.want)
|
||||
}
|
||||
require.NoError(t, h.run(map[string]string{"path": "out/data.txt", "retention": "1", "overwrite": "true"}))
|
||||
assert.Equal(t, []string{"warning: Unexpected input(s) 'retention', valid inputs are ['name', 'path', 'if-no-files-found', 'retention-days', 'compression-level', 'overwrite', 'include-hidden-files', 'archive']"}, h.annotation)
|
||||
if runtime.GOOS != "windows" {
|
||||
h.write("bad/a:b.txt", "x", 0o644)
|
||||
require.ErrorContains(t, h.run(map[string]string{"path": "bad"}), "The path for one of the files in artifact is not valid: /a:b.txt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package uploadartifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/glob"
|
||||
)
|
||||
|
||||
type found struct {
|
||||
path, source string
|
||||
mode fs.FileMode
|
||||
mtime time.Time
|
||||
body io.Reader
|
||||
}
|
||||
|
||||
type treeEntry struct {
|
||||
name, target string
|
||||
dir bool
|
||||
}
|
||||
|
||||
type tree struct {
|
||||
real string
|
||||
entries map[string][]treeEntry
|
||||
}
|
||||
|
||||
const listScript = `test -e "$1" || exit 1
|
||||
[ -d "$1" ] || { printf 'f\0'; exit 0; }
|
||||
cd "$1" || exit 2
|
||||
shift
|
||||
pwd -P; printf '\0'
|
||||
find . "$@" -type d ! -name . -print0; printf '\0'
|
||||
find . "$@" -type l -exec sh -c 'for l; do printf "%s\0" "$l"; readlink "$l"; printf "\0"; done' sh {} +; printf '\0'
|
||||
find . "$@" ! -type d ! -type l -print0`
|
||||
|
||||
func list(ctx context.Context, c *action.Context, source string, includeHidden bool) (*tree, error) {
|
||||
if c.Host() {
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(source)
|
||||
if err != nil || !info.IsDir() {
|
||||
return &tree{real: resolved}, err
|
||||
}
|
||||
entries, err := os.ReadDir(resolved)
|
||||
result := &tree{real: resolved, entries: map[string][]treeEntry{".": nil}}
|
||||
for _, entry := range entries {
|
||||
result.entries["."] = append(result.entries["."], treeEntry{name: entry.Name(), dir: entry.IsDir()})
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
command := []string{"sh", "-c", listScript, "sh", source}
|
||||
if !includeHidden {
|
||||
command = append(command, "!", "-name", ".", "-name", ".*", "-prune", "-o")
|
||||
}
|
||||
output, err := c.Container.ExecOutput(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer output.Close()
|
||||
data, err := io.ReadAll(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(data) == "f\x00" {
|
||||
return &tree{real: source}, nil
|
||||
}
|
||||
fields := strings.Split(string(data), "\x00")
|
||||
if len(fields) < 4 {
|
||||
return nil, fmt.Errorf("invalid directory listing for %s", source)
|
||||
}
|
||||
result := &tree{real: strings.TrimSuffix(fields[0], "\n"), entries: map[string][]treeEntry{".": nil}}
|
||||
section := 0
|
||||
for index := 1; index < len(fields)-1; index++ {
|
||||
name := fields[index]
|
||||
if name == "" {
|
||||
section++
|
||||
continue
|
||||
}
|
||||
entry := treeEntry{name: path.Base(name), dir: section == 0}
|
||||
switch section {
|
||||
case 0:
|
||||
result.entries[path.Clean(name)] = nil
|
||||
case 1:
|
||||
index++
|
||||
if index >= len(fields)-1 {
|
||||
return nil, fmt.Errorf("invalid symlink listing for %s", source)
|
||||
}
|
||||
entry.target = strings.TrimSuffix(fields[index], "\n")
|
||||
}
|
||||
result.entries[path.Dir(name)] = append(result.entries[path.Dir(name)], entry)
|
||||
}
|
||||
for _, entries := range result.entries {
|
||||
slices.SortFunc(entries, func(left, right treeEntry) int { return strings.Compare(left.name, right.name) })
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func selectFiles(ctx context.Context, c *action.Context, globber *glob.Globber, includeHidden bool) ([]found, error) {
|
||||
var files []found
|
||||
join := path.Join
|
||||
if c.Windows() {
|
||||
join = filepath.Join
|
||||
}
|
||||
var walk func(string, string, []string, *tree, string) error
|
||||
walk = func(virtual, source string, chain []string, listed *tree, relative string) error {
|
||||
match := globber.Match(virtual)
|
||||
if match == glob.MatchNone && !globber.PartialMatch(virtual) || !includeHidden && strings.HasPrefix(c.Base(virtual), ".") {
|
||||
return nil
|
||||
}
|
||||
if listed == nil {
|
||||
var err error
|
||||
listed, err = list(ctx, c, source, includeHidden)
|
||||
var exit container.ExitCodeError
|
||||
if errors.Is(err, fs.ErrNotExist) || errors.As(err, &exit) && exit == 1 {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
relative = "."
|
||||
}
|
||||
if listed.entries == nil {
|
||||
if match&glob.MatchFile != 0 {
|
||||
files = append(files, found{path: virtual, source: listed.real})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resolved := join(listed.real, relative)
|
||||
if slices.Contains(chain, resolved) {
|
||||
return nil
|
||||
}
|
||||
chain = append(slices.Clip(chain), resolved)
|
||||
for _, entry := range listed.entries[relative] {
|
||||
child := join(virtual, entry.name)
|
||||
source := join(resolved, entry.name)
|
||||
var subtree *tree
|
||||
switch {
|
||||
case entry.target != "":
|
||||
source = entry.target
|
||||
if !path.IsAbs(source) {
|
||||
source = path.Join(resolved, source)
|
||||
}
|
||||
case entry.dir && !c.Host():
|
||||
subtree = listed
|
||||
case !entry.dir && !c.Host():
|
||||
subtree = &tree{real: source}
|
||||
}
|
||||
if err := walk(child, source, chain, subtree, path.Join(relative, entry.name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, root := range globber.SearchPaths() {
|
||||
if err := walk(root, root, nil, nil, "."); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
Reference in new issue
Block a user