diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 17820e50..5149ab89 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -59,5 +59,8 @@ Each lives in `internal/pkg/action/` and is registered in `builtinActions` It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in parallel. +The `cli_register_daemon` scenario builds the binary and drives `register` and `daemon` as a user would. The +`failure_paths` scenario covers failed, skipped, tolerated and timed-out jobs. + The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated repository runners. A run path is `@` and a log row is `Z `. diff --git a/e2e/cli_test.go b/e2e/cli_test.go new file mode 100644 index 00000000..f66b9a25 --- /dev/null +++ b/e2e/cli_test.go @@ -0,0 +1,121 @@ +// 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" + "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 := filepath.Join(dir, "gitea-runner") + if out, err := exec.CommandContext(ctx, "go", "build", "-o", bin, "..").CombinedOutput(); err != nil { + t.Fatalf("build runner: %v\n%s", err, out) + } + + 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) + } +} diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 60d29ec6..9d0229b1 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -32,6 +32,8 @@ func TestCompatibility(t *testing.T) { t.Run("builtin_checkout", testBuiltinCheckout) t.Run("moving_action_ref", testMovingActionRef) t.Run("ephemeral", testEphemeralRunner) + t.Run("failure_paths", testFailurePaths) + t.Run("cli_register_daemon", testCLIRegisterDaemon) t.Run("kubernetes", testKubernetes) } diff --git a/e2e/failure_test.go b/e2e/failure_test.go new file mode 100644 index 00000000..ba33b55a --- /dev/null +++ b/e2e/failure_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "strings" + "testing" +) + +func testFailurePaths(t *testing.T) { + t.Parallel() + ctx := t.Context() + + api, repo := newScenario(t) + pushWorkflow(t, api, repo, "failure.yml") + + wfRun := waitForRun(t, api, repo) + completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, runTimeout) + if err != nil { + dumpRunLogs(t, api, repo, wfRun.ID) + t.Fatalf("wait for run: %v", err) + } + if completed.Conclusion != "failure" { + t.Errorf("run concluded %q, want failure", completed.Conclusion) + } + + jobs, err := api.Jobs(ctx, repo, wfRun.ID) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + want := map[string]string{ + "fail": "failure", + "tolerated": "success", + "dependent": "skipped", + "timeout": "failure", + } + for _, job := range jobs { + if w, ok := want[job.Name]; ok && job.Conclusion != w { + t.Errorf("job %q concluded %q, want %q", job.Name, job.Conclusion, w) + } + delete(want, job.Name) + } + for name := range want { + t.Errorf("job %q not found", name) + } + + var all strings.Builder + for _, job := range jobs { + if job.Conclusion == "skipped" { // a skipped job never started, so it has no log + continue + } + jobLogs, err := api.JobLogs(ctx, repo, job.ID) + if err != nil { + t.Fatalf("job logs for %s: %v", job.Name, err) + } + all.WriteString(jobLogs) + } + logs := all.String() + for _, marker := range []string{"e2e-before-fail", "e2e-failure-cleanup", "e2e-tolerated-next", "e2e-timeout-started"} { + if commandRow(logs, marker) != marker { + t.Errorf("logs are missing %q", marker) + } + } + if commandRow(logs, "e2e-skipped-step") != "" { + t.Error("a success() step ran after a failed step") + } + if t.Failed() { + t.Logf("full stored logs:\n%s", logs) + } +} diff --git a/e2e/testdata/workflows/cli.yml b/e2e/testdata/workflows/cli.yml new file mode 100644 index 00000000..a64c5349 --- /dev/null +++ b/e2e/testdata/workflows/cli.yml @@ -0,0 +1,7 @@ +name: cli +on: push +jobs: + hello: + runs-on: e2e-cli + steps: + - run: echo e2e-cli-job-ran diff --git a/e2e/testdata/workflows/failure.yml b/e2e/testdata/workflows/failure.yml new file mode 100644 index 00000000..7d9516f4 --- /dev/null +++ b/e2e/testdata/workflows/failure.yml @@ -0,0 +1,30 @@ +name: failure +on: push +jobs: + fail: + runs-on: ubuntu-latest + steps: + - run: echo e2e-before-fail + - run: exit 3 + - if: success() + run: echo e2e-skipped-step + - if: failure() + run: echo e2e-failure-cleanup + tolerated: + runs-on: ubuntu-latest + steps: + - continue-on-error: true + run: exit 1 + - run: echo e2e-tolerated-next + dependent: + needs: fail + runs-on: ubuntu-latest + steps: + - run: echo dependent + timeout: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - run: | + echo e2e-timeout-started + sleep 300