From 8260e2def29b1ff7b5e210e461d5ea6714410188 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 23 Aug 2026 12:32:22 +0000 Subject: [PATCH] test: add end-to-end Gitea compatibility suite (#1180) Adds a real-Gitea compatibility suite against stable and nightly to catch runner and API drift before release. The suite shares one Gitea and regular runner, using repository runners only for cache v1/v2 and ephemeral behavior. It covers registration, payload and log encoding, secrets, variables, services, artifacts, outputs, matrices, cache, dispatch, live logs, cancellation, and ephemeral teardown. CI runs both images in parallel. Warm local timings: | Image | Before | After | | --- | ---: | ---: | | `gitea/gitea:latest` | ~70s | 26.54s | | `gitea/gitea:main-nightly` | ~54s | 22.53s | The former serial matrix took about 2 minutes. Parallel suite execution is now bounded by the slower ~26.5-second variant. Shared Renovate matcher: https://gitea.com/gitea/renovate-config/pulls/552 --------- Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/runner/pulls/1180 Reviewed-by: Lunny Xiao Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni --- .gitea/workflows/test.yml | 47 ++- DEVELOPMENT.md | 9 + Makefile | 9 + act/runner/runner_test.go | 68 +--- act/runner/testdata/checkout/push.yml | 8 - .../testdata/mask-values/composite/action.yml | 12 - act/runner/testdata/mask-values/push.yml | 12 - e2e/cache_test.go | 30 ++ e2e/cancel_test.go | 74 ++++ e2e/compatibility_test.go | 99 +++++ e2e/dispatch_test.go | 54 +++ e2e/doc.go | 8 + e2e/ephemeral_test.go | 28 ++ e2e/gitea_api.go | 217 +++++++++++ e2e/gitea_fixture.go | 365 ++++++++++++++++++ e2e/main_test.go | 162 ++++++++ e2e/runner_harness.go | 120 ++++++ e2e/testdata/workflows/cache.yml | 26 ++ e2e/testdata/workflows/cancel.yml | 9 + e2e/testdata/workflows/dispatch.yml | 11 + e2e/testdata/workflows/ephemeral.yml | 7 + e2e/testdata/workflows/payloads.yml | 53 +++ internal/app/poll/poller.go | 8 +- internal/app/poll/poller_test.go | 41 +- tools/test-e2e.sh | 13 + 25 files changed, 1370 insertions(+), 120 deletions(-) delete mode 100644 act/runner/testdata/checkout/push.yml delete mode 100644 act/runner/testdata/mask-values/composite/action.yml delete mode 100644 act/runner/testdata/mask-values/push.yml create mode 100644 e2e/cache_test.go create mode 100644 e2e/cancel_test.go create mode 100644 e2e/compatibility_test.go create mode 100644 e2e/dispatch_test.go create mode 100644 e2e/doc.go create mode 100644 e2e/ephemeral_test.go create mode 100644 e2e/gitea_api.go create mode 100644 e2e/gitea_fixture.go create mode 100644 e2e/main_test.go create mode 100644 e2e/runner_harness.go create mode 100644 e2e/testdata/workflows/cache.yml create mode 100644 e2e/testdata/workflows/cancel.yml create mode 100644 e2e/testdata/workflows/dispatch.yml create mode 100644 e2e/testdata/workflows/ephemeral.yml create mode 100644 e2e/testdata/workflows/payloads.yml create mode 100755 tools/test-e2e.sh diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f98652e5..41aa9fcc 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -5,17 +5,18 @@ on: - main pull_request: +env: + # The runner image ships a stale docker.io login; point docker at an empty config so + # image pulls go straight to anonymous instead of attempting (and failing) that auth + # first. The path must be a literal: the `runner` context is unavailable in workflow-level + # env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back + # to ~/.docker with the stale credentials. + DOCKER_CONFIG: /tmp/docker-noauth + jobs: lint: name: check and test runs-on: ubuntu-latest - env: - # The runner image ships a stale docker.io login; point docker at an empty config so - # image pulls go straight to anonymous instead of attempting (and failing) that auth - # first. The path must be a literal: the `runner` context is unavailable in job-level - # env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back - # to ~/.docker with the stale credentials. - DOCKER_CONFIG: /tmp/docker-noauth steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 @@ -28,9 +29,16 @@ jobs: # the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host # daemon retains them between runs, so this is usually a fast manifest re-check. - name: pre-pull test images + env: + TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker + TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker run: | - for img in node:24-bookworm-slim nginx:alpine; do - for try in 1 2 3; do docker pull "$img" && break || sleep 5; done + for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do + for attempt in 1 2 3; do + docker pull "$image" && break + [ "$attempt" = 3 ] || sleep 5 + done + docker tag "$image" "${image%@*}" done - name: lint run: make lint @@ -48,4 +56,23 @@ jobs: - name: coverage report run: | make coverage-report - cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file + cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY" + + e2e: + name: gitea compatibility (${{ matrix.gitea_image }}) + runs-on: ubuntu-latest + strategy: + matrix: + gitea_image: + - gitea/gitea:latest + - gitea/gitea:main-nightly + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: 'go.mod' + check-latest: true + - name: prepare anonymous docker config + run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json" + - name: e2e compatibility + run: make test-e2e E2E_GITEA_IMAGE=${{ matrix.gitea_image }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0413bd58..615adb22 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,3 +30,12 @@ depending on the prefix: encoded forms too. - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter decodes exactly those two when folding a location into an annotation. + +## End-to-end compatibility tests + +`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image. +It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in +parallel. + +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/Makefile b/Makefile index 10b83389..8caa2bca 100644 --- a/Makefile +++ b/Makefile @@ -171,6 +171,15 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) @./scripts/test-dind.sh $(TARGET) +E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker +SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker +E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly +E2E_CONCURRENCY ?= 8 + +.PHONY: test-e2e +test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE + @E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh + .PHONY: install install: $(GOFILES) ## install the runner binary via `go install` $(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 1f33a032..02b71d6a 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -8,13 +8,10 @@ import ( "bytes" "context" "fmt" - "io" "os" "path/filepath" "runtime" - "slices" "strings" - "sync" "testing" "time" @@ -191,9 +188,6 @@ func TestGraphEvent(t *testing.T) { } } -// these two build the same action Dockerfiles into one image tag, so they cannot overlap -var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"} - // bounds concurrent plans: each job holds a network, and the daemon's address pool is finite var planSlots = make(chan struct{}, 4) @@ -244,11 +238,18 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config plan, err := planner.PlanEvent(j.eventName) assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act if err == nil && plan != nil { - err = func() error { + usesDocker := false + for _, platform := range j.platforms { + if platform != "-self-hosted" { + usesDocker = true + break + } + } + if usesDocker && !common.Dryrun(ctx) { planSlots <- struct{}{} defer func() { <-planSlots }() - return runner.NewPlanExecutor(plan)(ctx) - }() + } + err = runner.NewPlanExecutor(plan)(ctx) if j.errorMessage == "" { assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act } else { @@ -292,7 +293,6 @@ func TestRunEvent(t *testing.T) { {workdir, "basic", "push", "", platforms, secrets}, {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, - {workdir, "checkout", "push", "", platforms, secrets}, {workdir, "job-container", "push", "", platforms, secrets}, {workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets}, {workdir, "container-hostname", "push", "", platforms, secrets}, @@ -335,7 +335,6 @@ func TestRunEvent(t *testing.T) { {workdir, "services-empty-image", "push", "", platforms, secrets}, } - var sharedImageMu sync.Mutex for _, table := range tables { t.Run(table.workflowPath, func(t *testing.T) { if table.workflowPath == "container-volumes" { @@ -343,13 +342,10 @@ func TestRunEvent(t *testing.T) { requireLinuxDocker(t) } t.Parallel() - if slices.Contains(sharedImageWorkflows, table.workflowPath) { - sharedImageMu.Lock() - defer sharedImageMu.Unlock() - } config := &Config{ Secrets: table.secrets, + Env: map[string]string{"GITHUB_REPOSITORY": t.Name()}, } eventFile := filepath.Join(workdir, table.workflowPath, "event.json") @@ -401,7 +397,6 @@ func TestRunEventHostEnvironment(t *testing.T) { {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, - {workdir, "checkout", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets}, {workdir, "defaults-run", "push", "", platforms, secrets}, @@ -498,47 +493,6 @@ func TestReusableWorkflowCaller(t *testing.T) { table.runTest(context.Background(), t, &Config{Secrets: table.secrets}) } -type maskJobLoggerFactory struct { - Output bytes.Buffer -} - -func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger { - logger := log.New() - logger.SetOutput(io.MultiWriter(&f.Output, os.Stdout)) - logger.SetLevel(log.DebugLevel) - return logger -} - -func TestMaskValues(t *testing.T) { - t.Parallel() - assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act - found := strings.Contains(text, "composite secret") - if found { - fmt.Printf("\nFound Secret in the given text:\n%s\n", text) //nolint:forbidigo // pre-existing issue from nektos/act - } - assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act - } - - requireDocker(t) - - log.SetLevel(log.DebugLevel) - - tjfi := TestJobFileInfo{ - workdir: workdir, - workflowPath: "mask-values", - eventName: "push", - errorMessage: "", - platforms: platforms, - } - - logger := &maskJobLoggerFactory{} - tjfi.runTest(WithJobLoggerFactory(common.WithLogger(context.Background(), logger.WithJobLogger()), logger), t, &Config{}) - output := logger.Output.String() - - assertNoSecret(output, "secret value") - assertNoSecret(output, "YWJjCg==") -} - func TestRunEventSecrets(t *testing.T) { requireDocker(t) t.Parallel() diff --git a/act/runner/testdata/checkout/push.yml b/act/runner/testdata/checkout/push.yml deleted file mode 100644 index c18eaa1a..00000000 --- a/act/runner/testdata/checkout/push.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: checkout -on: push - -jobs: - checkout: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 diff --git a/act/runner/testdata/mask-values/composite/action.yml b/act/runner/testdata/mask-values/composite/action.yml deleted file mode 100644 index 08a1b79d..00000000 --- a/act/runner/testdata/mask-values/composite/action.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: composite -description: composite - -runs: - using: composite - steps: - - run: echo "secret value" - shell: bash - - run: echo "::add-mask::$(echo "abc" | base64)" - shell: bash - - run: echo "abc" | base64 - shell: bash diff --git a/act/runner/testdata/mask-values/push.yml b/act/runner/testdata/mask-values/push.yml deleted file mode 100644 index befe73fb..00000000 --- a/act/runner/testdata/mask-values/push.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: mask-values -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - run: echo "::add-mask::secret value" - - run: echo "secret value" - - uses: ./mask-values/composite - - run: echo "YWJjCg==" diff --git a/e2e/cache_test.go b/e2e/cache_test.go new file mode 100644 index 00000000..83e3f20e --- /dev/null +++ b/e2e/cache_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "testing" +) + +func testActionsCacheRoundTrip(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + v2 bool + }{ + {name: "cache_v2", v2: true}, + {name: "cache_v1", v2: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + api, repo, _ := startIsolatedScenario(t, "cache.yml", "e2e-cache", runnerOptions{cacheV2: &tc.v2}) + + wfRun := waitForRun(t, api, repo) + requireSuccess(t, api, repo, wfRun.ID) + }) + } +} diff --git a/e2e/cancel_test.go b/e2e/cancel_test.go new file mode 100644 index 00000000..24416e53 --- /dev/null +++ b/e2e/cancel_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "context" + "errors" + "net/http" + "testing" + "time" +) + +func testRunCancellation(t *testing.T) { + t.Parallel() + ctx := t.Context() + + api, repo := newScenario(t) + err := api.CancelRun(ctx, repo, 0) + cancelSupported := !errors.Is(err, ErrCancelUnsupported) + var response statusError + if cancelSupported && (!errors.As(err, &response) || response.code != http.StatusNotFound) { + t.Fatalf("probe run cancellation: %v", err) + } + + pushWorkflow(t, api, repo, "cancel.yml") + + wfRun := waitForRun(t, api, repo) + waitForRunningJobLog(t, api, repo, wfRun.ID, "e2e-live-log-marker") + if !cancelSupported { + requireSuccess(t, api, repo, wfRun.ID) + return + } + + if err := api.CancelRun(ctx, repo, wfRun.ID); err != nil { + dumpRunLogs(t, api, repo, wfRun.ID) + t.Fatalf("cancel run: %v", err) + } + completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, time.Minute) + if err != nil { + dumpRunLogs(t, api, repo, wfRun.ID) + t.Fatalf("cancelled run did not finish promptly: %v", err) + } + if completed.Conclusion != "cancelled" { + dumpRunLogs(t, api, repo, wfRun.ID) + t.Fatalf("cancelled run concluded %q, want cancelled", completed.Conclusion) + } +} + +func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + + for { + jobs, err := api.Jobs(ctx, repo, runID) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + if len(jobs) > 0 && jobs[0].Status == "in_progress" { + logs, err := api.JobLogs(ctx, repo, jobs[0].ID) + if err == nil && commandRow(logs, substr) == substr { + return + } + } + select { + case <-ctx.Done(): + t.Fatalf("running job for run %d never logged %q", runID, substr) + case <-time.After(pollInterval): + } + } +} diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go new file mode 100644 index 00000000..b21c7d38 --- /dev/null +++ b/e2e/compatibility_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "os" + "strings" + "testing" +) + +func TestCompatibility(t *testing.T) { + if skipReason != "" { + t.Skip(skipReason) + } + + sharedPoller := startRunner(t, "", "ubuntu-latest", runnerOptions{capacity: 16}) + t.Cleanup(func() { + select { + case <-sharedPoller.Done(): + t.Error("shared runner stopped") + default: + } + }) + + t.Run("payloads", testPayloads) + t.Run("cache", testActionsCacheRoundTrip) + t.Run("cancellation_and_log_streaming", testRunCancellation) + t.Run("dispatch", testWorkflowDispatch) + t.Run("ephemeral", testEphemeralRunner) +} + +func testPayloads(t *testing.T) { + t.Parallel() + ctx := t.Context() + const secretValue = "s3cr3t-value-xyz" + + api, repo := newScenario(t) + if err := api.CreateSecret(ctx, repo, "FOO", secretValue); err != nil { + t.Fatalf("create secret: %v", err) + } + if err := api.CreateVariable(ctx, repo, "GREETING", "hello-from-a-variable"); err != nil { + t.Fatalf("create variable: %v", err) + } + if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", os.Getenv("SERVICE_IMAGE")); err != nil { + t.Fatalf("create service image variable: %v", err) + } + pushWorkflow(t, api, repo, "payloads.yml") + + wfRun := waitForRun(t, api, repo) + requireSuccess(t, api, repo, wfRun.ID) + + logs := runLogs(t, api, repo, wfRun.ID) + for _, want := range []string{ + "hello-from-a-variable", + "plain-100%-done;-[bracket]", + "multiline-first", + "multiline-second", + "notice-payload-here", + "warning-payload-here", + "error-payload-here", + "group-payload-here", + "inside-the-group", + "received=produced-value-42", + "cell-a-1", + "cell-a-2", + "cell-b-1", + "cell-b-2", + } { + if !strings.Contains(logs, want) { + t.Errorf("stored logs are missing %q", want) + } + } + if forwarded := commandRow(logs, "::notice::encoded-first"); forwarded != "::notice::encoded-first%0Aencoded-second" { + t.Errorf("forwarded command row is %q, want its payload passed through unchanged", forwarded) + } + if !strings.Contains(logs, "plain-100%25-done") { + t.Error("the emitted command did not escape %") + } + if strings.Contains(logs, secretValue) || !strings.Contains(logs, "***") { + t.Error("job logs did not mask the secret") + } + + if t.Failed() { + t.Logf("full stored logs:\n%s", logs) + } +} + +func commandRow(logs, prefix string) string { + for line := range strings.SplitSeq(logs, "\n") { + _, payload, found := strings.Cut(line, "Z ") + if found && strings.HasPrefix(strings.TrimRight(payload, "\r"), prefix) { + return strings.TrimRight(payload, "\r") + } + } + return "" +} diff --git a/e2e/dispatch_test.go b/e2e/dispatch_test.go new file mode 100644 index 00000000..700a1819 --- /dev/null +++ b/e2e/dispatch_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "context" + "testing" + "time" +) + +func testWorkflowDispatch(t *testing.T) { + t.Parallel() + ctx := t.Context() + + api, repo := newScenario(t) + pushWorkflow(t, api, repo, "dispatch.yml") + + branch, err := api.DefaultBranch(ctx, repo) + if err != nil { + t.Fatalf("get default branch: %v", err) + } + inputs := map[string]string{"subject": "dispatch-input-value"} + if err := waitForDispatch(ctx, api, repo, branch, inputs); err != nil { + t.Fatalf("dispatch workflow: %v", err) + } + + dispatched := waitForRun(t, api, repo) + if dispatched.Event != "workflow_dispatch" { + t.Fatalf("run event is %q, want workflow_dispatch", dispatched.Event) + } + requireSuccess(t, api, repo, dispatched.ID) +} + +// Gitea indexes workflows asynchronously after the Contents API commit. +func waitForDispatch(ctx context.Context, api *GiteaAPI, repo, branch string, inputs map[string]string) error { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + var lastErr error + for { + lastErr = api.DispatchWorkflow(ctx, repo, "dispatch.yml", branch, inputs) + if lastErr == nil { + return nil + } + select { + case <-ctx.Done(): + return lastErr + case <-time.After(pollInterval): + } + } +} diff --git a/e2e/doc.go b/e2e/doc.go new file mode 100644 index 00000000..3ca4e113 --- /dev/null +++ b/e2e/doc.go @@ -0,0 +1,8 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +// Package e2e tests the runner against a real gitea/gitea container (build tag e2e). +// Run with `make test-e2e`; see DEVELOPMENT.md. +package e2e diff --git a/e2e/ephemeral_test.go b/e2e/ephemeral_test.go new file mode 100644 index 00000000..56e23e07 --- /dev/null +++ b/e2e/ephemeral_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "testing" + "time" +) + +func testEphemeralRunner(t *testing.T) { + t.Parallel() + api, repo, poller := startIsolatedScenario(t, "ephemeral.yml", "e2e-ephemeral", runnerOptions{ephemeral: true}) + + wfRun := waitForRun(t, api, repo) + requireSuccess(t, api, repo, wfRun.ID) + + select { + case <-poller.Done(): + case <-time.After(5 * time.Second): + t.Fatal("ephemeral runner was not deleted") + } + if !poller.Unregistered() { + t.Fatal("ephemeral runner stopped without being deleted") + } +} diff --git a/e2e/gitea_api.go b/e2e/gitea_api.go new file mode 100644 index 00000000..1f28ecde --- /dev/null +++ b/e2e/gitea_api.go @@ -0,0 +1,217 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const pollInterval = 250 * time.Millisecond + +type GiteaAPI struct { + baseURL string + token string +} + +type ActionRun struct { + ID int64 `json:"id"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` +} + +type ActionJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +func (a *GiteaAPI) CreateRepo(ctx context.Context, name string) error { + body := map[string]any{"name": name, "auto_init": true} + return a.doJSON(ctx, http.MethodPost, "/api/v1/user/repos", body, nil) +} + +func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message string) error { + body := map[string]any{ + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + } + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", giteaAdminUser, repo, path) + return a.doJSON(ctx, http.MethodPost, url, body, nil) +} + +func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error { + body := map[string]any{"data": value} + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name) + return a.doJSON(ctx, http.MethodPut, url, body, nil) +} + +func (a *GiteaAPI) DefaultBranch(ctx context.Context, repo string) (string, error) { + var resp struct { + DefaultBranch string `json:"default_branch"` + } + url := fmt.Sprintf("/api/v1/repos/%s/%s", giteaAdminUser, repo) + if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil { + return "", err + } + return resp.DefaultBranch, nil +} + +func (a *GiteaAPI) CreateVariable(ctx context.Context, repo, name, value string) error { + body := map[string]any{"value": value} + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", giteaAdminUser, repo, name) + return a.doJSON(ctx, http.MethodPost, url, body, nil) +} + +func (a *GiteaAPI) DispatchWorkflow(ctx context.Context, repo, workflowID, ref string, inputs map[string]string) error { + body := map[string]any{"ref": ref} + if len(inputs) > 0 { + body["inputs"] = inputs + } + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", giteaAdminUser, repo, workflowID) + return a.doJSON(ctx, http.MethodPost, url, body, nil) +} + +var ErrCancelUnsupported = errors.New("run cancellation is unsupported by this gitea version") + +func (a *GiteaAPI) CancelRun(ctx context.Context, repo string, runID int64) error { + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", giteaAdminUser, repo, runID) + err := a.doJSON(ctx, http.MethodPost, url, nil, nil) + var status statusError + if errors.As(err, &status) && status.routeAbsent() { + return ErrCancelUnsupported + } + return err +} + +func (a *GiteaAPI) Runs(ctx context.Context, repo string) ([]ActionRun, error) { + var resp struct { + WorkflowRuns []ActionRun `json:"workflow_runs"` + } + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?limit=1", giteaAdminUser, repo) + if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil { + return nil, err + } + return resp.WorkflowRuns, nil +} + +func (a *GiteaAPI) WaitForRunConclusion(ctx context.Context, repo string, runID int64, timeout time.Duration) (*ActionRun, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", giteaAdminUser, repo, runID) + for { + var run ActionRun + if err := a.doJSON(ctx, http.MethodGet, url, nil, &run); err != nil { + return nil, err + } + if run.Status == "completed" { + return &run, nil + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("run %d did not complete within %s (last status %q): %w", runID, timeout, run.Status, ctx.Err()) + case <-time.After(pollInterval): + } + } +} + +func (a *GiteaAPI) Jobs(ctx context.Context, repo string, runID int64) ([]ActionJob, error) { + var resp struct { + Jobs []ActionJob `json:"jobs"` + } + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", giteaAdminUser, repo, runID) + if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil { + return nil, err + } + return resp.Jobs, nil +} + +func (a *GiteaAPI) JobLogs(ctx context.Context, repo string, jobID int64) (string, error) { + url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", giteaAdminUser, repo, jobID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "token "+a.token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 300 { + return "", fmt.Errorf("GET %s: %d: %s", url, resp.StatusCode, body) + } + return string(body), nil +} + +func (a *GiteaAPI) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error { + var bodyReader io.Reader + if reqBody != nil { + encoded, err := json.Marshal(reqBody) + if err != nil { + return err + } + bodyReader = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, bodyReader) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+a.token) + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 300 { + return statusError{method: method, path: path, code: resp.StatusCode, body: string(body)} + } + if respBody != nil && len(body) > 0 { + return json.Unmarshal(body, respBody) + } + return nil +} + +type statusError struct { + method, path string + code int + body string +} + +func (e statusError) Error() string { + return fmt.Sprintf("%s %s: %d: %s", e.method, e.path, e.code, e.body) +} + +func (e statusError) routeAbsent() bool { + return e.code == http.StatusNotFound && strings.TrimSpace(e.body) == "404 page not found" +} diff --git a/e2e/gitea_fixture.go b/e2e/gitea_fixture.go new file mode 100644 index 00000000..a0f71c66 --- /dev/null +++ b/e2e/gitea_fixture.go @@ -0,0 +1,365 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "strconv" + "strings" + "time" + + "gitea.com/gitea/runner/act/container" + + apicontainer "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + mobyclient "github.com/moby/moby/client" +) + +const ( + giteaAdminUser = "e2e-admin" + giteaAdminMail = "e2e-admin@example.com" +) + +type GiteaFixture struct { + cli mobyclient.APIClient + id string + image string + version string + baseURL string + network string // set in container mode; jobs must join it + adminToken string +} + +func dockerClient(ctx context.Context) (mobyclient.APIClient, error) { + cli, err := container.GetDockerClient(ctx) + if err != nil { + return nil, err + } + if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil { + return nil, fmt.Errorf("docker daemon unreachable: %w", err) + } + return cli, nil +} + +// Prefer docker bridge gateway, then LAN IP; never 127.0.0.1 (ACTIONS_RUNTIME_URL must reach the host from job containers). +func hostAddress(ctx context.Context, cli mobyclient.APIClient) netip.Addr { + if gateway, ok := bridgeGateway(ctx, cli); ok && bindable(gateway) { + return gateway + } + + loopback := netip.AddrFrom4([4]byte{127, 0, 0, 1}) + + conn, err := net.Dial("udp", "192.0.2.1:80") // TEST-NET-1: routing table only + if err != nil { + return loopback + } + defer conn.Close() + + addr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return loopback + } + return addr.AddrPort().Addr().Unmap() +} + +func bridgeGateway(ctx context.Context, cli mobyclient.APIClient) (netip.Addr, bool) { + inspected, err := cli.NetworkInspect(ctx, "bridge", mobyclient.NetworkInspectOptions{}) + if err != nil { + return netip.Addr{}, false + } + for _, cfg := range inspected.Network.IPAM.Config { + if cfg.Gateway.Is4() { + return cfg.Gateway, true + } + } + return netip.Addr{}, false +} + +// When tests run inside a container (sibling docker daemon), join that network and address by name. +func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, bool) { + if _, err := os.Stat("/.dockerenv"); err != nil { + return "", false + } + + id, err := os.ReadFile("/proc/sys/kernel/hostname") + if err != nil { + id, err = os.ReadFile("/etc/hostname") + } + if err != nil { + return "", false + } + + inspected, err := cli.ContainerInspect(ctx, strings.TrimSpace(string(id)), mobyclient.ContainerInspectOptions{}) + if err != nil || inspected.Container.NetworkSettings == nil { + return "", false + } + for name := range inspected.Container.NetworkSettings.Networks { + if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS + return name, true + } + } + return "", false +} + +func bindable(addr netip.Addr) bool { + l, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0")) + if err != nil { + return false + } + _ = l.Close() + return true +} + +func freeHostPort(host string) (int, error) { + l, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + return 0, err + } + defer l.Close() + + addr, ok := l.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener address type %T", l.Addr()) + } + return addr.Port, nil +} + +func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) { + closeClient := true + defer func() { + if closeClient { + _ = cli.Close() + } + }() + + image := os.Getenv("E2E_GITEA_IMAGE") + if image == "" { + return nil, errors.New("E2E_GITEA_IMAGE is not set") + } + + name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano()) + containerPort := network.MustParsePort("3000/tcp") + + var ( + baseURL string + hostConfig = &apicontainer.HostConfig{} + netConfig *network.NetworkingConfig + sharedNet, _ = selfNetwork(ctx, cli) + ) + if sharedNet != "" { + baseURL = fmt.Sprintf("http://%s:3000", name) + netConfig = &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}, + } + } else { + host := hostAddress(ctx, cli) + port, err := freeHostPort(host.String()) + if err != nil { + return nil, fmt.Errorf("find a free host port: %w", err) + } + baseURL = fmt.Sprintf("http://%s:%d", host, port) + hostConfig.PortBindings = network.PortMap{ + containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}}, + } + } + + if _, err := cli.ImageInspect(ctx, image); err != nil { + return nil, fmt.Errorf("inspect gitea image %s: %w", image, err) + } + + resp, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{ + Config: &apicontainer.Config{ + Image: image, + Env: []string{ + "GITEA__security__INSTALL_LOCK=true", + "GITEA__database__DB_TYPE=sqlite3", + "GITEA__actions__ENABLED=true", + "GITEA__server__ROOT_URL=" + baseURL + "/", + }, + ExposedPorts: network.PortSet{containerPort: struct{}{}}, + }, + HostConfig: hostConfig, + NetworkingConfig: netConfig, + Name: name, + }) + if err != nil { + return nil, fmt.Errorf("create gitea container: %w", err) + } + + f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet} + closeClient = false + + if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil { + _ = f.Close(ctx) + return nil, fmt.Errorf("start gitea container: %w", err) + } + + if err := f.waitHealthy(ctx); err != nil { + _ = f.Close(ctx) + return nil, fmt.Errorf("gitea did not become healthy: %w", err) + } + + if err := f.bootstrapAdmin(ctx); err != nil { + _ = f.Close(ctx) + return nil, fmt.Errorf("bootstrap gitea admin: %w", err) + } + + if err := f.readVersion(ctx); err != nil { + _ = f.Close(ctx) + return nil, fmt.Errorf("read gitea version: %w", err) + } + + return f, nil +} + +func (f *GiteaFixture) readVersion(ctx context.Context) error { + var body struct { + Version string `json:"version"` + } + if err := f.doJSON(ctx, http.MethodGet, "/api/v1/version", nil, &body); err != nil { + return err + } + f.version = body.Version + return nil +} + +func (f *GiteaFixture) waitHealthy(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + url := f.baseURL + "/api/healthz" + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err == nil { + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } + } +} + +func (f *GiteaFixture) bootstrapAdmin(ctx context.Context) error { + password := randomToken(16) + if _, err := f.exec(ctx, []string{ + "gitea", "admin", "user", "create", + "--username", giteaAdminUser, + "--password", password, + "--email", giteaAdminMail, + "--admin", + "--must-change-password=false", + }); err != nil { + return fmt.Errorf("create admin user: %w", err) + } + + out, err := f.exec(ctx, []string{ + "gitea", "admin", "user", "generate-access-token", + "--username", giteaAdminUser, + "--scopes", "all", + "-t", "e2e-admin-token", + }) + if err != nil { + return fmt.Errorf("generate admin token: %w", err) + } + token := extractToken(out) + if token == "" { + return fmt.Errorf("could not parse access token from CLI output: %q", out) + } + f.adminToken = token + return nil +} + +// Last field of `gitea admin user generate-access-token` output (format varies by release). +func extractToken(cliOutput string) string { + fields := strings.Fields(cliOutput) + if len(fields) == 0 { + return "" + } + return fields[len(fields)-1] +} + +func randomToken(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) { + created, err := f.cli.ExecCreate(ctx, f.id, mobyclient.ExecCreateOptions{ + Cmd: cmd, + User: "git", // gitea refuses root + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", err + } + + attached, err := f.cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{}) + if err != nil { + return "", err + } + defer attached.Close() + + var out bytes.Buffer + if _, err := io.Copy(&out, attached.Reader); err != nil { + return "", err + } + + inspected, err := f.cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{}) + if err != nil { + return "", err + } + if inspected.ExitCode != 0 { + return "", fmt.Errorf("exec %v exited %d: %s", cmd, inspected.ExitCode, out.String()) + } + return out.String(), nil +} + +func (f *GiteaFixture) RegistrationToken(ctx context.Context, repo string) (string, error) { + var body struct { + Token string `json:"token"` + } + path := "/api/v1/admin/actions/runners/registration-token" + if repo != "" { + path = fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", giteaAdminUser, repo) + } + if err := f.doJSON(ctx, http.MethodPost, path, nil, &body); err != nil { + return "", err + } + return body.Token, nil +} + +func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error { + api := &GiteaAPI{baseURL: f.baseURL, token: f.adminToken} + return api.doJSON(ctx, method, path, reqBody, respBody) +} + +func (f *GiteaFixture) Close(ctx context.Context) error { + if f.id == "" { + return f.cli.Close() + } + _, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true}) + return errors.Join(removeErr, f.cli.Close()) +} diff --git a/e2e/main_test.go b/e2e/main_test.go new file mode 100644 index 00000000..bde84ee6 --- /dev/null +++ b/e2e/main_test.go @@ -0,0 +1,162 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "gitea.com/gitea/runner/internal/app/poll" +) + +const runTimeout = 3 * time.Minute + +var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func repoName(t *testing.T) string { + return strings.ToLower(nonRepoChars.ReplaceAllString(t.Name(), "-")) +} + +var fixture *GiteaFixture + +var skipReason string + +func TestMain(m *testing.M) { + os.Exit(runSuite(m)) +} + +func runSuite(m *testing.M) int { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cli, err := dockerClient(ctx) + if err != nil { + skipReason = fmt.Sprintf("docker unavailable: %v", err) + return m.Run() + } + + f, err := StartGitea(ctx, cli) + if err != nil { + fmt.Fprintf(os.Stderr, "start gitea fixture: %v\n", err) + return 1 + } + fixture = f + defer func() { _ = fixture.Close(context.Background()) }() + + fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version) + + return m.Run() +} + +func newScenario(t *testing.T) (*GiteaAPI, string) { + t.Helper() + + repo := repoName(t) + api := &GiteaAPI{baseURL: fixture.baseURL, token: fixture.adminToken} + if err := api.CreateRepo(t.Context(), repo); err != nil { + t.Fatalf("create repo: %v", err) + } + return api, repo +} + +func startIsolatedScenario(t *testing.T, workflow, label string, options runnerOptions) (*GiteaAPI, string, *poll.Poller) { + t.Helper() + + api, repo := newScenario(t) + poller := startRunner(t, repo, label, options) + pushWorkflow(t, api, repo, workflow) + return api, repo, poller +} + +func pushWorkflow(t *testing.T, api *GiteaAPI, repo, workflow string) { + t.Helper() + + content, err := os.ReadFile("testdata/workflows/" + workflow) + if err != nil { + t.Fatalf("read workflow fixture %s: %v", workflow, err) + } + if err := api.CreateFile(t.Context(), repo, ".gitea/workflows/"+workflow, string(content), "add "+workflow); err != nil { + t.Fatalf("push workflow %s: %v", workflow, err) + } +} + +func requireSuccess(t *testing.T, api *GiteaAPI, repo string, runID int64) { + t.Helper() + + completed, err := api.WaitForRunConclusion(t.Context(), repo, runID, runTimeout) + if err != nil { + dumpRunLogs(t, api, repo, runID) + t.Fatalf("wait for run: %v", err) + } + if completed.Conclusion != "success" { + dumpRunLogs(t, api, repo, runID) + t.Fatalf("run concluded %q, want success", completed.Conclusion) + } +} + +func runLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) string { + t.Helper() + ctx := t.Context() + + jobs, err := api.Jobs(ctx, repo, runID) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + var all strings.Builder + for _, job := range jobs { + logs, err := api.JobLogs(ctx, repo, job.ID) + if err != nil { + t.Fatalf("job logs for %s: %v", job.Name, err) + } + all.WriteString(logs) + } + return all.String() +} + +func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) { + t.Helper() + ctx := t.Context() + + jobs, err := api.Jobs(ctx, repo, runID) + if err != nil { + t.Logf("dump run %d: list jobs: %v", runID, err) + return + } + for _, job := range jobs { + logs, err := api.JobLogs(ctx, repo, job.ID) + if err != nil { + t.Logf("job %q (%s): logs unavailable: %v", job.Name, job.Conclusion, err) + continue + } + t.Logf("job %q concluded %q:\n%s", job.Name, job.Conclusion, logs) + } +} + +func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + + for { + runs, err := api.Runs(ctx, repo) + if err != nil { + t.Fatalf("get latest run: %v", err) + } + if len(runs) > 0 { + return &runs[0] + } + select { + case <-ctx.Done(): + t.Fatalf("no run appeared for %s within timeout", repo) + case <-time.After(pollInterval): + } + } +} diff --git a/e2e/runner_harness.go b/e2e/runner_harness.go new file mode 100644 index 00000000..eba48478 --- /dev/null +++ b/e2e/runner_harness.go @@ -0,0 +1,120 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build e2e + +package e2e + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "gitea.com/gitea/runner/internal/app/poll" + "gitea.com/gitea/runner/internal/app/run" + "gitea.com/gitea/runner/internal/pkg/client" + "gitea.com/gitea/runner/internal/pkg/config" + "gitea.com/gitea/runner/internal/pkg/labels" + + "connectrpc.com/connect" + pingv1 "gitea.dev/actionslib/ping/v1" + runnerv1 "gitea.dev/actionslib/runner/v1" +) + +type runnerOptions struct { + capacity int + ephemeral bool + cacheV2 *bool +} + +func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller { + t.Helper() + ctx := t.Context() + token, err := fixture.RegistrationToken(ctx, repo) + if err != nil { + t.Fatalf("get registration token: %v", err) + } + + cfg, err := config.LoadDefault("") + if err != nil { + t.Fatalf("load default config: %v", err) + } + if options.cacheV2 != nil { + cfg.Cache.V2 = options.cacheV2 + } + cfg.Container.DockerHost = os.Getenv("DOCKER_HOST") + if cfg.Container.DockerHost == "" { + cfg.Container.DockerHost = "unix:///var/run/docker.sock" + } + cfg.Cache.Dir = t.TempDir() + "/cache" + cfg.Runner.Insecure = true + cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture + cfg.Runner.FetchIntervalMax = 250 * time.Millisecond + cfg.Runner.StateReportInterval = 500 * time.Millisecond // so cancel reaches the job quickly + cfg.Runner.LogReportInterval = 500 * time.Millisecond + cfg.Runner.Capacity = max(options.capacity, 1) + if fixture.network != "" { + cfg.Container.Network = fixture.network + } + + rawLabel := labelName + ":docker://" + os.Getenv("E2E_JOB_IMAGE") + label, err := labels.Parse(rawLabel) + if err != nil { + t.Fatalf("parse label %q: %v", rawLabel, err) + } + labelNames := []string{label.Name} + + pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout) + if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil { + t.Fatalf("ping %s: %v", fixture.baseURL, err) + } + + regResp, err := pingCli.Register(ctx, connect.NewRequest(&runnerv1.RegisterRequest{ + Name: t.Name(), + Token: token, + Version: "e2e", + Labels: labelNames, + Ephemeral: options.ephemeral, + Capabilities: run.RunnerCapabilities(), + })) + if err != nil { + t.Fatalf("register runner: %v", err) + } + if options.ephemeral && !regResp.Msg.Runner.Ephemeral { + t.Fatal("gitea did not grant ephemeral registration") + } + + reg := &config.Registration{ + ID: regResp.Msg.Runner.Id, + UUID: regResp.Msg.Runner.Uuid, + Name: regResp.Msg.Runner.Name, + Token: regResp.Msg.Runner.Token, + Address: fixture.baseURL, + Labels: []string{rawLabel}, + Ephemeral: regResp.Msg.Runner.Ephemeral, + } + + cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout) + + runner := run.NewRunner(cfg, reg, cli) + declResp, err := runner.Declare(ctx, labelNames) + if err != nil { + _ = runner.Close() + t.Fatalf("declare runner: %v", err) + } + runner.SetCapabilitiesFromDeclare(declResp) + + poller := poll.New(cfg, cli, runner) + go poller.Poll() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := errors.Join(poller.Shutdown(ctx), runner.Close()); err != nil { + t.Logf("runner shutdown: %v", err) + } + }) + return poller +} diff --git a/e2e/testdata/workflows/cache.yml b/e2e/testdata/workflows/cache.yml new file mode 100644 index 00000000..163a6e9f --- /dev/null +++ b/e2e/testdata/workflows/cache.yml @@ -0,0 +1,26 @@ +name: cache +on: push +jobs: + save: + runs-on: e2e-cache + steps: + - run: | + mkdir -p cached + echo "cached-payload" > cached/data.txt + - uses: actions/cache@v4 + with: + path: cached + key: e2e-cache-${{ github.run_id }} + restore: + needs: save + runs-on: e2e-cache + steps: + - uses: actions/cache@v4 + id: restore + with: + path: cached + key: e2e-cache-${{ github.run_id }} + - run: | + echo "cache-hit=${{ steps.restore.outputs.cache-hit }}" + test "${{ steps.restore.outputs.cache-hit }}" = "true" + grep -q cached-payload cached/data.txt diff --git a/e2e/testdata/workflows/cancel.yml b/e2e/testdata/workflows/cancel.yml new file mode 100644 index 00000000..61db8ab5 --- /dev/null +++ b/e2e/testdata/workflows/cancel.yml @@ -0,0 +1,9 @@ +name: cancel +on: push +jobs: + slow: + runs-on: ubuntu-latest + steps: + - run: | + echo e2e-live-log-marker + timeout 2s tail -f /dev/null || true diff --git a/e2e/testdata/workflows/dispatch.yml b/e2e/testdata/workflows/dispatch.yml new file mode 100644 index 00000000..4aae8cd5 --- /dev/null +++ b/e2e/testdata/workflows/dispatch.yml @@ -0,0 +1,11 @@ +name: dispatch +on: + workflow_dispatch: + inputs: + subject: + required: true +jobs: + greet: + runs-on: ubuntu-latest + steps: + - run: test "${{ inputs.subject }}" = dispatch-input-value diff --git a/e2e/testdata/workflows/ephemeral.yml b/e2e/testdata/workflows/ephemeral.yml new file mode 100644 index 00000000..97abfdf9 --- /dev/null +++ b/e2e/testdata/workflows/ephemeral.yml @@ -0,0 +1,7 @@ +name: ephemeral +on: push +jobs: + hello: + runs-on: e2e-ephemeral + steps: + - run: echo hello diff --git a/e2e/testdata/workflows/payloads.yml b/e2e/testdata/workflows/payloads.yml new file mode 100644 index 00000000..3dc74b1d --- /dev/null +++ b/e2e/testdata/workflows/payloads.yml @@ -0,0 +1,53 @@ +name: payloads +on: push +jobs: + verify: + runs-on: ubuntu-latest + services: + web: + image: ${{ vars.E2E_SERVICE_IMAGE }} + steps: + - run: | + echo 'plain-100%-done;-[bracket]' + printf 'multiline-first\nmultiline-second\n' + echo '::notice::notice-payload-here' + echo '::warning::warning-payload-here' + echo '::error::error-payload-here' + echo '::group::group-payload-here' + echo 'inside-the-group' + echo '::endgroup::' + echo '::notice::encoded-first%0Aencoded-second' + echo "the variable is ${{ vars.GREETING }}" + echo "the secret is ${{ secrets.FOO }}" + curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/ + produce: + runs-on: ubuntu-latest + outputs: + token: ${{ steps.emit.outputs.token }} + steps: + - id: emit + run: echo "token=produced-value-42" >> "$GITHUB_OUTPUT" + - run: echo "artifact content" > payload.txt + - uses: actions/upload-artifact@v4 + with: + name: payload + path: payload.txt + consume: + needs: produce + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: payload + - run: grep -q 'artifact content' payload.txt + - run: | + echo "received=${{ needs.produce.outputs.token }}" + test "${{ needs.produce.outputs.token }}" = "produced-value-42" + cell: + runs-on: ubuntu-latest + strategy: + matrix: + letter: [a, b] + number: [1, 2] + steps: + - run: echo "cell-${{ matrix.letter }}-${{ matrix.number }}" diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index 95423e96..59aadcb0 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -368,7 +368,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, // An Unauthenticated response means the server no longer knows this // runner (e.g. it was deleted). Retrying forever is pointless, so stop // polling and let the daemon exit with an error instead of spinning. - if connect.CodeOf(err) == connect.CodeUnauthenticated { + if isUnregistered(err) { log.WithError(err).Error("server rejected the runner as unregistered, stopping poller") p.unregistered.Store(true) p.shutdownPolling() @@ -415,6 +415,12 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, return resp.Msg.Task, true } +func isUnregistered(err error) bool { + var connectErr *connect.Error + return errors.As(err, &connectErr) && (connectErr.Code() == connect.CodeUnauthenticated || + connectErr.Code() == connect.CodeUnknown && connect.IsWireError(err) && connectErr.Message() == "rpc error: code = Unauthenticated desc = unregistered runner") +} + func (p *Poller) markHealthyPoll() { p.lastHealthyPoll.Store(time.Now().UnixNano()) p.lastPollFailed.Store(false) diff --git a/internal/app/poll/poller_test.go b/internal/app/poll/poller_test.go index 33dcb482..f633bb1b 100644 --- a/internal/app/poll/poller_test.go +++ b/internal/app/poll/poller_test.go @@ -106,28 +106,29 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) { // response marks the runner as unregistered and cancels the polling context so // the daemon can exit instead of retrying forever. func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) { - client := mocks.NewClient(t) - client.On("FetchTask", mock.Anything, mock.Anything).Return( - func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) { - return nil, connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")) - }, - ) + for name, fetchErr := range map[string]error{ + "connect": connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")), + "gitea": connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")), + } { + t.Run(name, func(t *testing.T) { + client := mocks.NewClient(t) + client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, fetchErr) - cfg, err := config.LoadDefault("") - require.NoError(t, err) - p := New(cfg, client, nil) + cfg, err := config.LoadDefault("") + require.NoError(t, err) + p := New(cfg, client, nil) + s := &workerState{} + _, ok := p.fetchTask(context.Background(), s) + require.False(t, ok) - s := &workerState{} - _, ok := p.fetchTask(context.Background(), s) - require.False(t, ok) - - assert.True(t, p.Unregistered(), "runner should be marked unregistered") - assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff") - - select { - case <-p.pollingCtx.Done(): - default: - t.Fatal("expected polling context to be cancelled after an Unauthenticated response") + assert.True(t, p.Unregistered(), "runner should be marked unregistered") + assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff") + select { + case <-p.pollingCtx.Done(): + default: + t.Fatal("expected polling context to be cancelled after an Unauthenticated response") + } + }) } } diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh new file mode 100755 index 00000000..5920e6a5 --- /dev/null +++ b/tools/test-e2e.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +: "${E2E_CONCURRENCY:?E2E_CONCURRENCY is required}" +: "${E2E_JOB_IMAGE:?E2E_JOB_IMAGE is required}" +: "${E2E_GITEA_IMAGE:?E2E_GITEA_IMAGE is required}" +: "${SERVICE_IMAGE:?SERVICE_IMAGE is required}" + +docker pull "$E2E_JOB_IMAGE" +docker pull "$SERVICE_IMAGE" +docker pull "$E2E_GITEA_IMAGE" + +exec "${GO:-go}" test -tags e2e -count=1 -parallel "$E2E_CONCURRENCY" -timeout 20m -v ./e2e/...