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/...