Files
runner/e2e/cli_test.go
T
d77e216674 fix(exec): serve the v4 artifact API so upload-artifact v4 and later work (#1341)
`exec` forwarded the toolkit's ArtifactService calls to an instance it never registered, and its random runtime token held no backend IDs, so `actions/upload-artifact` and `actions/download-artifact` v4 and later failed.

The local artifact server now serves the v4 API, including the single Put Blob uploads docker/build-push-action uses. `exec` gives each workflow its own unsigned JWT runtime token and artifact run, so artifact names conflict and carry over between runs as on GitHub. Artifacts default to a temporary directory instead of the workspace that jobs copy.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1341
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-28 03:52:53 +00:00

169 lines
4.7 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
)
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
func testCLIRegisterDaemon(t *testing.T) {
t.Parallel()
ctx := t.Context()
api, repo := newScenario(t)
token, err := fixture.RegistrationToken(ctx, repo)
if err != nil {
t.Fatalf("get registration token: %v", err)
}
dir := t.TempDir()
bin, err := runnerBinary()
if err != nil {
t.Fatal(err)
}
cfgFile := filepath.Join(dir, "config.yaml")
cfg := fmt.Sprintf(`runner:
file: %s
fetch_interval: 250ms
fetch_interval_max: 250ms
state_report_interval: 500ms
log_report_interval: 500ms
cache:
dir: %s
`, filepath.Join(dir, ".runner"), filepath.Join(dir, "cache"))
if fixture.network != "" {
cfg += "container:\n network: " + fixture.network + "\n"
}
if err := os.WriteFile(cfgFile, []byte(cfg), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
label := "e2e-cli:docker://" + os.Getenv("E2E_JOB_IMAGE")
out, err := exec.CommandContext(ctx, bin, "register", "--no-interactive", "-c", cfgFile,
"--instance", fixture.baseURL, "--token", token, "--name", repo, "--labels", label).CombinedOutput()
if err != nil {
t.Fatalf("register: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(dir, ".runner")); err != nil {
t.Fatalf("register did not write the runner file: %v\n%s", err, out)
}
daemonLog := &syncBuffer{}
daemon := exec.Command(bin, "daemon", "-c", cfgFile)
daemon.Stdout, daemon.Stderr = daemonLog, daemonLog
if err := daemon.Start(); err != nil {
t.Fatalf("start daemon: %v", err)
}
exited := make(chan error, 1)
go func() { exited <- daemon.Wait() }()
t.Cleanup(func() {
_ = daemon.Process.Kill() // no-op once the daemon has exited
})
pushWorkflow(t, api, repo, "cli.yml")
wfRun := waitForRun(t, api, repo)
requireSuccessOrDumpDaemon(t, api, repo, wfRun.ID, daemonLog)
if logs := runLogs(t, api, repo, wfRun.ID); commandRow(logs, "e2e-cli-job-ran") == "" {
t.Errorf("job logs are missing the marker:\n%s", logs)
}
if err := daemon.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal daemon: %v", err)
}
select {
case err := <-exited:
if err != nil {
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, daemonLog)
}
case <-time.After(30 * time.Second):
t.Errorf("daemon did not exit within 30s of SIGTERM\n%s", daemonLog)
}
}
func requireSuccessOrDumpDaemon(t *testing.T, api *GiteaAPI, repo string, runID int64, daemonLog *syncBuffer) {
t.Helper()
completed, err := api.WaitForRunConclusion(context.WithoutCancel(t.Context()), repo, runID, runTimeout)
if err != nil || completed.Conclusion != "success" {
t.Logf("daemon output:\n%s", daemonLog)
requireSuccess(t, api, repo, runID)
}
}
func testExecArtifacts(t *testing.T) {
t.Parallel()
ctx := t.Context()
bin, err := runnerBinary()
if err != nil {
t.Fatal(err)
}
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
if err != nil {
t.Fatal(err)
}
workflows := t.TempDir()
for _, name := range []string{"a.yml", "b.yml"} {
if err := os.WriteFile(filepath.Join(workflows, name), workflow, 0o644); err != nil {
t.Fatal(err)
}
}
workdir := t.TempDir()
for _, args := range [][]string{
{"init", "-q"},
{"remote", "add", "origin", "https://example.com/e2e/exec.git"},
{"-c", "user.name=e2e", "-c", "[email protected]", "commit", "-q", "--allow-empty", "-m", "init"},
} {
if out, err := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
artifacts := t.TempDir()
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts}
if fixture.network != "" {
args = append(args, "--network", fixture.network)
}
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("exec: %v\n%s", err, out)
}
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
}
}