Compare commits

...
21 Commits
Author SHA1 Message Date
silverwind b5305c6785 docs: add a launchd example for macOS (#1408)
Adds `examples/launchd` with a plist and the steps to run the runner on macOS, next to the existing systemd example.

The runner runs in the login session of the user that jobs should run as, which gives jobs the login keychain and the GUI. The README also covers the two problems that setup runs into: macOS blocks the runner from hosts on the Mac's own subnet, and Git's keychain helper can stop a job at a dialog.

_Written by Claude (Fable 5.2)._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1408
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-10 17:17:35 +00:00
bircni a021d3139d enhance: make shared tool cache safe at any capacity (#1401)
Co-authored-by: bircni <[email protected]>
2026-11-10 14:25:50 +00:00
bircniandsilverwind 84c0fcd1fa fix!: remove the dbfs log workaround and require Gitea 1.26.2 (#1395)
Gitea 1.26.2 finalizes logs on an empty `UpdateLog` with `NoMore` (https://github.com/go-gitea/gitea/pull/37687), so the sentinel row is no longer needed. On older servers, the final log of most jobs would never be archived and stays in `dbfs_data`, so this raises the minimum Gitea version. It lives in `ver.MinGiteaVersion`, which the error for servers without `Declare` now names.

Fixes: https://gitea.com/gitea/runner/issues/953
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1395
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-10 11:35:16 +00:00
bircniandsilverwind c59caa9689 refactor!: remove the builtin actions (#1407)
Removes `builtin:checkout`, `builtin:upload-artifact` and `builtin:download-artifact`, along with the container helpers only they used.

Builtin actions are tied to the runner version, so users cannot pin them, and they diverge from the shareable actions ecosystem that tools like zizmor build on. Workflows should use `actions/checkout`, `actions/upload-artifact` and `actions/download-artifact`, or lightweight third-party actions such as https://github.com/taiki-e/checkout-action.

**Breaking:** steps with `uses: builtin:checkout`, released in v4.0.0, now fail and need to switch to one of the actions above. The artifact actions were never released.

Docs: https://gitea.com/gitea/docs/pulls/643
Fixes: https://gitea.com/gitea/runner/issues/1405
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1407
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-10 11:05:38 +00:00
Renovate Bot 85d007c47f chore(deps): update dependencies (#1406)
This PR contains the following updates:

| Package | Type | Update | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|
| docker.gitea.com/runner-images |  | digest | `657378e` → `b96ba46` |  |  |
| [github.com/docker/cli](https://github.com/docker/cli) | require | patch | `v29.9.2+incompatible` → `v29.9.3+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.9.3+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.9.2+incompatible/v29.9.3+incompatible?slim=true) |
| [rancher/k3s](https://github.com/k3s-io/k3s) |  | patch | `v1.37.1-k3s1` → `v1.37.2-k3s1` | ![age](https://developer.mend.io/api/mc/badges/age/docker/rancher%2fk3s/v1.37.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/rancher%2fk3s/v1.37.1/v1.37.2?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.9.3+incompatible`](https://github.com/docker/cli/compare/v29.9.2...v29.9.3)

[Compare Source](https://github.com/docker/cli/compare/v29.9.2...v29.9.3)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTkuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE5OS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1406
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-11-09 04:31:50 +00:00
silverwindandbircni d34e4cf11a fix: stop a stalled docker daemon from hanging jobs and the e2e suite (#1404)
When the docker daemon stopped answering on https://gitea.com/gitea/runner/actions/runs/1204971, unbounded exec runs hung the e2e suite until the 20m `go test` panic, which skipped teardown. Exec runs and teardown are now bounded, and attached exec streams, including the runner's user ID probe, close with their context.

CLI daemons now stop with SIGTERM, so job volumes no longer leak.

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1404
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-07 08:07:41 +00:00
8e999cd039 fix: re-detect the cache address per job and prefer IPv4 (#1398)
The cache address was detected once at startup, and the interface fallback ranked ethernet names above IPv4. A runner that started before DHCPv4 finished could advertise a global IPv6 address to IPv4-only job networks until it restarted, failing every artifact and cache call.

IPv4 now ranks first, and the address is detected again for each job unless `cache.host` sets it. Detecting an IPv6 address logs a warning.

Fixes: https://gitea.com/gitea/runner/issues/1379
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1398
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-07 06:41:25 +00:00
silverwind cd748053b0 docs: trim comments and docs from workspace emptying change (#1403)
Follow-up to https://gitea.com/gitea/runner/commit/79faf5e55df7a3b312fb4888b7405a58e01f567c. Reverts the post-task-script doc change, which is too much detail for that page. Also shortens the `DrainGracePeriod` comment and the other comments that commit added, since the code and log messages already say most of it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1403
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-07 06:14:41 +00:00
bircni 9bb72bbe5c enhance: add ubuntu-26.04 to the default labels (#1396)
Newly registered runners get an `ubuntu-26.04` label alongside the existing defaults.

Ref: https://gitea.com/gitea/runner/issues/1334
Reviewed-on: https://gitea.com/gitea/runner/pulls/1396
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-07 05:12:04 +00:00
79faf5e55d fix: empty root-owned bind workspaces for non-root runners (#1399)
A job running as root leaves root-owned files in the bind-mounted workspace. A non-root runner on Linux cannot remove them, so workspaces pile up with only a warning. Such a runner now empties the workspace as root from inside the job container, right before removing the container. Root runners and Docker Desktop already remove these files themselves and skip this.

The workspace is only emptied when it is exactly the runner's own mountpoint with no mount below it. Otherwise, or when the command fails, the existing removal and warning apply. Jobs of a reusable workflow share the workspace, so they are not covered and still end in the existing warning.

Fixes: https://gitea.com/gitea/runner/issues/879
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1399
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-07 04:33:19 +00:00
bircni 993389d428 docs: trust a private registry CA in the dind images (#1397)
The bundled Docker daemon pulls job images, so it needs to trust a private registry's CA itself, which `runner.ca_file` doesn't cover. Documents the `certs.d` mount for both dind images.

Ref: https://gitea.com/gitea/runner/issues/1390
Reviewed-on: https://gitea.com/gitea/runner/pulls/1397
Reviewed-by: Lunny Xiao <[email protected]>
2026-11-06 21:36:06 +00:00
silverwind db76fd5159 enhance: improve hashFiles coercion and log grouping (#1394)
- Fix `hashFiles()` argument conversion for numbers, booleans, and null to match GitHub expression semantics.
- Fix rendering of mixed-case, indented, and prefixed legacy group commands while preserving recognized prefixes and escaping.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1394
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-06 20:03:39 +00:00
bircni c8b16f4792 enhance: add builtin:download-artifact (#1344)
Co-authored-by: bircni <[email protected]>
2026-11-06 03:32:59 +00:00
bircni 02446620df enhance: add builtin:upload-artifact (#1343)
Co-authored-by: bircni <[email protected]>
2026-11-06 03:17:49 +00:00
8c8950d9ef refactor(container): stream copies into the job and archives out of the host (#1340)
Adds `CopyTarStream` to the job environments. It extracts directories and files into the job as they are written, kept below the destination and owned by the job user along with any directories it creates, so built-in actions can put large content in place without buffering it. The host's `GetContainerArchive` now streams instead of building the archive in memory.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1340
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-06 02:22:40 +00:00
bircniandsilverwind b88d111cc5 feat: port the path patterns of @actions/glob (#1342)
Built-in actions that select files need to match what the JavaScript actions match. This ports @actions/glob 0.6.1 and the minimatch 3 subset it uses, as bundled in `actions/upload-artifact` v7. It only answers questions about paths; walking the files stays with the caller. Only malformed bracket expressions and some non-ASCII case folding on Windows can match differently.

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1342
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-11-06 01:13:07 +00:00
silverwind bb6d793cb9 fix: make container.sweep opt-in (#1389)
Sweeping removed every container a job started through its Docker proxy, which silently tore down services that jobs deploy to the runner's own Docker daemon. Deploying from a runner on the target host is common, so `container.sweep` now defaults to `false` and CI-only runners can opt in with `container.sweep: true`.

Fixes https://gitea.com/gitea/runner/issues/1385

Reviewed-on: https://gitea.com/gitea/runner/pulls/1389
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-05 13:50:46 +00:00
silverwind 95ef17d022 fix: prevent masking gaps in workflow command logs (#1384)
Mask partially escaped workflow command output and complete one-character values while preserving literal output and stream line endings.

Fixes: https://gitea.com/gitea/runner/issues/300
Reviewed-on: https://gitea.com/gitea/runner/pulls/1384
Reviewed-by: bircni <[email protected]>
2026-11-05 08:01:44 +00:00
Lunny Xiaoandsilverwind ee03687688 fix: upgrade Alpine packages in the runner images (#1388)
All images run `apk upgrade` before installing packages, so they pick up Alpine fixes their base images lag on. `docker:29.9.2-dind` shipped libexpat 2.9.0-r0 while Alpine 3.24 provides 2.10.0-r0 with the security fixes, and both `docker:29.9.3-dind` and `alpine:3.24` still ship apk-tools 3.0.8-r0 where 3.0.9-r0 is available. The dind bases are also bumped to `docker:29.9.3`, which drops several vulnerable bundled binaries' findings.

Verified by building the targets locally: libexpat is 2.10.0-r0 (was 2.9.0-r0), apk-tools is 3.0.9-r0 (was 3.0.8-r0), and Trivy reports no Alpine package findings on the dind images.

Assisted-by: Codet:claude
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1388
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
2026-11-05 07:45:27 +00:00
silverwind 30e600d8f6 test: speed up (#1386)
Reduce redundant fixture setup, Git subprocesses, and large data copies while preserving test coverage.

Local macOS measurements against main:

| Measurement | Before | After |
| --- | ---: | ---: |
| Git package time, median of 3 alternating runs | 4.09 s | 3.48 s (-15%) |
| Allocations across 18 action-bundle cases | 489 MiB | 251 MiB (-49%) |
| Traced Git starts per checkout table run | 234 | 175 |

Reviewed-on: https://gitea.com/gitea/runner/pulls/1386
Reviewed-by: bircni <[email protected]>
2026-11-05 07:33:17 +00:00
silverwind 664978c819 test: fix windows path test (#1387)
`TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt` from https://gitea.com/gitea/runner/pulls/1366 fails on Windows. `HostEnvironment.GetActPath` turns backslashes into slashes there, so `NODE_EXTRA_CA_CERTS` gets the slash form while the test expects the raw temp dir path. The runner behaviour is correct, only the expectation is wrong.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1387
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-05 07:14:56 +00:00
87 changed files with 1136 additions and 1710 deletions

No files matched your search

+7 -9
View File
@@ -23,11 +23,14 @@ depending on the prefix:
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes `##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break. multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is - **Forwarding a command from step output?** Preserve its escaping unless masking changes it.
decoded once. Decoding here double-decodes and destroys multi-line. Decoding for display happens in the UI.
- **No prefix?** Do not escape, and split multi-line values into one row each. - **No prefix?** Do not escape, and split multi-line values into one row each.
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the - **Interpolating a secret?** Masking runs after escaping. Recognised command payloads are
encoded forms too. normalised before masking to cover partially escaped values. Their original escaping is
preserved when no replacement occurs. `AppendSecretMasker` also registers encoded forms for
other output. Complete nonblank one-character values are masked, while one-byte fragments
of multiline values are skipped.
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
handler folds an annotation's location into its decoded message and escapes the result again. handler folds an annotation's location into its decoded message and escapes the result again.
@@ -48,11 +51,6 @@ fields.
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the - Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
package functions. package functions.
## Built-in actions
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
`act/runner/step_action_builtin.go`.
## End-to-end compatibility tests ## End-to-end compatibility tests
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image. `make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
+7 -5
View File
@@ -19,14 +19,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \
### DIND VARIANT ### DIND VARIANT
# #
# #
FROM docker:29.9.2-dind AS dind FROM docker:29.9.3-dind AS dind
ARG VERSION=dev ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache s6 bash git tzdata nftables # upgrade first: the docker base image can lag behind Alpine security fixes
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
@@ -41,7 +42,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT ### DIND-ROOTLESS VARIANT
# #
# #
FROM docker:29.9.2-dind-rootless AS dind-rootless FROM docker:29.9.3-dind-rootless AS dind-rootless
ARG VERSION=dev ARG VERSION=dev
@@ -49,7 +50,8 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
USER root USER root
RUN apk add --no-cache s6 bash git tzdata nftables # upgrade first: the docker base image can lag behind Alpine security fixes
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
@@ -75,7 +77,7 @@ ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache tini bash git tzdata RUN apk upgrade --no-cache && apk add --no-cache tini bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY scripts/run.sh /usr/local/bin/run.sh COPY scripts/run.sh /usr/local/bin/run.sh
+2 -2
View File
@@ -4,10 +4,10 @@ EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:657378e5cfd898e36f820459c60ef8e69be8fcda9fd354e4381a8b654d091bbc # renovate: datasource=docker E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:b96ba465021b1a4c6a1c9e79220c2b9bfad0f29b09dbb528bbfb35411443a3a4 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker E2E_K3S_IMAGE ?= rancher/k3s:v1.37.2-k3s1@sha256:21ea6e0fac4e3930c2ce8aa40ddd41a8b18cb3e9a0222c030dc005e7e88c4332 # renovate: datasource=docker
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*") GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
+4 -10
View File
@@ -7,6 +7,7 @@ Gitea Runner runs the jobs of Gitea Actions workflows. Register it with a Gitea
### Prerequisites ### Prerequisites
- Linux, macOS or Windows, or FreeBSD. - Linux, macOS or Windows, or FreeBSD.
- Gitea 1.26.2 or newer.
- `git` 2.34.1 or newer. - `git` 2.34.1 or newer.
- `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/). - `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/).
@@ -14,9 +15,6 @@ In the job image, or on the host for host jobs:
- `bash` or `sh`, or `pwsh` or `powershell` on Windows. - `bash` or `sh`, or `pwsh` or `powershell` on Windows.
- `node` for JavaScript actions and `hashFiles()`. - `node` for JavaScript actions and `hashFiles()`.
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
- `git-lfs` for `builtin:checkout` with `lfs: true`.
- `ssh` for `builtin:checkout` with `ssh-key`.
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images. Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
@@ -112,7 +110,7 @@ Versioned and `nightly` tags take the same suffixes, like `nightly-dind`. The im
### Example deployments ### Example deployments
See the [examples](examples) directory for more deployments, among them systemd, Kubernetes and VMs. See the [examples](examples) directory for more deployments, among them systemd, launchd on macOS, Kubernetes and VMs.
## Configuration ## Configuration
@@ -183,7 +181,7 @@ Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`. `runner.to
| Mode | Tool cache | | Mode | Tool cache |
| --- | --- | | --- | --- |
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. | | `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
| `shared` | One volume for all jobs. Only safe with `runner.capacity: 1`. | | `shared` | One volume per `runner.capacity` slot, reused by later jobs. It hides the tools later job images ship, so use images without them. |
To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do: To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do:
@@ -243,11 +241,7 @@ volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data - ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
``` ```
In most Linux container jobs, the containers, networks and volumes a job creates are removed when it ends, `docker compose` services included. Set `container.sweep: false` if jobs deploy services to the runner's Docker daemon. Set `container.sweep: true` to remove the containers, networks and volumes most Linux container jobs create when they end, `docker compose` services included. Leave it off if jobs deploy services to the runner's Docker daemon.
### Built-in actions (`uses: builtin:<name>`)
`uses: builtin:checkout` checks out a repository without downloading `actions/checkout` or needing Node in the job image, see [docs/builtin-actions.md](docs/builtin-actions.md).
### Caching (`actions/cache`) ### Caching (`actions/cache`)
+34 -7
View File
@@ -104,7 +104,9 @@ type Handler struct {
stopSync context.CancelFunc stopSync context.CancelFunc
syncing sync.WaitGroup syncing sync.WaitGroup
outboundIPMu sync.RWMutex
outboundIP string outboundIP string
detectOutboundIP func() net.IP // nil when the address is configured
instanceTransport http.RoundTripper instanceTransport http.RoundTripper
// internalSecret guards /_internal/{register,revoke}. When set, a remote // internalSecret guards /_internal/{register,revoke}. When set, a remote
@@ -158,6 +160,7 @@ func StartHandler(opts Options) (*Handler, error) {
policy: opts.Policy.withDefaults(), policy: opts.Policy.withDefaults(),
freeDisk: disk.FreeBytes, freeDisk: disk.FreeBytes,
dbLock: make(chan struct{}, 1), dbLock: make(chan struct{}, 1),
outboundIP: opts.OutboundIP,
instanceTransport: opts.InstanceTransport, instanceTransport: opts.InstanceTransport,
} }
@@ -167,12 +170,11 @@ func StartHandler(opts Options) (*Handler, error) {
logger = logger.With("module", "artifactcache") logger = logger.With("module", "artifactcache")
h.logger = logger h.logger = logger
if opts.OutboundIP != "" { if opts.OutboundIP == "" {
h.outboundIP = opts.OutboundIP h.detectOutboundIP = common.GetOutboundIP
} else if ip := common.GetOutboundIP(); ip == nil { if h.RefreshExternalURL() == "" {
return nil, errors.New("unable to determine outbound IP address") logger.Warn("unable to determine an outbound IP address for the cache server yet, set cache.host to configure one")
} else { }
h.outboundIP = ip.String()
} }
if opts.Upstream != "" { if opts.Upstream != "" {
@@ -304,11 +306,36 @@ func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
}), nil }), nil
} }
// ExternalURL is "" while no address is known.
func (h *Handler) ExternalURL() string { func (h *Handler) ExternalURL() string {
// TODO: make the external url configurable if necessary if h == nil {
return ""
}
h.outboundIPMu.RLock()
defer h.outboundIPMu.RUnlock()
if h.outboundIP == "" {
return ""
}
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port)) return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
} }
// RefreshExternalURL re-detects an unconfigured address, which can change after startup.
func (h *Handler) RefreshExternalURL() string {
if h != nil && h.detectOutboundIP != nil {
if ip := h.detectOutboundIP(); ip != nil {
h.outboundIPMu.Lock()
if address := ip.String(); address != h.outboundIP {
h.outboundIP = address
if ip.To4() == nil {
h.logger.Warnf("detected IPv6 address %s for the cache server, jobs on IPv4-only networks cannot reach it, set cache.host if they fail to", address)
}
}
h.outboundIPMu.Unlock()
}
}
return h.ExternalURL()
}
func (h *Handler) baseURL(cred JobCredential) string { func (h *Handler) baseURL(cred JobCredential) string {
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" { if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
return base return base
+30 -8
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -22,6 +23,8 @@ import (
"testing/synctest" "testing/synctest"
"time" "time"
"gitea.com/gitea/runner/act/common/logging/logtest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
@@ -50,7 +53,23 @@ func signArtifactURL(h *Handler, id int64) string {
func TestHandler_ExternalURL(t *testing.T) { func TestHandler_ExternalURL(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL()) assert.Empty(t, (*Handler)(nil).RefreshExternalURL())
assert.Equal(t, "http://cache.example:8080", (&Handler{outboundIP: "cache.example", port: 8080}).RefreshExternalURL())
logger, logs := logtest.NewLogger()
var ip net.IP
handler := &Handler{port: 8080, logger: logger, detectOutboundIP: func() net.IP { return ip }}
assert.Empty(t, handler.RefreshExternalURL())
ip = net.ParseIP("2001:db8::1")
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
ip = net.ParseIP("192.0.2.1")
assert.Equal(t, "http://[2001:db8::1]:8080", handler.ExternalURL())
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
ip = nil
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
require.Len(t, logs.AllRecords(), 1)
assert.Contains(t, logs.LastRecord().Message, "IPv6")
} }
func TestHandler_Upstream(t *testing.T) { func TestHandler_Upstream(t *testing.T) {
@@ -851,6 +870,9 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler { func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
t.Helper() t.Helper()
handler := startTestHandler(t, Options{Policy: policy}) handler := startTestHandler(t, Options{Policy: policy})
if len(entries) == 0 {
return handler
}
db, err := handler.openDB() db, err := handler.openDB()
require.NoError(t, err) require.NoError(t, err)
@@ -940,6 +962,10 @@ func TestHandler_RejectsBearer(t *testing.T) {
func TestHandler_UnregisterRevokes(t *testing.T) { func TestHandler_UnregisterRevokes(t *testing.T) {
t.Parallel() t.Parallel()
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
require.NoError(t, err)
defer handler.Close()
for _, tc := range []struct { for _, tc := range []struct {
name string name string
token string token string
@@ -950,9 +976,6 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
{"reference counted", "shared", "v", 2}, {"reference counted", "shared", "v", 2},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
require.NoError(t, err)
defer handler.Close()
revokers := make([]func(), tc.registrations) revokers := make([]func(), tc.registrations)
for i := range revokers { for i := range revokers {
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo}) revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
@@ -1049,9 +1072,8 @@ func TestHandler_ArtifactSignature(t *testing.T) {
base := handler.ExternalURL() + apiPath base := handler.ExternalURL() + apiPath
good := signArtifactURL(handler, 1) good := signArtifactURL(handler, 1)
other, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache2")}) secret := bytes.Clone(handler.secret)
require.NoError(t, err) secret[0] ^= 1
defer other.Close()
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -1060,7 +1082,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)}, {"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
{"tampered signature", good[:len(good)-4] + "dead"}, {"tampered signature", good[:len(good)-4] + "dead"},
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))}, {"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
{"signature from a different server", base + strings.SplitN(signArtifactURL(other, 1), apiPath, 2)[1]}, {"signature from a different server", base + strings.SplitN(signArtifactURL(&Handler{secret: secret}, 1), apiPath, 2)[1]},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
resp, err := testClient.Get(tc.url) resp, err := testClient.Get(tc.url)
+14 -3
View File
@@ -36,7 +36,7 @@ func TestFetchAction(t *testing.T) {
root := t.TempDir() root := t.TempDir()
remote := filepath.Join(root, "org", "action") remote := filepath.Join(root, "org", "action")
require.NoError(t, gitCmd("init", "--initial-branch=main", remote)) require.NoError(t, os.CopyFS(remote, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link"))) require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
@@ -53,6 +53,7 @@ func TestFetchAction(t *testing.T) {
var archiveRequests []http.Header var archiveRequests []http.Header
var mirror *httptest.Server var mirror *httptest.Server
archiveCache := map[string][]byte{} archiveCache := map[string][]byte{}
revisions := map[string]string{}
requests, archives, failures := 0, "", 0 requests, archives, failures := 0, "", 0
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mu.Lock() mu.Lock()
@@ -79,7 +80,14 @@ func TestFetchAction(t *testing.T) {
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound) http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
return return
} }
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz")) sha, ok := revisions[ref]
if !ok {
var err error
sha, err = gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
if err == nil {
revisions[ref] = sha
}
}
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed") etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
if request.Header.Get("If-None-Match") == etag { if request.Header.Get("If-None-Match") == etag {
writer.WriteHeader(http.StatusNotModified) writer.WriteHeader(http.StatusNotModified)
@@ -208,6 +216,9 @@ func TestFetchAction(t *testing.T) {
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved")) require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1")) require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
expectedSha = gitRevParse(t, remote, "v1") expectedSha = gitRevParse(t, remote, "v1")
mu.Lock()
clear(revisions)
mu.Unlock()
moved, movedSha, err := FetchAction(t.Context(), input) moved, movedSha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expectedSha, movedSha) assert.Equal(t, expectedSha, movedSha)
@@ -253,7 +264,7 @@ func TestFetchAction(t *testing.T) {
}) })
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) { t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
sha := gitRevParse(t, remote, "main") sha := expectedSha
for _, mode := range []string{"prefixed", "none"} { for _, mode := range []string{"prefixed", "none"} {
serve(mode, 0) serve(mode, 0)
input := newInput(sha) input := newInput(sha)
+25 -31
View File
@@ -112,8 +112,7 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
t.Parallel() t.Parallel()
basedir := t.TempDir() basedir := t.TempDir()
const remoteURL = "https://github.com/owner/repo.git" const remoteURL = "https://github.com/owner/repo.git"
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir)) require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", basedir))
require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init")) require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)) require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git")) require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
@@ -205,16 +204,13 @@ func TestGitCloneExecutor(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes")) require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes"))
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial")) require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v2")) require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails")) require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
fullSha := gitRevParse(t, workDir, "main") fullSha := gitRevParse(t, workDir, "main")
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4])) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v2", "act-fails", "main:refs/heads/"+fullSha[:4]))
hostConfig := filepath.Join(t.TempDir(), "gitconfig") hostConfig := filepath.Join(t.TempDir(), "gitconfig")
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644)) require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
@@ -282,10 +278,10 @@ func TestGitCloneExecutor(t *testing.T) {
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) { func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
t.Parallel() t.Parallel()
createRemote := func(message string) string { createRemote := func(message string) string {
remoteDir, workDir := newGitRemote(t, message) dir := t.TempDir()
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", message))
return remoteDir return dir
} }
oldRemoteDir := createRemote("old-action") oldRemoteDir := createRemote("old-action")
@@ -405,31 +401,30 @@ func TestGitCloneExecutorShallow(t *testing.T) {
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3") remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1") unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v1"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") } shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} { for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1, URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
})(t.Context())) })(t.Context()))
assert.FileExists(t, shallowMarker(dir), "clone should be shallow") assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present") assert.Equal(t, []string{"c3"}, gitSubjects(t, dir), "only the tip commit should be present")
assert.Equal(t, "c3", gitHeadSubject(t, dir))
}) })
} }
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string { unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git") remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote)) require.NoError(t, gitCmd("clone", "--bare", "--template=", "--config", "uploadpack.allowAnySHA1InWant="+strconv.FormatBool(allowUnadvertised), remoteDir, remote))
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
return remote return remote
} }
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) { t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1, URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
@@ -438,6 +433,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) { t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
t.Parallel()
remote := unadvertisedRemote(t, true) remote := unadvertisedRemote(t, true)
dir := t.TempDir() dir := t.TempDir()
cloneAt := func(ref string) error { cloneAt := func(ref string) error {
@@ -448,7 +444,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, cloneAt(unadvertisedSHA)) require.NoError(t, cloneAt(unadvertisedSHA))
assert.FileExists(t, shallowMarker(dir)) assert.FileExists(t, shallowMarker(dir))
assert.Equal(t, 1, gitRevCount(t, dir)) assert.Len(t, gitSubjects(t, dir), 1)
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD")) assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
olderSHA := gitRevParse(t, workDir, "HEAD~2") olderSHA := gitRevParse(t, workDir, "HEAD~2")
@@ -461,6 +457,8 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) { t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
t.Parallel()
remoteDir, workDir := newGitRemote(t, "c2", "c3")
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
dir := t.TempDir() dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
@@ -473,19 +471,17 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
require.NoError(t, clone(t.Context())) require.NoError(t, clone(t.Context()))
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip") assert.Equal(t, []string{"c4"}, gitSubjects(t, dir), "reused shallow clone should update to the new tip")
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update") assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
assert.Equal(t, 1, gitRevCount(t, dir))
}) })
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) { t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
t.Parallel()
remote := filepath.Join(t.TempDir(), "remote.git") remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
work := t.TempDir() work := t.TempDir()
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work)) require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", work))
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned")) require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
require.NoError(t, gitCmd("-C", work, "push", "origin", "main")) require.NoError(t, gitCmd("clone", "--bare", "--template=", work, remote))
sha := gitRevParse(t, work, "HEAD") sha := gitRevParse(t, work, "HEAD")
dir := t.TempDir() dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1}) clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
@@ -504,11 +500,11 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
pinned := gitRevParse(t, workDir, "HEAD") pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
require.NoError(t, gitCmd("-C", workDir, "tag", pinned)) require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} { for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth, URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
@@ -540,13 +536,11 @@ func gitRevParse(t *testing.T, dir, rev string) string {
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
func gitRevCount(t *testing.T, dir string) int { func gitSubjects(t *testing.T, dir string) []string {
t.Helper() t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output() out, err := exec.Command("git", "-C", dir, "log", "--format=%s").Output()
require.NoError(t, err) require.NoError(t, err)
n, err := strconv.Atoi(strings.TrimSpace(string(out))) return strings.Split(strings.TrimSpace(string(out)), "\n")
require.NoError(t, err)
return n
} }
func gitHeadSubject(t *testing.T, dir string) string { func gitHeadSubject(t *testing.T, dir string) string {
@@ -680,7 +674,7 @@ func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
}) })
dir := filepath.Join(t.TempDir(), "cached-action") dir := filepath.Join(t.TempDir(), "cached-action")
require.NoError(t, gitCmd("init", dir)) require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL)) require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
+8 -4
View File
@@ -6,6 +6,7 @@ package common
import ( import (
"net" "net"
"slices"
"sort" "sort"
"strings" "strings"
) )
@@ -55,19 +56,22 @@ func GetOutboundIP() net.IP {
} }
func preferredIP(ips []interfaceIP) net.IP { func preferredIP(ips []interfaceIP) net.IP {
ips = slices.DeleteFunc(ips, func(ip interfaceIP) bool {
return len(ip.Name) == 15 && strings.HasPrefix(ip.Name, "br-") && strings.Trim(ip.Name[3:], "0123456789abcdef") == "" // a Docker network's bridge, removed with the network
})
if len(ips) == 0 { if len(ips) == 0 {
return nil return nil
} }
sort.Slice(ips, func(i, j int) bool { sort.Slice(ips, func(i, j int) bool {
first, second := ips[i], ips[j] first, second := ips[i], ips[j]
// ethernet is preferred // IPv4 is preferred, as job networks are IPv4-only by default
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj { if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
return vi return vi
} }
// IPv4 is preferred // ethernet is preferred
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj { if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
return vi return vi
} }
+2 -1
View File
@@ -13,8 +13,9 @@ import (
func TestPreferredIP(t *testing.T) { func TestPreferredIP(t *testing.T) {
assert.Nil(t, preferredIP(nil)) assert.Nil(t, preferredIP(nil))
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}})) assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{ assert.Equal(t, net.ParseIP("192.0.2.1"), preferredIP([]interfaceIP{
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"}, {IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"}, {IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
{IP: net.ParseIP("172.18.0.1"), Name: "br-0123456789ab"},
})) }))
} }
+4
View File
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -23,6 +24,9 @@ import (
var hostOS = runtime.GOOS // replaced in tests var hostOS = runtime.GOOS // replaced in tests
// DrainGracePeriod caps the wait for container output to drain so the log tail is not truncated
const DrainGracePeriod = 2 * time.Second
// ExitCodeError reports a non-zero process exit code from a container command. // ExitCodeError reports a non-zero process exit code from a container command.
type ExitCodeError int type ExitCodeError int
+1 -1
View File
@@ -42,11 +42,11 @@ func TestDockerBuildLabelsImageWithItsTagAndReportsDaemonError(t *testing.T) {
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) { func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "build"), 0o755))
for name, content := range map[string]string{ for name, content := range map[string]string{
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a", ".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep", "a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
} { } {
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
} }
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755)) require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
+11 -13
View File
@@ -41,13 +41,6 @@ import (
specs "github.com/opencontainers/image-spec/specs-go/v1" specs "github.com/opencontainers/image-spec/specs-go/v1"
) )
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
// finish draining a container's output before returning, so that neither a
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
// the tail of the log. It is a safety bound: in the common case the stream
// reaches EOF and the goroutine returns well before this elapses.
const drainGracePeriod = 2 * time.Second
// NewContainer creates a reference to a container // NewContainer creates a reference to a container
func NewContainer(input *NewContainerInput) ExecutionsEnvironment { func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
// Resolved up front because the image pull runs before the container is created. // Resolved up front because the image pull runs before the container is created.
@@ -861,6 +854,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
return nil return nil
} }
defer resp.Close() defer resp.Close()
defer context.AfterFunc(ctx, resp.Close)()
sid, err := resp.Reader.ReadString('\n') sid, err := resp.Reader.ReadString('\n')
if err != nil { if err != nil {
@@ -906,7 +900,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
if err != nil { if err != nil {
logger.Debugf("Failed to drain command output after cancellation: %v", err) logger.Debugf("Failed to drain command output after cancellation: %v", err)
} }
case <-time.After(drainGracePeriod): case <-time.After(DrainGracePeriod):
logger.Warn("Timed out draining command output after cancellation") logger.Warn("Timed out draining command output after cancellation")
} }
@@ -938,10 +932,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
logger.Error(err) logger.Error(err)
} }
}() }()
extractionRoot := "/" extractionRoot := cr.extractionRoot(dstPath)
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
}
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil { if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil {
return err return err
} }
@@ -954,6 +945,13 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
} }
} }
func (cr *containerReference) extractionRoot(dstPath string) string {
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
return actPath // keeps tar entries from crossing the image's /var/run symlink
}
return "/"
}
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor { func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
@@ -1046,7 +1044,7 @@ func (cr *containerReference) wait() common.Executor {
if cr.attachDone != nil { if cr.attachDone != nil {
select { select {
case <-cr.attachDone: case <-cr.attachDone:
case <-time.After(drainGracePeriod): case <-time.After(DrainGracePeriod):
logger.Warn("Timed out draining container output") logger.Warn("Timed out draining container output")
} }
cr.attachDone = nil cr.attachDone = nil
+27 -5
View File
@@ -122,6 +122,11 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1) return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
} }
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, id string, options mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
args := m.Called(ctx, id, options)
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
}
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) { func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
args := m.Called(ctx, id, opts) args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1) return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
@@ -351,6 +356,21 @@ func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestDockerReadIDStreamClosesWithItsContext(t *testing.T) {
conn, peer := net.Pipe()
t.Cleanup(func() { _ = peer.Close() })
client := &mockDockerClient{}
client.On("ExecCreate", mock.Anything, "123", mock.AnythingOfType("client.ExecCreateOptions")).
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", mock.Anything, "id", mobyclient.ExecAttachOptions{}).
Return(mobyclient.ExecAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
ctx, cancel := context.WithCancel(t.Context())
cancel()
require.NoError(t, (&containerReference{id: "123", cli: client}).tryReadID("-u", func(int) { t.Error("read an ID after cancellation") })(ctx))
client.AssertExpectations(t)
}
func TestDockerWaitFailure(t *testing.T) { func TestDockerWaitFailure(t *testing.T) {
ctx := t.Context() ctx := t.Context()
statusCh := make(chan container.WaitResponse, 1) statusCh := make(chan container.WaitResponse, 1)
@@ -540,6 +560,9 @@ func TestRejectsMissingContainer(t *testing.T) {
} }
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) { func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
for _, testcase := range []struct { for _, testcase := range []struct {
name, destination, extractionRoot, entry string name, destination, extractionRoot, entry string
directory bool directory bool
@@ -567,8 +590,6 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}} cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
if testcase.directory { if testcase.directory {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx)) require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
} else { } else {
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx)) require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
@@ -1108,6 +1129,10 @@ func TestConnectTakesDaemonOSFromPing(t *testing.T) {
} }
func TestContainerCopyModes(t *testing.T) { func TestContainerCopyModes(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
for _, testcase := range []struct { for _, testcase := range []struct {
hostOS, serverOS string hostOS, serverOS string
expected int64 expected int64
@@ -1117,9 +1142,6 @@ func TestContainerCopyModes(t *testing.T) {
{"linux", "linux", 0o666}, {"linux", "linux", 0o666},
} { } {
t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) { t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) {
source := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"} file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"}
original := hostOS original := hostOS
hostOS = testcase.hostOS hostOS = testcase.hostOS
+25 -28
View File
@@ -6,7 +6,6 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bytes"
"cmp" "cmp"
"context" "context"
"errors" "errors"
@@ -117,17 +116,24 @@ func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
} }
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
defer tw.Close()
srcPath = filepath.Clean(srcPath) srcPath = filepath.Clean(srcPath)
fi, err := os.Lstat(srcPath) fi, err := os.Lstat(srcPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tc := &filecollector.TarCollector{ reader, writer := io.Pipe()
TarWriter: tw, go func() {
} tw := tar.NewWriter(writer)
err := writeHostArchive(ctx, &filecollector.TarCollector{TarWriter: tw}, srcPath, fi)
if err == nil {
err = tw.Close()
}
writer.CloseWithError(err)
}()
return startedArchive(reader)
}
func writeHostArchive(ctx context.Context, tc *filecollector.TarCollector, srcPath string, fi fs.FileInfo) error {
if fi.IsDir() { if fi.IsDir() {
srcPrefix := srcPath srcPrefix := srcPath
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) { if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
@@ -138,31 +144,22 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
SrcPrefix: srcPrefix, SrcPrefix: srcPrefix,
Handler: tc, Handler: tc,
} }
err = filepath.Walk(srcPath, fc.CollectFiles(ctx)) return filepath.Walk(srcPath, fc.CollectFiles(ctx))
if err != nil { }
return nil, err var f io.ReadCloser
var linkname string
var err error
if fi.Mode()&fs.ModeSymlink != 0 {
if linkname, err = os.Readlink(srcPath); err != nil {
return err
} }
} else { } else {
var f io.ReadCloser if f, err = os.Open(srcPath); err != nil {
var linkname string return err
if fi.Mode()&fs.ModeSymlink != 0 {
linkname, err = os.Readlink(srcPath)
if err != nil {
return nil, err
}
} else {
f, err = os.Open(srcPath)
if err != nil {
return nil, err
}
defer f.Close()
}
err := tc.WriteFile(fi.Name(), fi, linkname, f)
if err != nil {
return nil, err
} }
defer f.Close()
} }
return io.NopCloser(buf), nil return tc.WriteFile(fi.Name(), fi, linkname, f)
} }
func (e *HostEnvironment) Pull(_ bool) common.Executor { func (e *HostEnvironment) Pull(_ bool) common.Executor {
+10 -6
View File
@@ -32,6 +32,11 @@ func TestActionPlatformNames(t *testing.T) {
} }
func TestCopyDir(t *testing.T) { func TestCopyDir(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
for _, test := range []struct { for _, test := range []struct {
name string name string
useGitIgnore, skipGitDir bool useGitIgnore, skipGitDir bool
@@ -41,11 +46,6 @@ func TestCopyDir(t *testing.T) {
{name: "unfiltered outside a git repository", useGitIgnore: true}, {name: "unfiltered outside a git repository", useGitIgnore: true},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
dest := filepath.Join(t.TempDir(), "dest") dest := filepath.Join(t.TempDir(), "dest")
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context())) require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
@@ -76,7 +76,11 @@ func TestGetContainerArchive(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expectedContent, content) assert.Equal(t, expectedContent, content)
_, err = reader.Next() _, err = reader.Next()
assert.ErrorIs(t, err, io.EOF) require.ErrorIs(t, err, io.EOF)
require.NoError(t, archive.Close())
_, err = e.GetContainerArchive(ctx, filepath.Join(e.Path, "missing"))
assert.ErrorIs(t, err, os.ErrNotExist)
} }
func TestHostEnvironmentExecExitCode(t *testing.T) { func TestHostEnvironmentExecExitCode(t *testing.T) {
+1 -10
View File
@@ -4,7 +4,6 @@
package container package container
import ( import (
"bufio"
"bytes" "bytes"
"cmp" "cmp"
"compress/gzip" "compress/gzip"
@@ -573,15 +572,7 @@ func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath s
} }
writer.CloseWithError(err) writer.CloseWithError(err)
}() }()
buffered := bufio.NewReader(reader) return startedArchive(reader)
if _, err := buffered.Peek(1); err != nil {
reader.Close()
return nil, err
}
return struct {
io.Reader
io.Closer
}{buffered, reader}, nil
} }
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
+17 -15
View File
@@ -5,6 +5,7 @@ package container
import ( import (
"bufio" "bufio"
"bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -15,14 +16,9 @@ import (
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
) )
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
t.Helper()
e := &HostEnvironment{Path: t.TempDir()}
return e, filepath.Join(e.Path, "envfile")
}
func TestParseEnvFile(t *testing.T) { func TestParseEnvFile(t *testing.T) {
t.Parallel() t.Parallel()
hostEnv := &HostEnvironment{Path: t.TempDir()}
largeValue := strings.Repeat("x", 2*1024*1024) largeValue := strings.Repeat("x", 2*1024*1024)
encodeUTF16 := func(endianness unicode.Endianness) string { encodeUTF16 := func(endianness unicode.Endianness) string {
content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n")) content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
@@ -51,15 +47,15 @@ func TestParseEnvFile(t *testing.T) {
{"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}}, {"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
e, envPath := newTestHostEnv(t) envPath := filepath.Join(hostEnv.Path, test.name)
require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600))
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env, false)(t.Context())) require.NoError(t, parseEnvFile(hostEnv, envPath, &env, false)(t.Context()))
assert.Equal(t, test.want, env) assert.Equal(t, test.want, env)
}) })
} }
t.Run("ordered entries preserve repeated names", func(t *testing.T) { t.Run("ordered entries preserve repeated names", func(t *testing.T) {
hostEnv, envPath := newTestHostEnv(t) envPath := filepath.Join(hostEnv.Path, "ordered")
require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600))
var entries [][2]string var entries [][2]string
require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) { require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) {
@@ -70,7 +66,7 @@ func TestParseEnvFile(t *testing.T) {
}, entries) }, entries)
}) })
t.Run("Windows line endings", func(t *testing.T) { t.Run("Windows line endings", func(t *testing.T) {
hostEnv, envPath := newTestHostEnv(t) envPath := filepath.Join(hostEnv.Path, "windows")
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600))
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context())) require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context()))
@@ -80,16 +76,22 @@ func TestParseEnvFile(t *testing.T) {
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) { func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
t.Parallel() t.Parallel()
e, envPath := newTestHostEnv(t) hostEnv := &HostEnvironment{Path: t.TempDir()}
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n"+strings.Repeat("x", 17*1024*1024)+"\nEOF\n"), 0o600)) envPath := filepath.Join(hostEnv.Path, "envfile")
const header, footer = "FOO<<EOF\n", "\nEOF\n"
content := bytes.Repeat([]byte("x"), len(header)+17*1024*1024+len(footer))
copy(content, header)
copy(content[len(content)-len(footer):], footer)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{} env := map[string]string{}
err := parseEnvFile(e, envPath, &env, false)(t.Context()) err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context())
require.ErrorIs(t, err, bufio.ErrTooLong) require.ErrorIs(t, err, bufio.ErrTooLong)
assert.Contains(t, err.Error(), "reading env file") assert.Contains(t, err.Error(), "reading env file")
} }
func TestParseEnvFileInvalidHeredoc(t *testing.T) { func TestParseEnvFileInvalidHeredoc(t *testing.T) {
hostEnv := &HostEnvironment{Path: t.TempDir()}
for name, content := range map[string]string{ for name, content := range map[string]string{
"missing delimiter": "FOO<<EOF\nline1\nline2\n", "missing delimiter": "FOO<<EOF\nline1\nline2\n",
"empty name": "<<EOF\nline1\nEOF\n", "empty name": "<<EOF\nline1\nEOF\n",
@@ -97,11 +99,11 @@ func TestParseEnvFileInvalidHeredoc(t *testing.T) {
"delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n", "delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n",
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
e, envPath := newTestHostEnv(t) envPath := filepath.Join(hostEnv.Path, name)
require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600)) require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600))
env := map[string]string{} env := map[string]string{}
err := parseEnvFile(e, envPath, &env, false)(t.Context()) err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context())
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "delimiter") assert.Contains(t, err.Error(), "delimiter")
assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env) assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env)
+14
View File
@@ -6,6 +6,7 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bufio"
"context" "context"
"io" "io"
"path/filepath" "path/filepath"
@@ -15,6 +16,19 @@ import (
"gitea.com/gitea/runner/act/filecollector" "gitea.com/gitea/runner/act/filecollector"
) )
// startedArchive returns reader once its first byte arrives, so failing to start archiving errors here, not on read.
func startedArchive(reader *io.PipeReader) (io.ReadCloser, error) {
buffered := bufio.NewReader(reader)
if _, err := buffered.Peek(1); err != nil {
reader.Close()
return nil, err
}
return struct {
io.Reader
io.Closer
}{buffered, reader}, nil
}
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error { func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
tw := tar.NewWriter(w) tw := tar.NewWriter(w)
for _, file := range files { for _, file := range files {
+9 -7
View File
@@ -28,10 +28,12 @@ func gitCmd(t *testing.T, args ...string) {
} }
func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) { func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
repoDir := filepath.Join(t.TempDir(), "mygitrepo") repoDir := t.TempDir()
for _, dir := range []string{repoDir, filepath.Join(repoDir, "sub"), filepath.Join(repoDir, "nested")} { gitCmd(t, "init", "-q", "--template=", repoDir)
gitCmd(t, "init", "-q", dir) for _, dir := range []string{"sub", "nested"} {
require.NoError(t, os.CopyFS(filepath.Join(repoDir, dir, ".git"), os.DirFS(filepath.Join(repoDir, ".git"))))
} }
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "ign"), 0o755))
for name, body := range map[string]string{ for name, body := range map[string]string{
".gitignore": ".*\nign/\n", ".gitignore": ".*\nign/\n",
".env": "", ".env": "",
@@ -43,15 +45,15 @@ func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
"sub/kept": "", "sub/kept": "",
"nested/kept": "", "nested/kept": "",
} { } {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repoDir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644))
} }
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755)) require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755))
gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked") gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked")
require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed"))) require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed")))
for _, gitlink := range []string{"sub", "uninitialized"} { gitCmd(t, "-C", repoDir, "update-index", "--add",
gitCmd(t, "-C", repoDir, "update-index", "--add", "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,"+gitlink) "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,sub",
} "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,uninitialized",
)
expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""} expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""}
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky)) require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky))
+1 -1
View File
@@ -472,7 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts(ctx) binds, mounts, _ := rc.GetBindsAndMounts(ctx)
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv() { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
+1 -1
View File
@@ -92,7 +92,7 @@ func TestCompositeActionParity(t *testing.T) {
return "", "", assert.AnError return "", "", assert.AnError
}) })
rc := &RunContext{ rc := &RunContext{
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}}, Config: &Config{GitHubInstance: "github.com", PresetGitHubContext: &model.GithubContext{}},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
} }
+17 -13
View File
@@ -249,6 +249,7 @@ func TestActionRunner(t *testing.T) {
} }
func TestGoActionUsesLinuxExecutablePaths(t *testing.T) { func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
actionRoot := t.TempDir()
for _, stage := range []string{"main", "pre", "post"} { for _, stage := range []string{"main", "pre", "post"} {
t.Run(stage, func(t *testing.T) { t.Run(stage, func(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
@@ -264,7 +265,7 @@ func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
JobContainer: cm, JobContainer: cm,
}, },
action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}}, action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}},
actionDir: t.TempDir(), actionDir: filepath.Join(actionRoot, stage),
env: map[string]string{}, env: map[string]string{},
remoteAction: mustNewRemoteAction(t, "org/repo@ref"), remoteAction: mustNewRemoteAction(t, "org/repo@ref"),
} }
@@ -504,11 +505,21 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
} }
func TestExecAsDockerImageChecks(t *testing.T) { func TestExecAsDockerImageChecks(t *testing.T) {
actionRoot := t.TempDir()
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
t.Cleanup(func() { t.Cleanup(func() {
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
}) })
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} } ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
var removed bool
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = true
_, _ = io.WriteString(writer, `[]`)
} else {
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
})
for _, testcase := range []struct { for _, testcase := range []struct {
name, platform string name, platform string
@@ -529,18 +540,11 @@ func TestExecAsDockerImageChecks(t *testing.T) {
checks = append(checks, platform) checks = append(checks, platform)
return testcase.exists && (platform == "any" || testcase.matches), nil return testcase.exists && (platform == "any" || testcase.matches), nil
} }
var built, removed bool var built bool
removed = false
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor { ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
return func(context.Context) error { built = true; return nil } return func(context.Context) error { built = true; return nil }
} }
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = true
_, _ = io.WriteString(writer, `[]`)
} else {
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
})
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "action"}, Step: &model.Step{ID: "action"},
RunContext: &RunContext{ RunContext: &RunContext{
@@ -555,7 +559,7 @@ func TestExecAsDockerImageChecks(t *testing.T) {
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}}, action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
env: map[string]string{}, env: map[string]string{},
} }
dir := t.TempDir() dir := filepath.Join(actionRoot, testcase.name)
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain)) require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
assert.Equal(t, testcase.wantChecks, checks) assert.Equal(t, testcase.wantChecks, checks)
assert.Equal(t, testcase.wantBuild, built) assert.Equal(t, testcase.wantBuild, built)
@@ -727,9 +731,9 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
cm.On("Close").Return(func(context.Context) error { return nil }) cm.On("Close").Return(func(context.Context) error { return nil })
if tc.stage == stepStageMain { if tc.stage == stepStageMain {
require.NoError(t, runActionImpl(step, t.TempDir(), step.remoteAction)(ctx)) require.NoError(t, runActionImpl(step, "", step.remoteAction)(ctx))
} else { } else {
require.NoError(t, execAsDocker(ctx, step, "action", t.TempDir(), t.TempDir(), false, tc.stage)) require.NoError(t, execAsDocker(ctx, step, "action", "", "", false, tc.stage))
} }
if tc.warning == "" { if tc.warning == "" {
assert.Empty(t, hook.AllRecords()) assert.Empty(t, hook.AllRecords())
+5 -1
View File
@@ -118,7 +118,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg)) rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
return false return false
case "group", "endgroup": case "group", "endgroup":
return true if strings.HasPrefix(line, "::"+command+"::") || strings.HasPrefix(line, "##["+command+"]") {
return true
}
rawLogger.Infof("::%s::%s", command, EscapeCommandData(arg))
return false
} }
return echoed return echoed
} }
+20 -2
View File
@@ -193,6 +193,11 @@ func TestCommandLogWriter(t *testing.T) {
log []string log []string
masks []string masks []string
}{ }{
{
name: "mask commands decode modern and legacy escapes once",
steps: [][]string{{"::add-mask::%25", "##[add-mask]alpha beta%3Bgamma%5Ddelta%250A"}},
masks: []string{"%", "alpha beta;gamma]delta%0A"},
},
{ {
name: "applied commands are hidden unless echoed", name: "applied commands are hidden unless echoed",
steps: [][]string{{ steps: [][]string{{
@@ -235,10 +240,23 @@ func TestCommandLogWriter(t *testing.T) {
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]", "::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
}, },
}, },
{
name: "groups normalize unsupported prefixes and preserve recognized prefixes",
steps: [][]string{{
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
"::GrOuP::a%25b%0Ac", "::EnDgRoUp::", " \t::group::indented", " \t::endgroup::",
"prefix ##[GrOuP]a%3Bb%5Dc%253B%0A", "prefix ##[EnDgRoUp]",
}},
log: []string{
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
"::group::a%25b%0Ac", "::endgroup::", "::group::indented", "::endgroup::",
"::group::a;b]c%253B%0A", "::endgroup::",
},
},
{ {
name: "stop-commands keeps lines verbatim until resumed", name: "stop-commands keeps lines verbatim until resumed",
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}}, steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::", "::add-mask::masked"}},
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"}, log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::"},
masks: []string{"resume-token", "masked"}, masks: []string{"resume-token", "masked"},
}, },
{ {
+5
View File
@@ -82,3 +82,8 @@ func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
err, _ := args.Get(1).(error) err, _ := args.Get(1).(error)
return info, err return info, err
} }
func (cm *containerMock) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
args := cm.Called(stdout, stderr)
return args.Get(0).(io.Writer), args.Get(1).(io.Writer)
}
@@ -49,6 +49,7 @@ func TestDockerProxyMountedJob(t *testing.T) {
ContainerNamePrefix: resourceName, ContainerNamePrefix: resourceName,
ContainerDaemonSocket: dockerClient.DaemonHost(), ContainerDaemonSocket: dockerClient.DaemonHost(),
ContainerMaxLifetime: 2 * time.Minute, ContainerMaxLifetime: 2 * time.Minute,
Sweep: true,
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro", ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"}, ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName}, Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
+1 -1
View File
@@ -136,7 +136,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
followSymlink := false followSymlink := false
for i, p := range v { for i, p := range v {
s := p.String() s := exprparser.CoerceToString(p)
if i == 0 && strings.HasPrefix(s, "--") { if i == 0 && strings.HasPrefix(s, "--") {
if strings.EqualFold(s, "--follow-symbolic-links") { if strings.EqualFold(s, "--follow-symbolic-links") {
followSymlink = true followSymlink = true
+6 -7
View File
@@ -178,7 +178,7 @@ func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writ
} }
func TestGetHashFilesFunction(t *testing.T) { func TestGetHashFilesFunction(t *testing.T) {
const failureMessage = "hashFiles('dir''file, *.sum') failed. Fail to hash files under directory '/workspace'" const failureMessage = "hashFiles('dir''file, *.sum, 123, true, ') failed. Fail to hash files under directory '/workspace'"
for _, test := range []struct { for _, test := range []struct {
name string name string
output string output string
@@ -192,7 +192,7 @@ func TestGetHashFilesFunction(t *testing.T) {
{name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"}, {name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
{name: "no matches", output: "__OUTPUT____OUTPUT__"}, {name: "no matches", output: "__OUTPUT____OUTPUT__"},
{name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage}, {name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage},
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum') couldn't finish within 120 seconds"}, {name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum, 123, true, ') couldn't finish within 120 seconds"},
{name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()}, {name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()},
{name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true}, {name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
{name: "node not executable", execErr: container.ExitCodeError(126), warning: true}, {name: "node not executable", execErr: container.ExitCodeError(126), warning: true},
@@ -205,7 +205,7 @@ func TestGetHashFilesFunction(t *testing.T) {
jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr} jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor { jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command) assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command)
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum", "followSymbolicLinks": "true"}, env) assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum\n123\ntrue\n", "followSymbolicLinks": "true"}, env)
assert.Empty(t, user) assert.Empty(t, user)
assert.Empty(t, workdir) assert.Empty(t, workdir)
return func(_ctx context.Context) error { return func(_ctx context.Context) error {
@@ -223,7 +223,7 @@ func TestGetHashFilesFunction(t *testing.T) {
result, err := getHashFilesFunction(ctx, &RunContext{ result, err := getHashFilesFunction(ctx, &RunContext{
Config: &Config{Workdir: "/workspace"}, Config: &Config{Workdir: "/workspace"},
JobContainer: jobContainer, JobContainer: jobContainer,
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum")}) })([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum"), reflect.ValueOf(float64(123)), reflect.ValueOf(true), {}})
if test.err == "" { if test.err == "" {
require.NoError(t, err) require.NoError(t, err)
} else { } else {
@@ -412,11 +412,10 @@ func TestGetEvaluatorInputsBoolean(t *testing.T) {
} }
for _, eventName := range []string{"workflow_call", "workflow_dispatch"} { for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
require.NoError(t, err)
for _, table := range tables { for _, table := range tables {
t.Run(eventName+"/"+table.name, func(t *testing.T) { t.Run(eventName+"/"+table.name, func(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
require.NoError(t, err)
rc := &RunContext{ rc := &RunContext{
Config: &Config{Workdir: "."}, Config: &Config{Workdir: "."},
Run: &model.Run{JobID: "job1", Workflow: wf}, Run: &model.Run{JobID: "job1", Workflow: wf},
+4 -3
View File
@@ -993,6 +993,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
tw := tar.NewWriter(buf) tw := tar.NewWriter(buf)
for _, entry := range entries { for _, entry := range entries {
buf.Grow(len(entry.body) + 3*512)
typeflag := entry.typeflag typeflag := entry.typeflag
if typeflag == 0 { if typeflag == 0 {
typeflag = tar.TypeReg typeflag = tar.TypeReg
@@ -1009,7 +1010,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
} }
require.NoError(t, tw.WriteHeader(header)) require.NoError(t, tw.WriteHeader(header))
if typeflag == tar.TypeReg { if typeflag == tar.TypeReg {
_, err := tw.Write([]byte(entry.body)) _, err := io.CopyN(tw, strings.NewReader(entry.body), int64(len(entry.body)))
require.NoError(t, err) require.NoError(t, err)
} }
} }
@@ -1149,12 +1150,12 @@ func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
content, err := testcase.encoding.NewEncoder().Bytes([]byte(testcase.content)) content, err := testcase.encoding.NewEncoder().String(testcase.content)
require.NoError(t, err) require.NoError(t, err)
cm := &containerMock{} cm := &containerMock{}
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return( cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{ io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
name: "step-summary-0.md", body: string(content), name: "step-summary-0.md", body: content,
}))), }))),
nil, nil,
).Once() ).Once()
+37 -9
View File
@@ -263,29 +263,27 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
return oldnew return oldnew
} }
// AppendSecretMasker registers v and each of its lines, as GitHub does. // AppendSecretMasker skips one-byte line fragments to avoid masking punctuation from structured secrets.
func AppendSecretMasker(oldnew []string, v string) []string { func AppendSecretMasker(oldnew []string, v string) []string {
ret := appendMaskedValue(oldnew, v) ret := appendMaskedValue(oldnew, v)
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) { for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
ret = appendMaskedValue(ret, strings.TrimSpace(l)) if line := strings.TrimSpace(l); len(line) > 1 {
ret = appendMaskedValue(ret, line)
}
} }
return ret return ret
} }
// appendMaskedValue registers one value and every shape it takes on its way into a log.
func appendMaskedValue(ret []string, v string) []string { func appendMaskedValue(ret []string, v string) []string {
// formatted JSON secrets could otherwise mask {,[,],} everywhere if strings.TrimSpace(v) == "" || slices.Contains(ret, v) {
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
return ret return ret
} }
ret = append(ret, v, "***") ret = append(ret, v, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(v, "%\r\n") { if strings.ContainsAny(v, "%\r\n") {
ret = append(ret, EscapeCommandData(v), "***") ret = append(ret, EscapeCommandData(v), "***")
} }
for _, encode := range secretValueEncoders { for _, encode := range secretValueEncoders {
encoded := encode(v) encoded := encode(v)
// An encoding that leaves the value unchanged is already masked above.
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) { if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
continue continue
} }
@@ -319,7 +317,7 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
masks := Masks(ctx) masks := Masks(ctx)
if len(*masks) == 0 { if len(*masks) == 0 {
r.Message = defReplacer.Replace(r.Message) r.Message = MaskLog(r.Message, defReplacer)
return r return r
} }
@@ -339,12 +337,42 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
cmasker := replacer cmasker := replacer
mu.Unlock() mu.Unlock()
r.Message = cmasker.Replace(r.Message) r.Message = MaskLog(r.Message, cmasker)
return r return r
} }
} }
// MaskLog decodes command data before masking to cover partially escaped values.
func MaskLog(message string, replacer *strings.Replacer) string {
var command, data string
var found bool
unescape := UnescapeCommandData
if strings.HasPrefix(message, "##[") {
command, data, found = strings.Cut(message[3:], "]")
unescape = UnescapeLegacyCommand
} else if strings.HasPrefix(message, "::") {
command, data, found = strings.Cut(message[2:], "::")
if name, _, properties := strings.Cut(command, " "); properties {
switch name {
case "error", "warning", "notice", "debug":
command = name
}
}
}
switch command {
case "error", "warning", "notice", "debug", "group", "endgroup", "add-matcher":
if found {
trimmedData := strings.TrimRight(data, "\r\n")
normalized := message[:len(message)-len(data)] + EscapeCommandData(unescape(trimmedData)) + data[len(trimmedData):]
if masked := replacer.Replace(normalized); masked != normalized {
return masked
}
}
}
return replacer.Replace(message)
}
type maskedHandler struct { type maskedHandler struct {
logging.Handler logging.Handler
masker entryProcessor masker entryProcessor
+61 -4
View File
@@ -28,6 +28,7 @@ func TestValueMasker(t *testing.T) {
secrets map[string]string secrets map[string]string
masks []string masks []string
disallowed []string disallowed []string
want string
}{ }{
{ {
name: "Multiline Private Key", name: "Multiline Private Key",
@@ -49,6 +50,60 @@ func TestValueMasker(t *testing.T) {
secrets: map[string]string{"TOKEN": "pass%word"}, secrets: map[string]string{"TOKEN": "pass%word"},
disallowed: []string{"pass%25word"}, disallowed: []string{"pass%25word"},
}, },
{
name: "one-character mask",
lines: "%",
masks: []string{"%"},
want: "***",
},
{
name: "fully escaped legacy secret",
lines: "##[group]alpha beta%3Bgamma%5Ddelta",
secrets: map[string]string{"TOKEN": "alpha beta;gamma]delta"},
want: "##[group]***",
},
{
name: "partially escaped legacy mask",
lines: "##[group]alpha beta%3Bgamma]delta",
masks: []string{"alpha beta;gamma]delta"},
want: "##[group]***",
},
{
name: "partially escaped modern mask preserves unrelated escapes",
lines: "::group::alpha%25beta%gamma tail%250A%0A",
masks: []string{"alpha%beta%gamma"},
want: "::group::*** tail%250A%0A",
},
{
name: "command escapes are decoded once",
lines: "##[group]alpha%253Bbeta%5Dgamma",
masks: []string{"alpha;beta]gamma"},
want: "##[group]alpha%253Bbeta%5Dgamma",
},
{
name: "plain output preserves escapes",
lines: "alpha beta%3Bgamma%5Ddelta",
masks: []string{"alpha beta;gamma]delta"},
want: "alpha beta%3Bgamma%5Ddelta",
},
{
name: "command display preserves literal escapes around a mask",
lines: "##[command]alpha tail%3Bvalue%5D",
masks: []string{"alpha"},
want: "##[command]*** tail%3Bvalue%5D",
},
{
name: "unknown modern commands preserve literal escapes",
lines: "::unknown::alpha tail%25value",
masks: []string{"alpha"},
want: "::unknown::*** tail%25value",
},
{
name: "masks can span command headers and data",
lines: "::group::hidden other",
masks: []string{"group::hidden", "other"},
want: "::*** ***",
},
} }
for _, entry := range table { for _, entry := range table {
t.Run(entry.name, func(t *testing.T) { t.Run(entry.name, func(t *testing.T) {
@@ -56,6 +111,9 @@ func TestValueMasker(t *testing.T) {
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets)) masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
for line := range strings.SplitSeq(entry.lines, "\n") { for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(ctx, logging.Record{Message: line}) lentry := masker(ctx, logging.Record{Message: line})
if entry.want != "" {
assert.Equal(t, entry.want, lentry.Message)
}
for _, line := range entry.disallowed { for _, line := range entry.disallowed {
assert.NotContains(t, lentry.Message, line) assert.NotContains(t, lentry.Message, line)
} }
@@ -139,6 +197,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"}, {"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
{"##[group]s3cr3t value\r\n", "##[group]***\r\n"},
} { } {
entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line}) entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line})
assert.Equal(t, tc.want, entry.Message, tc.line) assert.Equal(t, tc.want, entry.Message, tc.line)
@@ -194,17 +253,15 @@ func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
} }
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) { func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
// A token with no character an escape would touch only gains its base64 forms:
// JSON, query and path escaping all leave it unchanged.
pairs := AppendSecretMasker(nil, "plaintoken") pairs := AppendSecretMasker(nil, "plaintoken")
assert.Equal(t, []string{ assert.Equal(t, []string{
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***", "plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***", "aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
}, pairs) }, pairs)
// Too short to mask. assert.Contains(t, AppendSecretMasker(nil, "x"), "x")
assert.Empty(t, AppendSecretMasker(nil, "x"))
assert.Empty(t, AppendSecretMasker(nil, " \t")) assert.Empty(t, AppendSecretMasker(nil, " \t"))
assert.NotContains(t, AppendSecretMasker(nil, "{\nvalue\n}"), "{")
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456") assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
} }
+7 -6
View File
@@ -4,6 +4,7 @@
package runner package runner
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"io" "io"
@@ -209,14 +210,14 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte") assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
return return
} }
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed") assert.False(t, bytes.Contains(out, []byte(".LOCALHOST")), "a copy of the gate was missed")
if !strings.Contains(string(data), CacheServiceV2Env) { if !bytes.Contains(data, []byte(CacheServiceV2Env)) {
return return
} }
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"), assert.NotZero(t, bytes.Count(out, []byte("(process.env."+cacheURLEnv+"||process.env")),
"the cache service URL was not redirected") "the cache service URL was not redirected")
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv), assert.Equal(t, bytes.Count(data, []byte(resultsURLEnv)), bytes.Count(out, []byte(resultsURLEnv)),
"a read of the results URL was lost, it must stay as the fallback") "a read of the results URL was lost, it must stay as the fallback")
}) })
} }
@@ -311,18 +312,18 @@ func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
requireHostTools(t, "node", "rustc") requireHostTools(t, "node", "rustc")
dir := tempDirPath(t) dir := tempDirPath(t)
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist"), 0o755))
for _, name := range []string{ for _, name := range []string{
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js", "action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js", "dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
} { } {
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name)) body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
} }
body, err := os.ReadFile(filepath.Join(dir, "action.yml")) body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
require.NoError(t, err) require.NoError(t, err)
action, err := model.ReadAction(strings.NewReader(string(body))) action, err := model.ReadAction(bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"} inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
for name, input := range action.Inputs { for name, input := range action.Inputs {
+14 -12
View File
@@ -4,6 +4,7 @@
package runner package runner
import ( import (
"bytes"
"context" "context"
"os" "os"
"os/exec" "os/exec"
@@ -182,9 +183,9 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
out, patched := patchedBundle([]byte(embedded)) out, patched := patchedBundle([]byte(embedded))
require.True(t, patched) require.True(t, patched)
file := filepath.Join(t.TempDir(), "bundle.js") cmd := exec.CommandContext(t.Context(), "node", "--check", "-")
require.NoError(t, os.WriteFile(file, out, 0o600)) cmd.Stdin = bytes.NewReader(out)
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput() checked, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", checked) require.NoError(t, err, "%s", checked)
} }
@@ -256,6 +257,14 @@ func TestActionScriptPaths(t *testing.T) {
} }
func TestPatchActionsAtTheContainerCopy(t *testing.T) { func TestPatchActionsAtTheContainerCopy(t *testing.T) {
actionDir := t.TempDir()
script := filepath.Join(actionDir, "dist", "cache.js")
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
info, err := os.Stat(script)
require.NoError(t, err)
copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) { copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) {
t.Helper() t.Helper()
@@ -264,19 +273,12 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
Step: &model.Step{Uses: "owner/repo/sub@v1"}, Step: &model.Step{Uses: "owner/repo/sub@v1"},
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"}, remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}}, action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}},
actionDir: t.TempDir(), actionDir: actionDir,
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch}, Config: &Config{NoActionPatch: noPatch},
JobContainer: cm, JobContainer: cm,
}, },
} }
script := filepath.Join(sar.actionDir, "dist", "cache.js")
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
info, err := os.Stat(script)
require.NoError(t, err)
var calls []string var calls []string
var copied []*container.FileEntry var copied []*container.FileEntry
cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error { cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error {
+8 -16
View File
@@ -33,24 +33,17 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
} }
remoteDir := t.TempDir() remoteDir := t.TempDir()
gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir) gitMust(t, "", "init", "--template=", "--initial-branch=master", remoteDir)
workDir := t.TempDir()
gitMust(t, "", "clone", remoteDir, workDir)
gitMust(t, workDir, "config", "user.email", "test@test")
gitMust(t, workDir, "config", "user.name", "test")
gitMust(t, workDir, "checkout", "-b", "master")
const workflowPath = ".gitea/workflows/reusable.yml" const workflowPath = ".gitea/workflows/reusable.yml"
tmpl := func(tag string) string { tmpl := func(tag string) string {
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n" return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
} }
require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(remoteDir, ".gitea/workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v1")), 0o644))
gitMust(t, workDir, "add", workflowPath) gitMust(t, remoteDir, "add", workflowPath)
gitMust(t, workDir, "commit", "-m", "v1") gitMust(t, remoteDir, "commit", "-m", "v1")
gitMust(t, workDir, "push", "-u", "origin", "master")
rc := &RunContext{ rc := &RunContext{
Config: &Config{Env: testCheckoutEnv()}, Config: &Config{Env: testCheckoutEnv()},
@@ -70,9 +63,8 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
require.Equal(t, tmpl("v1"), string(got)) require.Equal(t, tmpl("v1"), string(got))
// Branch tip moves; cache key (cacheDir) does not. // Branch tip moves; cache key (cacheDir) does not.
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v2")), 0o644))
gitMust(t, workDir, "commit", "-am", "v2") gitMust(t, remoteDir, "commit", "-am", "v2")
gitMust(t, workDir, "push", "origin", "master")
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context())) require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath)) got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
@@ -214,7 +206,7 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
func gitMust(t *testing.T, dir string, args ...string) { func gitMust(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", append([]string{"-c", "user.name=test", "-c", "user.email=test@test"}, args...)...)
if dir != "" { if dir != "" {
cmd.Dir = dir cmd.Dir = dir
} }
+66 -8
View File
@@ -30,6 +30,7 @@ import (
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/ghcontext" "gitea.com/gitea/runner/act/ghcontext"
"gitea.com/gitea/runner/internal/pkg/lock" "gitea.com/gitea/runner/internal/pkg/lock"
@@ -75,6 +76,7 @@ type RunContext struct {
actionInputs map[string]any // inputs of the composite action this runs, nil for a job actionInputs map[string]any // inputs of the composite action this runs, nil for a job
Masks []string Masks []string
cleanUpJobContainer common.Executor cleanUpJobContainer common.Executor
taskWorkspace string
deferVolumeCleanup func(common.Executor) deferVolumeCleanup func(common.Executor)
caller *caller // job calling this RunContext (reusable workflows) caller *caller // job calling this RunContext (reusable workflows)
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
@@ -271,7 +273,7 @@ func (rc *RunContext) validVolumes() []string {
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath()) volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
} }
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
volumes = append(volumes, sharedToolCacheVolume) volumes = append(volumes, rc.toolCacheVolume())
} }
if rc.Config.BindWorkdir { if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot()) volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
@@ -331,6 +333,13 @@ func (rc *RunContext) startDockerProxy(ctx context.Context) {
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName()) rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
} }
func (rc *RunContext) toolCacheVolume() string {
if rc.Config.ToolCacheSlot == "" {
return sharedToolCacheVolume
}
return sharedToolCacheVolume + "-" + rc.Config.ToolCacheSlot
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string { func (rc *RunContext) toolCache(fallback string) string {
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback) return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
@@ -378,8 +387,8 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
return binds, mounts, targets return binds, mounts, targets
} }
// Returns the binds and mounts for the container, resolving paths as appopriate // Returns the container's binds, mounts and bound task workspace, resolving paths as appropriate
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) { func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string, string) {
name := rc.jobContainerName() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
@@ -391,7 +400,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
} }
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] { if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
mounts[sharedToolCacheVolume] = toolCache mounts[rc.toolCacheVolume()] = toolCache
} }
} }
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok { if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
@@ -399,12 +408,16 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
} }
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
taskWorkspace := ""
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] { if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
source := rc.workdirMountRoot() source := rc.workdirMountRoot()
target := ext.ToContainerPath(source) target := ext.ToContainerPath(source)
if claimed[target] { if claimed[target] {
source, target = rc.Config.Workdir, workdir source, target = rc.Config.Workdir, workdir
} }
if rc.Config.BindWorkdir && rc.Config.PresetGitHubContext != nil {
taskWorkspace = target
}
if rc.Config.BindWorkdir { if rc.Config.BindWorkdir {
bindModifiers := "" bindModifiers := ""
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
@@ -419,7 +432,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
} }
} }
return binds, mounts return binds, mounts, taskWorkspace
} }
func (rc *RunContext) startHostEnvironment() common.Executor { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -447,6 +460,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
toolCacheParent = cacheDir toolCacheParent = cacheDir
} }
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache")) toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
if rc.Config.ToolCacheSlot != "" {
toolCache = filepath.Join(toolCache, rc.Config.ToolCacheSlot)
rc.Env["RUNNER_TOOL_CACHE"] = toolCache // overrides a RUNNER_TOOL_CACHE the slots would otherwise share
}
if err := os.MkdirAll(toolCache, 0o777); err != nil { if err := os.MkdirAll(toolCache, 0o777); err != nil {
return err return err
} }
@@ -672,7 +689,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
return err return err
} }
rc.startDockerProxy(ctx) rc.startDockerProxy(ctx)
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx) var taskWorkspace string
containerInput.Binds, containerInput.Mounts, taskWorkspace = rc.GetBindsAndMounts(ctx)
containerInput.ValidVolumes = rc.validVolumes() containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
@@ -687,6 +705,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) }, func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
rc.JobContainer.Start(false), rc.JobContainer.Start(false),
func(context.Context) error { rc.taskWorkspace = taskWorkspace; return nil },
rc.captureJobContainerInfo(), rc.captureJobContainerInfo(),
rc.copyWorkflowFiles(), rc.copyWorkflowFiles(),
rc.waitForServiceContainers(), rc.waitForServiceContainers(),
@@ -801,7 +820,10 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
errs := []error{rc.closeDockerProxy(ctx)} errs := []error{rc.closeDockerProxy(ctx)}
var containers []common.Executor var containers []common.Executor
if rc.JobContainer != nil { if rc.JobContainer != nil {
containers = append(containers, rc.JobContainer.Remove()) containers = append(containers, func(ctx context.Context) error {
rc.emptyTaskWorkspace(ctx)
return rc.JobContainer.Remove()(ctx)
})
} }
if len(rc.serviceContainers) > 0 { if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName) logger.Infof("Cleaning up services for job %s", rc.JobName)
@@ -810,7 +832,7 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
errs = append(errs, runAll(ctx, containers)) errs = append(errs, runAll(ctx, containers))
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout) ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancel() defer cancel()
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) { if !common.Dryrun(ctx) && rc.Config.Sweep && (preclean || rc.hadDockerProxy) {
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks
} }
var rest []common.Executor var rest []common.Executor
@@ -831,6 +853,42 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
} }
} }
// exits 3 unless workspace $1 is safe to empty per mountinfo $2
const emptyTaskWorkspaceScript = `case "$1" in /?*/?*) ;; *) exit 3 ;; esac
mounted=
while read -r _ _ _ _ point _; do
case "$point" in "$1") mounted=1 ;; "$1"/*) exit 3 ;; esac
done <"$2"
[ "$mounted" ] || exit 3
rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*`
var runnerCanRemoveJobFiles = runtime.GOOS != "linux" || os.Geteuid() == 0 // root, or a desktop VM mapping file ownership, replaced in tests
// runs as root with root-owned PATH dirs only, skipped for called workflows sharing the workspace
func (rc *RunContext) emptyTaskWorkspace(ctx context.Context) {
if rc.taskWorkspace == "" || rc.caller != nil || runnerCanRemoveJobFiles {
return
}
budget := 30 * time.Second
if deadline, ok := ctx.Deadline(); ok {
budget = min(budget, time.Until(deadline)/2-container.DrainGracePeriod)
}
ctx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
defer rc.JobContainer.ReplaceLogWriter(stdout, stderr)
err := rc.JobContainer.Exec(
[]string{"sh", "-c", emptyTaskWorkspaceScript, "sh", rc.taskWorkspace, "/proc/self/mountinfo"},
map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/",
)(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
switch {
case errors.Is(err, container.ExitCodeError(3)):
logging.Infof("not emptying workspace %s of %s: under two levels deep, not a mountpoint as given, or a mount below it", rc.taskWorkspace, rc.jobContainerName())
case err != nil:
logging.Infof("could not empty workspace %s of %s: %v", rc.taskWorkspace, rc.jobContainerName(), err)
}
}
func runAll(ctx context.Context, executors []common.Executor) error { func runAll(ctx context.Context, executors []common.Executor) error {
errs := make([]error, len(executors)) errs := make([]error, len(executors))
var wg sync.WaitGroup var wg sync.WaitGroup
+158 -17
View File
@@ -16,6 +16,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices" "slices"
@@ -455,7 +456,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
t.Run(testcase.name+testBindSuffix, func(t *testing.T) { t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
rctemplate.Config.Workdir = testcase.name rctemplate.Config.Workdir = testcase.name
rctemplate.Config.BindWorkdir = bindWorkDir rctemplate.Config.BindWorkdir = bindWorkDir
gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context()) gotbind, gotmount, _ := rctemplate.GetBindsAndMounts(t.Context())
if bindWorkDir { if bindWorkDir {
fullBind := testcase.name + ":" + testcase.want fullBind := testcase.name + ":" + testcase.want
@@ -507,7 +508,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context())) require.NoError(t, rc.resolvePlatformImage(t.Context()))
gotbind, gotmount := rc.GetBindsAndMounts(t.Context()) gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -522,7 +523,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.containerSpec.Volumes = testcase.volumes rc.containerSpec.Volumes = testcase.volumes
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
gotbind, gotmount := rc.GetBindsAndMounts(t.Context()) gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -551,13 +552,19 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
t.Run("ToolCacheMount", func(t *testing.T) { t.Run("ToolCacheMount", func(t *testing.T) {
rc := newRunContext(&Config{}) rc := newRunContext(&Config{})
_, gotmount := rc.GetBindsAndMounts(t.Context()) _, gotmount, _ := rc.GetBindsAndMounts(t.Context())
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache} rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
_, gotmount = rc.GetBindsAndMounts(t.Context()) _, gotmount, _ = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
rc.Config.ToolCacheSlot = "1"
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume+"-1"])
assert.NotContains(t, gotmount, sharedToolCacheVolume)
assert.Contains(t, rc.validVolumes(), sharedToolCacheVolume+"-1")
}) })
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) { t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
@@ -569,16 +576,26 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}}, Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
} }
gotbind, _ := rc.GetBindsAndMounts(t.Context()) gotbind, _, workspace := rc.GetBindsAndMounts(t.Context())
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
assert.Equal(t, "/workspace/1", workspace)
rc.Config.BindWorkdir = false rc.Config.BindWorkdir = false
_, gotmount := rc.GetBindsAndMounts(t.Context()) _, gotmount, workspace := rc.GetBindsAndMounts(t.Context())
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
assert.Empty(t, workspace)
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"} rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
_, gotmount = rc.GetBindsAndMounts(t.Context()) _, gotmount, _ = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
rc.Config.BindWorkdir = true
_, _, workspace = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, "/workspace/1/owner/repo", workspace)
rc.containerSpec.Volumes = nil
rc.Config.PresetGitHubContext = nil
_, _, workspace = rc.GetBindsAndMounts(t.Context())
assert.Empty(t, workspace)
}) })
} }
@@ -611,7 +628,7 @@ func TestRunContextValidVolumes(t *testing.T) {
rc.JobContainer = hostEnv rc.JobContainer = hostEnv
rc.Config.BindWorkdir = false rc.Config.BindWorkdir = false
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
binds, mounts := rc.GetBindsAndMounts(t.Context()) binds, mounts, _ := rc.GetBindsAndMounts(t.Context())
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()}) assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
assert.Contains(t, mounts, sharedToolCacheVolume) assert.Contains(t, mounts, sharedToolCacheVolume)
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds)) assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
@@ -683,6 +700,110 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes))) assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
} }
func TestCleanupJobResourcesEmptiesTaskWorkspace(t *testing.T) {
defer func(previous bool) { runnerCanRemoveJobFiles = previous }(runnerCanRemoveJobFiles)
for _, tt := range []struct {
name, workspace string
caller *caller
runnerCanRemove bool
execErr error
log string
}{
{name: "refusal is logged", workspace: "/workspace/1", execErr: container.ExitCodeError(3), log: "not emptying workspace /workspace/1"},
{name: "failure does not fail the job", workspace: "/workspace/1", execErr: errors.New("no rm"), log: "could not empty workspace /workspace/1"},
{name: "no runner bind"},
{name: "called workflow shares it", workspace: "/workspace/1", caller: &caller{}},
{name: "runner removes it itself", workspace: "/workspace/1", runnerCanRemove: true},
} {
t.Run(tt.name, func(t *testing.T) {
runnerCanRemoveJobFiles = tt.runnerCanRemove
processLog := logtest.SetDefault(t)
var jobLog bytes.Buffer
var calls []string
job := &containerMock{}
if tt.log != "" {
job.On("ReplaceLogWriter", io.Discard, io.Discard).Return(&jobLog, &jobLog).Once()
job.On("ReplaceLogWriter", &jobLog, &jobLog).Return(io.Discard, io.Discard).Once()
job.On("Exec", mock.MatchedBy(func(command []string) bool { return command[4] == tt.workspace }), map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/").Return(func(ctx context.Context) error {
calls = append(calls, "exec")
deadline, ok := ctx.Deadline()
assert.True(t, ok)
assert.LessOrEqual(t, time.Until(deadline), 10*time.Second-container.DrainGracePeriod)
common.Logger(ctx).Infof("workspace script output")
return tt.execErr
})
}
job.On("Remove").Return(func(context.Context) error {
calls = append(calls, "remove")
return nil
})
rc := &RunContext{
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
JobContainer: job,
taskWorkspace: tt.workspace,
caller: tt.caller,
deferVolumeCleanup: func(common.Executor) {},
}
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
ctx = common.WithLogger(ctx, logging.New(logging.NewLevelHandler(newJobLogHandler(&jobLog, cyan), logging.LevelInfo)))
require.NoError(t, rc.cleanupJobResources("", false, false)(ctx))
job.AssertExpectations(t)
assert.NotContains(t, jobLog.String(), "workspace")
if tt.log == "" {
assert.Equal(t, []string{"remove"}, calls)
return
}
assert.Equal(t, []string{"exec", "remove"}, calls)
assert.True(t, slices.ContainsFunc(processLog.AllRecords(), func(record logging.Record) bool {
return strings.HasPrefix(record.Message, tt.log)
}), processLog.AllRecords())
})
}
}
func TestEmptyTaskWorkspaceScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the script runs in the linux job container")
}
run := func(t *testing.T, workspace string, mountpoints ...string) error {
t.Helper()
var mountinfo strings.Builder
for _, mountpoint := range mountpoints {
fmt.Fprintf(&mountinfo, "36 35 98:0 /src %s rw - ext4 /dev/sda1 rw\n", mountpoint)
}
info := filepath.Join(t.TempDir(), "mountinfo")
require.NoError(t, os.WriteFile(info, []byte(mountinfo.String()), 0o600))
return exec.Command("sh", "-c", emptyTaskWorkspaceScript, "sh", workspace, info).Run()
}
workspace := t.TempDir()
for _, file := range []string{"owner/repo/file", ".hidden", "..odd"} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(workspace, file)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workspace, file), nil, 0o600))
}
require.NoError(t, run(t, workspace, workspace))
entries, err := os.ReadDir(workspace)
require.NoError(t, err)
assert.Empty(t, entries)
for _, tt := range []struct {
name, workspace string
mountpoints []string
}{
{"a mount below it", "/nonexistent/1", []string{"/nonexistent/1", "/nonexistent/1/owner/repo/cache"}},
{"a workspace that is not a mountpoint", "/nonexistent/1", []string{"/nonexistent/2"}},
{"a top-level directory", "/nonexistent", []string{"/nonexistent"}},
} {
t.Run("refuses "+tt.name, func(t *testing.T) {
var exitErr *exec.ExitError
require.ErrorAs(t, run(t, tt.workspace, tt.mountpoints...), &exitErr)
assert.Equal(t, 3, exitErr.ExitCode())
})
}
}
func TestRemoveDockerActionImagesByTag(t *testing.T) { func TestRemoveDockerActionImagesByTag(t *testing.T) {
var removed []string var removed []string
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) { fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
@@ -725,7 +846,7 @@ func TestCleanupJobResources(t *testing.T) {
service.On("Remove").Return(removeAlongside(serviceRemoving, jobRemoving, removeError)).Once() service.On("Remove").Return(removeAlongside(serviceRemoving, jobRemoving, removeError)).Once()
service.On("Close").Return(func(context.Context) error { return closeError }).Once() service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"}, Config: &Config{CacheContainer: "cache-container", Sweep: name != "kept proxy"},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"}, Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
JobContainer: job, JobContainer: job,
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
@@ -1181,6 +1302,26 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
assert.Equal(t, want, buf.String()) assert.Equal(t, want, buf.String())
} }
func TestStartHostEnvironmentToolCacheSlotIsSubdirOfSharedCache(t *testing.T) {
cacheDir := t.TempDir()
override := filepath.Join(cacheDir, "override")
for sharedCache, env := range map[string]map[string]string{
filepath.Join(cacheDir, "tool_cache"): {},
override: {"RUNNER_TOOL_CACHE": override},
} {
rc := &RunContext{
Config: &Config{ActionCacheDir: cacheDir, SharedToolCache: true, ToolCacheSlot: "1"},
Env: env,
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
}
require.NoError(t, rc.startHostEnvironment()(t.Context()))
t.Cleanup(func() { require.NoError(t, rc.cleanUpJobContainer(context.Background())) })
want := filepath.Join(sharedCache, "1")
assert.Equal(t, want, rc.JobContainer.(*container.HostEnvironment).ToolCache)
assert.Equal(t, want, rc.Env["RUNNER_TOOL_CACHE"])
}
}
func TestRunContext_cleanupFailedStart(t *testing.T) { func TestRunContext_cleanupFailedStart(t *testing.T) {
type ctxKey string type ctxKey string
const sentinel = ctxKey("sentinel") const sentinel = ctxKey("sentinel")
@@ -1490,7 +1631,7 @@ func TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt(t *testi
return env return env
} }
assert.Equal(t, actPath+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"]) assert.Equal(t, filepath.ToSlash(actPath)+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"])
ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt")) ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "PEM\n", string(ca)) assert.Equal(t, "PEM\n", string(ca))
@@ -1666,21 +1807,21 @@ func TestRunContextUpdateExtraPath(t *testing.T) {
longPath := strings.Repeat("x", 64*1024+1) longPath := strings.Repeat("x", 64*1024+1)
for _, testcase := range []struct { for _, testcase := range []struct {
name string name string
content []byte content string
want string want string
wantErr bool wantErr bool
}{ }{
{name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"}, {name: "UTF-8 BOM", content: "\xef\xbb\xbf/utf8/tools\n", want: "/utf8/tools"},
{name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`}, {name: "UTF-16 BOM", content: "\xff\xfeC\x00:\x00\\\x00t\x00o\x00o\x00l\x00s\x00\n\x00", want: `C:\tools`},
{name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath}, {name: "over 64 KiB", content: longPath + "\n", want: longPath},
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true}, {name: "over 16 MiB", content: strings.Repeat("x", 16*1024*1024+1), wantErr: true},
} { } {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
logger := logging.New(logging.DiscardHandler) logger := logging.New(logging.DiscardHandler)
ctx := common.WithLogger(t.Context(), logger) ctx := common.WithLogger(t.Context(), logger)
jobContainer := &containerMock{} jobContainer := &containerMock{}
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path"). jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once() Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: testcase.content}))), nil).Once()
defer jobContainer.AssertExpectations(t) defer jobContainer.AssertExpectations(t)
rc := &RunContext{JobContainer: jobContainer} rc := &RunContext{JobContainer: jobContainer}
+3 -2
View File
@@ -84,14 +84,15 @@ type Config struct {
JobLoggerLevel *logging.Level // the level of job logger JobLoggerLevel *logging.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
ContainerVolumes []string // mounted into every job, service and docker action container ContainerVolumes []string // mounted into every job, service and docker action container
SharedToolCache bool // one tool cache for all jobs instead of one per job SharedToolCache bool // keeps the tool cache across jobs instead of one per job
ToolCacheSlot string // keeps concurrent tasks' tool caches apart, empty for slot 0 and in exec
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
CAFile string // PEM CA certificates the Gitea instance's certificate chains to CAFile string // PEM CA certificates the Gitea instance's certificate chains to
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process AllocatePTY bool // allocate a pseudo-TTY for each step's process
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default) ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy Sweep bool // remove the containers, networks and volumes a job creates through its docker proxy
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
-1
View File
@@ -455,7 +455,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "nix-prepend-path", "push", "", platforms, secrets}, {workdir, "nix-prepend-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets}, {workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "local-action-dockerfile", "push", "", platforms, secrets}, {workdir, "local-action-dockerfile", "push", "", platforms, secrets},
{workdir, "builtin-checkout", "push", "", platforms, secrets},
{workdir, "inputs-via-env-context", "push", "", platforms, secrets}, {workdir, "inputs-via-env-context", "push", "", platforms, secrets},
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
{workdir, "set-env-step-env-override", "push", "", platforms, secrets}, {workdir, "set-env-step-env-override", "push", "", platforms, secrets},
-68
View File
@@ -1,68 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/action"
"gitea.com/gitea/runner/internal/pkg/action/checkout"
"gitea.dev/actionslib/pkg/model"
)
var builtinActions = map[string]func(context.Context, *action.Context) error{
"checkout": checkout.Main,
}
type stepActionBuiltin struct {
Step *model.Step
RunContext *RunContext
run func(context.Context, *action.Context) error
env map[string]string
}
func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() }
func (sab *stepActionBuiltin) main() common.Executor {
sab.env = map[string]string{}
return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
printRunActionHeader(ctx, sab.Step, nil, sab.env, sab.RunContext)
rc := sab.RunContext
rc.ApplyExtraPath(ctx, &sab.env)
return sab.run(ctx, &action.Context{
Container: rc.JobContainer,
Github: getStepGithubContext(ctx, sab),
Inputs: sab.Step.With,
HostWorkdir: rc.Config.Workdir,
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
BindWorkdir: rc.Config.BindWorkdir,
NoSkipCheckout: rc.Config.NoSkipCheckout,
UseGitIgnore: rc.Config.UseGitIgnore,
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
ClientCertFile: rc.Config.ClientCertFile,
ClientKeyFile: rc.Config.ClientKeyFile,
CAFile: rc.jobCAFile(),
Env: sab.env,
})
})
}
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string {
return sab.Step.If.Value
}
+3 -9
View File
@@ -35,14 +35,8 @@ func setFetch(t *testing.T, fetch func(context.Context, git.NewGitCloneExecutorI
func TestShortSHAActionRejected(t *testing.T) { func TestShortSHAActionRejected(t *testing.T) {
actionRoot := t.TempDir() actionRoot := t.TempDir()
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action") repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
require.NoError(t, os.MkdirAll(repo, 0o755)) gitMust(t, "", "init", "--template=", "--initial-branch=main", repo)
gitMust(t, "", "init", "--initial-branch=main", repo) gitMust(t, repo, "commit", "--allow-empty", "-m", "initial")
gitMust(t, repo, "config", "user.email", "test@test")
gitMust(t, repo, "config", "user.name", "test")
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
gitMust(t, repo, "add", ".")
gitMust(t, repo, "commit", "-m", "initial")
output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output() output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output()
require.NoError(t, err) require.NoError(t, err)
@@ -393,7 +387,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600)) []byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
for _, args := range [][]string{ for _, args := range [][]string{
{"init", "--initial-branch=main", actionDir}, {"init", "--template=", "--initial-branch=main", actionDir},
{"-C", actionDir, "add", "action.yml"}, {"-C", actionDir, "add", "action.yml"},
{"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"}, {"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
} { } {
-15
View File
@@ -6,9 +6,6 @@ package runner
import ( import (
"fmt" "fmt"
"maps"
"slices"
"strings"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
) )
@@ -36,18 +33,6 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
runAction: runActionImpl, runAction: runActionImpl,
}, nil }, nil
case model.StepTypeUsesActionRemote: case model.StepTypeUsesActionRemote:
if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok {
run, ok := builtinActions[name]
if !ok {
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
name, slices.Sorted(maps.Keys(builtinActions)), rc.Name, stepModel)
}
return &stepActionBuiltin{
Step: stepModel,
RunContext: rc,
run: run,
}, nil
}
return &stepActionRemote{ return &stepActionRemote{
Step: stepModel, Step: stepModel,
RunContext: rc, RunContext: rc,
-2
View File
@@ -22,7 +22,6 @@ func TestStepFactoryNewStep(t *testing.T) {
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}}, {"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}}, {"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}}, {"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
{"StepBuiltinAction", &model.Step{Uses: "builtin:checkout"}, &stepActionBuiltin{}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{}) step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
@@ -35,7 +34,6 @@ func TestStepFactoryNewStep(t *testing.T) {
func TestStepFactoryInvalidStep(t *testing.T) { func TestStepFactoryInvalidStep(t *testing.T) {
for _, stepModel := range []*model.Step{ for _, stepModel := range []*model.Step{
{Uses: "remote/action@v1", Run: "cmd"}, {Uses: "remote/action@v1", Run: "cmd"},
{Uses: "builtin:unknown"},
} { } {
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"}) _, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
assert.ErrorContains(t, err, "job:build ", stepModel.Uses) assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
-10
View File
@@ -1,10 +0,0 @@
name: builtin-checkout
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: builtin:checkout
with:
path: ${{ 'sub' }}
- run: test -f sub/builtin-checkout/push.yml
-39
View File
@@ -1,39 +0,0 @@
# Built-in actions
Built-in actions ship with the runner, so a job needs no action download and no Node in its image.
```yaml
- uses: builtin:checkout
```
## checkout
Works like `actions/checkout` with these inputs. Any other input fails the step. It runs `git` where the job runs, see the [prerequisites](../README.md#prerequisites).
| Input | Default |
| --- | --- |
| `repository` | `github.repository` |
| `ref` | the triggering ref and commit, or the default branch of another repository |
| `token` | `github.token` |
| `ssh-key` | empty, a private key fetches over SSH instead |
| `ssh-known-hosts` | empty, host keys trusted besides `~/.ssh/known_hosts` |
| `ssh-strict` | `true`, rejects unknown host keys |
| `ssh-user` | the user of the repository's SSH URL, else `git` |
| `persist-credentials` | `true`, later steps of the job can fetch and push with `token` or `ssh-key` |
| `path` | workspace root |
| `clean` | `true`, removes untracked files, also in submodules |
| `filter` | empty, a partial clone filter such as `blob:none` |
| `sparse-checkout` | empty, the paths to check out, one per line |
| `sparse-checkout-cone-mode` | `true`, `false` reads `sparse-checkout` as patterns |
| `fetch-depth` | `1`, `0` for the full history of every branch and tag |
| `fetch-tags` | `false`, `true` fetches every tag |
| `show-progress` | `true`, shows the fetch progress |
| `lfs` | `false`, `true` downloads Git LFS files |
| `submodules` | `false`, `true` checks out submodules, `recursive` also theirs |
| `set-safe-directory` | `true`, lets checkout's `git` work in a directory another user owns |
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
-22
View File
@@ -1,22 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import "testing"
func testBuiltinCheckout(t *testing.T) {
t.Parallel()
api, repo := newScenario(t)
pushWorkflow(t, api, repo, "checkout.yml")
branchRun := waitForRun(t, api, repo)
if err := api.CreateTag(t.Context(), repo, "v1"); err != nil {
t.Fatalf("create tag: %v", err)
}
tagRun := waitForRunAfter(t, api, repo, branchRun.ID)
requireSuccess(t, api, repo, branchRun.ID)
requireSuccess(t, api, repo, tagRun.ID)
}
+36 -47
View File
@@ -90,20 +90,29 @@ container:
type cliDaemon struct { type cliDaemon struct {
cmd *exec.Cmd cmd *exec.Cmd
log *syncBuffer log *syncBuffer
exited chan error exited chan struct{}
err error
} }
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon { func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
t.Helper() t.Helper()
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan error, 1)} d := &cliDaemon{log: &syncBuffer{}, exited: make(chan struct{})}
d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...) d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
if err := d.cmd.Start(); err != nil { if err := d.cmd.Start(); err != nil {
t.Fatalf("start daemon: %v", err) t.Fatalf("start daemon: %v", err)
} }
go func() { d.exited <- d.cmd.Wait() }() go func() {
d.err = d.cmd.Wait()
close(d.exited)
}()
t.Cleanup(func() { t.Cleanup(func() {
_ = d.cmd.Process.Kill() // no-op once the daemon has exited _ = d.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-d.exited:
case <-time.After(30 * time.Second):
_ = d.cmd.Process.Kill()
}
}) })
return d return d
} }
@@ -112,8 +121,8 @@ func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error { func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
t.Helper() t.Helper()
select { select {
case err := <-d.exited: case <-d.exited:
return err return d.err
case <-time.After(timeout): case <-time.After(timeout):
t.Fatalf("daemon still running after %s\n%s", timeout, d.log) t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
return nil return nil
@@ -140,8 +149,8 @@ func (d *cliDaemon) requireSuccess(t *testing.T, c *cliRunner, runID int64) {
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon. // testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
func testCLIRegisterDaemon(t *testing.T) { func testCLIRegisterDaemon(t *testing.T) {
t.Parallel() t.Parallel()
c := newCLIRunner(t, "e2e-cli", "") c := newCLIRunner(t, "e2e-registered", "")
d := c.startDaemon(t) d := c.startDaemon(t, "--labels", "e2e-cli:docker://"+os.Getenv("E2E_JOB_IMAGE"))
pushWorkflow(t, c.api, c.repo, "cli.yml") pushWorkflow(t, c.api, c.repo, "cli.yml")
wfRun := waitForRun(t, c.api, c.repo) wfRun := waitForRun(t, c.api, c.repo)
@@ -150,6 +159,22 @@ func testCLIRegisterDaemon(t *testing.T) {
t.Errorf("job logs are missing the marker:\n%s", logs) t.Errorf("job logs are missing the marker:\n%s", logs)
} }
runners, err := c.api.Runners(t.Context(), c.repo)
if err != nil {
t.Fatalf("list runners: %v", err)
}
var labels []string
for _, runner := range runners {
if runner.Name == c.repo {
for _, label := range runner.Labels {
labels = append(labels, label.Name)
}
}
}
if !slices.Equal(labels, []string{"e2e-cli"}) {
t.Errorf("runner %q has labels %v on the server, want only the --labels override", c.repo, labels)
}
d.terminate(t) d.terminate(t)
if err := d.wait(t, 30*time.Second); err != nil { if err := d.wait(t, 30*time.Second); err != nil {
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log) t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log)
@@ -186,36 +211,6 @@ func testCLIOnce(t *testing.T) {
} }
} }
// testCLILabelsOverride checks that daemon --labels replaces the labels given at registration.
func testCLILabelsOverride(t *testing.T) {
t.Parallel()
c := newCLIRunner(t, "e2e-registered", "")
d := c.startDaemon(t, "--labels", "e2e-override:docker://"+os.Getenv("E2E_JOB_IMAGE"))
pushWorkflow(t, c.api, c.repo, "cli_labels.yml")
wfRun := waitForRun(t, c.api, c.repo)
d.requireSuccess(t, c, wfRun.ID)
runners, err := c.api.Runners(t.Context(), c.repo)
if err != nil {
t.Fatalf("list runners: %v", err)
}
for _, runner := range runners {
if runner.Name != c.repo {
continue
}
var labels []string
for _, label := range runner.Labels {
labels = append(labels, label.Name)
}
if !slices.Equal(labels, []string{"e2e-override"}) {
t.Errorf("runner has labels %v on the server, want only the --labels override", labels)
}
return
}
t.Errorf("runner %q not found among %+v", c.repo, runners)
}
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout. // testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
func testCLIShutdownDrains(t *testing.T) { func testCLIShutdownDrains(t *testing.T) {
t.Parallel() t.Parallel()
@@ -282,10 +277,6 @@ func testExecArtifacts(t *testing.T) {
t.Parallel() t.Parallel()
ctx := t.Context() ctx := t.Context()
bin, err := runnerBinary()
if err != nil {
t.Fatal(err)
}
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml") workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -309,11 +300,9 @@ func testExecArtifacts(t *testing.T) {
} }
artifacts := t.TempDir() artifacts := t.TempDir()
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network} stdout, stderr, err := runExec(t, nil, "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network)
cmd := exec.CommandContext(ctx, bin, args...) if err != nil {
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }) t.Fatalf("exec: %v\n%s%s", err, stdout, stderr)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("exec: %v\n%s", err, out)
} }
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 { if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs) t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
-2
View File
@@ -29,7 +29,6 @@ func TestCompatibility(t *testing.T) {
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips) t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
t.Run("cancellation_and_log_streaming", testRunCancellation) t.Run("cancellation_and_log_streaming", testRunCancellation)
t.Run("dispatch", inAllModes(testWorkflowDispatch)) t.Run("dispatch", inAllModes(testWorkflowDispatch))
t.Run("builtin_checkout", testBuiltinCheckout)
t.Run("exec_artifacts", testExecArtifacts) t.Run("exec_artifacts", testExecArtifacts)
t.Run("moving_action_ref", testMovingActionRef) t.Run("moving_action_ref", testMovingActionRef)
t.Run("ephemeral", testEphemeralRunner) t.Run("ephemeral", testEphemeralRunner)
@@ -37,7 +36,6 @@ func TestCompatibility(t *testing.T) {
t.Run("job_timeout", testJobTimeout) t.Run("job_timeout", testJobTimeout)
t.Run("cli_register_daemon", testCLIRegisterDaemon) t.Run("cli_register_daemon", testCLIRegisterDaemon)
t.Run("cli_once", testCLIOnce) t.Run("cli_once", testCLIOnce)
t.Run("cli_labels_override", testCLILabelsOverride)
t.Run("cli_shutdown_drains", testCLIShutdownDrains) t.Run("cli_shutdown_drains", testCLIShutdownDrains)
t.Run("cli_shutdown_cancels", testCLIShutdownCancels) t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
t.Run("host_mode", testHostMode) t.Run("host_mode", testHostMode)
-6
View File
@@ -65,12 +65,6 @@ func (a *GiteaAPI) CreateFiles(ctx context.Context, repo string, files map[strin
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil) return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil)
} }
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
body := map[string]any{"tag_name": name}
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
return a.doJSON(ctx, http.MethodPost, url, body, nil)
}
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error { func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
body := map[string]any{"data": value} body := map[string]any{"data": value}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name) url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
+14 -13
View File
@@ -80,11 +80,11 @@ func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, string,
return "", "", false return "", "", false
} }
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) { func StartGitea(ctx context.Context, cli mobyclient.APIClient) (_ *GiteaFixture, err error) {
closeClient := true f := &GiteaFixture{cli: cli}
defer func() { defer func() {
if closeClient { if err != nil {
_ = cli.Close() _ = f.Close()
} }
}() }()
@@ -111,7 +111,6 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
"GITEA__security__INSTALL_LOCK=true", "GITEA__security__INSTALL_LOCK=true",
"GITEA__database__DB_TYPE=sqlite3", "GITEA__database__DB_TYPE=sqlite3",
"GITEA__actions__ENABLED=true", "GITEA__actions__ENABLED=true",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__server__ROOT_URL=" + baseURL + "/", "GITEA__server__ROOT_URL=" + baseURL + "/",
}, },
}, },
@@ -123,26 +122,21 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
return nil, fmt.Errorf("create gitea container: %w", err) return nil, fmt.Errorf("create gitea container: %w", err)
} }
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet, selfIP: selfIP} f.id, f.image, f.baseURL, f.network, f.selfIP = resp.ID, image, baseURL, sharedNet, selfIP
closeClient = false
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil { if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("start gitea container: %w", err) return nil, fmt.Errorf("start gitea container: %w", err)
} }
if err := f.waitHealthy(ctx); err != nil { if err := f.waitHealthy(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("gitea did not become healthy: %w", err) return nil, fmt.Errorf("gitea did not become healthy: %w", err)
} }
if err := f.bootstrapAdmin(ctx); err != nil { if err := f.bootstrapAdmin(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("bootstrap gitea admin: %w", err) return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
} }
if err := f.readVersion(ctx); err != nil { if err := f.readVersion(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("read gitea version: %w", err) return nil, fmt.Errorf("read gitea version: %w", err)
} }
@@ -249,6 +243,7 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
if err != nil { if err != nil {
return "", err return "", err
} }
defer context.AfterFunc(ctx, attached.Close)()
defer attached.Close() defer attached.Close()
if stdin != nil { if stdin != nil {
go func() { go func() {
@@ -258,7 +253,11 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
} }
var out bytes.Buffer var out bytes.Buffer
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil { _, err = stdcopy.StdCopy(&out, &out, attached.Reader)
if ctx.Err() != nil {
return "", ctx.Err()
}
if err != nil {
return "", err return "", err
} }
@@ -291,10 +290,12 @@ func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody,
return api.doJSON(ctx, method, path, reqBody, respBody) return api.doJSON(ctx, method, path, reqBody, respBody)
} }
func (f *GiteaFixture) Close(ctx context.Context) error { func (f *GiteaFixture) Close() error {
if f.id == "" { if f.id == "" {
return f.cli.Close() return f.cli.Close()
} }
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) _, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
return errors.Join(removeErr, f.cli.Close()) return errors.Join(removeErr, f.cli.Close())
} }
+13 -7
View File
@@ -55,7 +55,9 @@ func checkClusterLeaks() error {
if err != nil { if err != nil {
return nil // the tests that needed the cluster already failed return nil // the tests that needed the cluster already failed
} }
if out, err := containerExec(context.Background(), dockerCLI, cluster.id, "", nil, ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
if out, err := containerExec(ctx, dockerCLI, cluster.id, "", nil,
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m", "kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
); err != nil { ); err != nil {
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out) return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
@@ -72,7 +74,7 @@ type kubeCluster struct {
var ( var (
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
clusterStarted atomic.Bool clusterStarted atomic.Bool
clusterCleanup []func() clusterCleanup []func(context.Context)
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) { sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
cluster, err := k3s() cluster, err := k3s()
if err != nil { if err != nil {
@@ -109,7 +111,9 @@ func stopCluster() {
} }
_, _ = k3s() _, _ = k3s()
for _, cleanup := range slices.Backward(clusterCleanup) { for _, cleanup := range slices.Backward(clusterCleanup) {
cleanup() ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
cleanup(ctx)
cancel()
} }
} }
@@ -138,8 +142,8 @@ func startK3s() (*kubeCluster, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("create k3s container: %w", err) return nil, fmt.Errorf("create k3s container: %w", err)
} }
clusterCleanup = append(clusterCleanup, func() { clusterCleanup = append(clusterCleanup, func(ctx context.Context) {
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) _, _ = cli.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
}) })
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil { if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
return nil, fmt.Errorf("start k3s container: %w", err) return nil, fmt.Errorf("start k3s container: %w", err)
@@ -170,7 +174,9 @@ func startK3s() (*kubeCluster, error) {
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{}) inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil { if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
var logs bytes.Buffer var logs bytes.Buffer
if reader, logsErr := cli.ContainerLogs(context.Background(), created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil { logsCtx, logsCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer logsCancel()
if reader, logsErr := cli.ContainerLogs(logsCtx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
_, _ = stdcopy.StdCopy(&logs, &logs, reader) _, _ = stdcopy.StdCopy(&logs, &logs, reader)
_ = reader.Close() _ = reader.Close()
} }
@@ -226,7 +232,7 @@ func preloadImages(ctx context.Context, k3sID string, images ...string) ([]strin
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil { if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
return nil, fmt.Errorf("tag %s: %w", image, err) return nil, fmt.Errorf("tag %s: %w", image, err)
} }
clusterCleanup = append(clusterCleanup, func() { _, _ = dockerCLI.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) }) clusterCleanup = append(clusterCleanup, func(ctx context.Context) { _, _ = dockerCLI.ImageRemove(ctx, alias, mobyclient.ImageRemoveOptions{}) })
aliases = append(aliases, alias) aliases = append(aliases, alias)
} }
archive, err := dockerCLI.ImageSave(ctx, aliases) archive, err := dockerCLI.ImageSave(ctx, aliases)
+6 -2
View File
@@ -31,6 +31,8 @@ import (
const runTimeout = 3 * time.Minute const runTimeout = 3 * time.Minute
const cleanupTimeout = 2 * time.Minute
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
var fixture *GiteaFixture var fixture *GiteaFixture
@@ -42,8 +44,10 @@ var binDir string
var dockerCLI mobyclient.APIClient var dockerCLI mobyclient.APIClient
var runnerBinary = sync.OnceValues(func() (string, error) { var runnerBinary = sync.OnceValues(func() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
defer cancel()
bin := filepath.Join(binDir, "gitea-runner") bin := filepath.Join(binDir, "gitea-runner")
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil { if out, err := exec.CommandContext(ctx, cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
return "", fmt.Errorf("build runner: %w\n%s", err, out) return "", fmt.Errorf("build runner: %w\n%s", err, out)
} }
return bin, nil return bin, nil
@@ -80,7 +84,7 @@ func runSuite(m *testing.M) int {
return 1 return 1
} }
fixture = f fixture = f
defer func() { _ = fixture.Close(context.Background()) }() defer func() { _ = fixture.Close() }()
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version) fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
+29 -15
View File
@@ -9,6 +9,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"cmp" "cmp"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
@@ -138,14 +139,31 @@ func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
return result return result
} }
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult { func runExec(t *testing.T, env []string, args ...string) (stdout, stderr []byte, err error) {
t.Helper() t.Helper()
ctx := t.Context()
bin, err := runnerBinary() bin, err := runnerBinary()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin, append([]string{"exec"}, args...)...)
cmd.Env = append(slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }), env...)
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
cmd.WaitDelay = cleanupTimeout
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdoutBuf, &stderrBuf
err = cmd.Run()
if ctx.Err() != nil {
t.Fatalf("exec did not finish within %s\n%s%s", runTimeout, stdoutBuf.Bytes(), stderrBuf.Bytes())
}
return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
}
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
ctx := t.Context()
workdir := t.TempDir() workdir := t.TempDir()
workflows := filepath.Join(workdir, ".gitea", "workflows") workflows := filepath.Join(workdir, ".gitea", "workflows")
if err := os.MkdirAll(workflows, 0o755); err != nil { if err := os.MkdirAll(workflows, 0o755); err != nil {
@@ -170,12 +188,12 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
} }
} }
args := []string{"exec", "--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"} args := []string{"--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }) var env []string
if m.kubernetes { if m.kubernetes {
cluster := requireCluster(t) cluster := requireCluster(t)
args = append(args, "-i", "kubernetes://"+cluster.jobImage) args = append(args, "-i", "kubernetes://"+cluster.jobImage)
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig) env = []string{"KUBECONFIG=" + cluster.kubernetes.Kubeconfig}
} else { } else {
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network) args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
} }
@@ -185,21 +203,17 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
args = append(args, "--input", name+"="+value) args = append(args, "--input", name+"="+value)
} }
} }
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...) stdout, stderr, runErr := runExec(t, env, append(args, spec.execArgs...)...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
runErr := cmd.Run()
var exitErr *exec.ExitError var exitErr *exec.ExitError
if runErr != nil && !errors.As(runErr, &exitErr) { if runErr != nil && !errors.As(runErr, &exitErr) {
t.Fatalf("exec: %v\n%s", runErr, stderr.String()) t.Fatalf("exec: %v\n%s", runErr, stderr)
} }
result := &workflowResult{conclusion: "success", jobs: map[string]string{}} result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
if runErr != nil { if runErr != nil {
result.conclusion = "failure" result.conclusion = "failure"
} }
scanner := bufio.NewScanner(&stdout) scanner := bufio.NewScanner(bytes.NewReader(stdout))
scanner.Buffer(nil, 1<<20) scanner.Buffer(nil, 1<<20)
for scanner.Scan() { for scanner.Scan() {
var entry struct { var entry struct {
@@ -220,8 +234,8 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
} }
} }
} }
if result.conclusion != "success" && len(result.jobs) == 0 { if len(result.jobs) == 0 {
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String()) t.Fatalf("exec ran no job: %v\n%s", runErr, stderr)
} }
return result return result
} }
-61
View File
@@ -1,61 +0,0 @@
name: checkout
on: push
jobs:
checkout:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- id: checkout
uses: builtin:checkout
- uses: builtin:checkout
with:
ref: ${{ github.ref_name }}
path: short
- run: test "$(git -C short rev-parse HEAD)" = "$GITHUB_SHA"
- if: github.ref_type == 'branch'
run: |
test "$(git symbolic-ref HEAD)" = "$GITHUB_REF"
test "${{ steps.checkout.outputs.ref }}" = "$GITHUB_REF"
test "${{ steps.checkout.outputs.commit }}" = "$GITHUB_SHA"
git -c user.name=e2e -c [email protected] commit --allow-empty -m pushed
git push
git tag moved
git push --force origin moved
git switch --orphan child
echo child > child.txt
git add child.txt
git -c user.name=e2e -c [email protected] commit -m child
git push origin child
git switch -c content main
git submodule add -b child "../${{ github.event.repository.name }}.git" sub
git lfs install --local
git lfs track '*.bin'
mkdir keep drop
echo big > keep/big.bin
echo big > drop/big.bin
git add .gitattributes keep drop
git -c user.name=e2e -c [email protected] commit -m content
git push origin content
- if: github.ref_type == 'branch'
uses: builtin:checkout
with:
ref: content
path: full
submodules: true
lfs: true
- if: github.ref_type == 'branch'
uses: builtin:checkout
with:
ref: content
path: sparse
sparse-checkout: keep
lfs: true
- if: github.ref_type == 'branch'
run: |
test "$(cat full/sub/child.txt)" = child
test "$(cat full/keep/big.bin)" = big
test "$(cat sparse/keep/big.bin)" = big
test ! -e sparse/drop
- if: github.ref_type == 'tag'
run: test "$(git describe --tags)" = "$GITHUB_REF_NAME"
-7
View File
@@ -1,7 +0,0 @@
name: cli-labels
on: push
jobs:
hello:
runs-on: e2e-override
steps:
- run: echo e2e-override-ran
-1
View File
@@ -16,7 +16,6 @@ This uses the recommended Docker-in-Docker image, see [image flavours](../../REA
timeout: 10s timeout: 10s
environment: environment:
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token. # GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
# The Gitea version must be v1.23 or higher.
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location. # It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token> # - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
+12
View File
@@ -12,3 +12,15 @@ docker run --privileged \
``` ```
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives. The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
#### Pull job images from a registry with a private CA
The bundled Docker daemon pulls job images, so it needs to trust the registry's CA itself. `runner.ca_file` doesn't cover this. Mount the CA where the daemon looks for it, naming the directory after the registry's host and port:
```sh
--mount "type=bind,src=$PWD/ca.crt,dst=/etc/docker/certs.d/registry.example.com:5000/ca.crt,readonly"
```
Use `--mount`, since `-v` rejects a container path with a port in it.
On `latest-dind-rootless`, use `/home/rootless/.config/docker/certs.d/registry.example.com:5000/ca.crt` as the target.
+54
View File
@@ -0,0 +1,54 @@
# Running the runner with launchd on macOS
[`com.gitea.runner.plist`](./com.gitea.runner.plist) runs the runner in the
login session of the user that jobs should run as. Jobs then have that user's
login keychain and the GUI.
## Setup
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
2. Register as that user, with a host label so that jobs run on the Mac itself:
```bash
mkdir ~/gitea-runner && cd ~/gitea-runner
gitea-runner register --instance https://gitea.example.com --token <token> --labels macos-latest:host
```
3. Replace `/Users/gitea-runner` in the file with the user's home directory,
then install it and start the runner. From now on, it also starts whenever
the user logs in:
```bash
mkdir -p ~/Library/LaunchAgents
cp com.gitea.runner.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gitea.runner.plist
```
4. Enable automatic login for the user in System Settings, so that the runner
starts after a restart too. macOS does not offer automatic login while
FileVault is on.
The runner does not get the environment of your shell. Set what jobs need, such
as proxy variables, in `EnvironmentVariables`.
## Local network access
macOS blocks a runner that was started this way from other hosts on the Mac's
own subnet, such as the Gitea instance or a shared cache server. The runner
then logs `no route to host`. Commands run in Terminal or over SSH are not
blocked, so registering works even when the running runner cannot connect. See
[local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
macOS asks on the Mac's screen whether to allow the access. To allow it without
that prompt, exempt the subnet on macOS 15.5 or later and restart the Mac. On
Wi-Fi, the key is `AllowedWiFiLocalNetworkAddresses`:
```bash
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "192.168.1.0/24"
```
## Git credentials
Git on macOS saves credentials from remote URLs to the keychain, which can stop
a job at a dialog that nobody answers. To turn that off, set
`GIT_CONFIG_NOSYSTEM` to `1` in `EnvironmentVariables`.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.gitea.runner</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/gitea-runner</string>
<string>daemon</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/gitea-runner/gitea-runner</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StandardErrorPath</key>
<string>/Users/gitea-runner/gitea-runner/runner.log</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
+1 -1
View File
@@ -12,7 +12,7 @@ require (
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0 github.com/distribution/reference v0.6.0
github.com/docker/cli v29.9.2+incompatible github.com/docker/cli v29.9.3+incompatible
github.com/docker/go-connections v0.8.2 github.com/docker/go-connections v0.8.2
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.5.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
+2 -2
View File
@@ -21,8 +21,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY= github.com/docker/cli v29.9.3+incompatible h1:SBaMp7ecohadEHMDrUJg22t9I3awqsAVhnJ7GOcWpfE=
github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.9.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+1 -1
View File
@@ -208,7 +208,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err): case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
return nil return nil
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented: case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later") logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v%s or later", ver.MinGiteaVersion)
return err return err
case err != nil: case err != nil:
logging.WithError(err).Error("fail to invoke Declare") logging.WithError(err).Error("fail to invoke Declare")
+5 -4
View File
@@ -128,6 +128,11 @@ func (*daemonTestService) UpdateLog(_ context.Context, req *runnerv1.UpdateLogRe
func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) { func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay) defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay)
declareRetryDelay = 0 declareRetryDelay = 0
dir := t.TempDir()
regFile := filepath.Join(dir, "registration.json")
configFile := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
for _, tc := range []struct { for _, tc := range []struct {
name string name string
ephemeral, beforeTask bool ephemeral, beforeTask bool
@@ -154,10 +159,6 @@ func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(
connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0)) connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0))
server := httptest.NewServer(http.StripPrefix("/api/actions", mux)) server := httptest.NewServer(http.StripPrefix("/api/actions", mux))
defer server.Close() defer server.Close()
dir := t.TempDir()
regFile := filepath.Join(dir, "registration.json")
configFile := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral})) require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil) err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask { if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask {
+6 -2
View File
@@ -188,10 +188,14 @@ func (i *executeArgs) eventJSON() (string, error) {
func (i *executeArgs) LoadEnvs() map[string]string { func (i *executeArgs) LoadEnvs() map[string]string {
envs := parseKVAndFile(i.envs, i.Envfile()) envs := parseKVAndFile(i.envs, i.Envfile())
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/" cacheURL := i.cacheHandler.ExternalURL()
if cacheURL == "" {
return envs
}
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
// The same server answers cache v2, which docker buildx reads from the results origin alone. // The same server answers cache v2, which docker buildx reads from the results origin alone.
if envs["ACTIONS_RESULTS_URL"] == "" { if envs["ACTIONS_RESULTS_URL"] == "" {
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL()) envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), cacheURL)
} }
envs[runner.CacheServiceV2Env] = "true" envs[runner.CacheServiceV2Env] = "true"
+27 -35
View File
@@ -75,40 +75,35 @@ func TestExecuteArgsLoadInputs(t *testing.T) {
} }
func TestExecuteArgsEventJSON(t *testing.T) { func TestExecuteArgsEventJSON(t *testing.T) {
payload := func(content string) string { dir := t.TempDir()
path := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
tests := []struct { tests := []struct {
name string name string
eventpath string payload string
inputs []string inputs []string
expected string expected string
}{ }{
{ {
name: "keeps a payload without inputs to merge", name: "keeps a payload without inputs to merge",
eventpath: payload(`{"inputs": {"kept": "from_event"}}`), payload: `{"inputs": {"kept": "from_event"}}`,
expected: `{"inputs": {"kept": "from_event"}}`, expected: `{"inputs": {"kept": "from_event"}}`,
}, },
{ {
name: "overrides the event inputs it names, keeping the rest", name: "overrides the event inputs it names, keeping the rest",
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`), payload: `{"inputs": {"kept": "from_event", "named": "from_event"}}`,
inputs: []string{"named=from_cli"}, inputs: []string{"named=from_cli"},
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`, expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
}, },
{ {
name: "adds inputs to a payload carrying none", name: "adds inputs to a payload carrying none",
eventpath: payload(`{"repository": {"name": "test-repo"}}`), payload: `{"repository": {"name": "test-repo"}}`,
inputs: []string{"flag=val"}, inputs: []string{"flag=val"},
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`, expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
}, },
{ {
name: "handles a null payload", name: "handles a null payload",
eventpath: payload("null"), payload: "null",
inputs: []string{"flag=val"}, inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`, expected: `{"inputs": {"flag": "val"}}`,
}, },
{ {
name: "builds a payload without an event file", name: "builds a payload without an event file",
@@ -119,7 +114,12 @@ func TestExecuteArgsEventJSON(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON() var eventpath string
if tt.payload != "" {
eventpath = filepath.Join(dir, tt.name+".json")
require.NoError(t, os.WriteFile(eventpath, []byte(tt.payload), 0o600))
}
eventJSON, err := (&executeArgs{eventpath: eventpath, inputs: tt.inputs}).eventJSON()
require.NoError(t, err) require.NoError(t, err)
require.JSONEq(t, tt.expected, eventJSON) require.JSONEq(t, tt.expected, eventJSON)
}) })
@@ -275,21 +275,13 @@ func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
return &model.Workflow{ return &model.Workflow{
Name: name, Name: name,
File: file, File: file,
RawOn: rawOnNode(event), RawOn: yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: event},
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
jobID: {Name: jobName}, jobID: {Name: jobName},
}, },
} }
} }
func rawOnNode(event string) yaml.Node {
var node yaml.Node
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
panic(err)
}
return *node.Content[0]
}
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) { func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"}) handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
+1
View File
@@ -89,6 +89,7 @@ const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
var defaultLabels = []string{ var defaultLabels = []string{
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
"ubuntu-26.04:docker://docker.gitea.com/runner-images:ubuntu-26.04",
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04", "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04", "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
} }
+3 -14
View File
@@ -27,16 +27,9 @@ func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
r := &Runner{ r := &Runner{
cfg: &config.Config{}, cfg: &config.Config{},
} }
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, r.cfg)
require.NotPanics(t, func() { require.NotPanics(t, func() {
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo") r.runPostTaskScript(t.Context(), nil, nil, "/workspace/owner/repo")
}) })
} }
@@ -59,9 +52,7 @@ func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{}) task := &runnerv1.Task{Id: 1, Context: &structpb.Struct{}}
require.NoError(t, err)
task := &runnerv1.Task{Id: 1, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg) reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
require.NotPanics(t, func() { require.NotPanics(t, func() {
@@ -146,9 +137,7 @@ func TestRunPostTaskScriptIntegration(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{}) task := &runnerv1.Task{Id: 11, Context: &structpb.Struct{}}
require.NoError(t, err)
task := &runnerv1.Task{Id: 11, Context: taskCtx}
reporter := report.NewReporter(ctx, cancel, nil, task, cfg) reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS) setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
+1 -1
View File
@@ -55,7 +55,7 @@ func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []stri
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy) setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
// no_proxy is merged rather than replaced: the hosts above are structural, and an // no_proxy is merged rather than replaced: the hosts above are structural, and an
// operator cannot list the cache server's startup-assigned address in advance. // operator cannot list the cache server's detected address in advance.
noProxy := appendNoProxy(cfg.NoProxy, direct...) noProxy := appendNoProxy(cfg.NoProxy, direct...)
for _, name := range []string{"no_proxy", "NO_PROXY"} { for _, name := range []string{"no_proxy", "NO_PROXY"} {
if existing, ok := jobEnvs[name]; ok { if existing, ok := jobEnvs[name]; ok {
+48 -27
View File
@@ -73,12 +73,13 @@ type Runner struct {
cacheHandler *artifactcache.Handler cacheHandler *artifactcache.Handler
capabilities string capabilities string
isolatedCacheContainer func() string isolatedCacheContainer func(cacheURL string) string
instanceTransport http.RoundTripper instanceTransport http.RoundTripper
runningTasks sync.Map runningTasks sync.Map
runningCount atomic.Int64 runningCount atomic.Int64
lastIdleCleanupUnixNano atomic.Int64 lastIdleCleanupUnixNano atomic.Int64
slots slotAllocator
now func() time.Time now func() time.Time
healthCheckLast time.Time healthCheckLast time.Time
healthCheckReady bool healthCheckReady bool
@@ -118,7 +119,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
// go on // go on
} else { } else {
cacheHandler = handler cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
} }
} }
@@ -141,7 +141,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
now: time.Now, now: time.Now,
runHealthCheck: executeHealthCheck, runHealthCheck: executeHealthCheck,
} }
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer) runner.isolatedCacheContainer = lastResult(runner.detectIsolatedCacheContainer)
runner.instanceTransport = instanceTransport runner.instanceTransport = instanceTransport
return runner return runner
} }
@@ -196,7 +196,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
return return
} }
cutoff := r.now().Add(-orphanDockerResourceAge) cutoff := r.now().Add(-orphanDockerResourceAge)
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(), cutoff); err != nil { if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(r.cacheHandler.ExternalURL()), cutoff); err != nil {
logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err) logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
} }
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil { if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
@@ -452,6 +452,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
} }
}() }()
slot := r.slots.acquire()
defer r.slots.release(slot)
workflowCallInputs := restoreCalledWorkflowContext(task) workflowCallInputs := restoreCalledWorkflowContext(task)
for _, line := range r.setupLines(task) { for _, line := range r.setupLines(task) {
reporter.Logf("%s", line) reporter.Logf("%s", line)
@@ -485,7 +488,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return fallbackPlatform() return fallbackPlatform()
} }
platform := platformPicker(job.RunsOn()) platform := platformPicker(job.RunsOn())
cacheURL, cacheContainer := r.cacheForJob(job, platform) cacheURL, cacheContainer := r.cacheForJob(job, platform, reporter.Logf)
if cacheURL != "" { if cacheURL != "" {
envs["ACTIONS_CACHE_URL"] = cacheURL + "/" envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
} }
@@ -612,7 +615,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}, },
ContainerOptions: r.cfg.Container.Options, ContainerOptions: r.cfg.Container.Options,
ServiceReadyTimeout: r.cfg.Container.ServiceReadyTimeout, ServiceReadyTimeout: r.cfg.Container.ServiceReadyTimeout,
NoSweep: r.cfg.Container.Sweep != nil && !*r.cfg.Container.Sweep, Sweep: r.cfg.Container.Sweep,
ContainerDaemonSocket: r.cfg.Container.DockerHost, ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged, Privileged: r.cfg.Container.Privileged,
DefaultActionInstance: r.getDefaultActionsURL(task), DefaultActionInstance: r.getDefaultActionsURL(task),
@@ -624,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
ValidVolumes: r.cfg.Container.ValidVolumes, ValidVolumes: r.cfg.Container.ValidVolumes,
ContainerVolumes: r.cfg.Container.Volumes, ContainerVolumes: r.cfg.Container.Volumes,
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared, SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
ToolCacheSlot: toolCacheSlot(r.cfg.Runner.ToolCacheMode, slot),
InsecureSkipTLS: r.cfg.Runner.Insecure, InsecureSkipTLS: r.cfg.Runner.Insecure,
ClientCertFile: r.cfg.Runner.ClientCertFile, ClientCertFile: r.cfg.Runner.ClientCertFile,
ClientKeyFile: r.cfg.Runner.ClientKeyFile, ClientKeyFile: r.cfg.Runner.ClientKeyFile,
@@ -663,11 +667,24 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr return execErr
} }
func (r *Runner) builtInCacheURL() string { func lastResult(compute func(key string) (string, bool)) func(key string) string {
if r.cacheHandler == nil { var (
return "" mu sync.Mutex
lastKey string
result string
)
return func(key string) string {
mu.Lock()
defer mu.Unlock()
if key == lastKey {
return result
}
value, ok := compute(key)
if ok {
lastKey, result = key, value
}
return value
} }
return r.envs["ACTIONS_CACHE_URL"]
} }
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) { func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
@@ -698,7 +715,7 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
// Safe with an empty token (older Gitea did not issue one). // Safe with an empty token (older Gitea did not issue one).
// It also returns the URL to advertise as ACTIONS_RESULTS_URL. // It also returns the URL to advertise as ACTIONS_RESULTS_URL.
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) { func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
if token == "" || r.cacheHandler == nil { if token == "" || cred.PublicURL == "" {
return func() {}, "" return func() {}, ""
} }
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
@@ -929,35 +946,39 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
} }
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there. // cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
func (r *Runner) cacheForJob(job *model.Job, platform string) (string, string) { func (r *Runner) cacheForJob(job *model.Job, platform string, logf func(string, ...any)) (string, string) {
cacheURL := r.cacheHandler.RefreshExternalURL()
jobContainer := job.Container() jobContainer := job.Container()
cacheContainer := "" if cacheURL == "" || strings.HasPrefix(platform, labels.SchemeKubernetes+"://") || (jobContainer == nil || jobContainer.Image == "") && platform == labels.SelfHostedPlatform {
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) { return cacheURL, ""
cacheContainer = r.isolatedCacheContainer()
} }
if cacheContainer == "" { if cacheContainer := r.isolatedCacheContainer(cacheURL); cacheContainer != "" {
return strings.TrimSuffix(r.builtInCacheURL(), "/"), "" isolatedURL, _ := url.Parse(cacheURL)
isolatedURL.Host = net.JoinHostPort(cacheContainer, isolatedURL.Port())
return isolatedURL.String(), cacheContainer
} }
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL()) network, enableIPv6 := r.cfg.Container.Network, r.cfg.Container.NetworkCreateOptions.EnableIPv6
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port()) if addr, err := netip.ParseAddr(hostOf(cacheURL)); err == nil && addr.Is6() && r.cfg.Cache.Host == "" &&
return cacheURL.String(), cacheContainer network != "host" && (network != "" || enableIPv6 == nil || !*enableIPv6) {
logf("::warning::%s", runner.EscapeCommandData("The runner's cache server is at the IPv6 address "+addr.String()+
", so cache and artifact steps fail if this job's network has no IPv6. Set cache.host to an address the job reaches, or enable IPv6 on the job network."))
}
return cacheURL, ""
} }
func (r *Runner) detectIsolatedCacheContainer() string { func (r *Runner) detectIsolatedCacheContainer(cacheURL string) (string, bool) {
if r.cacheHandler == nil { addr, err := netip.ParseAddr(hostOf(cacheURL))
return ""
}
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
if err != nil { if err != nil {
return "" return "", true
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network) cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
if err != nil { if err != nil {
logging.Warnf("cannot check whether jobs reach the cache server: %v", err) logging.Warnf("cannot check whether jobs reach the cache server: %v", err)
return "", false
} }
return cacheContainer return cacheContainer, true
} }
// taskLogFields returns key/value pairs for logging.With. // taskLogFields returns key/value pairs for logging.With.
+2 -3
View File
@@ -155,12 +155,11 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
}, &config.Registration{}, cli) }, &config.Registration{}, cli)
t.Cleanup(func() { _ = r.Close() }) t.Cleanup(func() { _ = r.Close() })
require.NotNil(t, r.cacheHandler) require.NotNil(t, r.cacheHandler)
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
const publicURL = "http://a1b2c3d4e5f6:8088" const publicURL = "http://a1b2c3d4e5f6:8088"
token := "external-task-token" token := "external-task-token"
repo := "owner/repoX" repo := "owner/repoX"
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache" base := r.cacheHandler.ExternalURL() + "/_apis/artifactcache"
probe := func() int { probe := func() int {
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "") resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
resp.Body.Close() resp.Body.Close()
@@ -213,7 +212,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.NoError(t, decodeJSON(resp, &hit)) require.NoError(t, decodeJSON(resp, &hit))
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation) require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other"}, nil) unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other", PublicURL: publicURL}, nil)
defer unregisterOther() defer unregisterOther()
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "") resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
resp.Body.Close() resp.Body.Close()
+5 -8
View File
@@ -21,8 +21,7 @@ import (
func TestRunnerCleanupStaleTaskDirs(t *testing.T) { func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := filepath.Join(t.TempDir(), "workspace") workdirRoot := t.TempDir()
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
oldTask := filepath.Join(workdirRoot, "1001") oldTask := filepath.Join(workdirRoot, "1001")
freshTask := filepath.Join(workdirRoot, "1002") freshTask := filepath.Join(workdirRoot, "1002")
@@ -59,8 +58,7 @@ func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
// be reclaimed, while the shared tool_cache and operator data are preserved. // be reclaimed, while the shared tool_cache and operator data are preserved.
func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) { func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
hostRoot := filepath.Join(t.TempDir(), "act") hostRoot := t.TempDir()
require.NoError(t, os.MkdirAll(hostRoot, 0o700))
staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex
freshScratch := filepath.Join(hostRoot, "fedcba9876543210") freshScratch := filepath.Join(hostRoot, "fedcba9876543210")
@@ -118,8 +116,7 @@ func TestRunnerCleanupStaleTaskDirsMissingRoot(t *testing.T) {
func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) { func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := filepath.Join(t.TempDir(), "workspace") workdirRoot := t.TempDir()
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
for i := 1001; i <= 1003; i++ { for i := 1001; i <= 1003; i++ {
dir := filepath.Join(workdirRoot, strconv.Itoa(i)) dir := filepath.Join(workdirRoot, strconv.Itoa(i))
@@ -344,7 +341,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }} r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" } r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
r.OnIdle(t.Context()) r.OnIdle(t.Context())
assert.Equal(t, []string{"runner-1"}, swept) assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes) assert.Equal(t, swept, sweptVolumes)
@@ -356,7 +353,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
dockerReachable = func(context.Context) bool { return false } dockerReachable = func(context.Context) bool { return false }
t.Cleanup(func() { dockerReachable = origDockerReachable }) t.Cleanup(func() { dockerReachable = origDockerReachable })
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }} hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
hostOnly.isolatedCacheContainer = func() string { return "" } hostOnly.isolatedCacheContainer = func(_cacheURL string) string { return "" }
hostOnly.OnIdle(t.Context()) hostOnly.OnIdle(t.Context())
assert.Equal(t, []string{"runner-1"}, swept) assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes) assert.Equal(t, swept, sweptVolumes)
+39 -4
View File
@@ -306,7 +306,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
require.NotContains(t, r.envs, "http_proxy") require.NotContains(t, r.envs, "http_proxy")
require.NotContains(t, r.envs, "no_proxy") require.NotContains(t, r.envs, "no_proxy")
proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil) proxyEnv := JobProxyEnv(r.envs, r.cacheHandler.ExternalURL(), nil)
assert.Contains(t, proxyEnv["no_proxy"], "runner.local") assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local") assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
} }
@@ -355,7 +355,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service") assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator") assert.NotContains(t, r.envs, "ACTIONS_CACHE_URL")
envs := r.cloneEnvs() envs := r.cloneEnvs()
r.setResultsService(envs, resultsURL) r.setResultsService(envs, resultsURL)
@@ -390,10 +390,10 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`)) workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
require.NoError(t, err) require.NoError(t, err)
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" } r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] } pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} { for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn())) cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()), t.Logf)
if isolated { if isolated {
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job) assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job) assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
@@ -404,6 +404,41 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
} }
} }
func TestCacheForJobIPv6WarningAndIsolationRecheck(t *testing.T) {
handler, err := artifactcache.StartHandler(artifactcache.Options{OutboundIP: "2001:db8::1", Upstream: "http://cache.invalid"})
require.NoError(t, err)
defer handler.Close()
var checked []string
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, isolatedCacheContainer: lastResult(func(cacheURL string) (string, bool) {
checked = append(checked, cacheURL)
return "", cacheURL != "http://unreachable"
})}
warnings := 0
logf := func(_format string, _args ...any) { warnings++ }
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, containerized: {runs-on: native, container: alpine}}`))
require.NoError(t, err)
for _, job := range []string{"native", "containerized", "containerized"} {
cacheURL, _ := r.cacheForJob(workflow.GetJob(job), labels.SelfHostedPlatform, logf)
assert.Equal(t, handler.ExternalURL(), cacheURL)
}
assert.Equal(t, 2, warnings)
assert.Equal(t, []string{handler.ExternalURL()}, checked)
r.cfg.Container.NetworkCreateOptions.EnableIPv6 = new(true)
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
assert.Equal(t, 2, warnings)
r.cfg.Container.Network = "bridge"
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
assert.Equal(t, 3, warnings)
r.cfg.Cache.Host = "2001:db8::1"
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
assert.Equal(t, 3, warnings)
r.isolatedCacheContainer("http://unreachable")
r.isolatedCacheContainer("http://unreachable")
assert.Len(t, checked, 3)
}
func TestRestoreCalledWorkflowContext(t *testing.T) { func TestRestoreCalledWorkflowContext(t *testing.T) {
newTask := func(workflowCall map[string]any) *runnerv1.Task { newTask := func(workflowCall map[string]any) *runnerv1.Task {
values := map[string]any{ values := map[string]any{
+2 -1
View File
@@ -96,6 +96,7 @@ func TestSetupLinesInputs(t *testing.T) {
} }
func TestPrettyOSName(t *testing.T) { func TestPrettyOSName(t *testing.T) {
dir := t.TempDir()
for _, tt := range []struct { for _, tt := range []struct {
name string name string
osRelease string osRelease string
@@ -109,7 +110,7 @@ func TestPrettyOSName(t *testing.T) {
{"missing file", "", "", true}, {"missing file", "", "", true},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "os-release") path := filepath.Join(dir, tt.name)
if !tt.missing { if !tt.missing {
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600)) require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
} }
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"slices"
"strconv"
"sync"
"gitea.com/gitea/runner/internal/pkg/config"
)
// slotAllocator hands out the lowest free slot, so slot numbers stay below capacity.
type slotAllocator struct {
mu sync.Mutex
used []bool
}
func (a *slotAllocator) acquire() int {
a.mu.Lock()
defer a.mu.Unlock()
slot := slices.Index(a.used, false)
if slot < 0 {
slot = len(a.used)
a.used = append(a.used, false)
}
a.used[slot] = true
return slot
}
func (a *slotAllocator) release(slot int) {
a.mu.Lock()
defer a.mu.Unlock()
a.used[slot] = false
}
// toolCacheSlot leaves slot 0 unnamed, so it keeps the cache a runner had before slots existed.
func toolCacheSlot(mode string, slot int) string {
if mode != config.ToolCacheModeShared || slot == 0 {
return ""
}
return strconv.Itoa(slot)
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToolCacheSlotReusesLowestFree(t *testing.T) {
var slots slotAllocator
require.Equal(t, 0, slots.acquire())
require.Equal(t, 1, slots.acquire())
require.Equal(t, 2, slots.acquire())
slots.release(1)
assert.Equal(t, 1, slots.acquire())
assert.Equal(t, 3, slots.acquire())
assert.Equal(t, "3", toolCacheSlot(config.ToolCacheModeShared, 3))
assert.Empty(t, toolCacheSlot(config.ToolCacheModeShared, 0))
assert.Empty(t, toolCacheSlot(config.ToolCacheModeNone, 3))
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package action holds the native actions run by `uses: builtin:<name>`.
package action
import (
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model"
)
type Context struct {
Container container.ExecutionsEnvironment
Github *model.GithubContext
Inputs map[string]string
HostWorkdir string
Workspace string // HostWorkdir as seen inside Container
BindWorkdir bool
NoSkipCheckout bool
UseGitIgnore bool
InsecureSkipTLS bool
ClientCertFile string
ClientKeyFile string
CAFile string // the runner CA as seen inside Container
Env map[string]string
}
-483
View File
@@ -1,483 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package checkout
import (
"cmp"
"context"
"crypto/sha256"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/action"
"gitea.dev/actionslib/pkg/model"
"github.com/kballard/go-shellquote"
)
var inputNames = []string{
"repository", "ref", "token", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "persist-credentials", "path", "clean",
"filter", "sparse-checkout", "sparse-checkout-cone-mode", "fetch-depth", "fetch-tags", "show-progress", "lfs", "submodules",
"set-safe-directory",
}
const credentialsInclude = "includeIf.gitdir:**.path" // the form actions/checkout removes from a repository it checks out again
func Main(ctx context.Context, c *action.Context) (err error) {
for name := range c.Inputs {
if !slices.Contains(inputNames, name) {
return fmt.Errorf("checkout: unsupported input %q", name)
}
}
runnerContext := c.Container.GetRunnerContext(ctx)
executionOS, _ := runnerContext["os"].(string)
windows := strings.EqualFold(executionOS, "windows")
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"], windows)
if err != nil {
return err
}
depth, err := fetchDepth(c.Inputs["fetch-depth"])
if err != nil {
return err
}
ghc := c.Github
repository := cmp.Or(c.Inputs["repository"], ghc.Repository)
ref := c.Inputs["ref"]
sshKey := strings.TrimSpace(c.Inputs["ssh-key"])
isWorkflowRepo := strings.EqualFold(repository, ghc.Repository)
local := isWorkflowRepo && slices.Contains([]string{"", strings.TrimPrefix(ghc.Ref, "refs/heads/"), ghc.Sha}, strings.TrimPrefix(ref, "refs/heads/")) && !c.NoSkipCheckout && sshKey == ""
if local && (!c.BindWorkdir || dst == c.Workspace) {
if !c.BindWorkdir {
if err := c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx); err != nil {
return err
}
}
if output := filepath.ToSlash(c.Env["GITHUB_OUTPUT"]); output != "" {
return c.Container.Copy(container.JoinPath(output, ".."), &container.FileEntry{Name: path.Base(output), Mode: 0o666, Body: fmt.Sprintf("ref=%s\ncommit=%s\n", ghc.Ref, ghc.Sha)})(ctx)
}
return nil
}
co := &checkout{
url: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository,
dst: dst,
windows: windows,
ref: ref,
depth: depth,
fetchTags: boolInput(c.Inputs, "fetch-tags", false),
clean: boolInput(c.Inputs, "clean", true),
lfs: boolInput(c.Inputs, "lfs", false),
progress: boolInput(c.Inputs, "show-progress", true),
cone: boolInput(c.Inputs, "sparse-checkout-cone-mode", true),
filter: strings.TrimSpace(c.Inputs["filter"]),
output: c.Env["GITHUB_OUTPUT"],
}
for pattern := range strings.Lines(c.Inputs["sparse-checkout"]) {
if pattern = strings.TrimSpace(pattern); pattern != "" {
co.sparse = append(co.sparse, pattern)
}
}
if len(co.sparse) > 0 {
co.filter = cmp.Or(co.filter, "blob:none")
}
if submodules := strings.ToLower(strings.TrimSpace(c.Inputs["submodules"])); submodules == "true" || submodules == "recursive" {
co.submodules = submodules
}
if sshKey != "" {
if co.url, err = sshURL(ghc, repository, strings.TrimSpace(c.Inputs["ssh-user"])); err != nil {
return err
}
}
co.objectFormat = git.ObjectFormat(ref)
switch {
case co.objectFormat != "":
co.sha, co.ref = ref, ""
case ref == "" && isWorkflowRepo && strings.HasPrefix(ghc.Ref, "refs/"):
co.sha, co.ref = ghc.Sha, ghc.Ref
case ref == "" && isWorkflowRepo: // a merged pull request's ref is its merge commit
co.sha = ghc.Sha
case ref == "HEAD":
co.ref = ""
}
if isWorkflowRepo {
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(ghc.Sha))
}
// named like actions/checkout's, which create-pull-request knows to hide, and gone with the job
runnerTemp, _ := runnerContext["temp"].(string)
runnerTemp = filepath.ToSlash(runnerTemp)
digest := sha256.Sum256([]byte(dst))
credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8])
co.credentials = container.JoinPath(runnerTemp, credentials)
persist := boolInput(c.Inputs, "persist-credentials", true)
config, remoteConfig := serverConfig(ghc.ServerURL, append(git.RemoteConfig(cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS), git.SSLConfig("http.", "", "", c.CAFile, executionOS)...), co.submodules != "" && sshKey == "")
if boolInput(c.Inputs, "set-safe-directory", true) {
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
}
if c.ClientCertFile != "" { // only for this step, later steps never get the runner's key
var certPEM, keyPEM []byte
if certPEM, err = os.ReadFile(c.ClientCertFile); err != nil {
return err
}
if keyPEM, err = os.ReadFile(c.ClientKeyFile); err != nil {
return err
}
cert, key := fmt.Sprintf("git-client-cert-%x.pem", digest[:8]), fmt.Sprintf("git-client-key-%x.pem", digest[:8])
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
}()
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil {
return err
}
remoteConfig = append(remoteConfig, git.SSLConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), "", executionOS)...)
}
env := gitEnv(c, remoteConfig)
env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go
if sshKey != "" {
key := fmt.Sprintf("git-ssh-key-%x", digest[:8])
files := []*container.FileEntry{{Name: key, Mode: 0o600, Body: sshKey + "\n"}}
ssh := []string{"ssh", "-i", container.JoinPath(runnerTemp, key)}
if boolInput(c.Inputs, "ssh-strict", true) {
ssh = append(ssh, "-o", "StrictHostKeyChecking=yes", "-o", "CheckHostIP=no")
}
if knownHosts := strings.TrimSpace(c.Inputs["ssh-known-hosts"]); knownHosts != "" {
name := fmt.Sprintf("git-ssh-known-hosts-%x", digest[:8])
files = append(files, &container.FileEntry{Name: name, Mode: 0o600, Body: knownHosts + "\n"})
ssh = append(ssh, "-o", "UserKnownHostsFile=~/.ssh/known_hosts "+strconv.Quote(container.JoinPath(runnerTemp, name)))
}
if !persist {
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
}()
}
if err := c.Container.Copy(runnerTemp, files...)(ctx); err != nil {
return err
}
env["GIT_SSH_COMMAND"] = shellquote.Join(ssh...)
config += "[core]\n\tsshCommand = " + strconv.Quote(env["GIT_SSH_COMMAND"]) + "\n"
}
if co.objectFormat == "" || co.sha == "" && !strings.HasPrefix(co.ref, "refs/") {
advertised, err := lsRemote(ctx, c, env, co.url, co.ref)
if err != nil {
return fmt.Errorf("checkout: %s@%s: %w", repository, ref, err)
}
if co.resolve(advertised); co.ref == "" && co.sha == "" {
return fmt.Errorf("checkout: %s has no ref %q", repository, ref)
}
}
co.outputRef = co.ref
if ref != co.sha {
co.outputRef = cmp.Or(ref, co.ref) // as given, like actions/checkout
}
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600})(ctx); err != nil {
return err
}
clone := func(commands [][]string) error {
if err := run(ctx, c, env, commands); err != nil {
return fmt.Errorf("checkout: clone %s@%s: %w", repository, cmp.Or(co.ref, co.sha), err)
}
return nil
}
commands, fetched := co.commands()
if co.sha != "" && strings.HasPrefix(co.ref, "refs/tags/") {
if err := clone(commands[:fetched]); err != nil {
return err
}
tagged, err := output(ctx, c, env, "git", "-C", dst, "rev-parse", "--verify", "--quiet", co.ref+"^{commit}")
if err != nil {
return fmt.Errorf("checkout: %s: %w", co.ref, err)
}
if !strings.EqualFold(strings.TrimSpace(tagged), co.sha) {
return fmt.Errorf("checkout: %s does not point to the expected commit %s, it may have been moved after the workflow was triggered", co.ref, co.sha)
}
commands = commands[fetched:]
}
if err := clone(commands); err != nil || !persist {
return err
}
return c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600, Body: config})(ctx) // for later steps, the step itself authenticates through env
}
func gitEnv(c *action.Context, config []string) map[string]string {
env := map[string]string{"GIT_TERMINAL_PROMPT": "0"}
maps.Copy(env, c.Env)
maps.DeleteFunc(env, func(name, _ string) bool { return slices.Contains(git.LocalEnvVars, name) })
maps.Copy(env, git.ConfigEnv(append([]string{"protocol.version=2", "init.defaultBranch=main", "advice.detachedHead=false", "maintenance.auto=false", "core.longpaths=true"}, config...)...))
return env
}
func run(ctx context.Context, c *action.Context, env map[string]string, commands [][]string) error {
if _, host := c.Container.(*container.HostEnvironment); !host || runtime.GOOS != "windows" {
script := []string{"set -e", `command -v git >/dev/null || { echo "builtin:checkout needs git on PATH" >&2; exit 127; }`}
for _, command := range commands {
script = append(script, shellquote.Join(command...))
}
return c.Container.Exec([]string{"sh", "-c", strings.Join(script, "\n")}, env, "", "")(ctx)
}
for _, command := range commands {
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
return err
}
}
return nil
}
// output runs where the job runs, so the job's network and git config apply as for the fetch.
func output(ctx context.Context, c *action.Context, env map[string]string, command ...string) (string, error) {
var out strings.Builder
stdout, stderr := c.Container.ReplaceLogWriter(&out, &out)
defer c.Container.ReplaceLogWriter(stdout, stderr)
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String()))
}
return out.String(), nil
}
func lsRemote(ctx context.Context, c *action.Context, env map[string]string, url, ref string) (string, error) {
patterns := []string{ref}
switch {
case ref == "":
patterns = []string{"HEAD"}
case !strings.HasPrefix(ref, "refs/"):
patterns = []string{"refs/heads/" + ref, "refs/tags/" + ref}
}
return output(ctx, c, env, append([]string{"git", "ls-remote", "--symref", url}, patterns...)...)
}
// serverConfig scopes the token to the server, so no other host like a repository's LFS endpoint gets it.
func serverConfig(serverURL string, settings []string, rewriteSSH bool) (file string, env []string) {
scope := strings.TrimSuffix(serverURL, "/") + "/"
file = fmt.Sprintf("[http %q]\n", scope)
for _, entry := range settings {
setting := strings.TrimPrefix(entry, "http.")
key, value, _ := strings.Cut(setting, "=")
file += "\t" + key + " = " + strconv.Quote(value) + "\n"
env = append(env, "http."+scope+"."+setting)
}
if server, err := url.Parse(serverURL); rewriteSSH && err == nil && server.Hostname() != "" { // submodules on the server fetch over HTTP with the token
file += fmt.Sprintf("[url %q]\n\tinsteadOf = git@%s:\n", scope, server.Hostname())
env = append(env, "url."+scope+".insteadOf=git@"+server.Hostname()+":")
}
return file, env
}
func sshURL(ghc *model.GithubContext, repository, user string) (string, error) {
endpoint := &url.URL{Scheme: "ssh"}
if advertised, _ := model.NestedMapLookup(ghc.Event, "repository", "ssh_url").(string); advertised != "" {
if colon := strings.LastIndex(advertised, ":"); !strings.HasPrefix(advertised, "ssh://") && colon >= 0 {
advertised = "ssh://" + advertised[:colon] + "/" + advertised[colon+1:] // scp-like git@host:owner/repo.git
}
var err error
if endpoint, err = url.Parse(advertised); err != nil {
return "", fmt.Errorf("checkout: parse the event's ssh_url: %w", err)
}
} else if server, err := url.Parse(ghc.ServerURL); err == nil {
endpoint.Host = server.Hostname()
}
endpoint.User = url.User(cmp.Or(user, endpoint.User.Username(), "git"))
endpoint.Path = "/" + repository + ".git"
return endpoint.String(), nil
}
type checkout struct {
url, dst, objectFormat, credentials, output, outputRef string
ref, sha string // ref is fully qualified or empty, sha pins it when known
filter, submodules string
sparse []string
depth int
fetchTags, clean, lfs, progress, cone, windows bool
}
const fullDepth = "2147483647" // git's INFINITE_DEPTH, also deepens a shallow dst
// commands checks out co.dst, the first `fetched` of them fetch so a fetched tag can be verified before the rest.
func (co *checkout) commands() (commands [][]string, fetched int) {
repo := func(args ...string) []string { return append([]string{"git", "-C", co.dst}, args...) }
commands = [][]string{
{"git", "init", "--quiet", "--object-format=" + co.objectFormat, co.dst},
repo("config", "remote.origin.url", co.url), // `remote add` fails on a repeated checkout
repo("config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"),
repo("config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials),
}
if co.windows {
commands = append(commands, repo("config", "core.longpaths", "true")) // later steps run git on the checkout too
}
if co.lfs {
commands = append(commands, repo("lfs", "install", "--local"))
}
depth := strconv.Itoa(co.depth)
var refSpecs []string
if co.depth == 0 {
depth = fullDepth
refSpecs = append(refSpecs, "+refs/heads/*:refs/remotes/origin/*")
}
fetch := []string{"fetch", "--force", "--no-tags", "--no-recurse-submodules", "--depth", depth}
if co.progress {
fetch = append(fetch, "--progress")
} else {
fetch = append(fetch, "--no-progress")
}
if co.filter != "" {
fetch = append(fetch, "--filter="+co.filter)
}
if co.fetchTags || co.depth == 0 {
refSpecs = append(refSpecs, "+refs/tags/*:refs/tags/*")
}
tag := strings.HasPrefix(co.ref, "refs/tags/") // fetched itself, which keeps an annotated tag's object
pinned := co.sha != "" && co.ref != "" && !tag
if co.ref != "" && !pinned {
refSpecs = append(refSpecs, "+"+co.ref+":"+trackingRef(co.ref))
}
if co.sha != "" && !tag {
refSpecs = append(refSpecs, co.sha)
}
commands = append(commands, repo(append(append(fetch, "origin"), refSpecs...)...))
if pinned {
commands = append(commands, repo("update-ref", trackingRef(co.ref), co.sha))
}
fetched = len(commands)
if len(co.sparse) > 0 {
mode := "--cone"
if !co.cone {
mode = "--no-cone"
}
commands = append(commands, repo("sparse-checkout", "init", mode), repo(append([]string{"sparse-checkout", "set", "--"}, co.sparse...)...))
}
if branch, ok := strings.CutPrefix(co.ref, "refs/heads/"); ok {
commands = append(commands, repo("checkout", "--force", "--ignore-other-worktrees", "-B", branch, "--track", trackingRef(co.ref)))
} else {
commands = append(commands, repo("checkout", "--force", cmp.Or(co.sha, trackingRef(co.ref))))
}
if len(co.sparse) == 0 { // after the checkout, so a sparse dst of another repository needs nothing of it
commands = append(commands, repo("sparse-checkout", "disable"), repo("config", "--local", "--unset-all", "extensions.worktreeConfig"))
}
if co.lfs && len(co.sparse) == 0 {
commands = append(commands, repo("lfs", "pull", "origin"))
}
if co.clean {
commands = append(commands, repo("clean", "-ffdx"))
}
if co.submodules != "" {
commands = append(commands, co.submoduleCommands(repo)...)
}
if co.output != "" {
commands = append(commands, repo("log", "-1", "--format=commit=%H%nref="+strings.ReplaceAll(co.outputRef, "%", "%%"), "--output="+co.output))
}
return commands, fetched
}
func (co *checkout) submoduleCommands(repo func(args ...string) []string) [][]string {
var recursive []string
if co.submodules == "recursive" {
recursive = []string{"--recursive"}
}
foreach := func(commands ...[]string) []string {
joined := make([]string, len(commands))
for i, command := range commands {
joined[i] = shellquote.Join(command...)
}
return repo(append(append([]string{"submodule", "foreach"}, recursive...), strings.Join(joined, " && "))...)
}
commands := [][]string{repo(append([]string{"submodule", "sync"}, recursive...)...)}
if co.clean {
commands = append(commands, foreach([]string{"git", "clean", "-ffdx"}))
}
update := append([]string{"submodule", "update", "--init", "--force"}, recursive...)
if co.depth > 0 {
update = append(update, "--depth="+strconv.Itoa(co.depth))
}
include := []string{"git", "config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials}
if co.lfs {
return append(commands, repo(update...), foreach([]string{"git", "lfs", "install", "--local"}, []string{"git", "lfs", "pull", "origin"}, include))
}
return append(commands, repo(update...), foreach(include))
}
func (co *checkout) resolve(advertised string) {
ref := co.ref
if !strings.HasPrefix(ref, "refs/") {
co.ref = ""
}
for line := range strings.Lines(advertised) {
value, name, _ := strings.Cut(strings.TrimSpace(line), "\t")
if target, ok := strings.CutPrefix(value, "ref: "); ok {
if name == "HEAD" {
co.ref = target
}
continue
}
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(value))
switch name {
case "HEAD":
if co.ref == "" {
co.sha = value
}
case "refs/heads/" + ref:
co.ref = name
case "refs/tags/" + ref:
co.ref = cmp.Or(co.ref, name)
}
}
co.objectFormat = cmp.Or(co.objectFormat, "sha1")
}
func trackingRef(ref string) string {
if branch, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
return "refs/remotes/origin/" + branch
}
if pull, ok := strings.CutPrefix(ref, "refs/pull/"); ok {
return "refs/remotes/pull/" + pull
}
return ref
}
func boolInput(inputs map[string]string, name string, fallback bool) bool {
value := strings.TrimSpace(inputs[name])
return strings.EqualFold(value, "true") || value == "" && fallback
}
func workspaceDestination(workspace, checkoutPath string, windows bool) (string, error) {
cleanPath := strings.TrimSpace(checkoutPath)
if windows {
cleanPath = strings.ReplaceAll(cleanPath, `\`, "/")
}
cleanPath = path.Clean(cleanPath)
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || windows && len(cleanPath) > 1 && cleanPath[1] == ':' {
return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath)
}
return path.Join(workspace, cleanPath), nil
}
func fetchDepth(input string) (int, error) {
input = strings.TrimSpace(input)
if input == "" {
return 1, nil
}
depth, err := strconv.Atoi(input)
if err != nil || depth < 0 {
return 0, fmt.Errorf("checkout: fetch-depth %q must be a non-negative integer", input)
}
return depth, nil
}
@@ -1,395 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package checkout
import (
"cmp"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/action"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runGit(t *testing.T, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "[email protected]",
"GIT_COMMITTER_NAME=test", "[email protected]",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
return strings.TrimSpace(string(out))
}
func gitLog(dir string) []string {
out, err := exec.Command("git", "--git-dir", filepath.Join(dir, ".git"), "log", "--format=%s").Output()
if err != nil {
return nil
}
return strings.Fields(string(out))
}
func TestCheckout(t *testing.T) {
server := t.TempDir()
serverURL := (&url.URL{Scheme: "file", Path: "/" + strings.TrimPrefix(filepath.ToSlash(server), "/")}).String()
var sha string
lfsPath, lfsErr := exec.LookPath("git-lfs")
require.True(t, t.Run("fixtures", func(t *testing.T) {
t.Run("history", func(t *testing.T) {
t.Parallel()
work := t.TempDir()
runGit(t, "init", "--initial-branch=main", work)
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1")
sha = runGit(t, "-C", work, "rev-parse", "HEAD")
runGit(t, "-C", work, "tag", "v1")
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c2")
runGit(t, "-C", work, "checkout", "-b", "feature")
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "f1")
runGit(t, "-C", work, "checkout", "--orphan", "trunk")
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t1")
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t2")
runGit(t, "-C", work, "tag", "trunk", "main")
runGit(t, "clone", "--bare", "--branch", "main", work, filepath.Join(server, "org", "repo"))
runGit(t, "clone", "--bare", "--branch", "trunk", work, filepath.Join(server, "other", "repo"))
runGit(t, "-C", filepath.Join(server, "other", "repo"), "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/main")
})
t.Run("sha256", func(t *testing.T) {
t.Parallel()
sha256Repo := filepath.Join(server, "org", "sha256")
runGit(t, "init", "--object-format=sha256", "--initial-branch=main", sha256Repo)
runGit(t, "-C", sha256Repo, "commit", "--allow-empty", "-m", "s1")
runGit(t, "-C", sha256Repo, "checkout", "--detach")
})
t.Run("submodules", func(t *testing.T) {
t.Parallel()
publish(t, server, "org/nested", map[string]string{"nested.txt": "nested"}, nil)
publish(t, server, "org/child", map[string]string{"child.txt": "child"}, map[string]string{"nested": "../nested"})
publish(t, server, "org/content", map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b"}, map[string]string{"sub": "../child"})
runGit(t, "-C", filepath.Join(server, "org", "content"), "config", "uploadpack.allowFilter", "true")
})
t.Run("lfs", func(t *testing.T) {
t.Parallel()
oid := fmt.Sprintf("%x", sha256.Sum256([]byte("big")))
pointer := "version https://git-lfs.github.com/spec/v1\noid sha256:" + oid + "\nsize 3\n"
publish(t, server, "org/lfs", map[string]string{".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", "keep/big.bin": pointer, "drop/big.bin": pointer}, nil)
object := filepath.Join(server, "org", "lfs", "lfs", "objects", oid[:2], oid[2:4], oid)
require.NoError(t, os.MkdirAll(filepath.Dir(object), 0o755))
require.NoError(t, os.WriteFile(object, []byte("big"), 0o644))
})
}))
host := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(host, "marker"), []byte("marker"), 0o644))
tools := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tools, "ssh"), []byte(`#!/bin/sh
test -s "$2" || exit 1
printf '%s\n' "$@" > "$SSH_ARGS"
for last; do :; done
exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git upload-pack '$SSH_SERVER/\1'#")"
`), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tools, "git-lfs"), []byte(`#!/bin/sh
git config --get-regexp extraheader > "$LFS_HEADERS"
exec "$LFS" "$@"
`), 0o755))
globalConfig := filepath.Join(t.TempDir(), "gitconfig")
require.NoError(t, os.WriteFile(globalConfig, []byte("[protocol \"file\"]\n\tallow = always\n"), 0o644))
sshEvent := func(url string) map[string]any { return map[string]any{"repository": map[string]any{"ssh_url": url}} }
for _, tc := range []struct {
name string
bind, noSkip bool
eventRef string
event map[string]any
before, inputs map[string]string
dst, head, err string
log, refs []string
files, config map[string]string
}{
{name: "bound workspace is skipped", bind: true},
{name: "workflow ref copies host workdir", inputs: map[string]string{"ref": "main", "path": "a/../src"}, dst: "src", files: map[string]string{"marker": "marker"}},
{name: "bound path checks out the event branch", bind: true, inputs: map[string]string{"path": "src"}, dst: "src", head: "main", log: []string{"c1"}},
{name: "no-skip checks out the event branch", noSkip: true, head: "main", log: []string{"c1"}, refs: []string{"refs/remotes/origin/main"}},
{name: "event tag is kept", noSkip: true, eventRef: "refs/tags/v1", log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
{name: "moved event tag fails", noSkip: true, eventRef: "refs/tags/trunk", err: "does not point to the expected commit " + sha},
{name: "deleted event tag fails", noSkip: true, eventRef: "refs/tags/deleted", err: "checkout: clone"},
{
name: "sha input removes previously persisted credentials without clean", noSkip: true, before: map[string]string{"ref": sha},
inputs: map[string]string{"ref": sha, "persist-credentials": "false", "clean": "false"},
log: []string{"c1"},
},
{name: "ref clones shallow", inputs: map[string]string{"ref": "refs/heads/feature"}, head: "feature", log: []string{"f1"}},
{name: "tag ref wins over same-named branch and is kept", inputs: map[string]string{"ref": "refs/tags/trunk"}, log: []string{"c2"}, refs: []string{"refs/tags/trunk"}},
{name: "branch ref wins over same-named tag", inputs: map[string]string{"ref": "refs/heads/trunk"}, head: "trunk", log: []string{"t2"}},
{
name: "short branch wins over same-named tag and fetch-tags fetches every tag", inputs: map[string]string{"ref": "trunk", "fetch-tags": "true"},
head: "trunk", log: []string{"t2"}, refs: []string{"refs/tags/trunk", "refs/tags/v1"},
},
{name: "short tag", inputs: map[string]string{"ref": "v1"}, log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
{
name: "fetch-depth 0 deepens a shallow checkout to every branch and tag", before: map[string]string{"ref": "feature"},
inputs: map[string]string{"ref": "refs/heads/feature", "fetch-depth": "0"}, head: "feature", log: []string{"f1", "c2", "c1"},
refs: []string{"refs/remotes/origin/feature", "refs/remotes/origin/main", "refs/remotes/origin/trunk", "refs/tags/trunk", "refs/tags/v1"},
},
{name: "other repository defaults to its default branch", inputs: map[string]string{"repository": "other/repo"}, head: "trunk", log: []string{"t2"}},
{name: "explicit HEAD checks out the default branch", inputs: map[string]string{"repository": "other/repo", "ref": "HEAD"}, head: "trunk", log: []string{"t2"}},
{name: "detached sha256 HEAD is checked out", inputs: map[string]string{"repository": "org/sha256"}, log: []string{"s1"}},
{
name: "ssh key with the advertised endpoint persists", event: sshEvent("ssh://[email protected]:2222/org/repo.git"),
inputs: map[string]string{"ssh-key": "KEY", "ssh-user": "me", "ssh-known-hosts": "ssh.example ssh-ed25519 AAAA"},
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]:2222/org/repo.git"},
},
{
name: "ssh key without persisting is emptied", event: sshEvent("[email protected]:org/repo.git"),
inputs: map[string]string{"ssh-key": "KEY", "persist-credentials": "false"},
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]/org/repo.git"},
},
{
name: "sparse cone checkout filters blobs", inputs: map[string]string{"repository": "org/content", "sparse-checkout": "a\n", "show-progress": "false"},
head: "main", log: []string{"content"}, files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": ""},
config: map[string]string{"remote.origin.partialclonefilter": "blob:none"},
},
{
name: "non-cone sparse checkout with an explicit filter", head: "main", log: []string{"content"},
inputs: map[string]string{"repository": "org/content", "sparse-checkout": "b/b.txt", "sparse-checkout-cone-mode": "false", "filter": "blob:limit=1024"},
files: map[string]string{"root.txt": "", "a/a.txt": "", "b/b.txt": "b"}, config: map[string]string{"remote.origin.partialclonefilter": "blob:limit=1024"},
},
{
name: "full checkout after a sparse one restores every file", before: map[string]string{"repository": "org/content", "sparse-checkout": "a"},
inputs: map[string]string{"repository": "org/content", "set-safe-directory": "false"}, head: "main", log: []string{"content"},
files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b", "sub/child.txt": ""},
},
{
name: "recursive submodules after plain ones", before: map[string]string{"repository": "org/content", "submodules": "true"},
inputs: map[string]string{"repository": "org/content", "submodules": "Recursive"}, head: "main", log: []string{"content"},
files: map[string]string{"sub/child.txt": "child", "sub/nested/nested.txt": "nested"},
},
{
name: "lfs content stays when narrowing a full checkout to a sparse one", before: map[string]string{"repository": "org/lfs", "lfs": "true"},
inputs: map[string]string{"repository": "org/lfs", "lfs": "true", "sparse-checkout": "keep"}, head: "main", log: []string{"content"},
files: map[string]string{"keep/big.bin": "big", "drop/big.bin": ""},
},
{name: "unknown ref", inputs: map[string]string{"ref": "missing"}, err: "has no ref"},
{name: "unsupported input", inputs: map[string]string{"github-server-url": "https://example.com"}, err: "unsupported input"},
{name: "relative path escaping workspace", inputs: map[string]string{"path": "../x"}, err: "must be relative"},
{name: "absolute path", inputs: map[string]string{"path": "/x"}, err: "must be relative"},
{name: "negative fetch-depth", inputs: map[string]string{"fetch-depth": "-1"}, err: "non-negative"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if tc.inputs["lfs"] == "true" && lfsErr != nil {
t.Skip("needs git-lfs")
}
if tc.inputs["ssh-key"] != "" && runtime.GOOS == "windows" {
t.Skip("Git for Windows puts its own ssh ahead of the fake one on PATH")
}
workspace, runnerTemp := t.TempDir(), t.TempDir()
output := filepath.Join(runnerTemp, "output")
checkout := func(inputs map[string]string) error {
return Main(t.Context(), &action.Context{
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
Github: &model.GithubContext{
Repository: "org/repo", Ref: cmp.Or(tc.eventRef, "refs/heads/main"), Sha: sha, ServerURL: serverURL, Token: "tok", Event: tc.event,
},
Inputs: inputs,
HostWorkdir: host,
Workspace: workspace,
BindWorkdir: tc.bind,
NoSkipCheckout: tc.noSkip,
CAFile: "ca.crt",
Env: map[string]string{
"PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1",
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
"LFS": lfsPath, "LFS_HEADERS": filepath.Join(runnerTemp, "lfs-headers"),
},
})
}
dst := filepath.Join(workspace, tc.dst)
if tc.err != "" {
require.ErrorContains(t, checkout(tc.inputs), tc.err)
if tc.eventRef == "" {
entries, _ := os.ReadDir(workspace)
assert.Empty(t, entries)
} else {
assert.Nil(t, gitLog(dst))
}
return
}
first := tc.before
if first == nil {
first = tc.inputs
}
require.NoError(t, checkout(first))
repos := []string{dst}
if fileExists(filepath.Join(dst, "sub", ".git")) {
repos = append(repos, filepath.Join(dst, "sub"))
}
for _, repo := range repos {
require.NoError(t, os.WriteFile(filepath.Join(repo, "untracked"), nil, 0o644))
}
require.NoError(t, checkout(tc.inputs))
outputs, err := os.ReadFile(output)
require.NoError(t, err)
assert.Equal(t, tc.log, gitLog(dst))
for name, content := range tc.files {
body, err := os.ReadFile(filepath.Join(dst, name))
assert.Equal(t, content, strings.TrimSpace(string(body)), name)
assert.Equal(t, content == "", os.IsNotExist(err), name)
}
assert.Equal(t, tc.files["marker"] != "", fileExists(filepath.Join(dst, "marker")))
for key, value := range tc.config {
assert.Equal(t, value, gitOutput(dst, "config", key), key)
}
if tc.refs != nil {
assert.Subset(t, strings.Fields(runGit(t, "-C", dst, "for-each-ref", "--format=%(refname)")), tc.refs)
}
if tc.log == nil {
assert.Contains(t, string(outputs), "commit="+sha+"\n")
assert.Contains(t, string(outputs), "ref="+cmp.Or(tc.eventRef, "refs/heads/main")+"\n")
return
}
if tc.head == "" {
assert.Empty(t, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
} else {
assert.Equal(t, "refs/heads/"+tc.head, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
assert.Equal(t, "origin/"+tc.head, gitOutput(dst, "rev-parse", "--abbrev-ref", "@{u}"))
}
assert.Contains(t, string(outputs), "commit="+gitOutput(dst, "rev-parse", "HEAD")+"\n")
if inputRef := tc.inputs["ref"]; inputRef != "" && inputRef != sha {
assert.Contains(t, string(outputs), "ref="+inputRef+"\n")
}
assert.Equal(t, tc.inputs["sparse-checkout"] != "", gitOutput(dst, "config", "--local", "extensions.worktreeConfig") == "true")
persisted := tc.inputs["persist-credentials"] != "false"
for _, repo := range repos {
assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo)
assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo)
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo)
}
if tc.inputs["lfs"] == "true" && runtime.GOOS != "windows" {
headers, _ := os.ReadFile(filepath.Join(runnerTemp, "lfs-headers"))
assert.Equal(t, 1, strings.Count(string(headers), "Authorization"), string(headers))
}
if tc.inputs["ssh-key"] != "" {
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-ssh-key-*"))
require.Len(t, keys, 1)
key, _ := os.ReadFile(keys[0])
if persisted {
assert.Equal(t, "KEY\n", string(key))
} else {
assert.Empty(t, key)
}
args, _ := os.ReadFile(filepath.Join(runnerTemp, "ssh-args"))
assert.Contains(t, string(args), "StrictHostKeyChecking=yes")
assert.Equal(t, tc.inputs["ssh-known-hosts"] != "", strings.Contains(string(args), "UserKnownHostsFile="))
assert.Equal(t, persisted, gitOutput(dst, "config", "core.sshCommand") != "")
}
})
}
}
func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testing.T) {
var gotCert bool
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCert = len(r.TLS.PeerCertificates) > 0
w.WriteHeader(http.StatusNotFound)
}))
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
server.StartTLS()
defer server.Close()
pair := server.TLS.Certificates[0]
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
require.NoError(t, err)
dir, workspace, runnerTemp := t.TempDir(), t.TempDir(), t.TempDir()
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
require.ErrorContains(t, Main(t.Context(), &action.Context{
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL},
Inputs: map[string]string{"repository": "other/repo"},
Workspace: workspace,
ClientCertFile: certFile,
ClientKeyFile: keyFile,
CAFile: certFile,
Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"},
}), "checkout: other/repo@")
require.True(t, gotCert)
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*"))
require.Len(t, keys, 1)
key, err := os.ReadFile(keys[0])
require.NoError(t, err)
require.Empty(t, key)
}
func publish(t *testing.T, server, name string, files, submodules map[string]string) {
work := t.TempDir()
runGit(t, "init", "--initial-branch=main", work)
for file, content := range files {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(work, file)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(work, file), []byte(content), 0o644))
}
runGit(t, "-C", work, "add", ".")
for path, url := range submodules {
runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".path", path)
runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".url", url)
commit := runGit(t, "--git-dir", filepath.Join(server, name, url), "rev-parse", "main")
runGit(t, "-C", work, "update-index", "--add", "--cacheinfo", "160000,"+commit+","+path)
runGit(t, "-C", work, "add", ".gitmodules")
}
runGit(t, "-C", work, "commit", "-m", "content")
runGit(t, "clone", "--bare", "--quiet", work, filepath.Join(server, name))
}
func gitOutput(dir string, args ...string) string {
out, _ := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
return strings.TrimSpace(string(out))
}
func fileExists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
func TestWorkspaceDestination(t *testing.T) {
for _, tc := range []struct {
path string
windows bool
want string
}{
{`..\x`, true, ""},
{`C:x`, true, ""},
{`a\b`, true, "/w/a/b"},
{`..\x`, false, `/w/..\x`},
{`a:b`, false, "/w/a:b"},
} {
got, err := workspaceDestination("/w", tc.path, tc.windows)
if tc.want == "" {
assert.ErrorContains(t, err, "must be relative", tc.path)
} else {
require.NoError(t, err, tc.path)
assert.Equal(t, tc.want, got, tc.path)
}
}
}
+6 -5
View File
@@ -45,7 +45,7 @@ runner:
#client_cert_file: /etc/gitea-runner/client.crt #client_cert_file: /etc/gitea-runner/client.crt
#client_key_file: /etc/gitea-runner/client.key #client_key_file: /etc/gitea-runner/client.key
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's. # PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
# Runner-side git and builtin:checkout use it instead of the system's bundle for the instance. # Runner-side git uses it instead of the system's bundle for the instance.
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS # Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the # pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes. # git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
@@ -119,6 +119,7 @@ runner:
# If it's empty when execute `daemon`, will use labels in `.runner` file. # If it's empty when execute `daemon`, will use labels in `.runner` file.
#labels: #labels:
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" # - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# - "ubuntu-26.04:docker://docker.gitea.com/runner-images:ubuntu-26.04"
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" # - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" # - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends. # Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
@@ -131,8 +132,8 @@ runner:
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools: # What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and # none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
# either way what it installs is gone when the job ends. # either way what it installs is gone when the job ends.
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once # shared: one tool cache per capacity slot, reused by later jobs. It hides the tools later job
# corrupt it, so use it only with capacity 1. # images ship, so use images without them.
#tool_cache_mode: none #tool_cache_mode: none
# Optional executable on the host, run once after each task's built-in cleanup # Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only. # (post-steps, container teardown, bind-workdir removal). Additive only.
@@ -306,8 +307,8 @@ container:
# healthy. A negative value (e.g. -1s) starts the steps without waiting. # healthy. A negative value (e.g. -1s) starts the steps without waiting.
#service_ready_timeout: 5m #service_ready_timeout: 5m
# Remove the containers, networks and volumes a job creates through its Docker proxy when # Remove the containers, networks and volumes a job creates through its Docker proxy when
# the job ends. Set to false when jobs deploy services to the runner's Docker daemon. # the job ends. Leave off when jobs deploy services to the runner's Docker daemon.
#sweep: true #sweep: false
host: host:
# The parent directory of a job's working directory. # The parent directory of a job's working directory.
+2 -6
View File
@@ -190,10 +190,10 @@ type Container struct {
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume. BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting. ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
Sweep *bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends. Unset means enabled. Sweep bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends.
} }
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one that every job reuses. // Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one per capacity slot that its jobs reuse.
const ( const (
ToolCacheModeNone = "none" ToolCacheModeNone = "none"
ToolCacheModeShared = "shared" ToolCacheModeShared = "shared"
@@ -454,10 +454,6 @@ func LoadDefault(file string) (*Config, error) {
} }
// Validate and fix invalid config combinations to prevent confusing behavior. // Validate and fix invalid config combinations to prevent confusing behavior.
if cfg.Runner.ToolCacheMode == ToolCacheModeShared && cfg.Runner.Capacity > 1 {
logging.Warnf("runner.tool_cache_mode %q with capacity %d: two jobs writing the same tool version at once corrupt it",
ToolCacheModeShared, cfg.Runner.Capacity)
}
if cfg.Runner.FetchTimeout > RequestTimeout { if cfg.Runner.FetchTimeout > RequestTimeout {
logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout) logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout)
cfg.Runner.FetchTimeout = RequestTimeout cfg.Runner.FetchTimeout = RequestTimeout
+5 -10
View File
@@ -6,7 +6,6 @@ package process
import ( import (
"os/exec" "os/exec"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -23,16 +22,13 @@ func TestGroupClosePropagatesToOrphan(t *testing.T) {
childPID := ready() childPID := ready()
require.NoError(t, cmd.Wait()) require.NoError(t, cmd.Wait())
require.Eventually(t, func() bool { require.True(t, cmd.ProcessState.Exited(), "the step process should have exited on its own")
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
require.True(t, processAlive(childPID), "orphan should outlive its parent") require.True(t, processAlive(childPID), "orphan should outlive its parent")
waitForExit := newProcessExitWaiter(t, childPID)
require.NoError(t, group.Close()) require.NoError(t, group.Close())
require.Eventually(t, func() bool { waitForExit()
return !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan")
} }
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer // TestGroupNestsWithKiller: assigned to the group first and the step's Killer
@@ -53,10 +49,9 @@ func TestGroupNestsWithKiller(t *testing.T) {
require.NoError(t, err, "the step's job object must nest inside the group's") require.NoError(t, err, "the step's job object must nest inside the group's")
defer killer.Close() defer killer.Close()
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid)
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
require.Eventually(t, func() bool { waitForExit()
return !processAlive(cmd.Process.Pid)
}, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree")
} }
// TestGroupNilSafe covers the fallback path: when the job object cannot be // TestGroupNilSafe covers the fallback path: when the job object cannot be
+3 -35
View File
@@ -9,8 +9,6 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv"
"strings" "strings"
"syscall" "syscall"
"testing" "testing"
@@ -46,23 +44,8 @@ func processAlive(pid int) bool {
return true return true
} }
// TestKillerKillsTree verifies that a process group captured by the killer is
// terminated together with a child the process spawns afterwards. This mirrors
// a step or post-task script that launches a child which spawns further
// processes, where cancelling must take down the whole tree, not just the
// direct child.
func TestKillerKillsTree(t *testing.T) { func TestKillerKillsTree(t *testing.T) {
dir := t.TempDir() cmd, ready := newTestProcess(t, "parent", false)
pidFile := filepath.Join(dir, "child.pid")
// Parent shell backgrounds a long-lived child (writing its PID to a file)
// and then sleeps. With job control off (non-interactive sh) the backgrounded
// child stays in the parent's process group, so the group kill must reach it.
script := fmt.Sprintf(`sleep 600 & echo $! > %q; sleep 600`, pidFile)
cmd := exec.Command("/bin/sh", "-c", script)
// Launch as its own process-group leader, exactly like a real process does
// (see SysProcAttr), so the killer's PGID == the process PID.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
require.NoError(t, cmd.Start()) require.NoError(t, cmd.Start())
t.Cleanup(func() { t.Cleanup(func() {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
@@ -73,25 +56,10 @@ func TestKillerKillsTree(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer killer.Close() defer killer.Close()
// Wait for the backgrounded child PID to be reported. childPID := ready()
var childPID int require.True(t, processAlive(childPID), "child process should start")
require.Eventually(t, func() bool {
b, e := os.ReadFile(pidFile)
if e != nil {
return false
}
s := strings.TrimSpace(string(b))
if s == "" {
return false
}
childPID, _ = strconv.Atoi(s)
return childPID > 0 && processAlive(childPID)
}, 20*time.Second, 100*time.Millisecond, "child process should start")
// Killing the group must terminate both the parent and the backgrounded child.
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
// Reap the parent so it does not linger as a zombie (which would still report
// as alive); SIGKILL makes Wait return promptly.
_ = cmd.Wait() _ = cmd.Wait()
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
+19 -5
View File
@@ -5,7 +5,6 @@ package process
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -25,6 +24,23 @@ func processAlive(pid int) bool {
return code == stillActive return code == stillActive
} }
func newProcessExitWaiter(t *testing.T, pids ...int) func() {
t.Helper()
handles := make([]windows.Handle, 0, len(pids))
for _, pid := range pids {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, windows.CloseHandle(handle)) })
handles = append(handles, handle)
}
return func() {
t.Helper()
result, err := windows.WaitForMultipleObjects(handles, true, 20000)
require.NoError(t, err)
require.EqualValues(t, windows.WAIT_OBJECT_0, result, "processes should exit: %v", pids)
}
}
func TestKillerKillsTree(t *testing.T) { func TestKillerKillsTree(t *testing.T) {
cmd, ready := newTestProcess(t, "parent", false) cmd, ready := newTestProcess(t, "parent", false)
require.NoError(t, cmd.Start()) require.NoError(t, cmd.Start())
@@ -39,9 +55,7 @@ func TestKillerKillsTree(t *testing.T) {
childPID := ready() childPID := ready()
require.True(t, processAlive(childPID), "child process should start") require.True(t, processAlive(childPID), "child process should start")
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid, childPID)
require.NoError(t, killer.Kill()) require.NoError(t, killer.Kill())
waitForExit()
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
} }
+1 -17
View File
@@ -691,22 +691,6 @@ func (r *Reporter) Close(lastWords string) error {
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait) logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
} }
// Gitea's UpdateLog short-circuits on len(Rows)==0 before honoring NoMore,
// so a final empty request never runs TransferLogs and dbfs_data leaks.
// Inject a sentinel row after the daemon has exited so it can't be flushed
// before ReportLog(true).
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
// supported branches, e.g. v1.28+.
r.stateMu.Lock()
if len(r.logRows) == 0 {
// Not appendLogRow: the sentinel is not job output and has no place in job.log.
r.logRows = append(r.logRows, &runnerv1.LogRow{
Time: timestamppb.Now(),
Content: "",
})
}
r.stateMu.Unlock()
// Separate budgets so a slow ReportLog can't starve the ReportState that // Separate budgets so a slow ReportLog can't starve the ReportState that
// carries the cancel acknowledgement. // carries the cancel acknowledgement.
err := errors.Join( err := errors.Join(
@@ -960,7 +944,7 @@ func logRow(entry logging.Record) *runnerv1.LogRow {
// mask repairs the content first, so a secret the repair itself spells out is still caught. // mask repairs the content first, so a secret the repair itself spells out is still caught.
func (r *Reporter) mask(content string) string { func (r *Reporter) mask(content string) string {
return r.logReplacer.Replace(strings.ToValidUTF8(content, "?")) return runner.MaskLog(strings.ToValidUTF8(content, "?"), r.logReplacer)
} }
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live // AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
+22 -28
View File
@@ -173,11 +173,9 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
) )
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{ reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx, Context: &structpb.Struct{},
}, cfg) }, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
@@ -357,7 +355,7 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
}) })
} }
func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) { func TestReporter_CloseFinalizesLogWithoutRows(t *testing.T) {
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest] var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
var noMoreCalls atomic.Int64 var noMoreCalls atomic.Int64
@@ -402,7 +400,7 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
final := lastReq.Load() final := lastReq.Load()
require.NotNil(t, final) require.NotNil(t, final)
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true") assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row") assert.Empty(t, final.Rows, "a job without output must not get a log row")
} }
func TestReporter_StateHeartbeat(t *testing.T) { func TestReporter_StateHeartbeat(t *testing.T) {
@@ -474,10 +472,8 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
reporter.stateMu.Lock() reporter.stateMu.Lock()
@@ -527,10 +523,8 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
) )
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
reporter.ResetSteps(newTestSteps(1)) reporter.ResetSteps(newTestSteps(1))
cancel() cancel()
@@ -671,19 +665,15 @@ func TestReporter_SetOutputs(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "caf\xff", got.value) assert.Equal(t, "caf\xff", got.value)
maxValue := strings.Repeat("v", maxOutputValueLen) r.SetOutputs(map[string]string{"atlimit": longValue[:maxOutputValueLen]})
r.SetOutputs(map[string]string{"atlimit": maxValue})
got, ok = r.outputs["atlimit"] got, ok = r.outputs["atlimit"]
require.True(t, ok) require.True(t, ok)
assert.Len(t, got.value, maxOutputValueLen) assert.Len(t, got.value, maxOutputValueLen)
} }
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) { func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{ reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{
Context: taskCtx, Context: &structpb.Struct{},
Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"}, Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"},
}, &config.Config{}) }, &config.Config{})
defer deregisterGlobalMasks(reporter) defer deregisterGlobalMasks(reporter)
@@ -760,8 +750,7 @@ func TestReporter_ParseResult(t *testing.T) {
func TestReporter_masksEncodedSecrets(t *testing.T) { func TestReporter_masksEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1` secret := `p@ss w"rd/1`
r := &Reporter{logReplacer: strings.NewReplacer()} r := &Reporter{logReplacer: strings.NewReplacer()}
r.oldnew = runner.AppendSecretMasker(r.oldnew, secret) r.AddMask(secret)
r.logReplacer = strings.NewReplacer(r.oldnew...)
for _, line := range []string{ for _, line := range []string{
"token: " + secret, "token: " + secret,
@@ -773,6 +762,17 @@ func TestReporter_masksEncodedSecrets(t *testing.T) {
assert.NotContains(t, masked, secret) assert.NotContains(t, masked, secret)
assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret))) assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret)))
} }
r.AddMask("alpha beta;gamma]delta")
r.AddMask("%")
for _, test := range []struct{ line, want string }{
{"##[group]alpha beta%3Bgamma%5Ddelta", "##[group]***"},
{"##[group]alpha beta%3Bgamma]delta", "##[group]***"},
{"%", "***"},
} {
r.appendLogRow(logRow(logging.Record{Message: test.line}))
assert.Equal(t, test.want, r.logRows[len(r.logRows)-1].Content)
}
} }
func TestReporter_AckIndexBeyondBuffer(t *testing.T) { func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
@@ -861,10 +861,8 @@ func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
close(reporter.daemon) close(reporter.daemon)
require.NoError(t, reporter.ReportState(false)) require.NoError(t, reporter.ReportState(false))
@@ -1031,9 +1029,7 @@ func newTestReporter(t *testing.T, client *mocks.Client, cfg *config.Config) *Re
t.Helper() t.Helper()
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel) t.Cleanup(cancel)
taskCtx, err := structpb.NewStruct(map[string]any{}) return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
require.NoError(t, err)
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
} }
func newTestSteps(n int) []*model.Step { func newTestSteps(n int) []*model.Step {
@@ -1051,11 +1047,9 @@ func TestReporter_ReportedSteps(t *testing.T) {
return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil
}) })
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil) client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil)
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("") cfg, _ := config.LoadDefault("")
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg) reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg)
reporter.RunDaemon() reporter.RunDaemon()
reporter.Logf("runner version") reporter.Logf("runner version")
reporter.ResetSteps(steps) reporter.ResetSteps(steps)
+2 -1
View File
@@ -117,7 +117,8 @@ func TestExportRetriesAndMatchesOTLP(t *testing.T) {
certificate := server.TLS.Certificates[0] certificate := server.TLS.Certificates[0]
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey) key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
require.NoError(t, err) require.NoError(t, err)
certificatePath, keyPath := filepath.Join(t.TempDir(), "cert.pem"), filepath.Join(t.TempDir(), "key.pem") dir := t.TempDir()
certificatePath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600)) require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600))
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600)) require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
setEnv(t, map[string]string{ setEnv(t, map[string]string{
+2
View File
@@ -6,6 +6,8 @@ package ver
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3" // go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
var version = "dev" var version = "dev"
const MinGiteaVersion = "1.26.2" // also in the README.md prerequisites
func Version() string { func Version() string {
return version return version
} }