mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 07:10:28 +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
@@ -247,7 +247,7 @@ Set `container.sweep: true` to remove the containers, networks and volumes most
|
||||
|
||||
### Built-in actions (`uses: builtin:<name>`)
|
||||
|
||||
`uses: builtin:checkout` checks out a repository without downloading `actions/checkout` or needing Node in the job image, see [docs/builtin-actions.md](docs/builtin-actions.md).
|
||||
`uses: builtin:checkout` and `builtin:upload-artifact` replace their `actions/` counterparts without an action download or Node in the job image, see [docs/builtin-actions.md](docs/builtin-actions.md).
|
||||
|
||||
### Caching (`actions/cache`)
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||
uploads(router, artifactPath)
|
||||
downloads(router, artifactPath)
|
||||
registerV4(router, artifactPath)
|
||||
RegisterV4(router, artifactPath)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: net.JoinHostPort(addr, port),
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestArtifactFlow(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
uploads(router, artifactPath)
|
||||
downloads(router, artifactPath)
|
||||
registerV4(router, artifactPath)
|
||||
RegisterV4(router, artifactPath)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
@@ -256,7 +256,7 @@ func TestArtifactFlow(t *testing.T) {
|
||||
require.Equal(t, "attachment; filename*=UTF-8''50%25%20art%40v1", header.Get("Content-Disposition"))
|
||||
|
||||
rerun := http.NewServeMux()
|
||||
registerV4(rerun, artifactPath)
|
||||
RegisterV4(rerun, artifactPath)
|
||||
recorder := httptest.NewRecorder()
|
||||
rerun.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, artifactServicePath+"/CreateArtifact", strings.NewReader("{"+artifactRef+"}")))
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
maxTwirpBytes = 1 << 20
|
||||
)
|
||||
|
||||
func registerV4(router *http.ServeMux, baseDir string) {
|
||||
func RegisterV4(router *http.ServeMux, baseDir string) {
|
||||
server := &serverV4{dir: filepath.Join(baseDir, "v4"), lastID: time.Now().UnixMilli(), created: map[string]bool{}} // IDs stay unique across invocations sharing baseDir
|
||||
router.HandleFunc("POST "+artifactServicePath+"/CreateArtifact", twirp(server.createArtifact))
|
||||
router.HandleFunc("POST "+artifactServicePath+"/FinalizeArtifact", twirp(server.finalizeArtifact))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -30,6 +31,38 @@ func (e ExitCodeError) Error() string {
|
||||
return fmt.Sprintf("Process completed with exit code %d.", int(e))
|
||||
}
|
||||
|
||||
type commandOutput struct {
|
||||
*io.PipeReader
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (output *commandOutput) Close() error {
|
||||
_, _ = io.Copy(io.Discard, output.PipeReader)
|
||||
output.PipeReader.Close()
|
||||
output.cancel()
|
||||
<-output.done
|
||||
return output.err
|
||||
}
|
||||
|
||||
func streamOutput(ctx context.Context, run func(context.Context, io.Writer, io.Writer) error) io.ReadCloser {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
reader, writer := io.Pipe()
|
||||
output := &commandOutput{PipeReader: reader, cancel: cancel, done: make(chan struct{})}
|
||||
go func() {
|
||||
defer close(output.done)
|
||||
defer cancel()
|
||||
var stderr bytes.Buffer
|
||||
output.err = run(ctx, writer, &stderr)
|
||||
if output.err != nil && strings.TrimSpace(stderr.String()) != "" {
|
||||
output.err = fmt.Errorf("%w: %s", output.err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
writer.CloseWithError(output.err)
|
||||
}()
|
||||
return output
|
||||
}
|
||||
|
||||
// NewContainerInput the input for the New function
|
||||
type NewContainerInput struct {
|
||||
Image string
|
||||
@@ -130,6 +163,7 @@ type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||
ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error)
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
CopyTarStream(ctx context.Context, destPath string, write func(*TarWriter) error) error
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
|
||||
@@ -146,6 +146,47 @@ func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, sk
|
||||
).IfNot(common.Dryrun)
|
||||
}
|
||||
|
||||
func (cr *containerReference) ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil, errors.New("DRYRUN is not supported in ExecOutput")
|
||||
}
|
||||
if err := cr.connect()(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cr.find()(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cr.id == "" {
|
||||
return nil, cr.missingContainerError("exec %v", command)
|
||||
}
|
||||
return streamOutput(ctx, func(ctx context.Context, stdout, stderr io.Writer) error {
|
||||
created, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
|
||||
Cmd: command, AttachStdout: true, AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attached, err := cr.cli.ExecAttach(ctx, created.ID, client.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer attached.Close()
|
||||
stop := context.AfterFunc(ctx, func() { attached.Close() })
|
||||
defer stop()
|
||||
if _, err := stdcopy.StdCopy(stdout, stderr, attached.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
inspected, err := cr.cli.ExecInspect(ctx, created.ID, client.ExecInspectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inspected.ExitCode != 0 {
|
||||
return ExitCodeError(inspected.ExitCode)
|
||||
}
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
||||
|
||||
@@ -260,6 +260,28 @@ func TestDockerExecFailure(t *testing.T) {
|
||||
assert.Equal(t, ExitCodeError(1), exitErr)
|
||||
assert.Equal(t, "Process completed with exit code 1.", err.Error())
|
||||
|
||||
client.On("ContainerInspect", ctx, "123", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{}, nil)
|
||||
client.On("ExecCreate", mock.Anything, "123", mobyclient.ExecCreateOptions{
|
||||
Cmd: []string{"printf", `a\b`}, AttachStdout: true, AttachStderr: true,
|
||||
}).Return(mobyclient.ExecCreateResult{ID: "output"}, nil)
|
||||
client.On("ExecAttach", mock.Anything, "output", mobyclient.ExecAttachOptions{}).
|
||||
Return(mobyclient.ExecAttachResult{
|
||||
Conn: &mockConn{},
|
||||
Reader: bufio.NewReader(bytes.NewReader(append(stdcopyFrame(stdcopy.Stdout, "a\x00b"), stdcopyFrame(stdcopy.Stderr, " failed \n")...))),
|
||||
}, nil)
|
||||
client.On("ExecInspect", mock.Anything, "output", mobyclient.ExecInspectOptions{}).
|
||||
Return(mobyclient.ExecInspectResult{ExitCode: 3}, nil)
|
||||
output, err := cr.ExecOutput(ctx, []string{"printf", `a\b`})
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(output)
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, ExitCodeError(3), exitErr)
|
||||
assert.Equal(t, "Process completed with exit code 3.: failed", err.Error())
|
||||
assert.Equal(t, "a\x00b", string(content))
|
||||
require.ErrorAs(t, output.Close(), &exitErr)
|
||||
assert.Equal(t, ExitCodeError(3), exitErr)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -533,6 +555,8 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
_, err = cr.ExecOutput(ctx, []string{"true"})
|
||||
check("ExecOutput", err)
|
||||
_, err = cr.Inspect(ctx)
|
||||
check("Inspect", err)
|
||||
|
||||
|
||||
@@ -116,6 +116,20 @@ func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
||||
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
return streamOutput(ctx, func(ctx context.Context, stdout, stderr io.Writer) error {
|
||||
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
|
||||
cmd.Dir, cmd.Stdout, cmd.Stderr = e.Workdir, stdout, stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exit, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return ExitCodeError(exit.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
srcPath = filepath.Clean(srcPath)
|
||||
fi, err := os.Lstat(srcPath)
|
||||
|
||||
@@ -132,6 +132,21 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
for _, read := range []bool{true, false} {
|
||||
output, err := (&HostEnvironment{AllocatePTY: true}).ExecOutput(t.Context(), []string{"sh", "-c", `test ! -t 1; printf 'out\000'; printf ' failed \n' >&2; exit 3`})
|
||||
require.NoError(t, err)
|
||||
if read {
|
||||
content, err := io.ReadAll(output)
|
||||
var exit ExitCodeError
|
||||
require.ErrorAs(t, err, &exit)
|
||||
assert.Equal(t, ExitCodeError(3), exit)
|
||||
assert.Equal(t, "out\x00", string(content))
|
||||
assert.Equal(t, "Process completed with exit code 3.: failed", err.Error())
|
||||
}
|
||||
var exit ExitCodeError
|
||||
require.ErrorAs(t, output.Close(), &exit)
|
||||
assert.Equal(t, ExitCodeError(3), exit)
|
||||
}
|
||||
for name, allocatePTY := range map[string]bool{"without PTY": false, "with PTY": true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
+10
-10
@@ -565,17 +565,17 @@ func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, sk
|
||||
}
|
||||
}
|
||||
|
||||
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
|
||||
func (c *kubernetesContainer) ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
return streamOutput(ctx, func(ctx context.Context, stdout, stderr io.Writer) error {
|
||||
return c.pod.client.exec(ctx, c.pod.name, c.name, command, nil, stdout, stderr)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
reader, writer := io.Pipe()
|
||||
go func() {
|
||||
var stderr bytes.Buffer
|
||||
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
writer.CloseWithError(err)
|
||||
}()
|
||||
reader, err := c.ExecOutput(ctx, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return startedArchive(reader)
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,17 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||
assert.Equal(t, "hunter2\nit's\nme\n5\n"+workdir+"\n", jobLog.String())
|
||||
assert.Equal(t, ExitCodeError(3), job.Exec([]string{"sh", "-c", "echo failing >&2; exit 3"}, nil, "", "")(ctx))
|
||||
assert.Contains(t, jobLog.String(), "failing\n")
|
||||
output, err := job.ExecOutput(ctx, []string{"sh", "-c", `printf 'out\000'; printf ' failed \n' >&2; exit 3`})
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(output)
|
||||
var exit ExitCodeError
|
||||
require.ErrorAs(t, err, &exit)
|
||||
assert.Equal(t, ExitCodeError(3), exit)
|
||||
assert.Contains(t, err.Error(), ": failed")
|
||||
assert.Equal(t, "out\x00", string(content))
|
||||
require.ErrorAs(t, output.Close(), &exit)
|
||||
assert.Equal(t, ExitCodeError(3), exit)
|
||||
|
||||
for _, query := range cluster.requests["exec"] {
|
||||
assert.NotContains(t, query, "hunter2")
|
||||
}
|
||||
@@ -285,7 +296,7 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||
reader := tar.NewReader(archive)
|
||||
header, err := reader.Next()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(reader)
|
||||
content, err = io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "event.json", header.Name)
|
||||
assert.JSONEq(t, `{"ref":"main"}`, string(content))
|
||||
|
||||
@@ -86,7 +86,7 @@ func pipeTar(uid, gid int, prefix string, parents []string, write func(*TarWrite
|
||||
}
|
||||
|
||||
// startedArchive returns reader once its first byte arrives, so failing to start archiving errors here, not on read.
|
||||
func startedArchive(reader *io.PipeReader) (io.ReadCloser, error) {
|
||||
func startedArchive(reader io.ReadCloser) (io.ReadCloser, error) {
|
||||
buffered := bufio.NewReader(reader)
|
||||
if _, err := buffered.Peek(1); err != nil {
|
||||
reader.Close()
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -182,3 +184,21 @@ func newTestCompositeRunContext(t *testing.T, parent *RunContext) *RunContext {
|
||||
require.NoError(t, err)
|
||||
return composite
|
||||
}
|
||||
|
||||
func TestBuiltinActionGetsLowercaseInputsAndTheJobTokenInComposites(t *testing.T) {
|
||||
job := makeTestRC(t, "runs-on: ubuntu-latest")
|
||||
job.Config.Secrets = map[string]string{"ACTIONS_RUNTIME_TOKEN": "daemon-token"}
|
||||
job.JobContainer = &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir(), Workdir: t.TempDir()}
|
||||
composite := newTestCompositeRunContext(t, job)
|
||||
composite.JobContainer = job.JobContainer
|
||||
for _, rc := range []*RunContext{job, composite} {
|
||||
var got *action.Context
|
||||
sab := &stepActionBuiltin{Step: &model.Step{ID: "x", With: map[string]string{"Name": "x"}}, RunContext: rc, run: func(_ctx context.Context, c *action.Context) error {
|
||||
got = c
|
||||
return nil
|
||||
}}
|
||||
require.NoError(t, sab.main()(t.Context()))
|
||||
assert.Equal(t, "daemon-token", got.RuntimeToken)
|
||||
assert.Equal(t, map[string]string{"name": "x"}, got.Inputs)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,12 @@ func (cm *containerMock) Exec(command []string, env map[string]string, user, wor
|
||||
return cm.Called(command, env, user, workdir).Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) ExecOutput(ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
args := cm.Called(ctx, command)
|
||||
output, _ := args.Get(0).(io.ReadCloser)
|
||||
return output, args.Error(1)
|
||||
}
|
||||
|
||||
func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
args := cm.Called(ctx, srcPath)
|
||||
err, _ := args.Get(1).(error)
|
||||
|
||||
@@ -71,6 +71,11 @@ func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.E
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) ExecOutput(_ctx context.Context, command []string) (io.ReadCloser, error) {
|
||||
c.cmd = command
|
||||
return io.NopCloser(bytes.NewBufferString(c.output)), c.err
|
||||
}
|
||||
|
||||
func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(c.pathTar)), nil
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ type Config struct {
|
||||
PlatformPicker func(labels []string) string
|
||||
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||
InstanceTransport http.RoundTripper
|
||||
ResultsURL string // the results service as the runner reaches it, never the job's ACTIONS_RESULTS_URL, which a step can rewrite
|
||||
JobLoggerLevel *logging.Level // the level of job logger
|
||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||
ContainerVolumes []string // mounted into every job, service and docker action container
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/action"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/checkout"
|
||||
"gitea.com/gitea/runner/internal/pkg/action/uploadartifact"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
var builtinActions = map[string]func(context.Context, *action.Context) error{
|
||||
"checkout": checkout.Main,
|
||||
"checkout": checkout.Main,
|
||||
"upload-artifact": uploadartifact.Main,
|
||||
}
|
||||
|
||||
type stepActionBuiltin struct {
|
||||
@@ -34,13 +39,18 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
}
|
||||
|
||||
printRunActionHeader(ctx, sab.Step, nil, sab.env, sab.RunContext)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
|
||||
rc := sab.RunContext
|
||||
rc.ApplyExtraPath(ctx, &sab.env)
|
||||
inputs := make(map[string]string, len(sab.Step.With))
|
||||
for name, value := range sab.Step.With {
|
||||
inputs[strings.ToLower(name)] = value // input names are case-insensitive on GitHub
|
||||
}
|
||||
return sab.run(ctx, &action.Context{
|
||||
Container: rc.JobContainer,
|
||||
Github: getStepGithubContext(ctx, sab),
|
||||
Inputs: sab.Step.With,
|
||||
Inputs: inputs,
|
||||
HostWorkdir: rc.Config.Workdir,
|
||||
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
BindWorkdir: rc.Config.BindWorkdir,
|
||||
@@ -51,6 +61,13 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||
CAFile: rc.jobCAFile(),
|
||||
Env: sab.env,
|
||||
ResultsURL: rc.Config.ResultsURL,
|
||||
RuntimeToken: cmp.Or(rc.runtimeToken, rc.topLevelRunContext().Config.Secrets["ACTIONS_RUNTIME_TOKEN"]), // composite contexts have no secrets
|
||||
Transport: rc.Config.InstanceTransport,
|
||||
Infof: rawLogger.Infof,
|
||||
Warning: func(format string, args ...any) {
|
||||
rawLogger.Infof("%s", formatAnnotation("warning", nil, fmt.Sprintf(format, args...)))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ Built-in actions ship with the runner, so a job needs no action download and no
|
||||
|
||||
```yaml
|
||||
- uses: builtin:checkout
|
||||
- uses: builtin:upload-artifact
|
||||
```
|
||||
|
||||
## checkout
|
||||
@@ -37,3 +38,24 @@ A branch is checked out as a local branch tracking `origin`, a tag or commit as
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||
|
||||
## upload-artifact
|
||||
|
||||
Works like `actions/upload-artifact` v7 with these inputs. Any other input logs a warning, as on GitHub. It needs Gitea 1.22.4 or later, and 1.26 for `archive: false`.
|
||||
|
||||
| Input | Default |
|
||||
| --- | --- |
|
||||
| `name` | `artifact`, ignored with `archive: false`, which names the artifact after the file |
|
||||
| `path` | required, files, directories or patterns, one per line, `!` excludes |
|
||||
| `if-no-files-found` | `warn`, also `error` or `ignore` |
|
||||
| `retention-days` | the server's default, at most the repository's retention limit |
|
||||
| `compression-level` | `6`, from `0` for none to `9` |
|
||||
| `overwrite` | `false`, `true` deletes an artifact of the same name first |
|
||||
| `include-hidden-files` | `false`, `true` uploads files and directories whose name starts with `.` |
|
||||
| `archive` | `true`, `false` uploads a single file as is |
|
||||
|
||||
Patterns, symlinks and the artifact's root directory follow `actions/upload-artifact`. `ACTIONS_ARTIFACT_UPLOAD_CONCURRENCY` sets upload concurrency (default `5`), and `ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS` sets the upload stall timeout (default `300000`). The step outputs `artifact-id`, `artifact-digest` and `artifact-url`. On Gitea 1.26 and later, the `artifact-url` is the artifact's link on the run page for this attempt, where `actions/upload-artifact` gives a link only GitHub serves.
|
||||
|
||||
Container jobs list the search paths in the job container and transfer only the selected files, gzipped when the image has gzip, requiring `sh`, `find` and `tar` in the image. On Kubernetes these files still pass through the API server, unlike `actions/upload-artifact`, which uploads from the pod.
|
||||
|
||||
With `gitea-runner exec`, it uses the local artifact server of `--artifact-server-path`.
|
||||
Vendored
+32
@@ -26,3 +26,35 @@ jobs:
|
||||
name: e2e-artifact-${{ github.run_id }}
|
||||
path: got
|
||||
- run: grep -q artifact-payload got/data.txt
|
||||
builtin-upload:
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- run: |
|
||||
mkdir -p out/sub
|
||||
echo "builtin-payload" > out/data.txt
|
||||
echo "nested" > out/sub/nested.txt
|
||||
echo "hidden" > out/.hidden
|
||||
echo "raw" > report.txt
|
||||
- id: zipped
|
||||
uses: builtin:upload-artifact
|
||||
with:
|
||||
name: e2e-builtin-${{ github.run_id }}
|
||||
path: out
|
||||
- run: test -n "${{ steps.zipped.outputs.artifact-id }}" && test -n "${{ steps.zipped.outputs.artifact-digest }}"
|
||||
- uses: builtin:upload-artifact
|
||||
with:
|
||||
path: report.txt
|
||||
archive: false
|
||||
js-downloads-builtin:
|
||||
needs: builtin-upload
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-builtin-${{ github.run_id }}
|
||||
path: got
|
||||
- run: grep -q builtin-payload got/data.txt && grep -q nested got/sub/nested.txt && test ! -e got/.hidden
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: report.txt
|
||||
- run: grep -q raw report.txt
|
||||
@@ -209,6 +209,10 @@ func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (i *executeArgs) artifactServerURL() string {
|
||||
return "http://" + net.JoinHostPort(i.artifactServerAddr, i.artifactServerPort)
|
||||
}
|
||||
|
||||
// Workdir returns path to workdir
|
||||
func (i *executeArgs) Workdir() string {
|
||||
return i.resolve(".")
|
||||
@@ -382,6 +386,7 @@ func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]st
|
||||
ArtifactServerPath: i.artifactServerPath,
|
||||
ArtifactServerPort: i.artifactServerPort,
|
||||
ArtifactServerAddr: i.artifactServerAddr,
|
||||
ResultsURL: i.artifactServerURL(),
|
||||
NoSkipCheckout: i.noSkipCheckout,
|
||||
EventName: eventName,
|
||||
EventJSON: eventJSON,
|
||||
@@ -513,7 +518,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
token := cmp.Or(inheritedToken, artifacts.LocalRuntimeToken(workflowRun, maxLifetime))
|
||||
config.RuntimeTokens[run.Workflow] = token
|
||||
config.ExtraMasks = append(config.ExtraMasks, token)
|
||||
handler.RegisterJob(token, artifactcache.JobCredential{Repo: "__local/__exec", Results: "http://" + net.JoinHostPort(execArgs.artifactServerAddr, execArgs.artifactServerPort)})
|
||||
handler.RegisterJob(token, artifactcache.JobCredential{Repo: "__local/__exec", Results: execArgs.artifactServerURL()})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -629,6 +629,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||
CAFile: r.cfg.Runner.CAFile,
|
||||
InstanceTransport: r.instanceTransport,
|
||||
ResultsURL: strings.TrimSuffix(r.client.Address(), "/"),
|
||||
RunnerName: r.name,
|
||||
KubernetesPicker: r.kubernetesOptions,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -24,4 +37,124 @@ type Context struct {
|
||||
ClientKeyFile string
|
||||
CAFile string // the runner CA as seen inside Container
|
||||
Env map[string]string
|
||||
ResultsURL string // the results service as the runner reaches it
|
||||
RuntimeToken string
|
||||
Transport http.RoundTripper // carries the instance's TLS settings, nil for the default
|
||||
Infof func(format string, args ...any)
|
||||
Warning func(format string, args ...any)
|
||||
}
|
||||
|
||||
func (c *Context) ApplyDefaults(pairs ...string) {
|
||||
var valid, unexpected []string
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
valid = append(valid, pairs[i])
|
||||
if _, set := c.Inputs[pairs[i]]; !set && pairs[i+1] != "" {
|
||||
c.Inputs[pairs[i]] = pairs[i+1]
|
||||
}
|
||||
}
|
||||
for name := range c.Inputs {
|
||||
if !slices.Contains(valid, name) {
|
||||
unexpected = append(unexpected, name)
|
||||
}
|
||||
}
|
||||
if len(unexpected) > 0 {
|
||||
slices.Sort(unexpected)
|
||||
c.Warning("Unexpected input(s) '%s', valid inputs are ['%s']", strings.Join(unexpected, "', '"), strings.Join(valid, "', '"))
|
||||
}
|
||||
}
|
||||
|
||||
// Input is the trimmed input, as @actions/core getInput returns it.
|
||||
func (c *Context) Input(name string) string {
|
||||
return strings.TrimSpace(c.Inputs[name])
|
||||
}
|
||||
|
||||
// BoolInput accepts what @actions/core getBooleanInput does.
|
||||
func (c *Context) BoolInput(name string) (bool, error) {
|
||||
switch c.Input(name) {
|
||||
case "true", "True", "TRUE":
|
||||
return true, nil
|
||||
case "false", "False", "FALSE":
|
||||
return false, nil
|
||||
}
|
||||
return false, Failf("Input does not meet YAML 1.2 \"Core Schema\" specification: %s\nSupport boolean input list: `true | True | TRUE | false | False | FALSE`", name)
|
||||
}
|
||||
|
||||
func (c *Context) SetOutputs(ctx context.Context, pairs ...string) error {
|
||||
output := c.Env["GITHUB_OUTPUT"]
|
||||
if output == "" {
|
||||
return nil
|
||||
}
|
||||
var body strings.Builder
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
if strings.ContainsAny(pairs[i+1], "\r\n") {
|
||||
return fmt.Errorf("output %s spans several lines", pairs[i])
|
||||
}
|
||||
body.WriteString(pairs[i] + "=" + pairs[i+1] + "\n")
|
||||
}
|
||||
return c.Container.Copy(c.Dir(output), &container.FileEntry{Name: c.Base(output), Mode: 0o666, Body: body.String()})(ctx)
|
||||
}
|
||||
|
||||
func (c *Context) Host() bool {
|
||||
_, host := c.Container.(*container.HostEnvironment)
|
||||
return host
|
||||
}
|
||||
|
||||
func (c *Context) Windows() bool {
|
||||
return c.Host() && runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
func (c *Context) Dir(p string) string {
|
||||
if c.Windows() {
|
||||
return filepath.Dir(p)
|
||||
}
|
||||
return path.Dir(p)
|
||||
}
|
||||
|
||||
func (c *Context) Base(p string) string {
|
||||
if c.Windows() {
|
||||
return filepath.Base(p)
|
||||
}
|
||||
return path.Base(p)
|
||||
}
|
||||
|
||||
// Home is the job user's home directory, what os.homedir() returns to a JavaScript action.
|
||||
func (c *Context) Home(ctx context.Context) (string, error) {
|
||||
if home := c.Env["HOME"]; home != "" {
|
||||
return home, nil
|
||||
}
|
||||
if home := c.Env["USERPROFILE"]; c.Windows() && home != "" {
|
||||
return home, nil
|
||||
}
|
||||
if c.Host() {
|
||||
return os.UserHomeDir()
|
||||
}
|
||||
var out strings.Builder
|
||||
stdout, stderr := c.Container.ReplaceLogWriter(&out, &out)
|
||||
err := c.Container.Exec([]string{"sh", "-c", `printf %s "$HOME"`}, nil, "", "")(ctx)
|
||||
c.Container.ReplaceLogWriter(stdout, stderr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot expand ~ without HOME: %w", err)
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "", errors.New("cannot expand ~, the job user has no HOME")
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// Failf is fmt.Errorf for messages worded as the JavaScript action words them, which the error string lint rejects.
|
||||
func Failf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// ParseInt reads a leading integer the way JavaScript's parseInt does.
|
||||
func ParseInt(s string) (int, error) {
|
||||
s = strings.TrimLeftFunc(s, unicode.IsSpace)
|
||||
end := 0
|
||||
if end < len(s) && (s[end] == '-' || s[end] == '+') {
|
||||
end++
|
||||
}
|
||||
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
return strconv.Atoi(s[:end])
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// BlockSize is the toolkit's upload chunk.
|
||||
const BlockSize = 8 << 20
|
||||
|
||||
type Uploaded struct {
|
||||
Size int64
|
||||
Digest string // sha256:<hex> for Finalize
|
||||
}
|
||||
|
||||
// Upload puts body to a signed URL as parallel Azure blocks, failing when upload progress stalls for stall.
|
||||
func (c *Client) Upload(ctx context.Context, signedURL, contentType string, body io.Reader, concurrency int, stall time.Duration) (result Uploaded, err error) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer func() {
|
||||
if ctx.Err() != nil {
|
||||
err = context.Cause(ctx)
|
||||
}
|
||||
cancel(nil)
|
||||
}()
|
||||
stall = max(stall, time.Millisecond)
|
||||
timer := time.AfterFunc(stall, func() { cancel(errors.New("upload progress stalled")) })
|
||||
defer timer.Stop()
|
||||
hash := sha256.New()
|
||||
reader := io.TeeReader(body, hash)
|
||||
group, blockCtx := errgroup.WithContext(ctx)
|
||||
if closer, ok := body.(io.ReadCloser); ok {
|
||||
stop := context.AfterFunc(blockCtx, func() { closer.Close() })
|
||||
defer stop()
|
||||
}
|
||||
buffers := make(chan []byte, concurrency)
|
||||
prefix := rand.Text()
|
||||
var ids []string
|
||||
var uploaded atomic.Int64
|
||||
for index := 0; ; index++ {
|
||||
var buf []byte
|
||||
if index < concurrency {
|
||||
buf = make([]byte, BlockSize)
|
||||
} else {
|
||||
select {
|
||||
case buf = <-buffers:
|
||||
case <-blockCtx.Done():
|
||||
return Uploaded{}, group.Wait()
|
||||
}
|
||||
}
|
||||
n, readErr := io.ReadFull(reader, buf)
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
} else if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
||||
if blockCtx.Err() == nil {
|
||||
cancel(readErr)
|
||||
}
|
||||
if err := group.Wait(); err != nil {
|
||||
return Uploaded{}, err
|
||||
}
|
||||
return Uploaded{}, readErr
|
||||
}
|
||||
id := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s-%08d", prefix, index)) // fixed width as Azure needs, unique as Gitea before 1.25.5 shares a run's blocks
|
||||
ids = append(ids, id)
|
||||
group.Go(func() error {
|
||||
defer func() { buffers <- buf }()
|
||||
if err := c.put(blockCtx, signedURL+"&comp=block&blockid="+url.QueryEscape(id), "x-ms-blob-type", "BlockBlob", buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
timer.Reset(stall)
|
||||
c.Logf("Uploaded bytes %d", uploaded.Add(int64(n)))
|
||||
return nil
|
||||
})
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := group.Wait(); err != nil {
|
||||
return Uploaded{}, err
|
||||
}
|
||||
var list strings.Builder
|
||||
list.WriteString("<BlockList>")
|
||||
for _, id := range ids {
|
||||
list.WriteString("<Latest>" + id + "</Latest>")
|
||||
}
|
||||
list.WriteString("</BlockList>")
|
||||
if err := c.put(ctx, signedURL+"&comp=blocklist", "x-ms-blob-content-type", contentType, []byte(list.String())); err != nil {
|
||||
return Uploaded{}, err
|
||||
}
|
||||
return Uploaded{Size: uploaded.Load(), Digest: "sha256:" + hex.EncodeToString(hash.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
func (c *Client) put(ctx context.Context, signedURL, header, value string, body []byte) error {
|
||||
resp, err := c.Retry.do(ctx, c.Logf, "blob upload", func() (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(header, value)
|
||||
resp, err := c.blob.Do(req)
|
||||
return resp, scrub(err)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// Download GETs a signed URL, a read failing when nothing arrives for stall.
|
||||
func (c *Client) Download(ctx context.Context, signedURL string, stall time.Duration) (*http.Response, error) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, signedURL, nil)
|
||||
if err != nil {
|
||||
cancel(nil)
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.blob.Do(req)
|
||||
if err != nil {
|
||||
cancel(nil)
|
||||
return nil, scrub(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
cancel(nil)
|
||||
return nil, fmt.Errorf("unexpected HTTP response from blob storage: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
}
|
||||
resp.Body = &stallReader{body: resp.Body, timer: time.AfterFunc(stall, func() {
|
||||
cancel(fmt.Errorf("blob storage chunk did not respond in %s", stall))
|
||||
}), stall: stall, cancel: cancel, ctx: ctx}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func scrub(err error) error { // the signature of a signed URL is a credential
|
||||
if urlErr, ok := errors.AsType[*url.Error](err); ok {
|
||||
if parsed, parseErr := url.Parse(urlErr.URL); parseErr == nil {
|
||||
parsed.RawQuery = ""
|
||||
urlErr.URL = parsed.String()
|
||||
} else {
|
||||
urlErr.URL = "<unparsable URL>"
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type stallReader struct {
|
||||
body io.ReadCloser
|
||||
timer *time.Timer
|
||||
stall time.Duration
|
||||
cancel context.CancelCauseFunc
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (r *stallReader) Read(p []byte) (int, error) {
|
||||
n, err := r.body.Read(p)
|
||||
if n > 0 {
|
||||
r.timer.Reset(r.stall)
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) && context.Cause(r.ctx) != nil {
|
||||
err = context.Cause(r.ctx)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *stallReader) Close() error {
|
||||
r.timer.Stop()
|
||||
r.cancel(nil)
|
||||
return r.body.Close()
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package artifact talks to the v4 results service of the job's run.
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Artifact struct {
|
||||
ID int64 `json:"databaseId,string"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size,string"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type Retry struct {
|
||||
Attempts int
|
||||
Base time.Duration
|
||||
Multiplier float64
|
||||
MaxWait time.Duration
|
||||
}
|
||||
|
||||
var DefaultRetry = Retry{Attempts: 5, Base: 8 * time.Second, Multiplier: 1.5, MaxWait: 2 * time.Minute}
|
||||
|
||||
type Client struct {
|
||||
base *url.URL
|
||||
token string
|
||||
ids backendIDsMessage
|
||||
api, blob *http.Client
|
||||
Retry Retry
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// New reaches base through transport, which carries the instance's TLS settings, and signed URLs elsewhere directly.
|
||||
func New(base, token string, transport http.RoundTripper) (*Client, error) {
|
||||
if base == "" {
|
||||
return nil, errors.New("no artifact service is configured for this runner")
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return nil, errors.New("ACTIONS_RUNTIME_TOKEN is not set")
|
||||
}
|
||||
ids, err := backendIDs(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{
|
||||
base: parsed, token: token, ids: ids,
|
||||
api: &http.Client{Transport: transport, Timeout: 3 * time.Minute},
|
||||
blob: &http.Client{Transport: blobTransport{instance: parsed.Host, transport: transport}},
|
||||
Retry: DefaultRetry,
|
||||
Logf: func(string, ...any) {},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// backendIDs reads the unverified scp claim of a runtime token, as the toolkit does.
|
||||
func backendIDs(token string) (backendIDsMessage, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return backendIDsMessage{}, errors.New("the runtime token is not a JWT, artifacts v4 need Gitea 1.22 or later")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||
if err != nil {
|
||||
return backendIDsMessage{}, err
|
||||
}
|
||||
var claims struct {
|
||||
Scp string `json:"scp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return backendIDsMessage{}, err
|
||||
}
|
||||
for scope := range strings.FieldsSeq(claims.Scp) {
|
||||
if fields := strings.Split(scope, ":"); fields[0] == "Actions.Results" && len(fields) == 3 {
|
||||
return backendIDsMessage{fields[1], fields[2]}, nil
|
||||
}
|
||||
}
|
||||
return backendIDsMessage{}, errors.New("the runtime token has no Actions.Results scope")
|
||||
}
|
||||
|
||||
type blobTransport struct { // the instance's TLS settings only for the instance, as a signed URL can point at object storage
|
||||
instance string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t blobTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == t.instance && t.transport != nil {
|
||||
return t.transport.RoundTrip(req)
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
type StatusError struct {
|
||||
Status int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
if e.Msg == "" {
|
||||
return fmt.Sprintf("(%d) %s", e.Status, http.StatusText(e.Status))
|
||||
}
|
||||
return fmt.Sprintf("(%d) %s: %s", e.Status, http.StatusText(e.Status), e.Msg)
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
var status *StatusError
|
||||
return errors.As(err, &status) && status.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
func (r Retry) do(ctx context.Context, logf func(string, ...any), what string, send func() (*http.Response, error)) (*http.Response, error) {
|
||||
var waited time.Duration
|
||||
for attempt := 0; ; attempt++ {
|
||||
resp, err := send()
|
||||
var wait time.Duration
|
||||
if err == nil {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
return resp, nil
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
err = &StatusError{Status: resp.StatusCode, Msg: twirpMessage(body)}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && resp.StatusCode == http.StatusTooManyRequests {
|
||||
wait = time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if attempt+1 >= r.Attempts {
|
||||
return nil, fmt.Errorf("%s failed after %d attempts: %w", what, r.Attempts, err)
|
||||
}
|
||||
if wait == 0 {
|
||||
wait = time.Duration(float64(r.Base) * math.Pow(r.Multiplier, float64(attempt)))
|
||||
}
|
||||
if waited+wait > r.MaxWait {
|
||||
return nil, fmt.Errorf("%s: a retry wait of %s would exceed %s: %w", what, wait, r.MaxWait, err)
|
||||
}
|
||||
logf("Attempt %d of %d failed with error: %v. Retrying request in %d ms...", attempt+1, r.Attempts, err, wait.Milliseconds())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
waited += wait
|
||||
}
|
||||
}
|
||||
|
||||
// twirpMessage is the msg of a twirp error, the message of a Gitea API one, or the body Gitea answers in plain text.
|
||||
func twirpMessage(body []byte) string {
|
||||
var twirpErr struct {
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(body, &twirpErr) == nil && cmp.Or(twirpErr.Msg, twirpErr.Message) != "" {
|
||||
return cmp.Or(twirpErr.Msg, twirpErr.Message)
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
type ( // the ArtifactService messages in the protobuf JSON mapping, requests with the proto names the toolkit sends
|
||||
backendIDsMessage struct {
|
||||
WorkflowRunBackendID string `json:"workflow_run_backend_id"`
|
||||
WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"`
|
||||
}
|
||||
namedRequest struct {
|
||||
backendIDsMessage
|
||||
Name string `json:"name"`
|
||||
}
|
||||
createRequest struct {
|
||||
namedRequest
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Version int32 `json:"version"`
|
||||
MimeType string `json:"mime_type,omitempty"` // Gitea before 1.26 rejects the field
|
||||
}
|
||||
finalizeRequest struct {
|
||||
namedRequest
|
||||
Size int64 `json:"size,string"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
listRequest struct {
|
||||
backendIDsMessage
|
||||
NameFilter string `json:"name_filter,omitempty"`
|
||||
}
|
||||
idResponse struct {
|
||||
ArtifactID int64 `json:"artifactId,string"`
|
||||
}
|
||||
)
|
||||
|
||||
func call[Resp any](ctx context.Context, c *Client, method string, req any) (Resp, error) {
|
||||
var resp Resp
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
endpoint := c.base.JoinPath("/twirp/github.actions.results.api.v1.ArtifactService", method).String()
|
||||
httpResp, err := c.Retry.do(ctx, c.Logf, method, func() (*http.Response, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||
return c.api.Do(httpReq)
|
||||
})
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("failed to %s: %w", method, err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
err = json.UnmarshalRead(httpResp.Body, &resp)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (c *Client) Create(ctx context.Context, name, mimeType string, expiresAt *time.Time) (string, error) {
|
||||
resp, err := call[struct {
|
||||
SignedUploadURL string `json:"signedUploadUrl"`
|
||||
}](ctx, c, "CreateArtifact", createRequest{namedRequest{c.ids, name}, expiresAt, 7, mimeType})
|
||||
return resp.SignedUploadURL, err
|
||||
}
|
||||
|
||||
func (c *Client) Finalize(ctx context.Context, name string, size int64, digest string) (int64, error) {
|
||||
resp, err := call[idResponse](ctx, c, "FinalizeArtifact", finalizeRequest{namedRequest{c.ids, name}, size, digest})
|
||||
return resp.ArtifactID, err
|
||||
}
|
||||
|
||||
func (c *Client) List(ctx context.Context, name string) ([]Artifact, error) {
|
||||
resp, err := call[struct {
|
||||
Artifacts []Artifact `json:"artifacts"`
|
||||
}](ctx, c, "ListArtifacts", listRequest{c.ids, name})
|
||||
return resp.Artifacts, err
|
||||
}
|
||||
|
||||
func (c *Client) SignedURL(ctx context.Context, name string) (string, error) {
|
||||
resp, err := call[struct {
|
||||
SignedURL string `json:"signedUrl"`
|
||||
}](ctx, c, "GetSignedArtifactURL", namedRequest{c.ids, name})
|
||||
return resp.SignedURL, err
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, name string) (int64, error) {
|
||||
resp, err := call[idResponse](ctx, c, "DeleteArtifact", namedRequest{c.ids, name})
|
||||
return resp.ArtifactID, err
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifacts"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientRoundTrip(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
artifacts.RegisterV4(router, t.TempDir())
|
||||
var blocks atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Query().Get("comp") == "block" {
|
||||
blocks.Add(1)
|
||||
}
|
||||
router.ServeHTTP(writer, request)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, artifacts.LocalRuntimeToken("7", time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
for _, content := range [][]byte{nil, bytes.Repeat([]byte("payload"), 2*BlockSize/7+123)} {
|
||||
sum := sha256.Sum256(content)
|
||||
uploadURL, err := client.Create(t.Context(), "art", "text/plain", nil)
|
||||
require.NoError(t, err)
|
||||
uploaded, err := client.Upload(t.Context(), uploadURL, "text/plain", bytes.NewReader(content), 2, time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32((len(content)+BlockSize-1)/BlockSize), blocks.Swap(0))
|
||||
assert.Equal(t, Uploaded{Size: int64(len(content)), Digest: "sha256:" + hex.EncodeToString(sum[:])}, uploaded)
|
||||
id, err := client.Finalize(t.Context(), "art", uploaded.Size, uploaded.Digest)
|
||||
require.NoError(t, err)
|
||||
listed, err := client.List(t.Context(), "art")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []Artifact{{ID: id, Name: "art", Size: int64(len(content)), Digest: uploaded.Digest}}, listed)
|
||||
signed, err := client.SignedURL(t.Context(), "art")
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Download(t.Context(), signed, time.Minute)
|
||||
require.NoError(t, err)
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.True(t, bytes.Equal(content, got))
|
||||
deleted, err := client.Delete(t.Context(), "art")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, deleted)
|
||||
_, err = client.SignedURL(t.Context(), "art")
|
||||
assert.True(t, IsNotFound(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetries(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
var blockIDs []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
blockIDs = append(blockIDs, r.URL.Query().Get("blockid"))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
assert.NotContains(t, string(body), "mime_type", "older Gitea rejects the field")
|
||||
assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ey"), "the runtime token is the bearer")
|
||||
switch calls.Add(1) {
|
||||
case 1:
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
case 2:
|
||||
http.Error(w, "Error create or get artifact", http.StatusInternalServerError)
|
||||
case 3:
|
||||
w.WriteHeader(http.StatusFound)
|
||||
_, _ = io.WriteString(w, `{"ok":true,"signedUploadUrl":"http://blob/u?sig=x"}`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"code":"invalid_argument","msg":"bad name"}`)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, artifacts.LocalRuntimeToken("7", time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
client.Retry = Retry{Attempts: 3}
|
||||
var logged []string
|
||||
client.Logf = func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
|
||||
|
||||
uploadURL, err := client.Create(t.Context(), "art", "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://blob/u?sig=x", uploadURL)
|
||||
assert.Contains(t, logged[1], "Error create or get artifact")
|
||||
|
||||
_, err = client.Create(t.Context(), "art", "", nil)
|
||||
require.ErrorContains(t, err, "(400) Bad Request: bad name")
|
||||
assert.Equal(t, int32(4), calls.Load(), "a 400 is not retried")
|
||||
|
||||
for range 2 {
|
||||
_, err = client.Upload(t.Context(), server.URL+"/u?sig=secret", "application/zip", strings.NewReader("x"), 1, time.Minute)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.NotEqual(t, blockIDs[0], blockIDs[2], "each upload names its blocks anew")
|
||||
server.Close()
|
||||
_, err = client.Upload(t.Context(), server.URL+"/u?sig=secret", "application/zip", strings.NewReader("x"), 1, time.Minute)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error()+strings.Join(logged, ""), "secret")
|
||||
}
|
||||
|
||||
func TestTransferStalls(t *testing.T) {
|
||||
for _, test := range []struct{ name, want string }{
|
||||
{"api", "Client.Timeout"},
|
||||
{"failed upload", "(400) Bad Request"},
|
||||
{"source", "upload progress stalled"},
|
||||
{"commit", "upload progress stalled"},
|
||||
{"download", "did not respond"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
if test.name == "failed upload" {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if test.name == "download" {
|
||||
_, _ = io.WriteString(writer, "partial")
|
||||
writer.(http.Flusher).Flush()
|
||||
}
|
||||
<-request.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, artifacts.LocalRuntimeToken("7", time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
client.Retry = Retry{Attempts: 1}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
switch test.name {
|
||||
case "api":
|
||||
assert.Equal(t, 3*time.Minute, client.api.Timeout)
|
||||
client.api.Timeout = time.Millisecond
|
||||
_, err = client.Create(ctx, "art", "", nil)
|
||||
case "download":
|
||||
var resp *http.Response
|
||||
resp, err = client.Download(ctx, server.URL, time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
case "commit":
|
||||
_, err = client.Upload(ctx, server.URL+"?sig=x", "text/plain", strings.NewReader(""), 1, time.Millisecond)
|
||||
default:
|
||||
reader, writer := io.Pipe()
|
||||
defer reader.Close()
|
||||
defer writer.Close()
|
||||
done := make(chan error, 1)
|
||||
stall := time.Millisecond
|
||||
if test.name == "failed upload" {
|
||||
stall = time.Minute
|
||||
go func() {
|
||||
_, err := writer.Write(make([]byte, BlockSize))
|
||||
done <- err
|
||||
}()
|
||||
}
|
||||
_, err = client.Upload(ctx, server.URL+"?sig=x", "text/plain", reader, 2, stall)
|
||||
if test.name == "failed upload" {
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
}
|
||||
require.ErrorContains(t, err, test.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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