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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1180
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni
2026-08-23 12:32:22 +00:00
committed by silverwind
parent b97c61aa14
commit 8260e2def2
25 changed files with 1370 additions and 120 deletions
+37 -10
View File
@@ -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"
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 }}
+9
View File
@@ -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 `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
+9
View File
@@ -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)'
+11 -57
View File
@@ -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()
-8
View File
@@ -1,8 +0,0 @@
name: checkout
on: push
jobs:
checkout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
-12
View File
@@ -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
-12
View File
@@ -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=="
+30
View File
@@ -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)
})
}
}
+74
View File
@@ -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):
}
}
}
+99
View File
@@ -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 ""
}
+54
View File
@@ -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):
}
}
}
+8
View File
@@ -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
+28
View File
@@ -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")
}
}
+217
View File
@@ -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"
}
+365
View File
@@ -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())
}
+162
View File
@@ -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):
}
}
}
+120
View File
@@ -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
}
+26
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
name: ephemeral
on: push
jobs:
hello:
runs-on: e2e-ephemeral
steps:
- run: echo hello
+53
View File
@@ -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 }}"
+7 -1
View File
@@ -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)
+21 -20
View File
@@ -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")
}
})
}
}
+13
View File
@@ -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/...