mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-23 21:46:19 +00:00
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:
committed by
silverwind
co-authored by
silverwind
parent
b97c61aa14
commit
8260e2def2
+37
-10
@@ -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 }}
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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
@@ -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
@@ -1,8 +0,0 @@
|
||||
name: checkout
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
checkout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -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
@@ -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=="
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+26
@@ -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
|
||||
Vendored
+9
@@ -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
|
||||
Vendored
+11
@@ -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
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
name: ephemeral
|
||||
on: push
|
||||
jobs:
|
||||
hello:
|
||||
runs-on: e2e-ephemeral
|
||||
steps:
|
||||
- run: echo hello
|
||||
Vendored
+53
@@ -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 }}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+13
@@ -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/...
|
||||
Reference in New Issue
Block a user