mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
Compare commits
90 Commits
984b47c716
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34bfa19150 | ||
|
|
96d9f491db | ||
|
|
14ec00b66e | ||
|
|
68c6a5b4f1 | ||
|
|
0cd0e52a24 | ||
|
|
47d5b5ad03 | ||
|
|
2398d4a527 | ||
|
|
b7a3bf98bc | ||
|
|
da4037899a | ||
|
|
e4fe49dba4 | ||
|
|
41c72216bf | ||
|
|
3f7fd16ea1 | ||
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 | ||
|
|
fc0e03e5a9 | ||
|
|
333eb17d19 | ||
|
|
c3b39e0d99 | ||
|
|
78a74f78f8 | ||
|
|
8c519ce318 | ||
|
|
de43c84203 | ||
|
|
3c5ef1721a | ||
|
|
94ab020204 | ||
|
|
b4a64b97dd | ||
|
|
26f9fb12af | ||
|
|
c9c4957e38 | ||
|
|
b1a02cdd5d | ||
|
|
0fd8602ac3 | ||
|
|
6133d64270 | ||
|
|
c43cbe87ca | ||
|
|
7bec310002 | ||
|
|
8af385d147 | ||
|
|
46f22c78d2 | ||
|
|
068afc3996 | ||
|
|
0e8896c52a | ||
|
|
89467c9dd0 | ||
|
|
0c08b0f2da | ||
|
|
aa7a29a157 | ||
|
|
ad967330a8 | ||
|
|
60177008a5 | ||
|
|
58c5eb8d21 | ||
|
|
d6882b3df5 | ||
|
|
7e7e3ef1a6 | ||
|
|
16357a34b2 | ||
|
|
d53538ac38 | ||
|
|
554b3b7671 | ||
|
|
65756d60b3 | ||
|
|
be9b4502d6 | ||
|
|
1d74ae636a | ||
|
|
b12d02c25f | ||
|
|
f2e0cf9131 | ||
|
|
0ee4643d4a | ||
|
|
e774003c18 | ||
|
|
eeb479ea89 | ||
|
|
eba33e178d | ||
|
|
3396021e0f | ||
|
|
745b0ab6e4 | ||
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 | ||
|
|
40e021309a | ||
|
|
d3b3519dea | ||
|
|
6bdcb54828 | ||
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 | ||
|
|
33e6d1d8ff | ||
|
|
56979e6ab8 | ||
|
|
bf99e6a758 | ||
|
|
740a3d4db4 | ||
|
|
822af5029f | ||
|
|
526c46b485 | ||
|
|
355289bc54 | ||
|
|
e583b0706b | ||
|
|
8ad84cd96a | ||
|
|
0a2f28244d | ||
|
|
443b0e336c | ||
|
|
53c4db6a4b | ||
|
|
1073c8bfec | ||
|
|
ff7d9ca8d0 |
@@ -18,8 +18,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- run: make lint-pr-title
|
||||
|
||||
@@ -17,14 +17,26 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
args: release --nightly
|
||||
@@ -35,6 +47,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -43,27 +59,33 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
# The basic image is built from source and can target any arch the
|
||||
# toolchain supports. The dind variants are limited to the arches the
|
||||
# docker:dind base image publishes.
|
||||
- target: basic
|
||||
tag_suffix: ""
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||
- target: dind
|
||||
tag_suffix: "-dind"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- target: dind-rootless
|
||||
tag_suffix: "-dind-rootless"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -77,14 +99,12 @@ jobs:
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.variant.target }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
platforms: ${{ matrix.variant.platforms }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||
|
||||
@@ -9,21 +9,33 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.PASSPHRASE }}
|
||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
args: release
|
||||
@@ -34,6 +46,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
@@ -42,12 +58,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
# The basic image is built from source and can target any arch the
|
||||
# toolchain supports. The dind variants are limited to the arches the
|
||||
# docker:dind base image publishes.
|
||||
- target: basic
|
||||
tag_suffix: ""
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||
- target: dind
|
||||
tag_suffix: "-dind"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- target: dind-rootless
|
||||
tag_suffix: "-dind-rootless"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
@@ -55,25 +77,25 @@ jobs:
|
||||
DOCKER_LATEST: latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: "Docker meta"
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_ORG }}/runner
|
||||
@@ -86,14 +108,12 @@ jobs:
|
||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.variant.target }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
platforms: ${{ matrix.variant.platforms }}
|
||||
push: true
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -17,8 +17,8 @@ jobs:
|
||||
# to ~/.docker with the stale credentials.
|
||||
DOCKER_CONFIG: /tmp/docker-noauth
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: prepare anonymous docker config
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
done
|
||||
- name: lint
|
||||
run: make lint
|
||||
- name: checks
|
||||
run: make checks
|
||||
- name: build
|
||||
run: make build
|
||||
- name: test
|
||||
@@ -42,3 +44,7 @@ jobs:
|
||||
# after `make test` so the images it needs are already present on the host daemon.
|
||||
- name: test against dind image
|
||||
run: make test-dind
|
||||
- name: coverage report
|
||||
run: |
|
||||
make coverage-report
|
||||
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,7 +2,9 @@
|
||||
.env
|
||||
!/act/runner/testdata/secrets/.env
|
||||
.runner
|
||||
.runner.lock
|
||||
coverage.txt
|
||||
.tmp/
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains
|
||||
@@ -12,3 +14,4 @@ coverage.txt
|
||||
__debug_bin
|
||||
# gorelease binary folder
|
||||
/dist
|
||||
.DS_Store
|
||||
@@ -11,6 +11,7 @@ linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- goheader
|
||||
@@ -102,6 +103,9 @@ linters:
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: cmd
|
||||
- linters:
|
||||
- forcetypeassert
|
||||
path: _test\.go
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -93,6 +93,37 @@ blobs:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
#
|
||||
# This publisher fires 109 times for 73 distinct keys because
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
# as UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||
# appends this block's own extra_files with no de-duplication. It
|
||||
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||
# this publisher declares its own complete file set rather than
|
||||
# implicitly depending on the `release:` block's globs.
|
||||
publishers:
|
||||
- name: cloudflare-r2
|
||||
checksum: true
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} gitea-runner/{{ .Version }}/{{ .ArtifactName }}
|
||||
env:
|
||||
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
name_template: "{{ .Binary }}"
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -17,14 +17,14 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.5.2-dind AS dind
|
||||
FROM docker:29.6.2-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||
|
||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.5.2-dind-rootless AS dind-rootless
|
||||
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -45,7 +45,7 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
|
||||
USER root
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||
|
||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
@@ -63,7 +63,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### BASIC VARIANT
|
||||
#
|
||||
#
|
||||
FROM alpine:3.23 AS basic
|
||||
FROM alpine:3.24 AS basic
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
39
Makefile
39
Makefile
@@ -4,9 +4,9 @@ DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||
XGO_VERSION := go-1.26.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
@@ -18,8 +18,10 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
STATIC ?=
|
||||
EXTLDFLAGS ?=
|
||||
@@ -38,12 +40,15 @@ endif
|
||||
ifeq ($(OS), Windows_NT)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||
else ifeq ($(OS), Windows)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||
else
|
||||
GOFLAGS := -v
|
||||
EXECUTABLE ?= $(EXECUTABLE)
|
||||
GO_ENV_WINDOWS := GOOS=windows
|
||||
endif
|
||||
|
||||
STORED_VERSION_FILE := VERSION
|
||||
@@ -92,7 +97,7 @@ go-check:
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: fmt
|
||||
@diff=$$(git diff --color=always); \
|
||||
@diff=$$(git diff --color=always -- '*.go'); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
@@ -107,13 +112,21 @@ deps-tools: ## install tool dependencies
|
||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||
wait
|
||||
|
||||
.PHONY: checks
|
||||
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go ## lint everything
|
||||
lint: lint-go lint-go-windows ## lint everything
|
||||
|
||||
.PHONY: lint-go
|
||||
lint-go: ## lint go files
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||
|
||||
.PHONY: lint-go-windows
|
||||
lint-go-windows: ## lint Windows go files
|
||||
$(GO) install $(GOLANGCI_LINT_PACKAGE)
|
||||
$(GO_ENV_WINDOWS) golangci-lint run
|
||||
|
||||
.PHONY: lint-go-fix
|
||||
lint-go-fix: ## lint go files and fix issues
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||
@@ -123,7 +136,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||
@node ./tools/lint-pr-title.ts
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: deps-tools
|
||||
security-check:
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
|
||||
.PHONY: tidy
|
||||
@@ -140,8 +153,14 @@ tidy-check: tidy
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: coverage-report
|
||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||
@mkdir -p .tmp
|
||||
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
|
||||
@echo "Wrote .tmp/coverage.md"
|
||||
|
||||
.PHONY: test-dind
|
||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||
@@ -210,7 +229,7 @@ docker: ## build the docker image
|
||||
.PHONY: clean
|
||||
clean: ## delete binary and coverage files
|
||||
$(GO) clean -x -i ./...
|
||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
||||
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
|
||||
|
||||
.PHONY: version
|
||||
version: ## print the version
|
||||
|
||||
211
README.md
211
README.md
@@ -85,6 +85,48 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
|
||||
|
||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||
|
||||
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
||||
|
||||
### Image flavours
|
||||
|
||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||
|
||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||
|
||||
#### `latest` — basic
|
||||
|
||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||
```
|
||||
|
||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||
|
||||
#### `latest-dind` — Docker-in-Docker
|
||||
|
||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||
|
||||
```bash
|
||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
--name my_runner gitea/runner:latest-dind
|
||||
```
|
||||
|
||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||
|
||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||
|
||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||
|
||||
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
||||
|
||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||
|
||||
### Configuration
|
||||
|
||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||
@@ -105,26 +147,146 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
|
||||
|
||||
#### Without a config file
|
||||
|
||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
|
||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
||||
|
||||
| Variable | Effect |
|
||||
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
|
||||
### Labels
|
||||
|
||||
Labels decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job.
|
||||
|
||||
A label is written as:
|
||||
|
||||
```text
|
||||
<name>[:<schema>[:<args>]]
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
| --- | --- |
|
||||
| `GITEA_DEBUG` | If true, sets log level to `debug` |
|
||||
| `GITEA_TRACE` | If true, sets log level to `trace` |
|
||||
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
|
||||
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
|
||||
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
|
||||
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
|
||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||
|
||||
Prefer a YAML file for all settings.
|
||||
Two schemas are supported:
|
||||
|
||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||
|
||||
```text
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||
|
||||
```text
|
||||
macos:host
|
||||
```
|
||||
|
||||
So with the labels
|
||||
|
||||
```text
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||
```
|
||||
|
||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||
|
||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||
|
||||
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||
|
||||
#### Registration vs config labels
|
||||
|
||||
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
||||
|
||||
#### External cache (`actions/cache`)
|
||||
The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins:
|
||||
|
||||
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
|
||||
```
|
||||
--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file
|
||||
```
|
||||
|
||||
Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup.
|
||||
|
||||
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||
|
||||
#### Proxy
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
|
||||
```sh
|
||||
http_proxy=http://proxy.example:3128
|
||||
https_proxy=http://proxy.example:3128
|
||||
no_proxy=gitea.internal,.example.local
|
||||
```
|
||||
|
||||
The runner uses them for its own requests and gives them to every job, in lower and upper case.
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
|
||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||
|
||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
|
||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||
|
||||
Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network.
|
||||
|
||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||
|
||||
#### Caching (`actions/cache`)
|
||||
|
||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
|
||||
**Cache service v2**
|
||||
|
||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
v2: false
|
||||
```
|
||||
|
||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
||||
|
||||
**Shared cache across multiple runners**
|
||||
|
||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
|
||||
1. Create a config file for the cache server host:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
dir: /data/actcache
|
||||
port: 8088
|
||||
external_secret: "replace-with-a-strong-random-secret"
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
|
||||
```bash
|
||||
gitea-runner -c cache-server-config.yaml cache-server
|
||||
```
|
||||
|
||||
3. On every runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||
|
||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||
|
||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
||||
|
||||
#### Official Docker image
|
||||
|
||||
@@ -132,11 +294,32 @@ Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image en
|
||||
|
||||
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
||||
|
||||
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
|
||||
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
|
||||
While the runner is idle it cleans up after earlier jobs:
|
||||
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||
|
||||
#### Post-task script (`runner.post_task_script`)
|
||||
|
||||
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
|
||||
|
||||
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
|
||||
|
||||
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
|
||||
|
||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||
|
||||
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||
|
||||
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||
|
||||
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||
|
||||
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||
|
||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||
|
||||
### Example Deployments
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ type Handler struct {
|
||||
storage *Storage
|
||||
router *httprouter.Router
|
||||
listener net.Listener
|
||||
port int
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
|
||||
@@ -157,12 +158,13 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||
// does not attach Authorization when downloading archiveLocation.
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||
// cache API can authenticate them. Always wired so the routes exist; the
|
||||
// handlers themselves 401 when internalSecret is unset.
|
||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||
h.registerV2Routes(router)
|
||||
|
||||
h.router = router
|
||||
|
||||
@@ -177,6 +179,12 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
listener.Close()
|
||||
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||
}
|
||||
h.port = addr.Port
|
||||
server := &http.Server{
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
@@ -194,9 +202,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
return fmt.Sprintf("http://%s:%d",
|
||||
h.outboundIP,
|
||||
h.listener.Addr().(*net.TCPAddr).Port)
|
||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||
}
|
||||
|
||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||
@@ -334,7 +340,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findCache(db, cred.Repo, keys, version)
|
||||
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
@@ -343,15 +349,6 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.storage.Exist(cache.ID); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
@@ -359,6 +356,25 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
})
|
||||
}
|
||||
|
||||
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
||||
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
||||
// entry is dropped on the way out.
|
||||
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||
cache, err := findCache(db, repo, keys, version)
|
||||
if err != nil || cache == nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := h.storage.Exist(cache.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
return nil, nil //nolint:nilnil // absence is not an error here
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
@@ -433,7 +449,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
@@ -474,23 +490,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
|
||||
db.Close()
|
||||
|
||||
size, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = size
|
||||
|
||||
db, err = h.openDB()
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache.Complete = true
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
@@ -498,8 +498,28 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
|
||||
// have closed its store first: Commit concatenates the whole archive and would otherwise
|
||||
// hold bolt's exclusive file lock for the duration.
|
||||
func (h *Handler) commitCache(cache *Cache) error {
|
||||
written, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = written
|
||||
cache.Complete = true
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
||||
// Authenticated via signed URL (see signedAuth), not bearer, because the
|
||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||
// Repository scoping is already enforced at find() time; the signature binds
|
||||
// the URL to the specific cache ID and an expiry.
|
||||
@@ -509,7 +529,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.storage.Serve(w, r, uint64(id))
|
||||
}
|
||||
|
||||
@@ -543,7 +563,9 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
@@ -566,7 +588,7 @@ func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||
return
|
||||
}
|
||||
expected := h.computeSignature(id, exp)
|
||||
expected := h.computeSignature(purpose, id, exp)
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||
return
|
||||
@@ -650,19 +672,26 @@ func credFromContext(ctx context.Context) JobCredential {
|
||||
return JobCredential{}
|
||||
}
|
||||
|
||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
||||
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
|
||||
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
|
||||
// other way round. Downloads use the empty purpose, the message v1 has always signed.
|
||||
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
||||
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
||||
expUnix := exp.Unix()
|
||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
||||
q := url.Values{}
|
||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||
q.Set("sig", sig)
|
||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
||||
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
@@ -670,16 +699,12 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
cache := &Cache{}
|
||||
for _, prefix := range keys {
|
||||
// if a key in the list matches exactly, don't return partial matches
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(prefix).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
exact, err := findExactCache(db, repo, prefix, version, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exact != nil {
|
||||
return exact, nil
|
||||
}
|
||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||
re, err := regexp.Compile(prefixPattern)
|
||||
@@ -702,6 +727,34 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
|
||||
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
|
||||
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
|
||||
//
|
||||
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
||||
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||
// touches UsedAt on every part.
|
||||
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
|
||||
sortBy := "UsedAt"
|
||||
if complete {
|
||||
sortBy = "CreatedAt"
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(complete).
|
||||
SortBy(sortBy).Reverse())
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
return nil, nil //nolint:nilnil // absence is not an error here
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||
return fmt.Errorf("insert cache: %w", err)
|
||||
@@ -713,18 +766,30 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) useCache(id int64) {
|
||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
|
||||
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
|
||||
// other jobs restore no longer matching its recorded size. An entry missing from the store is
|
||||
// accepted, since the signature proves the id was handed out.
|
||||
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache := &Cache{}
|
||||
if err := db.Get(id, cache); err != nil {
|
||||
return
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if requireIncomplete && cache.Complete {
|
||||
return fmt.Errorf("cache %d: already complete", id)
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
_ = db.Update(cache.ID, cache)
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -445,13 +445,6 @@ func TestHandler(t *testing.T) {
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with not exist id", func(t *testing.T) {
|
||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with multiple keys", func(t *testing.T) {
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
key := strings.ToLower(t.Name())
|
||||
@@ -469,7 +462,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -554,7 +548,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -607,7 +602,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -646,6 +642,20 @@ func TestHandler(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// backdateCache rewrites a cache's CreatedAt. It has one-second resolution, so age-ordering
|
||||
// tests set it directly instead of sleeping a second between uploads.
|
||||
func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration) {
|
||||
db, err := handler.openDB()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
var caches []*Cache
|
||||
require.NoError(t, db.Find(&caches, bolthold.Where("Key").Eq(key)))
|
||||
require.Len(t, caches, 1)
|
||||
caches[0].CreatedAt = time.Now().Add(-age).Unix()
|
||||
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
||||
}
|
||||
|
||||
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
var id uint64
|
||||
{
|
||||
@@ -1031,14 +1041,14 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||
sig := first.computeSignature(42, exp)
|
||||
sig := first.computeSignature("", 42, exp)
|
||||
require.NoError(t, first.Close())
|
||||
|
||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer second.Close()
|
||||
|
||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
||||
assert.Equal(t, sig, second.computeSignature("", 42, exp))
|
||||
}
|
||||
|
||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
||||
|
||||
268
act/artifactcache/handler_v2.go
Normal file
268
act/artifactcache/handler_v2.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
// The cache service v2 API. A client on this version talks twirp to
|
||||
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||
// Both API versions are served from the same store, so a repository keeps its cache
|
||||
// when a workflow moves between action versions.
|
||||
const (
|
||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||
|
||||
// blobPath authenticates by signature, because the client uploads without an
|
||||
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||
blobPath = apiPath + "/blobs"
|
||||
|
||||
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||
blobUploadPurpose = "upload:"
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||
}
|
||||
|
||||
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||
// the upload.
|
||||
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||
// existing and silently never saved.
|
||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
} else if existing != nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
}
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
// int64 fields travel as strings in the proto JSON mapping.
|
||||
"entryId": strconv.FormatUint(cache.ID, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
"matchedKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||
// in order.
|
||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.touchCache(id, true); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
switch strings.ToLower(query.Get("comp")) {
|
||||
case "block":
|
||||
blockID := query.Get("blockid")
|
||||
if blockID == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||
return
|
||||
}
|
||||
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||
case "blocklist":
|
||||
var list struct{ Latest []string }
|
||||
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||
return
|
||||
}
|
||||
err = h.storage.OrderBlocks(id, list.Latest)
|
||||
default:
|
||||
err = h.storage.Write(id, 0, r.Body)
|
||||
}
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||
}
|
||||
|
||||
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||
// instead of a parse error.
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||
type (
|
||||
v2CreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
v2FinalizeRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes json.Number `json:"size_bytes"`
|
||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
v2DownloadRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
RestoreKeys []string `json:"restore_keys"`
|
||||
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||
}
|
||||
)
|
||||
|
||||
func (d v2DownloadRequest) keys() []string {
|
||||
restoreKeys := d.RestoreKeys
|
||||
if len(restoreKeys) == 0 {
|
||||
restoreKeys = d.RestoreKeysCamel
|
||||
}
|
||||
return append([]string{d.Key}, restoreKeys...)
|
||||
}
|
||||
|
||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||
var req T
|
||||
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||
return req, err
|
||||
}
|
||||
236
act/artifactcache/handler_v2_test.go
Normal file
236
act/artifactcache/handler_v2_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// v2Call posts a twirp request to the cache service and returns the decoded response.
|
||||
// Field names are the proto ones, which is what the toolkit's client sends.
|
||||
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
return got
|
||||
}
|
||||
|
||||
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
|
||||
func putBlob(t *testing.T, url string, content []byte) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getURL(t *testing.T, url string) []byte {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func startTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
|
||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
return handler
|
||||
}
|
||||
|
||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||
// with the upload URL it used.
|
||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||
t.Helper()
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||
require.Equal(t, true, created["ok"])
|
||||
uploadURL, _ = created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||
|
||||
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": key, "version": version,
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
}), uploadURL
|
||||
}
|
||||
|
||||
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
|
||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||
// or to replace a finalized entry.
|
||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
content := []byte("the cached archive")
|
||||
|
||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||
|
||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
assert.NotEmpty(t, finalized["entryId"])
|
||||
|
||||
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||
// and it is an upload URL only: nothing reads a blob back through it.
|
||||
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
|
||||
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-v1", got["matchedKey"])
|
||||
downloadURL, _ := got["signedDownloadUrl"].(string)
|
||||
require.NotEmpty(t, downloadURL)
|
||||
assert.Equal(t, content, getURL(t, downloadURL))
|
||||
}
|
||||
|
||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||
uploadURL, _ := created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
|
||||
blocks := map[string][]byte{}
|
||||
var order []string
|
||||
for i, part := range []string{"hello ", "world", "!"} {
|
||||
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||
blocks[blockID] = []byte(part)
|
||||
order = append(order, blockID)
|
||||
}
|
||||
// Upload in an order that is not the block list order.
|
||||
for _, blockID := range []string{order[2], order[0], order[1]} {
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||
}
|
||||
|
||||
var list bytes.Buffer
|
||||
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||
for _, blockID := range order {
|
||||
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||
}
|
||||
list.WriteString(`</BlockList>`)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||
|
||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
})
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
||||
}
|
||||
|
||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||
require.Equal(t, true, saved["ok"])
|
||||
|
||||
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The toolkit serialises with the proto field names; the camelCase spellings of the same
|
||||
// proto JSON mapping are accepted alongside them.
|
||||
for _, field := range []string{"restore_keys", "restoreKeys"} {
|
||||
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||
})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-abc", got["matchedKey"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, again["ok"])
|
||||
})
|
||||
|
||||
// A key that is only a prefix of an existing one is a different entry, so the
|
||||
// reservation check must be exact and not a restore-key prefix match, or the shorter
|
||||
// key would be reported as existing and silently never saved.
|
||||
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||
require.Equal(t, true, reserved["ok"])
|
||||
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
||||
})
|
||||
|
||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The size the client declares is what Commit validates the assembled archive against.
|
||||
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
||||
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// Both API versions are served from one store, so an entry written through v2 is a hit for
|
||||
// a v1 client asking for the same key and version.
|
||||
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||
assert.NotEmpty(t, got["archiveLocation"])
|
||||
})
|
||||
|
||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||
handler.RegisterJob("other-runtime-token", "other/repo")
|
||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||
|
||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
}
|
||||
@@ -5,12 +5,15 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
return s.writeFile(s.tempName(id, offset), reader)
|
||||
}
|
||||
|
||||
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||
return s.writeFile(s.blockName(id, blockID), reader)
|
||||
}
|
||||
|
||||
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
for i, blockID := range blockIDs {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := assemble(name, tempNames)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||
func assemble(name string, tempNames []string) (int64, error) {
|
||||
if len(tempNames) == 1 {
|
||||
info, err := os.Stat(tempNames[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), os.Rename(tempNames[0], name)
|
||||
}
|
||||
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -119,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||
// Commit's name-ordered concatenation.
|
||||
const blockFilePrefix = "block-"
|
||||
|
||||
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||
// path element.
|
||||
sum := sha256.Sum256([]byte(blockID))
|
||||
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
@@ -127,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +390,43 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
||||
fsys := readWriteFSImpl{}
|
||||
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||
|
||||
w, err := fsys.OpenWritable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("first"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
w, err = fsys.OpenAppendable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("-second"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err := os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "first-second", string(got))
|
||||
|
||||
w, err = fsys.OpenWritable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("replaced"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err = os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "replaced", string(got))
|
||||
}
|
||||
|
||||
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||
require.NotNil(t, cancel)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
|
||||
73
act/common/context_helpers_test.go
Normal file
73
act/common/context_helpers_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestDryrunContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if Dryrun(ctx) {
|
||||
t.Fatal("plain context should not be dryrun")
|
||||
}
|
||||
if !Dryrun(WithDryrun(ctx, true)) {
|
||||
t.Fatal("WithDryrun(true) should set dryrun")
|
||||
}
|
||||
if Dryrun(WithDryrun(ctx, false)) {
|
||||
t.Fatal("WithDryrun(false) should clear dryrun")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobErrorContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := errors.New("job failed")
|
||||
|
||||
SetJobError(ctx, err)
|
||||
if got := JobError(ctx); got != nil {
|
||||
t.Fatalf("JobError without container = %v, want nil", got)
|
||||
}
|
||||
|
||||
ctx = WithJobErrorContainer(ctx)
|
||||
SetJobError(ctx, err)
|
||||
if got := JobError(ctx); !errors.Is(got, err) {
|
||||
t.Fatalf("JobError = %v, want %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerAndHookContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if Logger(ctx) != logrus.StandardLogger() {
|
||||
t.Fatal("plain context should use standard logger")
|
||||
}
|
||||
if LoggerHook(ctx) != nil {
|
||||
t.Fatal("plain context should not have a logger hook")
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
ctx = WithLogger(ctx, logger)
|
||||
if Logger(ctx) != logger {
|
||||
t.Fatal("WithLogger should set logger")
|
||||
}
|
||||
|
||||
hook := testHook{}
|
||||
ctx = WithLoggerHook(ctx, hook)
|
||||
if LoggerHook(ctx) != hook {
|
||||
t.Fatal("WithLoggerHook should set hook")
|
||||
}
|
||||
}
|
||||
|
||||
type testHook struct{}
|
||||
|
||||
func (testHook) Levels() []logrus.Level {
|
||||
return logrus.AllLevels
|
||||
}
|
||||
|
||||
func (testHook) Fire(*logrus.Entry) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
||||
func TestMaxParallel2Quick(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
|
||||
executors := make([]Executor, 4)
|
||||
for i := range 4 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
||||
"Should not exceed max-parallel: 2")
|
||||
}
|
||||
|
||||
// Test that verifies max-parallel: 1 enforces sequential execution
|
||||
func TestMaxParallel1Sequential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
var executionOrder []int
|
||||
var orderMutex sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track execution order
|
||||
orderMutex.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
orderMutex.Unlock()
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
||||
"max-parallel: 1 should only run 1 task at a time")
|
||||
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
||||
func TestMaxParallelJobExecution(t *testing.T) {
|
||||
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
var executionOrder []int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track max concurrent
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
||||
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
||||
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
||||
var maxConcurrent int32
|
||||
var currentRunning atomic.Int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// When maxParallel is 0 or negative, it defaults to 1
|
||||
err := NewParallelExecutor(0, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelWithErrors tests error handling with max-parallel
|
||||
func TestMaxParallelWithErrors(t *testing.T) {
|
||||
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
||||
var successCount int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
if taskID == 2 {
|
||||
return assert.AnError
|
||||
}
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
// Should return the error from task 2
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Other tasks should still execute
|
||||
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
||||
})
|
||||
|
||||
t.Run("ContextCancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var startedCount int32
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
atomic.AddInt32(&startedCount, 1)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel after a short delay
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Not all tasks should start due to cancellation (but timing may vary)
|
||||
// Just verify cancellation occurred
|
||||
t.Logf("Started %d tasks before cancellation", startedCount)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||
var sharedCounter int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 100)
|
||||
for i := range 100 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
mu.Lock()
|
||||
sharedCounter++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(10, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
||||
})
|
||||
|
||||
t.Run("ChannelCommunication", func(t *testing.T) {
|
||||
resultChan := make(chan int, 50)
|
||||
|
||||
executors := make([]Executor, 50)
|
||||
for i := range 50 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
resultChan <- taskID
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(5, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
close(resultChan)
|
||||
|
||||
results := make(map[int]bool)
|
||||
for result := range resultChan {
|
||||
results[result] = true
|
||||
}
|
||||
|
||||
assert.Len(t, results, 50, "All task IDs should be received")
|
||||
})
|
||||
}
|
||||
@@ -7,9 +7,11 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -80,44 +82,45 @@ func TestNewConditionalExecutor(t *testing.T) {
|
||||
assert.Equal(1, falseCount)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||
// find the gate already open so the last one still finishes with no partner left.
|
||||
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
|
||||
var counted, active, peak atomic.Int32
|
||||
var once sync.Once
|
||||
reached := make(chan struct{})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var count, activeCount, maxCount atomic.Int32
|
||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
|
||||
active := activeCount.Add(1)
|
||||
return func(ctx context.Context) error {
|
||||
counted.Add(1)
|
||||
running := active.Add(1)
|
||||
for {
|
||||
m := maxCount.Load()
|
||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
||||
seen := peak.Load()
|
||||
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
activeCount.Add(-1)
|
||||
|
||||
if running >= wantActive {
|
||||
once.Do(func() { close(reached) })
|
||||
}
|
||||
<-reached
|
||||
active.Add(-1)
|
||||
return nil
|
||||
})
|
||||
}, &counted, &peak
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
exec, count, maxActive := concurrencyProbe(2)
|
||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||
|
||||
// Reset to test running the executor with 0 parallelism
|
||||
count.Store(0)
|
||||
activeCount.Store(0)
|
||||
maxCount.Store(0)
|
||||
|
||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||
assert.NoError(errSingle)
|
||||
// parallelism below 1 falls back to a single worker
|
||||
exec, count, maxActive = concurrencyProbe(1)
|
||||
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||
@@ -170,3 +173,60 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||
assert.Equal(int32(3), count.Load())
|
||||
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
||||
var successCount atomic.Int32
|
||||
executors := make([]Executor, 5)
|
||||
for i := range executors {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
if i == 2 {
|
||||
return errors.New("fake error")
|
||||
}
|
||||
successCount.Add(1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
||||
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||
}
|
||||
|
||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var calls []string
|
||||
record := func(name string) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
calls = append(calls, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
|
||||
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
|
||||
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
|
||||
require.NoError(t, record("if-bool").IfBool(true)(ctx))
|
||||
require.NoError(t, record("main").Finally(record("finally"))(ctx))
|
||||
|
||||
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
|
||||
if !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||
mainErr := errors.New("main failed")
|
||||
finalErr := errors.New("cleanup failed")
|
||||
|
||||
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
||||
require.Error(t, err)
|
||||
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionalNot(t *testing.T) {
|
||||
cond := Conditional(func(context.Context) bool { return false })
|
||||
if !cond.Not()(context.Background()) {
|
||||
t.Fatal("inverted conditional should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
@@ -32,7 +33,7 @@ var (
|
||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||
|
||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
@@ -43,10 +44,7 @@ var (
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||
func AcquireCloneLock(dir string) func() {
|
||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
return cloneLocks.Lock(dir)
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
@@ -257,6 +255,14 @@ type NewGitCloneExecutorInput struct {
|
||||
Token string
|
||||
OfflineMode bool
|
||||
|
||||
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
|
||||
// 0 for full clone.
|
||||
Depth int
|
||||
|
||||
// Quiet drops the informational clone line to debug level, for callers that log their own
|
||||
// download summary (the setup section's action report).
|
||||
Quiet bool
|
||||
|
||||
// For Gitea
|
||||
InsecureSkipTLS bool
|
||||
}
|
||||
@@ -265,8 +271,23 @@ type NewGitCloneExecutorInput struct {
|
||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||
r, err := git.PlainOpen(input.Dir)
|
||||
if err == nil {
|
||||
// Reuse existing clone
|
||||
return r, true, nil
|
||||
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||
remote, err := r.Remote("origin")
|
||||
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||
// Reuse existing clone
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||
} else if len(remote.Config().URLs) == 0 {
|
||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||
} else {
|
||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||
}
|
||||
if err := os.RemoveAll(input.Dir); err != nil {
|
||||
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
var progressWriter io.Writer
|
||||
@@ -294,7 +315,7 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
||||
}
|
||||
}
|
||||
|
||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
||||
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||
return nil, false, err
|
||||
@@ -328,7 +349,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
if input.Quiet {
|
||||
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
} else {
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
}
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
defer AcquireCloneLock(input.Dir)()
|
||||
@@ -349,6 +374,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
}
|
||||
|
||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||
shallow := isShallow(r)
|
||||
if shallow {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
err = r.Fetch(&fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
@@ -416,11 +451,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
|
||||
reusedMsg := ""
|
||||
|
||||
if !isOfflineMode {
|
||||
switch {
|
||||
case !isOfflineMode && !shallow:
|
||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||
}
|
||||
} else if reused {
|
||||
case isOfflineMode && reused:
|
||||
reusedMsg = " (reused in offline mode)"
|
||||
}
|
||||
|
||||
@@ -453,3 +490,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||
if input.Depth > 0 {
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
}
|
||||
|
||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||
}
|
||||
|
||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
if _, err := r.Reference(tagRef, false); err == nil {
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||
}
|
||||
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -49,6 +53,13 @@ func TestFindGitSlug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWrapsCommitAndCause(t *testing.T) {
|
||||
err := &Error{err: ErrShortRef, commit: "abc123"}
|
||||
require.Equal(t, ErrShortRef.Error(), err.Error())
|
||||
require.ErrorIs(t, err, ErrShortRef)
|
||||
require.Equal(t, "abc123", err.Commit())
|
||||
}
|
||||
|
||||
func cleanGitHooks(dir string) error {
|
||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||
files, err := os.ReadDir(hooksDir)
|
||||
@@ -95,6 +106,22 @@ func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert.Equal(remoteURL, u)
|
||||
}
|
||||
|
||||
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", basedir))
|
||||
require.NoError(t, cleanGitHooks(basedir))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
|
||||
|
||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "owner/repo", slug)
|
||||
|
||||
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "team/project", slug)
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
|
||||
@@ -235,6 +262,51 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
createRemote := func(message string) string {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
return remoteDir
|
||||
}
|
||||
|
||||
oldRemoteDir := createRemote("old-action")
|
||||
newRemoteDir := createRemote("new-action")
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: oldRemoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: newRemoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRemoteDir, originURL)
|
||||
|
||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
||||
|
||||
_, err = os.Stat(markerPath)
|
||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||
@@ -335,6 +407,134 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
||||
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: t.TempDir(),
|
||||
Quiet: quiet,
|
||||
})(ctx))
|
||||
|
||||
var cloneLines int
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if strings.HasPrefix(entry.Message, "git clone ") {
|
||||
cloneLines++
|
||||
}
|
||||
}
|
||||
if quiet {
|
||||
assert.Zero(t, cloneLines)
|
||||
} else {
|
||||
assert.Equal(t, 1, cloneLines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
for _, m := range []string{"c1", "c2", "c3"} {
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||
}
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||
|
||||
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||
|
||||
t.Run("branch is cloned shallowly", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
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, "c3", gitHeadSubject(t, dir))
|
||||
})
|
||||
|
||||
t.Run("tag is cloned shallowly", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
})
|
||||
|
||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
|
||||
// Advance main on the remote, then reuse the existing shallow clone.
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
})
|
||||
}
|
||||
|
||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
require.NoError(t, err)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func gitRevCount(t *testing.T, dir string) int {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func gitHeadSubject(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func gitCmd(args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -409,12 +609,4 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
t.Fatal("acquire on a different directory must not block")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
require.Same(t, v1, v2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func SetJobError(ctx context.Context, err error) {
|
||||
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
||||
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
|
||||
container["error"] = err
|
||||
}
|
||||
}
|
||||
|
||||
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||
|
||||
@@ -12,6 +12,13 @@ import (
|
||||
// LineHandler is a callback function for handling a line
|
||||
type LineHandler func(line string) bool
|
||||
|
||||
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||
// final line (when it is not newline-terminated) is not lost.
|
||||
type Flusher interface {
|
||||
Flush()
|
||||
}
|
||||
|
||||
type lineWriter struct {
|
||||
buffer bytes.Buffer
|
||||
handlers []LineHandler
|
||||
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||
return w
|
||||
}
|
||||
|
||||
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||
// callers can flush an io.Writer without knowing its concrete type.
|
||||
func FlushWriter(w io.Writer) {
|
||||
if f, ok := w.(Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
pBuf := bytes.NewBuffer(p)
|
||||
written := 0
|
||||
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||
// no-ops.
|
||||
func (lw *lineWriter) Flush() {
|
||||
if lw.buffer.Len() == 0 {
|
||||
return
|
||||
}
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
}
|
||||
|
||||
func (lw *lineWriter) handleLine(line string) {
|
||||
for _, h := range lw.handlers {
|
||||
ok := h(line)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
|
||||
assert.Equal(" and another\n", lines[2])
|
||||
assert.Equal("last line\n", lines[3])
|
||||
}
|
||||
|
||||
func TestLineWriterFlush(t *testing.T) {
|
||||
lines := make([]string, 0)
|
||||
lineHandler := func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
}
|
||||
|
||||
lineWriter := NewLineWriter(lineHandler)
|
||||
|
||||
assert := assert.New(t)
|
||||
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
|
||||
|
||||
// Only the newline-terminated line is emitted before flushing.
|
||||
assert.Equal([]string{"complete line\n"}, lines)
|
||||
|
||||
// Flushing emits the buffered, not-yet-terminated trailing line.
|
||||
FlushWriter(lineWriter)
|
||||
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||
|
||||
// Flushing again is a no-op: nothing is buffered.
|
||||
FlushWriter(lineWriter)
|
||||
assert.Len(lines, 2)
|
||||
}
|
||||
|
||||
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
|
||||
// FlushWriter must be a safe no-op for writers that do not buffer lines.
|
||||
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ func GetOutboundIP() net.IP {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP
|
||||
}
|
||||
}
|
||||
|
||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||
|
||||
@@ -82,6 +82,14 @@ type NewDockerBuildExecutorInput struct {
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
BuildArgs map[string]*string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||
type NewDockerNetworkCreateExecutorInput struct {
|
||||
EnableIPv4 *bool
|
||||
EnableIPv6 *bool
|
||||
RunnerUUID string
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
|
||||
@@ -49,6 +49,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
Remove: true,
|
||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||
Dockerfile: input.Dockerfile,
|
||||
BuildArgs: input.BuildArgs,
|
||||
}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
|
||||
@@ -498,6 +498,79 @@ func TestParseDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceByServerOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device string
|
||||
serverOS string
|
||||
want container.DeviceMapping
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "linux source only",
|
||||
device: "/dev/snd",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rwm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux source and mode",
|
||||
device: "/dev/snd:rw",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rw",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux source target and mode",
|
||||
device: "/dev/snd:/container/snd:m",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/container/snd",
|
||||
CgroupPermissions: "m",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows passes value through",
|
||||
device: `class/GUID`,
|
||||
serverOS: "windows",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: `class/GUID`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid server OS",
|
||||
device: "/dev/snd",
|
||||
serverOS: "plan9",
|
||||
wantErr: "unknown server OS: plan9",
|
||||
},
|
||||
{
|
||||
name: "too many linux fields",
|
||||
device: "/dev/snd:/container/snd:rw:extra",
|
||||
serverOS: "linux",
|
||||
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseDevice(tc.device, tc.serverOS)
|
||||
if tc.wantErr != "" {
|
||||
assert.Error(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -930,6 +1003,82 @@ func TestValidateDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeviceByServerOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
serverOS string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "linux preserves three-field container path",
|
||||
value: "/host:/container/../device:rw",
|
||||
serverOS: "linux",
|
||||
want: "/host:/container/../device:rw",
|
||||
},
|
||||
{
|
||||
name: "linux source path can be relative when target is absolute",
|
||||
value: "relative-host:/container/device",
|
||||
serverOS: "linux",
|
||||
want: "relative-host:/container/device",
|
||||
},
|
||||
{
|
||||
name: "windows defers validation",
|
||||
value: `class/GUID`,
|
||||
serverOS: "windows",
|
||||
want: `class/GUID`,
|
||||
},
|
||||
{
|
||||
name: "linux rejects bad mode",
|
||||
value: "/host:/container:ro",
|
||||
serverOS: "linux",
|
||||
wantError: "bad mode specified: ro",
|
||||
},
|
||||
{
|
||||
name: "linux target must be absolute",
|
||||
value: "/host:relative",
|
||||
serverOS: "linux",
|
||||
wantError: "relative is not an absolute path",
|
||||
},
|
||||
{
|
||||
name: "unknown server OS",
|
||||
value: "/dev/snd",
|
||||
serverOS: "plan9",
|
||||
wantError: "unknown server OS: plan9",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateDevice(tc.value, tc.serverOS)
|
||||
if tc.wantError != "" {
|
||||
assert.Error(t, err, tc.wantError)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, "c 1:3 rwm")
|
||||
|
||||
_, err = validateDeviceCgroupRule("invalid")
|
||||
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
||||
|
||||
if invalidParameter(nil) != nil {
|
||||
t.Fatal("invalidParameter(nil) should be nil")
|
||||
}
|
||||
err = invalidParameter(errors.New("bad input"))
|
||||
assert.Assert(t, err != nil)
|
||||
var invalid interface{ InvalidParameter() }
|
||||
assert.Assert(t, errors.As(err, &invalid))
|
||||
}
|
||||
|
||||
func TestParseSystemPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
|
||||
86
act/container/docker_create_flags.go
Normal file
86
act/container/docker_create_flags.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
pullPolicyAlways = "always"
|
||||
pullPolicyMissing = "missing"
|
||||
pullPolicyNever = "never"
|
||||
)
|
||||
|
||||
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
|
||||
|
||||
// createFlags are the flags docker/cli registers on the `create` and `run` commands
|
||||
// instead of in addFlags, so they are not part of containerOptions.
|
||||
type createFlags struct {
|
||||
platform string
|
||||
pull string
|
||||
name string
|
||||
useAPISocket bool
|
||||
}
|
||||
|
||||
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
|
||||
cf := new(createFlags)
|
||||
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
|
||||
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
|
||||
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
|
||||
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
|
||||
// Accepted without effect: pull progress is only logged at debug level, and docker
|
||||
// no longer implements content trust.
|
||||
flags.BoolP("quiet", "q", false, "Suppress the pull output")
|
||||
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
|
||||
return cf
|
||||
}
|
||||
|
||||
// parseContainerOptions parses a container options string. The flags are returned even
|
||||
// on error, holding whatever was read before the failure.
|
||||
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
copts := addFlags(flags)
|
||||
cf := registerCreateFlags(flags)
|
||||
|
||||
args, err := shellquote.Split(options)
|
||||
if err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
return flags, copts, cf, nil
|
||||
}
|
||||
|
||||
// createFlagsFromOptions reads the create-level flags that have to be known before the
|
||||
// container is created. Malformed options keep the defaults here and are reported by
|
||||
// mergeContainerConfigs at create time.
|
||||
func createFlagsFromOptions(options string) *createFlags {
|
||||
_, _, cf, _ := parseContainerOptions(options)
|
||||
return cf
|
||||
}
|
||||
|
||||
func (cf *createFlags) validate() error {
|
||||
if !slices.Contains(pullPolicies, cf.pull) {
|
||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||
}
|
||||
|
||||
if cf.useAPISocket {
|
||||
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
62
act/container/docker_create_flags_test.go
Normal file
62
act/container/docker_create_flags_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateFlagsFromOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
platform string
|
||||
pull string
|
||||
}{
|
||||
{"", "", pullPolicyMissing},
|
||||
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
|
||||
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
|
||||
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
cf := createFlagsFromOptions(tc.options)
|
||||
assert.Equal(t, tc.platform, cf.platform)
|
||||
assert.Equal(t, tc.pull, cf.pull)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
wantErr string
|
||||
}{
|
||||
{"--quiet --disable-content-trust --name mine", ""},
|
||||
{"--pull sometimes", `invalid --pull option "sometimes"`},
|
||||
{"--use-api-socket", "--use-api-socket is not supported"},
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
err := createFlagsFromOptions(tc.options).validate()
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||
cr, ok := NewContainer(input).(*containerReference)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||
NewContainer(kept)
|
||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
||||
|
||||
if msg.ErrorDetail.Message != "" {
|
||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||
return errors.New(msg.Error)
|
||||
return errors.New(msg.ErrorDetail.Message)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
|
||||
@@ -8,13 +8,70 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
const (
|
||||
networkCreateAttempts = 3
|
||||
networkCreateRetryDelay = time.Second
|
||||
|
||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||
// those of another runner sharing the daemon
|
||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
)
|
||||
|
||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||
}
|
||||
|
||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
// the emptiness check, not the label, is what keeps a live job of another process
|
||||
// sharing this registration safe
|
||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||
continue
|
||||
}
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
@@ -36,16 +93,45 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
if runnerUUID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||
}
|
||||
|
||||
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||
// malformed request, so the message is the only discriminator.
|
||||
func isAddressPoolExhausted(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
@@ -64,6 +150,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
@@ -71,16 +158,17 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Network.Containers) == 0 {
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
common.Logger(ctx).Debugf("%v", err)
|
||||
}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||
// it holds a subnet out of the daemon's pool until something reclaims it
|
||||
if len(result.Network.Containers) != 0 {
|
||||
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
||||
continue
|
||||
}
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
50
act/container/docker_network_test.go
Normal file
50
act/container/docker_network_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||
// test on its own, since testify has no expectation to match it against.
|
||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||
{Network: network.Network{ID: "orphan"}},
|
||||
{Network: network.Network{ID: "busy"}},
|
||||
{Network: network.Network{ID: "starting"}},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
@@ -30,23 +30,19 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return nil
|
||||
}
|
||||
|
||||
pull := input.ForcePull
|
||||
if !pull {
|
||||
// skip the pull when the image is already here: either none was forced, or a digest
|
||||
// pins the content so a forced pull could only fetch the same bytes again
|
||||
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||
logger.Debugf("Image exists? %v", imageExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||
}
|
||||
|
||||
if !imageExists {
|
||||
pull = true
|
||||
if imageExists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !pull {
|
||||
return nil
|
||||
}
|
||||
|
||||
imageRef := cleanImage(ctx, input.Image)
|
||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||
|
||||
@@ -61,20 +57,32 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
// the daemon reports a failure that happens after the first progress line in the
|
||||
// stream rather than on the call itself, so both have to be checked
|
||||
pullOnce := func(opts client.ImagePullOptions) error {
|
||||
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
return streamErr
|
||||
}
|
||||
return nil
|
||||
|
||||
err = pullOnce(imagePullOptions)
|
||||
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
err = pullOnce(imagePullOptions)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// a registry that is down should not fail a job whose image is already here
|
||||
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +128,15 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
|
||||
func isPinnedImage(image string) bool {
|
||||
ref, err := reference.ParseAnyReference(image)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, pinned := ref.(reference.Canonical)
|
||||
return pinned
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, imageName string) string {
|
||||
ref, err := reference.ParseAnyReference(imageName)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,11 +6,15 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -65,3 +69,21 @@ func TestGetImagePullOptions(t *testing.T) {
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||
}
|
||||
|
||||
// A digest-pinned image is immutable, so its local copy is always current.
|
||||
func TestIsPinnedImage(t *testing.T) {
|
||||
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||
assert.False(t, isPinnedImage("alpine:latest"))
|
||||
}
|
||||
|
||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||
// whichever of the two shapes the daemon used.
|
||||
func TestLogDockerResponseError(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
streamErr := func(line string) error {
|
||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||
}
|
||||
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
@@ -34,7 +36,6 @@ import (
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
@@ -42,13 +43,26 @@ import (
|
||||
"github.com/moby/moby/api/types/system"
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
cr := new(containerReference)
|
||||
cr.input = input
|
||||
// Resolved up front because the image pull runs before the container is created.
|
||||
cf := createFlagsFromOptions(input.Options)
|
||||
if cf.platform != "" {
|
||||
cr.input.Platform = cf.platform
|
||||
}
|
||||
cr.pullPolicy = cf.pull
|
||||
return cr
|
||||
}
|
||||
|
||||
@@ -129,6 +143,11 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
||||
}
|
||||
|
||||
func (cr *containerReference) Pull(forcePull bool) common.Executor {
|
||||
if cr.pullPolicy == pullPolicyNever {
|
||||
return common.NewInfoExecutor("docker pull skipped image=%s, --pull=never in the options", cr.input.Image)
|
||||
}
|
||||
forcePull = forcePull || cr.pullPolicy == pullPolicyAlways
|
||||
|
||||
return common.
|
||||
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
||||
Then(
|
||||
@@ -224,11 +243,16 @@ func (cr *containerReference) ReplaceLogWriter(stdout, stderr io.Writer) (io.Wri
|
||||
}
|
||||
|
||||
type containerReference struct {
|
||||
cli client.APIClient
|
||||
id string
|
||||
input *NewContainerInput
|
||||
UID int
|
||||
GID int
|
||||
cli client.APIClient
|
||||
id string
|
||||
input *NewContainerInput
|
||||
pullPolicy string
|
||||
UID int
|
||||
GID int
|
||||
// attachDone is closed by the attach() streaming goroutine once it has
|
||||
// drained and flushed the container's output. wait() blocks on it so the
|
||||
// tail of the log lands before the step proceeds.
|
||||
attachDone chan struct{}
|
||||
LinuxContainerEnvironmentExtensions
|
||||
}
|
||||
|
||||
@@ -366,25 +390,69 @@ func (cr *containerReference) find() common.Executor {
|
||||
|
||||
func (cr *containerReference) remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
idOrName := cr.id
|
||||
if idOrName == "" && cr.input != nil {
|
||||
idOrName = cr.input.Name
|
||||
}
|
||||
if idOrName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
_, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
|
||||
// Kill first so removal never waits out a daemon's stop timeout: Docker kills outright
|
||||
// on a forced remove, Podman sends SIGTERM and waits. Only worth it for a container
|
||||
// this started, and removal can still deal with one it could not kill.
|
||||
if cr.id != "" {
|
||||
_, err := cr.cli.ContainerKill(ctx, cr.id, client.ContainerKillOptions{Signal: "SIGKILL"})
|
||||
if err != nil && !cerrdefs.IsConflict(err) && !cerrdefs.IsNotFound(err) {
|
||||
logger.Debugf("Container %s could not be killed: %v", cr.id, err)
|
||||
}
|
||||
}
|
||||
_, err := cr.cli.ContainerRemove(ctx, idOrName, client.ContainerRemoveOptions{
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
||||
switch {
|
||||
case cerrdefs.IsConflict(err):
|
||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
||||
// references and the network endpoint only once it finishes
|
||||
cr.waitForRemoval(ctx, idOrName)
|
||||
case err != nil && !cerrdefs.IsNotFound(err):
|
||||
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
||||
return nil // keep the id, the container is still there for a later Remove()
|
||||
}
|
||||
|
||||
logger.Debugf("Removed container: %v", cr.id)
|
||||
logger.Debugf("Removed container: %v", idOrName)
|
||||
cr.id = ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
||||
// per container, against the one minute the post-job executor allows for the whole
|
||||
// cleanup, so a job with several services can spend most of that budget here
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
waitResult := cr.cli.ContainerWait(ctx, idOrName, client.ContainerWaitOptions{
|
||||
Condition: container.WaitConditionRemoved,
|
||||
})
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
case <-ctx.Done():
|
||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
||||
// behind or its goroutine parks on the send for the lifetime of the process
|
||||
go func() {
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
}
|
||||
}()
|
||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
||||
logger := common.Logger(ctx)
|
||||
input := cr.input
|
||||
@@ -394,17 +462,13 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
}
|
||||
|
||||
// parse configuration from CLI container.options
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
copts := addFlags(flags)
|
||||
|
||||
optionsArgs, err := shellquote.Split(input.Options)
|
||||
flags, copts, cf, err := parseContainerOptions(input.Options)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot split container options: '%s': '%w'", input.Options, err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = flags.Parse(optionsArgs)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
|
||||
if err := cf.validate(); err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||
}
|
||||
|
||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
||||
@@ -438,6 +502,16 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// When privileged mode is disabled, container.options is workflow-controlled
|
||||
// untrusted input. Strip the HostConfig fields that would let a workflow break
|
||||
// out of the container (host namespaces, capability expansion, security profile
|
||||
// overrides, device and runtime access). Otherwise these survive into the final
|
||||
// HostConfig even though --privileged is forced off.
|
||||
if !hostConfig.Privileged {
|
||||
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
|
||||
}
|
||||
|
||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||
|
||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
||||
@@ -448,8 +522,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
|
||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||
|
||||
hostConfig.Binds = append(hostConfig.Binds, containerConfig.HostConfig.Binds...)
|
||||
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
|
||||
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||
binds := hostConfig.Binds
|
||||
mounts := hostConfig.Mounts
|
||||
networkMode := hostConfig.NetworkMode
|
||||
@@ -459,6 +532,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
}
|
||||
hostConfig.Binds = binds
|
||||
hostConfig.Mounts = mounts
|
||||
if cf.name != "" {
|
||||
logger.Warn("--name in the options will be ignored.")
|
||||
}
|
||||
if len(copts.netMode.Value()) > 0 {
|
||||
logger.Warn("--network and --net in the options will be ignored.")
|
||||
}
|
||||
@@ -730,7 +806,9 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
||||
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
cmdResponse := make(chan error)
|
||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||
// drain below times out and no one is left to receive.
|
||||
cmdResponse := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
var outWriter io.Writer
|
||||
@@ -749,6 +827,11 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, resp.Reader)
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line so the
|
||||
// final line of a command's output is not lost (e.g. an error message
|
||||
// printed without a trailing newline before the process exits).
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
cmdResponse <- err
|
||||
}()
|
||||
|
||||
@@ -760,6 +843,16 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
logger.Warnf("Failed to send CTRL+C: %+s", err)
|
||||
}
|
||||
|
||||
// Give the copy goroutine a brief grace period to drain output already
|
||||
// produced by the command before we return, so cancellation does not
|
||||
// truncate the tail of the log. The goroutine exits once the hijacked
|
||||
// stream is closed by resp.Close() in the caller's defer.
|
||||
select {
|
||||
case <-cmdResponse:
|
||||
case <-time.After(drainGracePeriod):
|
||||
logger.Warn("Timed out draining command output after cancellation")
|
||||
}
|
||||
|
||||
// we return the context canceled error to prevent other steps
|
||||
// from executing
|
||||
return ctx.Err()
|
||||
@@ -772,23 +865,59 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
}
|
||||
}
|
||||
|
||||
// mkdirInContainer creates containerPath and returns it with the symlinked components
|
||||
// replaced by the targets the daemon reports for them. Docker 29.7 rejects tar entries
|
||||
// traversing a symlink to an absolute target, like the "/var/run" of most images, with
|
||||
// "path escapes from parent", and not every daemon creates the implied parents of a
|
||||
// directory entry, so one entry per missing component is extracted at the deepest
|
||||
// existing ancestor.
|
||||
// WORKAROUND: https://github.com/moby/moby/issues/53258
|
||||
func (cr *containerReference) mkdirInContainer(ctx context.Context, containerPath string) (string, error) {
|
||||
parts := strings.Split(strings.Trim(path.Clean(containerPath), "/"), "/")
|
||||
existing := "/"
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return existing, nil
|
||||
}
|
||||
stat, err := cr.cli.ContainerStatPath(ctx, cr.id, client.ContainerStatPathOptions{Path: path.Join(existing, part)})
|
||||
if err != nil {
|
||||
// nothing below exists either, so create the remaining components
|
||||
return path.Join(existing, path.Join(parts[i:]...)), cr.mkdirEntries(ctx, existing, parts[i:])
|
||||
}
|
||||
existing = path.Join(existing, part)
|
||||
if target := stat.Stat.LinkTarget; target != "" {
|
||||
if !path.IsAbs(target) {
|
||||
target = path.Join(path.Dir(existing), target)
|
||||
}
|
||||
existing = target
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (cr *containerReference) mkdirEntries(ctx context.Context, destPath string, missing []string) error {
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
for i := range missing {
|
||||
_ = tw.WriteHeader(&tar.Header{
|
||||
Name: path.Join(missing[:i+1]...),
|
||||
Mode: 0o777,
|
||||
Typeflag: tar.TypeDir,
|
||||
})
|
||||
}
|
||||
tw.Close()
|
||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||
DestinationPath: destPath,
|
||||
Content: buf,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy to %s", destPath)
|
||||
}
|
||||
// Mkdir
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
_ = tw.WriteHeader(&tar.Header{
|
||||
Name: destPath,
|
||||
Mode: 0o777,
|
||||
Typeflag: tar.TypeDir,
|
||||
})
|
||||
tw.Close()
|
||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||
DestinationPath: "/",
|
||||
Content: buf,
|
||||
})
|
||||
destPath, err := cr.mkdirInContainer(ctx, destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
||||
}
|
||||
@@ -813,6 +942,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
dstPath, err := cr.mkdirInContainer(ctx, dstPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir to copy directory to container: %w", err)
|
||||
}
|
||||
tarFile, err := os.CreateTemp("", "act")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -945,14 +1078,23 @@ func (cr *containerReference) attach() common.Executor {
|
||||
if errWriter == nil {
|
||||
errWriter = os.Stderr
|
||||
}
|
||||
done := make(chan struct{})
|
||||
cr.attachDone = done
|
||||
go func() {
|
||||
defer close(done)
|
||||
var copyErr error
|
||||
if !isTerminal || os.Getenv("NORAW") != "" {
|
||||
_, err = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, out.Reader)
|
||||
_, copyErr = io.Copy(outWriter, out.Reader)
|
||||
}
|
||||
if err != nil {
|
||||
common.Logger(ctx).Error(err)
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once
|
||||
// the stream reaches EOF, so the final line of the container's
|
||||
// output is not lost when it is not newline-terminated.
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
if copyErr != nil {
|
||||
common.Logger(ctx).Error(copyErr)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -991,6 +1133,18 @@ func (cr *containerReference) wait() common.Executor {
|
||||
|
||||
logger.Debugf("Return status: %v", statusCode)
|
||||
|
||||
// The container has exited; wait for the attach() streaming goroutine to
|
||||
// finish draining and flushing its output before returning, so the tail
|
||||
// of the log is not lost. Bounded so a stuck stream cannot hang the step.
|
||||
if cr.attachDone != nil {
|
||||
select {
|
||||
case <-cr.attachDone:
|
||||
case <-time.After(drainGracePeriod):
|
||||
logger.Warn("Timed out draining container output")
|
||||
}
|
||||
cr.attachDone = nil
|
||||
}
|
||||
|
||||
if statusCode == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -999,6 +1153,77 @@ func (cr *containerReference) wait() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
|
||||
// workflow-controlled container.options string that could be used to escape the
|
||||
// container when privileged mode is disabled. It must only be called when the
|
||||
// runner has privileged mode turned off; with privileged mode enabled the
|
||||
// administrator has already opted into host access.
|
||||
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
|
||||
warn := func(option string) {
|
||||
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
|
||||
}
|
||||
|
||||
if hostConfig.PidMode != "" {
|
||||
warn("--pid")
|
||||
hostConfig.PidMode = ""
|
||||
}
|
||||
if hostConfig.IpcMode != "" {
|
||||
warn("--ipc")
|
||||
hostConfig.IpcMode = ""
|
||||
}
|
||||
if hostConfig.UTSMode != "" {
|
||||
warn("--uts")
|
||||
hostConfig.UTSMode = ""
|
||||
}
|
||||
if hostConfig.CgroupnsMode != "" {
|
||||
warn("--cgroupns")
|
||||
hostConfig.CgroupnsMode = ""
|
||||
}
|
||||
// UsernsMode is set from the runner-controlled input; never let options
|
||||
// override it (e.g. --userns=host disables user namespace remapping).
|
||||
if hostConfig.UsernsMode != "" {
|
||||
warn("--userns")
|
||||
hostConfig.UsernsMode = ""
|
||||
}
|
||||
if len(hostConfig.CapAdd) > 0 {
|
||||
warn("--cap-add")
|
||||
hostConfig.CapAdd = nil
|
||||
}
|
||||
if len(hostConfig.SecurityOpt) > 0 {
|
||||
warn("--security-opt")
|
||||
hostConfig.SecurityOpt = nil
|
||||
}
|
||||
if len(hostConfig.Devices) > 0 {
|
||||
warn("--device")
|
||||
hostConfig.Devices = nil
|
||||
}
|
||||
if len(hostConfig.DeviceCgroupRules) > 0 {
|
||||
warn("--device-cgroup-rule")
|
||||
hostConfig.DeviceCgroupRules = nil
|
||||
}
|
||||
if len(hostConfig.DeviceRequests) > 0 {
|
||||
warn("--gpus")
|
||||
hostConfig.DeviceRequests = nil
|
||||
}
|
||||
if len(hostConfig.VolumesFrom) > 0 {
|
||||
warn("--volumes-from")
|
||||
hostConfig.VolumesFrom = nil
|
||||
}
|
||||
if hostConfig.Runtime != "" {
|
||||
warn("--runtime")
|
||||
hostConfig.Runtime = ""
|
||||
}
|
||||
if hostConfig.CgroupParent != "" {
|
||||
warn("--cgroup-parent")
|
||||
hostConfig.CgroupParent = ""
|
||||
}
|
||||
if len(hostConfig.Sysctls) > 0 {
|
||||
warn("--sysctl")
|
||||
hostConfig.Sysctls = nil
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||
@@ -1044,6 +1269,34 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
return config, hostConfig
|
||||
}
|
||||
|
||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||
func bindTarget(bind string) string {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Target
|
||||
}
|
||||
|
||||
// overlayVolumes appends src's volumes to dst, dropping the dst ones they mount over. Docker
|
||||
// rejects two mounts on one target, so the volumes declared last have to win.
|
||||
func overlayVolumes(dst, src *container.HostConfig) {
|
||||
claimed := map[string]bool{}
|
||||
for _, bind := range src.Binds {
|
||||
if target := bindTarget(bind); target != "" {
|
||||
claimed[target] = true
|
||||
}
|
||||
}
|
||||
for _, mt := range src.Mounts {
|
||||
claimed[mt.Target] = true
|
||||
}
|
||||
|
||||
dst.Binds = append(slices.DeleteFunc(slices.Clone(dst.Binds),
|
||||
func(bind string) bool { return claimed[bindTarget(bind)] }), src.Binds...)
|
||||
dst.Mounts = append(slices.DeleteFunc(slices.Clone(dst.Mounts),
|
||||
func(mt mount.Mount) bool { return claimed[mt.Target] }), src.Mounts...)
|
||||
}
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []glob.Glob
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
@@ -20,7 +22,9 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -89,6 +93,16 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
|
||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||
@@ -109,6 +123,31 @@ func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.Co
|
||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
@@ -206,6 +245,71 @@ func TestDockerExecFailure(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||
// length) followed by the payload.
|
||||
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||
b := make([]byte, 8+len(payload))
|
||||
b[0] = byte(stream)
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||
copy(b[8:], payload)
|
||||
return b
|
||||
}
|
||||
|
||||
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||
// attach() streaming goroutine has drained and flushed the container's output,
|
||||
// so a final line without a trailing newline is not lost.
|
||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||
|
||||
var lines []string
|
||||
logWriter := common.NewLineWriter(func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
})
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||
Return(mobyclient.ContainerAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: &mockConn{},
|
||||
Reader: bufio.NewReader(framed),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||
errCh := make(chan error, 1)
|
||||
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||
Return(mobyclient.ContainerWaitResult{
|
||||
Result: (<-chan container.WaitResponse)(statusCh),
|
||||
Error: (<-chan error)(errCh),
|
||||
})
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cr.attach()(ctx))
|
||||
require.NoError(t, cr.wait()(ctx))
|
||||
|
||||
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||
// the goroutine closes after the final append.
|
||||
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -237,15 +341,37 @@ func TestDockerWaitFailure(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
||||
// when they are a symlink, everything else does not exist.
|
||||
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
||||
for containerPath, target := range existing {
|
||||
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
||||
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
||||
}
|
||||
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
||||
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
||||
}
|
||||
|
||||
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
||||
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
||||
func TestDockerCopyTarStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var mkdirNames []string
|
||||
client := &mockDockerClient{}
|
||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
if opts.DestinationPath != "/run" || opts.Content == nil {
|
||||
return false
|
||||
}
|
||||
tr := tar.NewReader(opts.Content)
|
||||
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
||||
mkdirNames = append(mkdirNames, hdr.Name)
|
||||
}
|
||||
return true
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
@@ -255,57 +381,103 @@ func TestDockerCopyTarStream(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
assert.Equal(t, []string{"act"}, mkdirNames)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
||||
merr := errors.New("Failure")
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mkdirErr error
|
||||
copyErr error
|
||||
}{
|
||||
{"mkdir", merr, nil},
|
||||
{"copy content", nil, merr},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
client := &mockDockerClient{}
|
||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||
// be logged as one.
|
||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
wantFailure bool
|
||||
}{
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||
{name: "removed cleanly", err: nil},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
if tc.wantWait {
|
||||
removed := make(chan container.WaitResponse, 1)
|
||||
removed <- container.WaitResponse{}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
merr := errors.New("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
assert.Equal(t, "abc", cr.id)
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
// A container whose id was never learned, because find() could not reach the daemon or
|
||||
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||
// was never started here, so it is not worth a kill of its own.
|
||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -430,9 +602,8 @@ func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||
_ = rc.Close()(ctx)
|
||||
})
|
||||
|
||||
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
||||
// step that fails on the broken daemon.
|
||||
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
||||
// exact step that fails on a broken daemon.
|
||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -513,6 +684,110 @@ func TestCheckVolumes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
|
||||
dangerous := func() *container.HostConfig {
|
||||
return &container.HostConfig{
|
||||
PidMode: "host",
|
||||
IpcMode: "host",
|
||||
UTSMode: "host",
|
||||
CgroupnsMode: "host",
|
||||
UsernsMode: "host",
|
||||
CapAdd: []string{"ALL"},
|
||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||
VolumesFrom: []string{"other"},
|
||||
Runtime: "runc",
|
||||
Resources: container.Resources{
|
||||
CgroupParent: "/custom",
|
||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
}
|
||||
}
|
||||
|
||||
hostConfig := dangerous()
|
||||
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.Devices)
|
||||
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||
// OS-independent options only: --device parsing requires a linux/windows
|
||||
// server OS, which is not guaranteed for the test host.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
|
||||
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: dangerousOptions,
|
||||
NetworkMode: "bridge",
|
||||
UsernsMode: "private",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: false,
|
||||
UsernsMode: container.UsernsMode("private"),
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hostConfig.Privileged)
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
})
|
||||
|
||||
t.Run("privileged preserves options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||
NetworkMode: "bridge",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: true,
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
@@ -549,3 +824,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||
assert.Empty(t, hostConf.Mounts)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package container
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -61,7 +62,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -72,3 +73,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,12 +17,14 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
@@ -42,6 +44,25 @@ type HostEnvironment struct {
|
||||
CleanUp func()
|
||||
StdOut io.Writer
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
|
||||
// procGroup owns every process the job's steps start. Atomic: Remove may read
|
||||
// it while a step is still starting.
|
||||
procGroupOnce sync.Once
|
||||
procGroup atomic.Pointer[process.Group]
|
||||
}
|
||||
|
||||
// processGroup returns the job-scoped process group, creating it on first use.
|
||||
// Returns nil if the job object could not be created; Group is nil-safe.
|
||||
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
||||
e.procGroupOnce.Do(func() {
|
||||
group, err := process.NewGroup()
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
||||
return
|
||||
}
|
||||
e.procGroup.Store(group)
|
||||
})
|
||||
return e.procGroup.Load()
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||
@@ -261,7 +282,7 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||
return ppty, tty, nil
|
||||
}
|
||||
|
||||
@@ -309,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
} else {
|
||||
wd = e.Path
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||
defer common.FlushWriter(e.StdOut)
|
||||
|
||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -321,30 +346,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
cmd.Env = envList
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||
|
||||
// On Windows a step often launches a process tree (a shell that starts a
|
||||
// child which spawns further GUI or background processes). The default
|
||||
// context cancellation only kills the direct child, leaving the rest of the
|
||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
||||
// writer can never hang Wait indefinitely.
|
||||
var killer atomic.Pointer[processKiller]
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd.Cancel = func() error {
|
||||
if k := killer.Load(); k != nil {
|
||||
return k.Kill()
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Once the step process has exited, give its I/O pipes at most this long
|
||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
}
|
||||
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
|
||||
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
|
||||
treeKill := process.NewTreeKill(cmd)
|
||||
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
@@ -375,17 +381,15 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
killer.Store(k)
|
||||
defer k.Close()
|
||||
}
|
||||
// Assign before the step's Killer so the step's job nests inside the group's;
|
||||
// cancellation still scopes to this step's tree.
|
||||
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
|
||||
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
|
||||
}
|
||||
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
defer k.Close()
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
@@ -429,6 +433,23 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
// removeAll is a var so tests can substitute a blocking stub.
|
||||
var removeAll = os.RemoveAll
|
||||
|
||||
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
|
||||
// cancellation the goroutine leaks: a delete inside a syscall cannot be
|
||||
// interrupted (see runWithTimeout).
|
||||
func removeAllWithContext(ctx context.Context, path string) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- removeAll(path) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func removePathWithRetry(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
@@ -448,25 +469,23 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
lastErr = os.RemoveAll(path)
|
||||
lastErr = removeAllWithContext(ctx, path)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
|
||||
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
|
||||
// of the given absolute workspace dirs, releasing file handles for cleanup.
|
||||
//
|
||||
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
|
||||
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
|
||||
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
|
||||
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
|
||||
// literal.
|
||||
//
|
||||
// Pure function so the quote-escaping can be unit-tested without PowerShell.
|
||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
|
||||
// every process tree whose ExecutablePath or CommandLine references one of the
|
||||
// given workspace dirs, releasing file handles for cleanup. Win32_Process
|
||||
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
|
||||
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
|
||||
// (job1 vs job10) is spared and path metacharacters stay literal.
|
||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
||||
quoted := make([]string, len(dirs))
|
||||
for i, d := range dirs {
|
||||
@@ -502,9 +521,8 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Workspace dirs we own. Any process running from or referencing one is a
|
||||
// leftover job process. ToolCache is shared across jobs; Workdir only when
|
||||
// we own it (else it's a caller-provided checkout, e.g. act local mode).
|
||||
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
||||
// across jobs, and Workdir may be a caller-owned checkout.
|
||||
owned := []string{e.Path, e.TmpDir}
|
||||
if e.CleanWorkdir {
|
||||
owned = append(owned, e.Workdir)
|
||||
@@ -531,25 +549,67 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||
if err != nil {
|
||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Win32_Process exposes no working directory, so the scan above misses a
|
||||
// process that merely runs in a workspace dir while pinning a handle on it.
|
||||
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
|
||||
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
|
||||
} else if killed > 0 {
|
||||
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
|
||||
}
|
||||
}
|
||||
|
||||
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||
// wedge the runner slot. A var so tests can shrink it.
|
||||
var hostCleanupTimeout = 30 * time.Second
|
||||
|
||||
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
|
||||
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
|
||||
// mount) cannot be interrupted, and leaking scratch state beats losing the
|
||||
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
|
||||
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
fn()
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
// Ensure any lingering child processes are ended before attempting
|
||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// End lingering processes before removing the workspace; on Windows their
|
||||
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
||||
if err := e.procGroup.Load().Close(); err != nil {
|
||||
logger.Debugf("closing the job's process group failed: %v", err)
|
||||
}
|
||||
e.terminateRunningProcesses(ctx)
|
||||
|
||||
// Only removes per-job misc state. Must not remove the cache/toolcache root.
|
||||
// Removes per-job misc state only, never the toolcache root. Bounded because
|
||||
// CleanUp is a caller-supplied, typically unbounded os.RemoveAll.
|
||||
if e.CleanUp != nil {
|
||||
e.CleanUp()
|
||||
logger.Debugf("running host environment cleanup callback")
|
||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||
} else {
|
||||
logger.Debugf("host environment cleanup callback finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||
// which absorb Windows file-handle release lag after the kill above.
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
|
||||
defer rmCancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
var errs []error
|
||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||
@@ -561,7 +621,13 @@ func (e *HostEnvironment) Remove() common.Executor {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
for _, err := range errs {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -188,6 +189,118 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
orig := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||
// to return before restoring the var, so the restore can't race the read.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = orig
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := removeAllWithContext(ctx, t.TempDir())
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||
// container". Remove must time out the callback and complete job teardown.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||
orig := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
CleanUp: func() {
|
||||
<-release // simulate a delete syscall stuck indefinitely
|
||||
},
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck CleanUp callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
|
||||
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
|
||||
// #1023) wedging job completion after the CleanUp callback has already timed
|
||||
// out or finished.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||
origTimeout := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
|
||||
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
origRemoveAll := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// The stuck delete goroutine outlives the timed-out Remove and still reads
|
||||
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = origRemoveAll
|
||||
})
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck path removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||
t.Run("single dir", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
||||
|
||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "Linux",
|
||||
"arch": RunnerArch(ctx),
|
||||
"temp": "/tmp",
|
||||
"tool_cache": "/opt/hostedtoolcache",
|
||||
"tool_cache": DefaultToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||
@@ -28,11 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
s := bufio.NewScanner(reader)
|
||||
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
|
||||
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
|
||||
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||
|
||||
s := bufio.NewScanner(decoded)
|
||||
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
||||
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
// GitHub's runner ignores blank lines
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
singleLineEnv := strings.Index(line, "=")
|
||||
multiLineEnv := strings.Index(line, "<<")
|
||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
||||
@@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "reading env file")
|
||||
}
|
||||
|
||||
// Regression test: a blank line used to fail the job at "Complete Job", after
|
||||
// every step had already been recorded as successful.
|
||||
func TestParseEnvFileBlankLines(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "qux", env["BAZ"])
|
||||
}
|
||||
|
||||
// blank lines inside a heredoc value are content, not separators
|
||||
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
}
|
||||
|
||||
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
|
||||
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
|
||||
func TestParseEnvFileUTF16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
encoder *encoding.Encoder
|
||||
}{
|
||||
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
|
||||
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "line1", env["MULTI"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package container
|
||||
|
||||
import "os"
|
||||
|
||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
||||
type processKiller struct{}
|
||||
|
||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
||||
|
||||
func (k *processKiller) Kill() error { return nil }
|
||||
|
||||
func (k *processKiller) Close() error { return nil }
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// processKiller terminates a step process together with its entire descendant
|
||||
// tree via a Windows Job Object.
|
||||
//
|
||||
// Background: a step often launches a process tree (a shell that starts a
|
||||
// child which in turn spawns further GUI or background processes). The default
|
||||
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
||||
// job left the rest of the tree running. Because those orphans inherited the
|
||||
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
||||
//
|
||||
// Assigning the step process to a Job Object lets us kill the whole tree
|
||||
// atomically on cancellation (TerminateJobObject), which also closes the
|
||||
// inherited pipe handles so cmd.Wait() can return.
|
||||
type processKiller struct {
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
// newProcessKiller creates a Job Object and assigns p (an already-started
|
||||
// process) to it. Children spawned by p afterwards are automatically part of
|
||||
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
||||
// the handle on normal completion does not kill legitimate background
|
||||
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
||||
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &processKiller{job: job}, nil
|
||||
}
|
||||
|
||||
// Kill terminates every process currently assigned to the job (the step process
|
||||
// and all of its descendants).
|
||||
func (k *processKiller) Kill() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
return windows.TerminateJobObject(k.job, 1)
|
||||
}
|
||||
|
||||
// Close releases the job handle. It does not terminate the processes.
|
||||
func (k *processKiller) Close() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
h := k.job
|
||||
k.job = 0
|
||||
return windows.CloseHandle(h)
|
||||
}
|
||||
@@ -8,23 +8,10 @@ package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func getSysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
|
||||
if tty {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
}
|
||||
}
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return pty.Open()
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Rfork: syscall.RFNOTEG,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,13 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result != "success" {
|
||||
if jobs[needs].NeedsResult() != "success" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -274,8 +274,17 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// jobStatus returns the current job status, treating a nil Job context as an
|
||||
// empty status so status-check functions never panic on a nil dereference.
|
||||
func (impl *interperterImpl) jobStatus() string {
|
||||
if impl.env.Job == nil {
|
||||
return ""
|
||||
}
|
||||
return impl.env.Job.Status
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "success", nil
|
||||
return impl.jobStatus() == "success", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
@@ -283,7 +292,7 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result == "failure" {
|
||||
if jobs[needs].NeedsResult() == "failure" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -292,9 +301,9 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "failure", nil
|
||||
return impl.jobStatus() == "failure", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "cancelled", nil
|
||||
return impl.jobStatus() == "cancelled", nil
|
||||
}
|
||||
|
||||
@@ -254,3 +254,27 @@ func TestFunctionFormat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFunctionsNilJob(t *testing.T) {
|
||||
// A nil Job context must not panic: the status-check functions should treat
|
||||
// it as an empty status and return false rather than dereferencing nil.
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
table := []struct {
|
||||
input string
|
||||
context string
|
||||
name string
|
||||
}{
|
||||
{"cancelled()", "job", "cancelled-nil-job"},
|
||||
{"success()", "step", "step-success-nil-job"},
|
||||
{"failure()", "step", "step-failure-nil-job"},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, false, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLiterals(t *testing.T) {
|
||||
@@ -523,7 +524,9 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||
assert.True(t, math.IsNaN(output.(float64)))
|
||||
number, ok := output.(float64)
|
||||
require.True(t, ok, "want a number, got %T", output)
|
||||
assert.True(t, math.IsNaN(number))
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
@@ -221,3 +222,63 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "target", resolved)
|
||||
}
|
||||
|
||||
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
|
||||
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
|
||||
|
||||
fsys := &DefaultFs{}
|
||||
var walked []string
|
||||
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
require.NoError(t, err)
|
||||
walked = append(walked, info.Name())
|
||||
return nil
|
||||
}))
|
||||
require.Contains(t, walked, "file.txt")
|
||||
require.Contains(t, walked, "link.txt")
|
||||
|
||||
file, err := fsys.Open(filepath.Join(root, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
data, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
require.Equal(t, "content", string(data))
|
||||
|
||||
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file.txt", link)
|
||||
}
|
||||
|
||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
|
||||
walk := fc.CollectFiles(cancelledContext(t), nil)
|
||||
|
||||
err := walk("file", fakeFileInfo{name: "file"}, nil)
|
||||
require.EqualError(t, err, "copy cancelled")
|
||||
|
||||
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
|
||||
require.ErrorIs(t, err, os.ErrPermission)
|
||||
}
|
||||
|
||||
func cancelledContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return f.name }
|
||||
func (f fakeFileInfo) Size() int64 { return 0 }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (f fakeFileInfo) IsDir() bool { return false }
|
||||
func (f fakeFileInfo) Sys() any { return nil }
|
||||
|
||||
74
act/lookpath/lp_unix_test.go
Normal file
74
act/lookpath/lp_unix_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package lookpath
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testEnv map[string]string
|
||||
|
||||
func (e testEnv) Getenv(name string) string {
|
||||
return e[name]
|
||||
}
|
||||
|
||||
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "tool")
|
||||
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != exe {
|
||||
t.Fatalf("LookPath2() = %q, want %q", got, exe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "tool")
|
||||
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := LookPath2(exe, testEnv{"PATH": ""})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != exe {
|
||||
t.Fatalf("LookPath2() = %q, want %q", got, exe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "not-executable")
|
||||
if err := os.WriteFile(file, []byte("plain text"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LookPath2(file, testEnv{"PATH": dir})
|
||||
var pathErr *Error
|
||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
|
||||
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
|
||||
}
|
||||
if pathErr.Error() != fs.ErrPermission.Error() {
|
||||
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
|
||||
}
|
||||
|
||||
_, err = LookPath2("missing", testEnv{"PATH": dir})
|
||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
|
||||
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func LookPath2(file string, lenv Env) (string, error) {
|
||||
var exts []string
|
||||
x := lenv.Getenv(`PATHEXT`)
|
||||
if x != "" {
|
||||
for _, e := range strings.Split(strings.ToLower(x), `;`) {
|
||||
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -76,17 +76,19 @@ func (a ActionRunsUsing) IsComposite() bool {
|
||||
|
||||
// ActionRuns are a field in Action
|
||||
type ActionRuns struct {
|
||||
Using ActionRunsUsing `yaml:"using"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Main string `yaml:"main"`
|
||||
Pre string `yaml:"pre"`
|
||||
PreIf string `yaml:"pre-if"`
|
||||
Post string `yaml:"post"`
|
||||
PostIf string `yaml:"post-if"`
|
||||
Image string `yaml:"image"`
|
||||
Entrypoint string `yaml:"entrypoint"`
|
||||
Args []string `yaml:"args"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
Using ActionRunsUsing `yaml:"using"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Main string `yaml:"main"`
|
||||
Pre string `yaml:"pre"`
|
||||
PreIf string `yaml:"pre-if"`
|
||||
Post string `yaml:"post"`
|
||||
PostIf string `yaml:"post-if"`
|
||||
Image string `yaml:"image"`
|
||||
PreEntrypoint string `yaml:"pre-entrypoint"`
|
||||
Entrypoint string `yaml:"entrypoint"`
|
||||
PostEntrypoint string `yaml:"post-entrypoint"`
|
||||
Args []string `yaml:"args"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
||||
|
||||
82
act/model/action_test.go
Normal file
82
act/model/action_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
||||
action, err := ReadAction(strings.NewReader(`
|
||||
name: example
|
||||
runs:
|
||||
using: NoDe24
|
||||
main: dist/index.js
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Runs.Using != ActionRunsUsingNode24 {
|
||||
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
||||
}
|
||||
if action.Runs.PreIf != "always()" {
|
||||
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
||||
}
|
||||
if action.Runs.PostIf != "always()" {
|
||||
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
||||
action, err := ReadAction(strings.NewReader(`
|
||||
runs:
|
||||
using: composite
|
||||
pre-if: success()
|
||||
post-if: failure()
|
||||
steps:
|
||||
- run: echo hello
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
||||
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
||||
}
|
||||
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
||||
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
||||
_, err := ReadAction(strings.NewReader(`
|
||||
runs:
|
||||
using: node99
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown runs.using to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "node99") {
|
||||
t.Fatalf("error = %q, want invalid value", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadActionDockerEntrypoints(t *testing.T) {
|
||||
action, err := ReadAction(strings.NewReader(`
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: pre.sh
|
||||
post-entrypoint: post.sh
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Runs.PreEntrypoint != "pre.sh" {
|
||||
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
|
||||
}
|
||||
if action.Runs.PostEntrypoint != "post.sh" {
|
||||
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type WorkflowPlanTest struct {
|
||||
@@ -65,3 +67,133 @@ func TestWorkflow(t *testing.T) {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
||||
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
name: Build project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: make build
|
||||
test:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: make test
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
||||
|
||||
eventPlan, err := planner.PlanEvent("push")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, eventPlan.Stages, 2)
|
||||
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
||||
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
||||
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
||||
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
||||
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
||||
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
||||
|
||||
jobPlan, err := planner.PlanJob("test")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobPlan.Stages, 2)
|
||||
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
||||
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
||||
|
||||
allPlan, err := planner.PlanAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, allPlan.Stages, 2)
|
||||
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
||||
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
||||
}
|
||||
|
||||
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
||||
first := mustReadWorkflow(t, `
|
||||
name: First
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: make build
|
||||
`)
|
||||
second := mustReadWorkflow(t, `
|
||||
name: Second
|
||||
on: push
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: make lint
|
||||
test:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: make test
|
||||
`)
|
||||
|
||||
planner := CombineWorkflowPlanner(first, second)
|
||||
plan, err := planner.PlanEvent("push")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plan.Stages, 2)
|
||||
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
||||
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
||||
|
||||
empty, err := planner.PlanEvent("schedule")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, empty.Stages)
|
||||
}
|
||||
|
||||
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
||||
workflow := mustReadWorkflow(t, `
|
||||
name: Cyclic
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
needs: b
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo a
|
||||
b:
|
||||
needs: a
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo b
|
||||
`)
|
||||
planner := CombineWorkflowPlanner(workflow)
|
||||
|
||||
plan, err := planner.PlanJob("missing")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, plan.Stages)
|
||||
assert.Contains(t, err.Error(), "Could not find any stages")
|
||||
|
||||
plan, err = planner.PlanEvent("push")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, plan.Stages)
|
||||
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
||||
}
|
||||
|
||||
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
||||
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "file is empty")
|
||||
|
||||
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "workflow is not valid")
|
||||
}
|
||||
|
||||
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
||||
t.Helper()
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
if workflow.Name == "" {
|
||||
workflow.Name = "workflow"
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@ func (w *Workflow) OnSchedule() []string {
|
||||
case []any:
|
||||
allSchedules := []string{}
|
||||
for _, v := range val {
|
||||
for k, cron := range v.(map[string]any) {
|
||||
if k != "cron" {
|
||||
continue
|
||||
}
|
||||
allSchedules = append(allSchedules, cron.(string))
|
||||
entry, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cron, ok := entry["cron"].(string); ok {
|
||||
allSchedules = append(allSchedules, cron)
|
||||
}
|
||||
}
|
||||
return allSchedules
|
||||
@@ -190,23 +191,52 @@ func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
||||
|
||||
// Job is the structure of one job in a workflow
|
||||
type Job struct {
|
||||
Name string `yaml:"name"`
|
||||
RawNeeds yaml.Node `yaml:"needs"`
|
||||
RawRunsOn yaml.Node `yaml:"runs-on"`
|
||||
Env yaml.Node `yaml:"env"`
|
||||
If yaml.Node `yaml:"if"`
|
||||
Steps []*Step `yaml:"steps"`
|
||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||
Services map[string]*ContainerSpec `yaml:"services"`
|
||||
Strategy *Strategy `yaml:"strategy"`
|
||||
RawContainer yaml.Node `yaml:"container"`
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
Outputs map[string]string `yaml:"outputs"`
|
||||
Uses string `yaml:"uses"`
|
||||
With map[string]any `yaml:"with"`
|
||||
RawSecrets yaml.Node `yaml:"secrets"`
|
||||
RawPermissions yaml.Node `yaml:"permissions"`
|
||||
Result string
|
||||
Name string `yaml:"name"`
|
||||
RawNeeds yaml.Node `yaml:"needs"`
|
||||
RawRunsOn yaml.Node `yaml:"runs-on"`
|
||||
Env yaml.Node `yaml:"env"`
|
||||
If yaml.Node `yaml:"if"`
|
||||
Steps []*Step `yaml:"steps"`
|
||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||
RawContinueOnError string `yaml:"continue-on-error"`
|
||||
Services map[string]*ContainerSpec `yaml:"services"`
|
||||
Strategy *Strategy `yaml:"strategy"`
|
||||
RawContainer yaml.Node `yaml:"container"`
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
Outputs map[string]string `yaml:"outputs"`
|
||||
Uses string `yaml:"uses"`
|
||||
With map[string]any `yaml:"with"`
|
||||
RawSecrets yaml.Node `yaml:"secrets"`
|
||||
RawPermissions yaml.Node `yaml:"permissions"`
|
||||
Result string
|
||||
// Runtime fields set during execution (not from YAML):
|
||||
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
|
||||
hasFirmFailure bool // true once any combination failed without continue-on-error
|
||||
}
|
||||
|
||||
// SetContinueOnError records whether this combination's failure should not fail the workflow.
|
||||
// Must be called under the job lock. Safe across parallel matrix combinations.
|
||||
func (j *Job) SetContinueOnError(continueOnErr bool) {
|
||||
if continueOnErr {
|
||||
if !j.hasFirmFailure {
|
||||
j.ContinueOnError = true
|
||||
}
|
||||
} else {
|
||||
j.hasFirmFailure = true
|
||||
j.ContinueOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsResult returns the job result as seen by dependent jobs through the
|
||||
// `needs` context. A job that failed but was tolerated via continue-on-error
|
||||
// reports "success" to its dependents, matching GitHub: such a failure must not
|
||||
// block jobs gated on the default `if: success()`, even though the overall
|
||||
// workflow run is still marked as failed.
|
||||
func (j *Job) NeedsResult() string {
|
||||
if j.Result == "failure" && j.ContinueOnError {
|
||||
return "success"
|
||||
}
|
||||
return j.Result
|
||||
}
|
||||
|
||||
// Strategy for the job
|
||||
@@ -414,9 +444,9 @@ func normalizeMatrixValue(key string, val any) ([]any, error) {
|
||||
// Scalar values are wrapped into single-element arrays automatically.
|
||||
// Template expressions are resolved by EvaluateYamlNode before this method is
|
||||
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
||||
func (j *Job) Matrix() map[string][]any {
|
||||
func (j *Job) Matrix() (map[string][]any, error) {
|
||||
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
return map[string][]any{}, nil
|
||||
}
|
||||
|
||||
// Decode to flexible map first so that scalar values don't cause a type error.
|
||||
@@ -426,9 +456,9 @@ func (j *Job) Matrix() map[string][]any {
|
||||
// Fall back to the strict array-only format for backward compatibility.
|
||||
var val map[string][]any
|
||||
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
||||
return nil
|
||||
return map[string][]any{}, nil
|
||||
}
|
||||
return val
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Convert flexible format to expected format with validation
|
||||
@@ -436,12 +466,11 @@ func (j *Job) Matrix() map[string][]any {
|
||||
for k, v := range flexVal {
|
||||
normalized, err := normalizeMatrixValue(k, v)
|
||||
if err != nil {
|
||||
log.Errorf("matrix validation error: %v", err)
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
val[k] = normalized
|
||||
}
|
||||
return val
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetMatrixes returns the matrix cross product
|
||||
@@ -453,38 +482,38 @@ func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
||||
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
||||
|
||||
if m := j.Matrix(); m != nil {
|
||||
m, err := j.Matrix()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(m) > 0 {
|
||||
includes := make([]map[string]any, 0)
|
||||
extraIncludes := make([]map[string]any, 0)
|
||||
addInclude := func(raw any) error {
|
||||
include, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
|
||||
}
|
||||
for k := range include {
|
||||
if _, ok := m[k]; ok {
|
||||
includes = append(includes, include)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
extraIncludes = append(extraIncludes, include)
|
||||
return nil
|
||||
}
|
||||
for _, v := range m["include"] {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
for _, i := range t {
|
||||
i := i.(map[string]any)
|
||||
extraInclude := true
|
||||
for k := range i {
|
||||
if _, ok := m[k]; ok {
|
||||
includes = append(includes, i)
|
||||
extraInclude = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if extraInclude {
|
||||
extraIncludes = append(extraIncludes, i)
|
||||
if err := addInclude(i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case any:
|
||||
v := v.(map[string]any)
|
||||
extraInclude := true
|
||||
for k := range v {
|
||||
if _, ok := m[k]; ok {
|
||||
includes = append(includes, v)
|
||||
extraInclude = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if extraInclude {
|
||||
extraIncludes = append(extraIncludes, v)
|
||||
if err := addInclude(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,10 +521,13 @@ func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||
|
||||
excludes := make([]map[string]any, 0)
|
||||
for _, e := range m["exclude"] {
|
||||
e := e.(map[string]any)
|
||||
for k := range e {
|
||||
exclude, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
|
||||
}
|
||||
for k := range exclude {
|
||||
if _, ok := m[k]; ok {
|
||||
excludes = append(excludes, e)
|
||||
excludes = append(excludes, exclude)
|
||||
} else {
|
||||
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -32,6 +33,216 @@ func TestStepCloneIsolatesMutableFields(t *testing.T) {
|
||||
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
|
||||
}
|
||||
|
||||
// TestJobNeedsResult guards the continue-on-error semantics exposed to dependent
|
||||
// jobs through the `needs` context: a failed-but-tolerated job reports "success"
|
||||
// so it does not block dependents gated on the default `if: success()`, matching
|
||||
// GitHub. A firm failure and any non-failure result are reported verbatim.
|
||||
func TestJobNeedsResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result string
|
||||
continueOnError bool
|
||||
want string
|
||||
}{
|
||||
{"tolerated failure reports success", "failure", true, "success"},
|
||||
{"firm failure reports failure", "failure", false, "failure"},
|
||||
{"success is unchanged", "success", false, "success"},
|
||||
{"success with continue-on-error is unchanged", "success", true, "success"},
|
||||
{"empty result is unchanged", "", true, ""},
|
||||
{"skipped is unchanged", "skipped", true, "skipped"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
j := &Job{Result: tc.result, ContinueOnError: tc.continueOnError}
|
||||
assert.Equal(t, tc.want, j.NeedsResult())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSetContinueOnErrorFirmFailureWins(t *testing.T) {
|
||||
job := &Job{}
|
||||
job.SetContinueOnError(true)
|
||||
assert.True(t, job.ContinueOnError)
|
||||
|
||||
job.SetContinueOnError(false)
|
||||
assert.False(t, job.ContinueOnError)
|
||||
|
||||
job.SetContinueOnError(true)
|
||||
assert.False(t, job.ContinueOnError, "a later tolerated failure must not hide an earlier firm failure")
|
||||
}
|
||||
|
||||
func TestStepStatusText(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status stepStatus
|
||||
text string
|
||||
}{
|
||||
{StepStatusSuccess, "success"},
|
||||
{StepStatusFailure, "failure"},
|
||||
{StepStatusSkipped, "skipped"},
|
||||
} {
|
||||
t.Run(tc.text, func(t *testing.T) {
|
||||
got, err := tc.status.MarshalText()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.text, string(got))
|
||||
|
||||
var parsed stepStatus
|
||||
require.NoError(t, parsed.UnmarshalText(got))
|
||||
assert.Equal(t, tc.status, parsed)
|
||||
assert.Equal(t, tc.text, parsed.String())
|
||||
})
|
||||
}
|
||||
|
||||
var parsed stepStatus
|
||||
require.Error(t, parsed.UnmarshalText([]byte("cancelled")))
|
||||
assert.Empty(t, stepStatus(99).String())
|
||||
}
|
||||
|
||||
func TestWorkflowCallConfig(t *testing.T) {
|
||||
workflow, err := ReadWorkflow(strings.NewReader(`
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
name:
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
digest:
|
||||
value: ${{ jobs.build.outputs.digest }}
|
||||
jobs: {}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
|
||||
config := workflow.WorkflowCallConfig()
|
||||
require.NotNil(t, config)
|
||||
require.Contains(t, config.Inputs, "name")
|
||||
assert.True(t, config.Inputs["name"].Required)
|
||||
assert.Equal(t, "string", config.Inputs["name"].Type)
|
||||
assert.Equal(t, "${{ jobs.build.outputs.digest }}", config.Outputs["digest"].Value)
|
||||
|
||||
listWorkflow, err := ReadWorkflow(strings.NewReader("on: [workflow_call]\njobs: {}\n"))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, listWorkflow.WorkflowCallConfig())
|
||||
assert.Empty(t, listWorkflow.WorkflowCallConfig().Inputs)
|
||||
}
|
||||
|
||||
func TestJobSecretsAndEnvironment(t *testing.T) {
|
||||
inheritJob := readJob(t, `
|
||||
secrets: inherit
|
||||
env:
|
||||
A: one
|
||||
B: two
|
||||
`)
|
||||
assert.True(t, inheritJob.InheritSecrets())
|
||||
assert.Nil(t, inheritJob.Secrets())
|
||||
assert.Equal(t, map[string]string{"A": "one", "B": "two"}, inheritJob.Environment())
|
||||
|
||||
mappingJob := readJob(t, `
|
||||
secrets:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
`)
|
||||
assert.False(t, mappingJob.InheritSecrets())
|
||||
assert.Equal(t, map[string]string{"TOKEN": "${{ secrets.TOKEN }}"}, mappingJob.Secrets())
|
||||
}
|
||||
|
||||
func TestJobTypeAndString(t *testing.T) {
|
||||
tests := []struct {
|
||||
job Job
|
||||
want JobType
|
||||
wantErr bool
|
||||
}{
|
||||
{job: Job{}, want: JobTypeDefault},
|
||||
{job: Job{Uses: "./.github/workflows/reuse.yml"}, want: JobTypeReusableWorkflowLocal},
|
||||
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml@v1"}, want: JobTypeReusableWorkflowRemote},
|
||||
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml"}, want: JobTypeInvalid, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf("%s/%s", tc.job.Uses, tc.want), func(t *testing.T) {
|
||||
got, err := tc.job.Type()
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, "default", JobTypeDefault.String())
|
||||
assert.Equal(t, "local-reusable-workflow", JobTypeReusableWorkflowLocal.String())
|
||||
assert.Equal(t, "remote-reusable-workflow", JobTypeReusableWorkflowRemote.String())
|
||||
assert.Equal(t, "unknown", JobType(99).String())
|
||||
}
|
||||
|
||||
func TestStepStringEnvironmentEnvAndType(t *testing.T) {
|
||||
step := readStep(t, `
|
||||
id: example
|
||||
env:
|
||||
DIRECT: value
|
||||
with:
|
||||
mixed-key: input
|
||||
`)
|
||||
assert.Equal(t, "example", step.String())
|
||||
assert.Equal(t, map[string]string{"DIRECT": "value"}, step.Environment())
|
||||
assert.Equal(t, map[string]string{"DIRECT": "value", "INPUT_MIXED-KEY": "input"}, step.GetEnv())
|
||||
|
||||
for _, tc := range []struct {
|
||||
step Step
|
||||
want StepType
|
||||
}{
|
||||
{step: Step{}, want: StepTypeInvalid},
|
||||
{step: Step{Run: "echo hi"}, want: StepTypeRun},
|
||||
{step: Step{Run: "echo hi", Uses: "actions/checkout@v4"}, want: StepTypeInvalid},
|
||||
{step: Step{Uses: "docker://alpine:latest"}, want: StepTypeUsesDockerURL},
|
||||
{step: Step{Uses: "./.github/workflows/reuse.yml"}, want: StepTypeReusableWorkflowLocal},
|
||||
{step: Step{Uses: "owner/repo/.github/workflows/reuse.yml@v1"}, want: StepTypeReusableWorkflowRemote},
|
||||
{step: Step{Uses: "./actions/local"}, want: StepTypeUsesActionLocal},
|
||||
{step: Step{Uses: "actions/checkout@v4"}, want: StepTypeUsesActionRemote},
|
||||
} {
|
||||
t.Run(tc.want.String(), func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, tc.step.Type())
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, "invalid", StepTypeInvalid.String())
|
||||
assert.Equal(t, "run", StepTypeRun.String())
|
||||
assert.Equal(t, "local-action", StepTypeUsesActionLocal.String())
|
||||
assert.Equal(t, "remote-action", StepTypeUsesActionRemote.String())
|
||||
assert.Equal(t, "docker", StepTypeUsesDockerURL.String())
|
||||
assert.Equal(t, "local-reusable-workflow", StepTypeReusableWorkflowLocal.String())
|
||||
assert.Equal(t, "remote-reusable-workflow", StepTypeReusableWorkflowRemote.String())
|
||||
assert.Equal(t, "unknown", StepType(99).String())
|
||||
assert.NotEmpty(t, (&Step{Uses: "actions/checkout@v4"}).UsesHash())
|
||||
}
|
||||
|
||||
func TestWorkflowGetJobAndIDs(t *testing.T) {
|
||||
workflow := &Workflow{Jobs: map[string]*Job{"build": {}}}
|
||||
assert.Equal(t, []string{"build"}, workflow.GetJobIDs())
|
||||
|
||||
job := workflow.GetJob("build")
|
||||
require.NotNil(t, job)
|
||||
assert.Equal(t, "build", job.Name)
|
||||
assert.Equal(t, "success()", job.If.Value)
|
||||
assert.Nil(t, workflow.GetJob("missing"))
|
||||
}
|
||||
|
||||
func TestRawConcurrencyYaml(t *testing.T) {
|
||||
var expr RawConcurrency
|
||||
require.NoError(t, yaml.Unmarshal([]byte("group-${{ github.ref }}"), &expr))
|
||||
assert.Equal(t, "group-${{ github.ref }}", expr.RawExpression)
|
||||
marshaled, err := expr.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "group-${{ github.ref }}", marshaled)
|
||||
|
||||
var object RawConcurrency
|
||||
require.NoError(t, yaml.Unmarshal([]byte("group: ci\ncancel-in-progress: true\n"), &object))
|
||||
assert.Equal(t, "ci", object.Group)
|
||||
assert.Equal(t, "true", object.CancelInProgress)
|
||||
marshaled, err = object.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, (*objectConcurrency)(&object), marshaled)
|
||||
}
|
||||
|
||||
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
@@ -456,7 +667,9 @@ func TestReadWorkflow_Strategy(t *testing.T) {
|
||||
matrixes, err := job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
matrix, err := job.Matrix()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, matrix)
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 2) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, true) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
@@ -464,7 +677,9 @@ func TestReadWorkflow_Strategy(t *testing.T) {
|
||||
matrixes, err = job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
matrix, err = job.Matrix()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, matrix)
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 4) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, false) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
@@ -472,7 +687,9 @@ func TestReadWorkflow_Strategy(t *testing.T) {
|
||||
matrixes, err = job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
matrix, err = job.Matrix()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, matrix)
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 2) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, false) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
@@ -489,7 +706,9 @@ func TestReadWorkflow_Strategy(t *testing.T) {
|
||||
{"datacenter": "site-b", "node-version": "12.x", "site": "dev"},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, job.Matrix(), //nolint:testifylint // pre-existing issue from nektos/act
|
||||
matrix, err = job.Matrix()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, matrix, //nolint:testifylint // pre-existing issue from nektos/act
|
||||
map[string][]any{
|
||||
"datacenter": {"site-c", "site-d"},
|
||||
"exclude": {
|
||||
@@ -881,13 +1100,15 @@ jobs:
|
||||
t.Fatal("job not found")
|
||||
}
|
||||
|
||||
matrix := job.Matrix()
|
||||
matrix, err := job.Matrix()
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, matrix, "matrix should be nil on error")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tt.wantLen == 0 {
|
||||
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
|
||||
assert.Empty(t, matrix, "no matrix for jobs without strategy")
|
||||
} else {
|
||||
assert.NotNil(t, matrix, "matrix should not be nil")
|
||||
assert.Len(t, matrix, tt.wantLen, "matrix should have expected number of keys")
|
||||
@@ -919,10 +1140,24 @@ func TestJobMatrixValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Attempt to get matrix
|
||||
matrix := job.Matrix()
|
||||
|
||||
// Should return nil due to validation error
|
||||
assert.Nil(t, matrix, "matrix with nested map should return nil")
|
||||
matrix, err := job.Matrix()
|
||||
require.ErrorContains(t, err, `matrix key "config" has invalid nested object value`)
|
||||
assert.Nil(t, matrix)
|
||||
})
|
||||
}
|
||||
|
||||
func readJob(t *testing.T, content string) *Job {
|
||||
t.Helper()
|
||||
|
||||
var job Job
|
||||
require.NoError(t, yaml.Unmarshal([]byte(content), &job))
|
||||
return &job
|
||||
}
|
||||
|
||||
func readStep(t *testing.T, content string) *Step {
|
||||
t.Helper()
|
||||
|
||||
var step Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(content), &step))
|
||||
return &step
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -127,6 +129,16 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
|
||||
return action, err
|
||||
}
|
||||
|
||||
// cachedActionTar returns the action's tree from the action cache, which only a remote action
|
||||
// has an entry in.
|
||||
func cachedActionTar(ctx context.Context, step actionStep, name, includePrefix string) (io.ReadCloser, error) {
|
||||
remote, ok := step.(*stepActionRemote)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("action %q is a remote action but runs as %T", name, step)
|
||||
}
|
||||
return step.getRunContext().Config.ActionCache.GetTarArchive(ctx, remote.cacheDir, remote.resolvedSha, includePrefix)
|
||||
}
|
||||
|
||||
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
@@ -145,8 +157,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
}
|
||||
|
||||
if rc.Config != nil && rc.Config.ActionCache != nil {
|
||||
raction := step.(*stepActionRemote)
|
||||
ta, err := rc.Config.ActionCache.GetTarArchive(ctx, raction.cacheDir, raction.resolvedSha, "")
|
||||
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,7 +216,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
||||
if remoteAction == nil {
|
||||
location = containerActionDir
|
||||
}
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
|
||||
case x.IsComposite():
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -272,8 +283,38 @@ func removeGitIgnore(ctx context.Context, directory string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dockerActionImageTag derives the local docker image tag used when an action
|
||||
// is built from a Dockerfile.
|
||||
//
|
||||
// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName
|
||||
// that is the workspace-relative path of the action. That path is identical
|
||||
// across repositories (e.g. "./" for a self-referencing action), so without
|
||||
// namespacing, every repository's local docker action would build and reuse the
|
||||
// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent
|
||||
// repository would then silently run the image built for an earlier one.
|
||||
// Including the repository keeps the tag stable for caching within a repository
|
||||
// while preventing cross-repository collisions.
|
||||
// See https://gitea.com/gitea/runner/issues/1039.
|
||||
func dockerActionImageTag(repository, actionName string, localAction bool) string {
|
||||
name := actionName
|
||||
if localAction {
|
||||
name = path.Join(repository, actionName)
|
||||
}
|
||||
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
|
||||
sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-")
|
||||
if localAction {
|
||||
// For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository.
|
||||
sum := sha256.Sum256([]byte(repository + "\x00" + actionName))
|
||||
sanitized += "-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
||||
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest")
|
||||
image = "act-" + strings.TrimLeft(image, "-")
|
||||
return strings.ToLower(image)
|
||||
}
|
||||
|
||||
// TODO: break out parts of function to reduce complexicity
|
||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
@@ -286,10 +327,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
// Apply forcePull only for prebuild docker images
|
||||
forcePull = rc.Config.ForcePull
|
||||
} else {
|
||||
// "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
||||
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
|
||||
image = "act-" + strings.TrimLeft(image, "-")
|
||||
image = strings.ToLower(image)
|
||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||
|
||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||
@@ -322,8 +360,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
}
|
||||
defer buildContext.Close()
|
||||
} else if rc.Config.ActionCache != nil {
|
||||
rstep := step.(*stepActionRemote)
|
||||
buildContext, err = rc.Config.ActionCache.GetTarArchive(ctx, rstep.cacheDir, rstep.resolvedSha, contextDir)
|
||||
buildContext, err = cachedActionTar(ctx, step, actionName, contextDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -335,6 +372,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
ImageTag: image,
|
||||
BuildContext: buildContext,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
BuildArgs: rc.proxyBuildArgs(),
|
||||
})
|
||||
if buildContext == nil {
|
||||
// Held across the whole build: the daemon drains contextDir lazily.
|
||||
@@ -357,16 +395,9 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
cmd = action.Runs.Args
|
||||
evalDockerArgs(ctx, step, action, &cmd)
|
||||
}
|
||||
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]))
|
||||
if len(entrypoint) == 0 {
|
||||
if action.Runs.Entrypoint != "" {
|
||||
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
entrypoint = nil
|
||||
}
|
||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
||||
return common.NewPipelineExecutor(
|
||||
@@ -376,10 +407,34 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
stepContainer.Start(true),
|
||||
).Finally(
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||
).Finally(stepContainer.Close())(ctx)
|
||||
}
|
||||
|
||||
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
||||
// stage. Only the main stage honours the `entrypoint` input.
|
||||
func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
|
||||
runs := step.getActionModel().Runs
|
||||
|
||||
var entrypoint string
|
||||
switch stage {
|
||||
case stepStagePre:
|
||||
entrypoint = runs.PreEntrypoint
|
||||
case stepStagePost:
|
||||
entrypoint = runs.PostEntrypoint
|
||||
default:
|
||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
||||
return fields, nil
|
||||
}
|
||||
entrypoint = runs.Entrypoint
|
||||
}
|
||||
|
||||
if entrypoint == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return shellquote.Split(entrypoint)
|
||||
}
|
||||
|
||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
@@ -426,23 +481,18 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
networkMode = "default"
|
||||
}
|
||||
stepContainer := container.NewContainer(&container.NewContainerInput{
|
||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||
Cmd: cmd,
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
@@ -532,44 +582,57 @@ func hasPreStep(step actionStep) common.Conditional {
|
||||
return action.Runs.Using.IsComposite() ||
|
||||
(action.Runs.Using.IsNode() &&
|
||||
action.Runs.Pre != "") ||
|
||||
(action.Runs.Using.IsDocker() &&
|
||||
action.Runs.PreEntrypoint != "") ||
|
||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||
action.Runs.Pre != "")
|
||||
}
|
||||
}
|
||||
|
||||
// actionStagePaths resolves where a step's action lives and where the job container sees
|
||||
// it, for the pre and post stage.
|
||||
func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
if sar, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = sar.actionDir()
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
}
|
||||
|
||||
actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc)
|
||||
return actionDir, actionPath, actionName, containerActionDir
|
||||
}
|
||||
|
||||
// execDockerActionStage runs a docker action's image for its pre or post stage.
|
||||
func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error {
|
||||
actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step)
|
||||
|
||||
_, remote := step.(*stepActionRemote)
|
||||
location := containerActionDir
|
||||
if remote {
|
||||
location = path.Join(actionDir, actionPath)
|
||||
}
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage)
|
||||
}
|
||||
|
||||
func runPreStep(step actionStep) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("run pre step for '%s'", step.getStepModel())
|
||||
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
action := step.getActionModel()
|
||||
|
||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||
|
||||
x := action.Runs.Using
|
||||
switch {
|
||||
case x.IsNode():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -582,6 +645,12 @@ func runPreStep(step actionStep) common.Executor {
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePre)
|
||||
|
||||
case x.IsComposite():
|
||||
if step.getCompositeSteps() == nil {
|
||||
step.getCompositeRunContext(ctx)
|
||||
@@ -595,25 +664,6 @@ func runPreStep(step actionStep) common.Executor {
|
||||
case x == model.ActionRunsUsingGo:
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -666,6 +716,8 @@ func hasPostStep(step actionStep) common.Conditional {
|
||||
return action.Runs.Using.IsComposite() ||
|
||||
(action.Runs.Using.IsNode() &&
|
||||
action.Runs.Post != "") ||
|
||||
(action.Runs.Using.IsDocker() &&
|
||||
action.Runs.PostEntrypoint != "") ||
|
||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||
action.Runs.Post != "")
|
||||
}
|
||||
@@ -677,28 +729,9 @@ func runPostStep(step actionStep) common.Executor {
|
||||
logger.Debugf("run post step for '%s'", step.getStepModel())
|
||||
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
action := step.getActionModel()
|
||||
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||
|
||||
x := action.Runs.Using
|
||||
switch {
|
||||
@@ -713,6 +746,11 @@ func runPostStep(step actionStep) common.Executor {
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePost)
|
||||
|
||||
case x.IsComposite():
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -85,6 +86,19 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
||||
return compositerc
|
||||
}
|
||||
|
||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||
// is already present in dst. This prevents the parent RunContext's Masks slice
|
||||
// from growing exponentially when composite actions are nested or repeated,
|
||||
// since each composite RunContext is seeded with its parent's masks.
|
||||
func appendUniqueMasks(dst, src []string) []string {
|
||||
for _, m := range src {
|
||||
if !slices.Contains(dst, m) {
|
||||
dst = append(dst, m)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func execAsComposite(step actionStep) common.Executor {
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
@@ -110,7 +124,11 @@ func execAsComposite(step actionStep) common.Executor {
|
||||
}, eval.Interpolate(ctx, output.Value))
|
||||
}
|
||||
|
||||
rc.Masks = append(rc.Masks, compositeRC.Masks...)
|
||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||
// and may have additional masks appended while the composite action runs.
|
||||
// Only append masks that are not already present, otherwise nested or
|
||||
// repeated composite actions grow rc.Masks exponentially.
|
||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||
rc.ExtraPath = compositeRC.ExtraPath
|
||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||
|
||||
70
act/runner/action_composite_test.go
Normal file
70
act/runner/action_composite_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAppendUniqueMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dst []string
|
||||
src []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "appends new masks",
|
||||
dst: []string{"a"},
|
||||
src: []string{"b", "c"},
|
||||
want: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "skips masks already present",
|
||||
dst: []string{"a", "b"},
|
||||
src: []string{"a", "b"},
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "deduplicates within src",
|
||||
dst: []string{"a"},
|
||||
src: []string{"b", "b", "a"},
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "empty src leaves dst unchanged",
|
||||
dst: []string{"a"},
|
||||
src: nil,
|
||||
want: []string{"a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, appendUniqueMasks(tt.dst, tt.src))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of
|
||||
// the parent's Masks slice observed with nested/repeated composite actions. A
|
||||
// composite RunContext is seeded with its parent's masks and the whole seeded
|
||||
// slice was previously appended back into the parent, doubling its length on
|
||||
// every composite action.
|
||||
func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
|
||||
parentMasks := []string{"secret"}
|
||||
|
||||
for range 20 {
|
||||
// compositeRC.Masks starts as a copy of the parent's masks (it is
|
||||
// seeded with parent.Masks in newCompositeRunContext).
|
||||
compositeMasks := make([]string, len(parentMasks))
|
||||
copy(compositeMasks, parentMasks)
|
||||
|
||||
parentMasks = appendUniqueMasks(parentMasks, compositeMasks)
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"secret"}, parentMasks)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type closerMock struct {
|
||||
@@ -150,6 +151,44 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||
func TestExecAsDockerAutoRemove(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
autoRemove bool
|
||||
removes int
|
||||
}{
|
||||
{false, 2}, // stale + post-run
|
||||
{true, 1}, // post-run skipped
|
||||
} {
|
||||
cm := &containerMock{}
|
||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{AutoRemove: tc.autoRemove},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}},
|
||||
}
|
||||
|
||||
removes := 0
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain))
|
||||
cm.AssertExpectations(t)
|
||||
assert.Equal(t, tc.removes, removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunner(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
@@ -258,6 +297,54 @@ func TestActionRunner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "test",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
env := map[string]string{}
|
||||
step := &stepMock{}
|
||||
step.On("getRunContext").Return(rc)
|
||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||
step.On("getEnv").Return(&env)
|
||||
|
||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
|
||||
|
||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||
assert.Empty(t, captured.Username)
|
||||
assert.Empty(t, captured.Password)
|
||||
step.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -378,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
|
||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
|
||||
|
||||
select {
|
||||
case <-innerEntered:
|
||||
@@ -407,3 +494,133 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerActionImageTag(t *testing.T) {
|
||||
// Remote actions already carry a unique, ref-scoped actionName (the uses
|
||||
// hash), so the tag must be left untouched for backwards compatibility.
|
||||
assert.Equal(t,
|
||||
"act-abc123-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "abc123", false),
|
||||
)
|
||||
|
||||
// Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName).
|
||||
// See https://gitea.com/gitea/runner/issues/1039.
|
||||
assert.Equal(t,
|
||||
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
)
|
||||
assert.Equal(t,
|
||||
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
|
||||
// Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix.
|
||||
// The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another.
|
||||
collisions := [][2]struct {
|
||||
repoName string
|
||||
actionName string
|
||||
}{
|
||||
// Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c".
|
||||
{{"a/b-c", "./"}, {"a-b/c", "./"}},
|
||||
// A repository's root action vs another repository's sub-path action:
|
||||
// "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a".
|
||||
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
|
||||
}
|
||||
for _, c := range collisions {
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
|
||||
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
|
||||
"local docker action tags must differ for %q/%q vs %q/%q",
|
||||
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
|
||||
)
|
||||
}
|
||||
|
||||
// Distinct local actions within the same repository keep distinct tags.
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
}
|
||||
|
||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stage stepStage
|
||||
wantEntrypoint []string
|
||||
}{
|
||||
{
|
||||
name: "main stage prefers the entrypoint input",
|
||||
stage: stepStageMain,
|
||||
wantEntrypoint: []string{"input.sh"},
|
||||
},
|
||||
{
|
||||
name: "pre stage uses runs.pre-entrypoint",
|
||||
stage: stepStagePre,
|
||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||
},
|
||||
{
|
||||
name: "post stage uses runs.post-entrypoint",
|
||||
stage: stepStagePost,
|
||||
wantEntrypoint: []string{"post.sh"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
var input *container.NewContainerInput
|
||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
input = in
|
||||
return cm
|
||||
}
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{
|
||||
Using: "docker",
|
||||
Image: "docker://node:14",
|
||||
PreEntrypoint: "pre.sh --verbose",
|
||||
Entrypoint: "main.sh",
|
||||
PostEntrypoint: "post.sh",
|
||||
Args: []string{"hello"},
|
||||
Env: map[string]string{"MY_VAR": "world"},
|
||||
}},
|
||||
env: map[string]string{},
|
||||
}
|
||||
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||
require.NotNil(t, input)
|
||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
||||
newStep := func(runs model.ActionRuns) actionStep {
|
||||
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
|
||||
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
|
||||
assert.True(t, hasPreStep(newStep(withStages))(ctx))
|
||||
assert.True(t, hasPostStep(newStep(withStages))(ctx))
|
||||
}
|
||||
|
||||
312
act/runner/cancellation_test.go
Normal file
312
act/runner/cancellation_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// TestCancelledJobStatusEnablesAlwaysAndCancelledSteps verifies that once a job is
|
||||
// cancelled, getJobContext reports the "cancelled" status so the step `if` functions
|
||||
// evaluate the way GitHub Actions does: cancelled()/always() are true, success()/failure()
|
||||
// are false. A step that defaults to success() is therefore skipped while an always() step
|
||||
// still runs. Before the fix the status could only ever be success/failure, so cancelled()
|
||||
// was structurally impossible and cancel-only cleanup steps never ran.
|
||||
func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
rc.markCancelled()
|
||||
|
||||
// The core fix: the job status context now reports "cancelled" instead of being
|
||||
// pinned to success/failure.
|
||||
jobCtx := rc.getJobContext()
|
||||
require.Equal(t, "cancelled", jobCtx.Status)
|
||||
|
||||
// Feed that status through the step-context expression functions, which is what a
|
||||
// step `if` evaluates. On a cancelled job only always()/cancelled() are true.
|
||||
interp := exprparser.NewInterpeter(
|
||||
&exprparser.EvaluationEnvironment{Job: jobCtx},
|
||||
exprparser.Config{Context: "step"},
|
||||
)
|
||||
for expr, want := range map[string]bool{
|
||||
"cancelled()": true,
|
||||
"always()": true,
|
||||
"success()": false,
|
||||
"failure()": false,
|
||||
"!cancelled()": false,
|
||||
} {
|
||||
got, err := interp.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
require.NoErrorf(t, err, "Evaluate(%q)", expr)
|
||||
assert.Equalf(t, want, got, "Evaluate(%q) on a cancelled job", expr)
|
||||
}
|
||||
|
||||
// A step without an `if` defaults to success() and must be skipped on cancel,
|
||||
// while an `if: always()` step must still run.
|
||||
disabled, err := interp.Evaluate("", exprparser.DefaultStatusCheckSuccess)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, disabled, "default-success step must be skipped on a cancelled job")
|
||||
|
||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||
}
|
||||
|
||||
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step
|
||||
// still runs (so a main-stage always() step is reached), it runs under a fresh,
|
||||
// non-cancelled context, and the job is marked cancelled. The interrupt error is still
|
||||
// propagated so callers up the chain see the cancellation.
|
||||
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var laterStepCtxErr error
|
||||
steps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "step1")
|
||||
cancel() // server cancellation lands while step1 runs
|
||||
return nil
|
||||
},
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
laterStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
|
||||
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
|
||||
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
|
||||
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||
}
|
||||
|
||||
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path.
|
||||
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled),
|
||||
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success.
|
||||
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
// A short deadline that we let elapse between steps, so no step records the error itself.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var laterStepCtxErr error
|
||||
steps := []common.Executor{
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "step1")
|
||||
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
|
||||
<-c.Done()
|
||||
return nil
|
||||
},
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
laterStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
|
||||
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
|
||||
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
|
||||
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
|
||||
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
|
||||
|
||||
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
}
|
||||
|
||||
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
|
||||
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
|
||||
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
|
||||
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
|
||||
// short-circuit skipped the main steps entirely when a pre step was cancelled.
|
||||
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var mainStepCtxErr error
|
||||
preSteps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "pre1")
|
||||
cancel() // server cancellation lands during the pre phase
|
||||
return nil
|
||||
},
|
||||
}
|
||||
steps := []common.Executor{
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
mainStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newStepsExecutor(rc, preSteps, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
|
||||
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
|
||||
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
|
||||
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||
}
|
||||
|
||||
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
|
||||
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
|
||||
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
|
||||
// evaluation is what skips success()-default steps, so running them here is safe.
|
||||
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
var ran []string
|
||||
preSteps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "pre1")
|
||||
return assert.AnError
|
||||
},
|
||||
}
|
||||
steps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
|
||||
|
||||
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
||||
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
||||
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
|
||||
}
|
||||
|
||||
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
|
||||
// main-step `if` evaluation. A pre-step failure is not present in StepResults, so
|
||||
// recording only the context job error is not enough: getJobContext must also report
|
||||
// failure so success()-default main steps skip and failure() steps run.
|
||||
func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
|
||||
reportStepError(ctx, rc, assert.AnError)
|
||||
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
require.ErrorIs(t, common.JobError(ctx), assert.AnError)
|
||||
|
||||
defaultStep := &stepRun{
|
||||
RunContext: rc,
|
||||
Step: &model.Step{ID: "default-step"},
|
||||
env: map[string]string{},
|
||||
}
|
||||
defaultEnabled, err := isStepEnabled(ctx, defaultStep.getIfExpression(ctx, stepStageMain), defaultStep, stepStageMain)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, defaultEnabled, "default success() main step must skip after a pre-step failure")
|
||||
|
||||
failureStep := &stepRun{
|
||||
RunContext: rc,
|
||||
Step: &model.Step{
|
||||
ID: "failure-step",
|
||||
If: yaml.Node{Value: "failure()"},
|
||||
},
|
||||
env: map[string]string{},
|
||||
}
|
||||
failureEnabled, err := isStepEnabled(ctx, failureStep.getIfExpression(ctx, stepStageMain), failureStep, stepStageMain)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, failureEnabled, "failure() main step must run after a pre-step failure")
|
||||
}
|
||||
|
||||
// TestPostStepsContextCancelledIsUsableForFailingStep guards against a panic: post/cleanup
|
||||
// steps run on a context derived from the cancelled job context, and a failing post step
|
||||
// records its error via common.SetJobError. If that derived context lacks a job-error container,
|
||||
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
cancel()
|
||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||
|
||||
postCtx, done := postStepsContext(cancelled)
|
||||
defer done()
|
||||
|
||||
// Detached from cancellation, so the post steps actually run.
|
||||
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||
|
||||
// A failing post step records its error instead of panicking.
|
||||
require.NotPanics(t, func() {
|
||||
common.SetJobError(postCtx, assert.AnError)
|
||||
}, "a failing post step must not panic on the cancel path")
|
||||
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError)
|
||||
}
|
||||
|
||||
// TestPostStepsContextDeadlinePreservesJobError verifies the job-timeout path keeps the original
|
||||
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
||||
// survive into the post phase and the job is still reported as failed.
|
||||
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||
base := common.WithJobErrorContainer(context.Background())
|
||||
common.SetJobError(base, assert.AnError)
|
||||
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
||||
defer cancel()
|
||||
require.ErrorIs(t, expired.Err(), context.DeadlineExceeded)
|
||||
|
||||
postCtx, done := postStepsContext(expired)
|
||||
defer done()
|
||||
|
||||
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
|
||||
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
|
||||
}
|
||||
|
||||
// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an
|
||||
// interruption, never a job failure.
|
||||
func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
||||
rc := &RunContext{}
|
||||
|
||||
// stray read cancellation while the job context is live: ignored, not a failure
|
||||
live := common.WithJobErrorContainer(context.Background())
|
||||
reportStepError(live, rc, context.Canceled)
|
||||
require.NoError(t, common.JobError(live))
|
||||
assert.False(t, rc.jobFailed)
|
||||
assert.False(t, rc.jobCancelled)
|
||||
|
||||
// genuine job cancellation: recorded as cancelled, still not a failure
|
||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
cancel()
|
||||
reportStepError(cancelled, rc, context.Canceled)
|
||||
require.NoError(t, common.JobError(cancelled))
|
||||
assert.False(t, rc.jobFailed)
|
||||
assert.True(t, rc.jobCancelled)
|
||||
|
||||
// a real error still fails the job
|
||||
failed := common.WithJobErrorContainer(context.Background())
|
||||
reportStepError(failed, rc, assert.AnError)
|
||||
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
||||
assert.True(t, rc.jobFailed)
|
||||
}
|
||||
@@ -48,8 +48,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
if resumeCommand != "" && command != resumeCommand {
|
||||
// There should not be any emojis in the log output for Gitea.
|
||||
// The code in the switch statement is the same.
|
||||
// Return true (not false) so the line still reaches the raw_output
|
||||
// log handler; otherwise everything between ::stop-commands:: and
|
||||
// its end token is silently dropped from the step log.
|
||||
logger.Infof("%s", line)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
arg = UnescapeCommandData(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs)
|
||||
@@ -151,30 +154,25 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
||||
return rtn
|
||||
}
|
||||
|
||||
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
|
||||
var (
|
||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||
)
|
||||
|
||||
// escapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
||||
func escapeCommandData(arg string) string {
|
||||
return commandDataEscaper.Replace(arg)
|
||||
}
|
||||
|
||||
func UnescapeCommandData(arg string) string {
|
||||
escapeMap := map[string]string{
|
||||
"%25": "%",
|
||||
"%0D": "\r",
|
||||
"%0A": "\n",
|
||||
}
|
||||
for k, v := range escapeMap {
|
||||
arg = strings.ReplaceAll(arg, k, v)
|
||||
}
|
||||
return arg
|
||||
return commandDataUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeCommandProperty(arg string) string {
|
||||
escapeMap := map[string]string{
|
||||
"%25": "%",
|
||||
"%0D": "\r",
|
||||
"%0A": "\n",
|
||||
"%3A": ":",
|
||||
"%2C": ",",
|
||||
}
|
||||
for k, v := range escapeMap {
|
||||
arg = strings.ReplaceAll(arg, k, v)
|
||||
}
|
||||
return arg
|
||||
return commandPropertyUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
||||
|
||||
@@ -28,6 +28,29 @@ func TestSetEnv(t *testing.T) {
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
}
|
||||
|
||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
// Stop command processing until the matching end token is seen.
|
||||
a.True(handler("::stop-commands::my-end-token\n"))
|
||||
|
||||
// A command-shaped line while stopped must not be executed (env unchanged),
|
||||
// but must still return true so it reaches the raw_output log handler and is
|
||||
// not dropped from the step log.
|
||||
a.True(handler("::set-env name=x::valz\n"))
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
// The matching end token resumes command processing.
|
||||
a.True(handler("::my-end-token::\n"))
|
||||
|
||||
// Commands are processed again after resuming.
|
||||
a.True(handler("::set-env name=y::valy\n"))
|
||||
a.Equal("valy", rc.Env["y"])
|
||||
}
|
||||
|
||||
func TestSetOutput(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
@@ -191,3 +214,10 @@ func TestSaveState(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "state-value", rc.IntraActionState["step"]["state-name"])
|
||||
}
|
||||
|
||||
func TestEscapeCommandData(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
|
||||
a.Equal("a%25b%0Dc%0Ad%250A", escapeCommandData("a%b\rc\nd%0A"))
|
||||
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
for _, needs := range jobNeeds {
|
||||
using[needs] = exprparser.Needs{
|
||||
Outputs: jobs[needs].Outputs,
|
||||
Result: jobs[needs].Result,
|
||||
Result: jobs[needs].NeedsResult(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
Inputs: inputs,
|
||||
HashFiles: getHashFilesFunction(ctx, rc),
|
||||
}
|
||||
if rc.JobContainer != nil {
|
||||
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
|
||||
}
|
||||
ee.Runner = rc.getRunnerContext(ctx)
|
||||
return expressionEvaluator{
|
||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||
Run: rc.Run,
|
||||
@@ -127,7 +125,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
|
||||
for _, needs := range jobNeeds {
|
||||
using[needs] = exprparser.Needs{
|
||||
Outputs: jobs[needs].Outputs,
|
||||
Result: jobs[needs].Result,
|
||||
Result: jobs[needs].NeedsResult(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +147,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
|
||||
Inputs: inputs,
|
||||
HashFiles: getHashFilesFunction(ctx, rc),
|
||||
}
|
||||
if rc.JobContainer != nil {
|
||||
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
|
||||
}
|
||||
ee.Runner = rc.getRunnerContext(ctx)
|
||||
return expressionEvaluator{
|
||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||
Run: rc.Run,
|
||||
@@ -229,7 +225,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
||||
logger.Debugf("evaluating expression '%s'", in)
|
||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||
|
||||
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
|
||||
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
|
||||
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)")
|
||||
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
|
||||
|
||||
return evaluated, err
|
||||
@@ -497,11 +494,7 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
|
||||
if value == nil {
|
||||
value = v.Default
|
||||
}
|
||||
if v.Type == "boolean" {
|
||||
inputs[k] = value == "true"
|
||||
} else {
|
||||
inputs[k] = value
|
||||
}
|
||||
inputs[k] = coerceInputValue(value, v.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,17 +507,26 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
|
||||
if value == nil {
|
||||
value = v.Default
|
||||
}
|
||||
if v.Type == "boolean" {
|
||||
inputs[k] = value == "true"
|
||||
} else {
|
||||
inputs[k] = value
|
||||
}
|
||||
inputs[k] = coerceInputValue(value, v.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
// coerceInputValue converts an input value to the type declared by the workflow.
|
||||
// The event payload carries natively typed JSON values on newer Gitea versions,
|
||||
// while defaults and older servers provide strings.
|
||||
func coerceInputValue(value any, inputType string) any {
|
||||
if inputType != "boolean" {
|
||||
return value
|
||||
}
|
||||
if b, ok := value.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
||||
if rc.caller != nil {
|
||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||
@@ -548,7 +550,7 @@ func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunCon
|
||||
}
|
||||
}
|
||||
|
||||
(*inputs)[name] = value
|
||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -321,3 +323,82 @@ func TestRewriteSubExpressionForceFormat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||
workflows := map[string]string{
|
||||
"workflow_call": `
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
flag:
|
||||
type: boolean
|
||||
default: true
|
||||
name:
|
||||
type: string
|
||||
default: gitea
|
||||
`,
|
||||
"workflow_dispatch": `
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flag:
|
||||
type: boolean
|
||||
default: true
|
||||
name:
|
||||
type: string
|
||||
default: gitea
|
||||
`,
|
||||
}
|
||||
|
||||
tables := []struct {
|
||||
name string
|
||||
event map[string]any
|
||||
flag any
|
||||
}{
|
||||
{
|
||||
// Gitea >= 1.27 resolves the inputs server-side and sends native JSON types
|
||||
name: "native bool true",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": true}},
|
||||
flag: true,
|
||||
},
|
||||
{
|
||||
name: "native bool false",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": false}},
|
||||
flag: false,
|
||||
},
|
||||
{
|
||||
name: "string true",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": "true"}},
|
||||
flag: true,
|
||||
},
|
||||
{
|
||||
name: "string false",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": "false"}},
|
||||
flag: false,
|
||||
},
|
||||
{
|
||||
name: "default is used when the event carries no inputs",
|
||||
event: map[string]any{},
|
||||
flag: true,
|
||||
},
|
||||
}
|
||||
|
||||
for eventName, workflow := range workflows {
|
||||
for _, table := range tables {
|
||||
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
||||
wf, err := model.ReadWorkflow(strings.NewReader(workflow))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{Workdir: "."},
|
||||
Run: &model.Run{JobID: "job1", Workflow: wf},
|
||||
}
|
||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||
|
||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
||||
assert.Equal(t, table.flag, inputs["flag"])
|
||||
assert.Equal(t, "gitea", inputs["name"])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
@@ -42,18 +40,6 @@ func requireDocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
||||
// gating lets the rest of the suite run offline without these failing.
|
||||
func requireNetwork(t *testing.T) {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: network unavailable: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||
func requireHostTools(t *testing.T, tools ...string) {
|
||||
|
||||
@@ -5,15 +5,48 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
const maxJobSummaryBytes = 1024 * 1024
|
||||
|
||||
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
||||
// so the rendered output makes the truncation visible instead of silently cutting off.
|
||||
const jobSummaryTruncationMarker = "\n\n---\n\n*Job summary truncated: it exceeded the maximum allowed size.*\n"
|
||||
|
||||
var (
|
||||
jobSummaryUploadRetryDelay = time.Second
|
||||
// jobSummaryUploadRequestTimeout bounds a single step upload request. It is kept
|
||||
// below jobSummaryUploadPhaseTimeout so one slow or unreachable request times out
|
||||
// and lets the remaining steps still upload within the phase budget, instead of a
|
||||
// single stuck request consuming the whole phase.
|
||||
jobSummaryUploadRequestTimeout = 5 * time.Second
|
||||
// jobSummaryUploadPhaseTimeout bounds the total time spent uploading all step
|
||||
// summaries. The uploads run inside the job cleanup budget that is also used to
|
||||
// stop and remove the container, so a slow or unreachable endpoint must not be
|
||||
// allowed to consume it; this keeps the remaining budget available for teardown.
|
||||
jobSummaryUploadPhaseTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
type jobInfo interface {
|
||||
matrix() map[string]any
|
||||
steps() []*model.Step
|
||||
@@ -24,16 +57,81 @@ type jobInfo interface {
|
||||
result(result string)
|
||||
}
|
||||
|
||||
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
||||
// the error against the job so the job is reported as failed.
|
||||
func reportStepError(ctx context.Context, err error) {
|
||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
||||
// reportStepError records a step error so the job is reported failed — except a
|
||||
// cancellation, which is an interruption, not a failure.
|
||||
func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Defer to the job context: a genuine cancel reports cancelled, a stray teardown
|
||||
// cancellation on a live ctx is ignored — never a step FAILURE.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
return
|
||||
}
|
||||
common.Logger(ctx).Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
rc.markFailed()
|
||||
}
|
||||
|
||||
// actionPreparer is implemented by steps that download an action before they run, so the job
|
||||
// executor can fetch all of them up front.
|
||||
type actionPreparer interface {
|
||||
prepareActionExecutor() common.Executor
|
||||
actionDownloadInfo() (reference, sha string, ok bool)
|
||||
}
|
||||
|
||||
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
||||
// them as actions/runner's "Prepare all required actions" section does. The steps still call
|
||||
// prepareActionExecutor themselves; it is a no-op once the action is resolved here.
|
||||
func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if len(preparers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger.Infof("Prepare all required actions")
|
||||
|
||||
for _, preparer := range preparers {
|
||||
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||
// No step has run yet, so the failure belongs to the job.
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
}
|
||||
reference, sha, ok := preparer.actionDownloadInfo()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sha == "" {
|
||||
rawLogger.Infof("Download action repository '%s'", reference)
|
||||
} else {
|
||||
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
||||
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
// Name holds a matrix combination; JobName is the shared name GitHub reports.
|
||||
name := rc.JobName
|
||||
if name == "" {
|
||||
name = rc.Name
|
||||
}
|
||||
if name == "" && rc.Run != nil {
|
||||
name = rc.Run.JobID
|
||||
}
|
||||
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||
steps := make([]common.Executor, 0)
|
||||
preSteps := make([]common.Executor, 0)
|
||||
// Collected separately: every action is downloaded before the first pre step runs.
|
||||
stepPreSteps := make([]common.Executor, 0)
|
||||
preparers := make([]actionPreparer, 0)
|
||||
var postExecutor common.Executor
|
||||
|
||||
steps = append(steps, func(ctx context.Context) error {
|
||||
@@ -80,35 +178,43 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
return common.NewErrorExecutor(err)
|
||||
}
|
||||
|
||||
if preparer, ok := step.(actionPreparer); ok {
|
||||
preparers = append(preparers, preparer)
|
||||
}
|
||||
|
||||
stepIdx := stepModel.Number
|
||||
preExec := step.pre()
|
||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||
stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
preErr := preExec(ctx)
|
||||
if preErr != nil {
|
||||
reportStepError(ctx, preErr)
|
||||
reportStepError(ctx, rc, preErr)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, ctx.Err())
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return preErr
|
||||
}))
|
||||
|
||||
stepExec := step.main()
|
||||
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
err := stepExec(ctx)
|
||||
if err != nil {
|
||||
reportStepError(ctx, err)
|
||||
reportStepError(ctx, rc, err)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, ctx.Err())
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
postFn := step.post()
|
||||
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
err := postFn(ctx)
|
||||
if err != nil {
|
||||
reportStepError(ctx, err)
|
||||
reportStepError(ctx, rc, err)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, ctx.Err())
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return err
|
||||
})
|
||||
@@ -120,15 +226,31 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
}
|
||||
}
|
||||
|
||||
// The setup section of the job log. The started hook goes first, so what it sets up is
|
||||
// in place for the first action download and the first step.
|
||||
preSteps = append(preSteps, rc.runJobStartedHook)
|
||||
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||
preSteps = append(preSteps, stepPreSteps...)
|
||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||
|
||||
// Ahead of the teardown below, while the job environment is still up.
|
||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||
|
||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||
jobError := common.JobError(ctx)
|
||||
var err error
|
||||
if rc.Config.AutoRemove || jobError == nil {
|
||||
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
|
||||
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
|
||||
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
|
||||
// context now carries its own error container so a failing post step makes jobError
|
||||
// non-nil — OR in rc.jobCancelled so cancellation still always tears the container down.
|
||||
if rc.Config.AutoRemove || jobError == nil || rc.jobCancelled {
|
||||
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
// For Gitea
|
||||
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
||||
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
@@ -161,25 +283,107 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
return err
|
||||
})
|
||||
|
||||
pipeline := make([]common.Executor, 0)
|
||||
pipeline = append(pipeline, preSteps...)
|
||||
pipeline = append(pipeline, steps...)
|
||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||
|
||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||
Finally(func(ctx context.Context) error {
|
||||
var cancel context.CancelFunc
|
||||
if ctx.Err() == context.Canceled {
|
||||
// in case of an aborted run, we still should execute the
|
||||
// post steps to allow cleanup.
|
||||
ctx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), 5*time.Minute)
|
||||
defer cancel()
|
||||
}
|
||||
return postExecutor(ctx)
|
||||
// Record an interrupt (backstop for interrupts that land outside the main
|
||||
// step loop) so the post steps observe the cancelled/failed job status.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
postCtx, cancel := postStepsContext(ctx)
|
||||
defer cancel()
|
||||
return postExecutor(postCtx)
|
||||
}).
|
||||
Finally(info.interpolateOutputs()).
|
||||
Finally(info.closeContainer()))
|
||||
}
|
||||
|
||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||
//
|
||||
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
||||
// the post steps can run.
|
||||
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||
// keeps the original values — including the job-error container — so the timeout failure and
|
||||
// any post-step error are preserved and the job is still reported as failed.
|
||||
// - otherwise: run on the live context unchanged.
|
||||
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
switch ctx.Err() {
|
||||
case context.Canceled:
|
||||
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
||||
// container installed at the job root. Re-attach a fresh one so a failing post step
|
||||
// records its error via SetJobError instead of panicking on a nil container.
|
||||
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
||||
case context.DeadlineExceeded:
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
default:
|
||||
return ctx, func() {}
|
||||
}
|
||||
}
|
||||
|
||||
// newStepsExecutor sequences the job's pre steps and main steps.
|
||||
//
|
||||
// The pre steps run as a normal pipeline that short-circuits on the first failure or
|
||||
// cancellation. The main-steps executor then runs unconditionally — even if a pre step failed
|
||||
// or the job was interrupted — so always()/cancelled()/failure() main steps still run, mirroring
|
||||
// GitHub Actions. This is safe because each main step re-evaluates its own `if` (a pre-step
|
||||
// failure flips the expression job status to failure, so success()-default steps skip) and
|
||||
// newMainStepsExecutor detaches from an interrupted context before running the remaining steps.
|
||||
//
|
||||
// A pre-step failure or interrupt is still propagated so the job is reported with the correct
|
||||
// conclusion; the pre error takes precedence since it happened first.
|
||||
func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.Executor {
|
||||
preExecutor := common.NewPipelineExecutor(preSteps...)
|
||||
mainExecutor := newMainStepsExecutor(rc, steps)
|
||||
return func(ctx context.Context) error {
|
||||
preErr := preExecutor(ctx)
|
||||
mainErr := mainExecutor(ctx)
|
||||
if preErr != nil {
|
||||
return preErr
|
||||
}
|
||||
return mainErr
|
||||
}
|
||||
}
|
||||
|
||||
// newMainStepsExecutor runs the job's main-stage step executors in order. Unlike a plain
|
||||
// pipeline, an interruption (context.Canceled from a server cancel, or context.DeadlineExceeded
|
||||
// from the job timeout) does not abandon the remaining steps: it marks the job cancelled when
|
||||
// appropriate and keeps iterating under a fresh, bounded context so steps whose `if` still
|
||||
// evaluates true — always() and cancelled() — run for cleanup, mirroring GitHub Actions. Steps
|
||||
// that default to success() skip themselves because success() is false once the job is no longer
|
||||
// successful. The main-step wrappers report their own errors and return nil, so the loop drives
|
||||
// step ordering off the context, not return values.
|
||||
func newMainStepsExecutor(rc *RunContext, steps []common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for i, step := range steps {
|
||||
if ctx.Err() != nil {
|
||||
return runMainStepsAfterInterrupt(ctx, rc, steps[i:])
|
||||
}
|
||||
_ = step(ctx)
|
||||
}
|
||||
// An interrupt can land during the final step, after the loop's last context
|
||||
// check; record it so the post steps still observe the cancelled/failed status.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// runMainStepsAfterInterrupt runs the remaining main steps after the job context was cancelled or
|
||||
// timed out. It detaches from the interrupted context (keeping its values: logger and job error)
|
||||
// and applies a fresh deadline so always()/cancelled() steps run to completion. The original
|
||||
// interrupt error is returned so callers up the chain still see the job as cancelled/timed out.
|
||||
func runMainStepsAfterInterrupt(ctx context.Context, rc *RunContext, steps []common.Executor) error {
|
||||
interruptErr := ctx.Err()
|
||||
rc.markInterrupted(interruptErr)
|
||||
freshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
defer cancel()
|
||||
for _, step := range steps {
|
||||
_ = step(freshCtx)
|
||||
}
|
||||
return interruptErr
|
||||
}
|
||||
|
||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
@@ -187,6 +391,12 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
// read-modify-write of the job result so a failing combination is not lost-updated by a
|
||||
// concurrent succeeding one.
|
||||
job := rc.Run.Job()
|
||||
var continueOnError bool
|
||||
if !success {
|
||||
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
||||
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
||||
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
||||
}
|
||||
jobResult := func() string {
|
||||
defer lockJob(job)()
|
||||
result := "success"
|
||||
@@ -197,6 +407,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
}
|
||||
if !success {
|
||||
result = "failure"
|
||||
job.SetContinueOnError(continueOnError)
|
||||
}
|
||||
info.result(result)
|
||||
return result
|
||||
@@ -235,6 +446,206 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||
// step-level evaluateStepTimeout in step.go.
|
||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||
if timeout != "" {
|
||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||
}
|
||||
}
|
||||
return ctx, func() {}
|
||||
}
|
||||
|
||||
// evaluateJobContinueOnError evaluates the job-level continue-on-error expression.
|
||||
func evaluateJobContinueOnError(ctx context.Context, rc *RunContext, job *model.Job) bool {
|
||||
expr := strings.TrimSpace(job.RawContinueOnError)
|
||||
if expr == "" {
|
||||
return false
|
||||
}
|
||||
continueOnError, err := EvalBool(ctx, rc.NewExpressionEvaluator(ctx), expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("continue-on-error expression %q evaluation failed: %v", expr, err)
|
||||
return false
|
||||
}
|
||||
return continueOnError
|
||||
}
|
||||
|
||||
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
||||
return
|
||||
}
|
||||
// Bound the whole upload phase so a slow or unreachable endpoint cannot consume
|
||||
// the job cleanup budget reserved for stopping and removing the container.
|
||||
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
|
||||
defer cancel()
|
||||
env := rc.GetEnv()
|
||||
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"])
|
||||
if !hasJobSummaryCapability(caps) {
|
||||
// Server did not advertise support. Do not attempt upload.
|
||||
return
|
||||
}
|
||||
runtimeURL := strings.TrimSpace(env["ACTIONS_RUNTIME_URL"])
|
||||
runtimeToken := strings.TrimSpace(env["ACTIONS_RUNTIME_TOKEN"])
|
||||
runID := strings.TrimSpace(env["GITEA_RUN_ID"])
|
||||
if runtimeURL == "" || runtimeToken == "" || runID == "" {
|
||||
return
|
||||
}
|
||||
if rc.Run == nil || rc.Run.Job() == nil {
|
||||
return
|
||||
}
|
||||
// The numeric ActionRunJob ID is not exposed in the proto Task message or task context,
|
||||
// but the server signs it into the ACTIONS_RUNTIME_TOKEN JWT claims. We decode the
|
||||
// unverified claims to retrieve it; the server re-verifies the token on the request.
|
||||
jobID := extractJobIDFromRuntimeToken(runtimeToken)
|
||||
if jobID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
base := strings.TrimRight(runtimeURL, "/") + "/_apis/pipelines/workflows/" + runID +
|
||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
// Reuse a single client across all step uploads so connections can be pooled.
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
||||
for i := range rc.Run.Job().Steps {
|
||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||
if !ok || len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
||||
}
|
||||
}
|
||||
|
||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var claims struct {
|
||||
JobID int64 `json:"JobID"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return 0
|
||||
}
|
||||
return claims.JobID
|
||||
}
|
||||
|
||||
func hasJobSummaryCapability(caps string) bool {
|
||||
return slices.Contains(strings.FieldsFunc(caps, func(r rune) bool {
|
||||
return r == ',' || unicode.IsSpace(r)
|
||||
}), "job-summary")
|
||||
}
|
||||
|
||||
func uploadJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
var lastStatus int
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
status, err := putJobSummary(ctx, client, url, runtimeToken, body)
|
||||
if err == nil && status/100 == 2 {
|
||||
return
|
||||
}
|
||||
lastStatus = status
|
||||
lastErr = err
|
||||
if attempt == 1 || !isTransientJobSummaryUploadFailure(status, err) {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(jobSummaryUploadRetryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
lastErr = ctx.Err()
|
||||
attempt = 1
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort only; do not fail job, but log because capability was advertised.
|
||||
if lastErr != nil {
|
||||
logger.WithError(lastErr).Warn("job summary upload failed")
|
||||
return
|
||||
}
|
||||
logger.Warnf("job summary upload failed: status=%d", lastStatus)
|
||||
}
|
||||
|
||||
func putJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) (int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+runtimeToken)
|
||||
req.Header.Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func isTransientJobSummaryUploadFailure(status int, err error) bool {
|
||||
return err != nil || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status/100 == 5
|
||||
}
|
||||
|
||||
func readSingleFileFromContainerArchive(ctx context.Context, env container.ExecutionsEnvironment, p string, maxBytes int64) ([]byte, bool) {
|
||||
rc, err := env.GetContainerArchive(ctx, p)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
tr := tar.NewReader(rc)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if !archiveEntryMatchesPath(header.Name, p) {
|
||||
continue
|
||||
}
|
||||
// Summaries larger than the limit are truncated rather than dropped, so the
|
||||
// user still gets the leading content (mirroring how GitHub caps oversized
|
||||
// step summaries instead of discarding them). Read one extra byte so an
|
||||
// over-limit file is detected from the actual stream rather than trusting
|
||||
// header.Size, then cap the returned content at maxBytes.
|
||||
b, err := io.ReadAll(io.LimitReader(tr, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if int64(len(b)) > maxBytes {
|
||||
// Reserve room for the marker so the marked-up result still fits in maxBytes.
|
||||
marker := []byte(jobSummaryTruncationMarker)
|
||||
keep := max(maxBytes-int64(len(marker)), 0)
|
||||
b = append(b[:keep], marker...)
|
||||
common.Logger(ctx).Warnf("job summary truncated: path=%s max=%d", p, maxBytes)
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
|
||||
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
||||
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
|
||||
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
|
||||
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
||||
}
|
||||
|
||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||
@@ -252,6 +663,11 @@ func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, execu
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once the
|
||||
// step has finished, so the final line of the step's output is not lost
|
||||
// when it is not newline-terminated.
|
||||
defer common.FlushWriter(logWriter)
|
||||
|
||||
return executor(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,35 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestJobExecutor(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||
tables := []TestJobFileInfo{
|
||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||
@@ -34,6 +47,7 @@ func TestJobExecutor(t *testing.T) {
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
table.runTest(ctx, t, &Config{})
|
||||
})
|
||||
}
|
||||
@@ -100,6 +114,182 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
|
||||
return args.Get(0).(step), args.Error(1)
|
||||
}
|
||||
|
||||
// actionPreparerMock stands in for a step whose action is downloaded before the job's first step.
|
||||
type actionPreparerMock struct {
|
||||
reference string
|
||||
sha string
|
||||
ok bool
|
||||
err error
|
||||
prepared int
|
||||
}
|
||||
|
||||
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
||||
return func(context.Context) error {
|
||||
apm.prepared++
|
||||
return apm.err
|
||||
}
|
||||
}
|
||||
|
||||
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
|
||||
return apm.reference, apm.sha, apm.ok
|
||||
}
|
||||
|
||||
func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
|
||||
preparers := []actionPreparer{
|
||||
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
|
||||
// A resolved commit is best effort; the ref alone is reported when it is unknown.
|
||||
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
|
||||
// A step that downloads nothing, such as the checkout of the workflow's own repository.
|
||||
&actionPreparerMock{ok: false},
|
||||
}
|
||||
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | Prepare all required actions",
|
||||
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
||||
"[j1] | Download action repository 'actions/setup-go@v6'",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
|
||||
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
||||
|
||||
assert.Empty(t, buf.String())
|
||||
}
|
||||
|
||||
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||
logger, _ := logrustest.NewNullLogger()
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
|
||||
|
||||
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
||||
rc := &RunContext{}
|
||||
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
|
||||
|
||||
err := printPrepareActions(rc, []actionPreparer{
|
||||
&actionPreparerMock{err: downloadErr},
|
||||
remaining,
|
||||
})(ctx)
|
||||
|
||||
require.ErrorIs(t, err, downloadErr)
|
||||
// No step has run yet, so the failure has to be recorded against the job itself.
|
||||
assert.Equal(t, downloadErr, common.JobError(ctx))
|
||||
assert.True(t, rc.jobFailed)
|
||||
assert.Zero(t, remaining.prepared)
|
||||
}
|
||||
|
||||
func TestPrintCompleteJobName(t *testing.T) {
|
||||
for name, tt := range map[string]struct {
|
||||
rc *RunContext
|
||||
want string
|
||||
}{
|
||||
"job name": {rc: &RunContext{JobName: "lint", Name: "lint-1"}, want: "lint"},
|
||||
"falls back to name": {rc: &RunContext{Name: "lint-1"}, want: "lint-1"},
|
||||
"falls back to jobID": {rc: &RunContext{Run: &model.Run{JobID: "lint"}}, want: "lint"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
|
||||
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
|
||||
|
||||
assert.Equal(t, "[j1] | Complete job name: "+tt.want+"\n", buf.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// actionStepMock is a step whose action has to be downloaded before it can run.
|
||||
type actionStepMock struct {
|
||||
*stepMock
|
||||
*actionPreparerMock
|
||||
}
|
||||
|
||||
// TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep pins the shape of the setup section:
|
||||
// every action is downloaded before any step runs, and the job name closes the section. A pre
|
||||
// step that downloaded its own action would leave the log interleaved with the downloads.
|
||||
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
JobContainer: &jobContainerMock{},
|
||||
Run: &model.Run{
|
||||
JobID: "test",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{"test": {}},
|
||||
},
|
||||
},
|
||||
Config: &Config{},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
|
||||
steps := []*model.Step{{ID: "1"}, {ID: "2"}}
|
||||
executorOrder := make([]string, 0)
|
||||
|
||||
jim.On("steps").Return(steps)
|
||||
jim.On("matrix").Return(map[string]any{})
|
||||
jim.On("startContainer").Return(func(context.Context) error { return nil })
|
||||
jim.On("stopContainer").Return(func(context.Context) error { return nil })
|
||||
jim.On("closeContainer").Return(func(context.Context) error { return nil })
|
||||
jim.On("interpolateOutputs").Return(func(context.Context) error { return nil })
|
||||
jim.On("result", "success")
|
||||
|
||||
for _, stepModel := range steps {
|
||||
sm := &stepMock{}
|
||||
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, ok: true}
|
||||
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
||||
|
||||
sm.On("pre").Return(func(context.Context) error {
|
||||
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
||||
return nil
|
||||
})
|
||||
sm.On("main").Return(func(context.Context) error {
|
||||
executorOrder = append(executorOrder, "step"+stepModel.ID)
|
||||
return nil
|
||||
})
|
||||
sm.On("post").Return(func(context.Context) error { return nil })
|
||||
|
||||
defer sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
||||
|
||||
setup := make([]string, 0)
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
||||
strings.HasPrefix(entry.Message, "Complete job name") {
|
||||
setup = append(setup, entry.Message)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"Prepare all required actions",
|
||||
"Download action repository 'actions/checkout@v1'",
|
||||
"Download action repository 'actions/checkout@v2'",
|
||||
"Complete job name: test",
|
||||
}, setup)
|
||||
}
|
||||
|
||||
func TestNewJobExecutor(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
@@ -336,3 +526,559 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
|
||||
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
|
||||
// the post steps (cleanup hooks like actions/checkout post and cache save) must
|
||||
// still run against a fresh, non-expired context, and the job must still be
|
||||
// reported as failed.
|
||||
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
// The timeout is generous so the main step (which blocks on ctx.Done below) is
|
||||
// always reached before the deadline fires; otherwise the pipeline would
|
||||
// short-circuit before the step runs and the job error would never be set.
|
||||
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
JobContainer: &jobContainerMock{},
|
||||
Run: &model.Run{
|
||||
JobID: "test",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"test": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &Config{},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
|
||||
stepModel := &model.Step{ID: "1"}
|
||||
jim.On("steps").Return([]*model.Step{stepModel})
|
||||
jim.On("matrix").Return(map[string]any{})
|
||||
jim.On("startContainer").Return(func(ctx context.Context) error { return nil })
|
||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil })
|
||||
jim.On("closeContainer").Return(func(ctx context.Context) error { return nil })
|
||||
// The job timed out, so it must be reported as failed. stopContainer is left
|
||||
// unexpected on purpose: a timed-out (failed) job preserves its error state, so
|
||||
// the graceful stop is skipped exactly like any other failure without AutoRemove.
|
||||
jim.On("result", "failure")
|
||||
|
||||
sm := &stepMock{}
|
||||
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
||||
sm.On("pre").Return(func(ctx context.Context) error { return nil })
|
||||
// The main step runs past the job timeout: it blocks until the job context is
|
||||
// done, mirroring a step that overruns timeout-minutes.
|
||||
sm.On("main").Return(func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
|
||||
var postRan bool
|
||||
var postCtxErr error
|
||||
sm.On("post").Return(func(ctx context.Context) error {
|
||||
postRan = true
|
||||
postCtxErr = ctx.Err()
|
||||
return nil
|
||||
})
|
||||
|
||||
executor := newJobExecutor(jim, sfm, rc)
|
||||
// The executor itself returns nil on timeout: the failure is surfaced through
|
||||
// the job result ("failure", asserted via the result mock below), not the
|
||||
// return value.
|
||||
require.NoError(t, executor(ctx))
|
||||
|
||||
assert.True(t, postRan, "post step must run after a job timeout")
|
||||
require.NoError(t, postCtxErr, "post step must run against a fresh, non-expired context")
|
||||
|
||||
jim.AssertExpectations(t)
|
||||
sfm.AssertExpectations(t)
|
||||
sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestSetJobResultMatrixContinueOnError exercises the parallel-matrix path
|
||||
// end-to-end: two combinations share one *model.Job and continue-on-error is
|
||||
// keyed on matrix.experimental, so one combination tolerates its failure and the
|
||||
// other does not. The job is reported as continue-on-error only when EVERY failing
|
||||
// combination was tolerated; a single firm failure makes the whole job firm, and
|
||||
// handleFailure then fails the run.
|
||||
func TestSetJobResultMatrixContinueOnError(t *testing.T) {
|
||||
const jobYAML = "continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"
|
||||
|
||||
newSharedJob := func(t *testing.T) (*model.Job, *model.Workflow) {
|
||||
t.Helper()
|
||||
var job *model.Job
|
||||
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
|
||||
return job, &model.Workflow{
|
||||
Name: "workflow1",
|
||||
Jobs: map[string]*model.Job{"job1": job},
|
||||
}
|
||||
}
|
||||
|
||||
planFor := func(wf *model.Workflow) *model.Plan {
|
||||
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// fail drives a single matrix combination through the failure path; each
|
||||
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job.
|
||||
fail := func(wf *model.Workflow, experimental bool) {
|
||||
rc := newTestRC(wf, map[string]any{"experimental": experimental})
|
||||
setJobResult(ctx, rc, rc, false)
|
||||
}
|
||||
|
||||
t.Run("one tolerated and one firm failure fails the run", func(t *testing.T) {
|
||||
job, wf := newSharedJob(t)
|
||||
// Order is intentional: the tolerated combination finishes first, then the
|
||||
// firm one. The firm-failure latch must still win regardless of order.
|
||||
fail(wf, true)
|
||||
fail(wf, false)
|
||||
|
||||
assert.Equal(t, "failure", job.Result)
|
||||
assert.False(t, job.ContinueOnError, "a single firm failure must make the whole job firm")
|
||||
assert.Error(t, handleFailure(planFor(wf))(ctx))
|
||||
})
|
||||
|
||||
t.Run("all tolerated failures do not fail the run", func(t *testing.T) {
|
||||
job, wf := newSharedJob(t)
|
||||
fail(wf, true)
|
||||
fail(wf, true)
|
||||
|
||||
assert.Equal(t, "failure", job.Result)
|
||||
assert.True(t, job.ContinueOnError, "every failing combination was tolerated")
|
||||
assert.NoError(t, handleFailure(planFor(wf))(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasJobSummaryCapability(t *testing.T) {
|
||||
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
||||
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
|
||||
assert.False(t, hasJobSummaryCapability("not-job-summary,job-summary-v2"))
|
||||
}
|
||||
|
||||
// fakeRuntimeToken builds a JWT-shaped string whose middle (claims) segment encodes
|
||||
// the given JobID. The header and signature segments are filler — the runner does not
|
||||
// verify the signature; the server does.
|
||||
func fakeRuntimeToken(jobID int64) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"JobID":%d}`, jobID))
|
||||
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
|
||||
return header + "." + claims + "." + sig
|
||||
}
|
||||
|
||||
func newJobSummaryRC(env map[string]string, jobContainer container.ExecutionsEnvironment, stepCount int) *RunContext {
|
||||
steps := make([]*model.Step, stepCount)
|
||||
for i := range steps {
|
||||
steps[i] = &model.Step{ID: strconv.Itoa(i)}
|
||||
}
|
||||
return &RunContext{
|
||||
Config: &Config{},
|
||||
JobContainer: jobContainer,
|
||||
Env: env,
|
||||
Run: &model.Run{
|
||||
JobID: "test",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"test": {Steps: steps},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
||||
oldDelay := jobSummaryUploadRetryDelay
|
||||
jobSummaryUploadRetryDelay = 0
|
||||
defer func() {
|
||||
jobSummaryUploadRetryDelay = oldDelay
|
||||
}()
|
||||
|
||||
runtimeToken := fakeRuntimeToken(34)
|
||||
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
assert.Equal(t, http.MethodPut, r.Method)
|
||||
assert.Equal(t, "/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", r.URL.Path)
|
||||
assert.Equal(t, "Bearer "+runtimeToken, r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "text/markdown; charset=utf-8", r.Header.Get("Content-Type"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("# summary"), body)
|
||||
if requests == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
rc := newJobSummaryRC(map[string]string{
|
||||
"GITEA_ACTIONS_CAPABILITIES": "cache, job-summary",
|
||||
"ACTIONS_RUNTIME_URL": server.URL,
|
||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, cm, 1)
|
||||
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
|
||||
assert.Equal(t, 2, requests)
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
||||
oldPhase := jobSummaryUploadPhaseTimeout
|
||||
jobSummaryUploadPhaseTimeout = 100 * time.Millisecond
|
||||
defer func() {
|
||||
jobSummaryUploadPhaseTimeout = oldPhase
|
||||
}()
|
||||
|
||||
runtimeToken := fakeRuntimeToken(34)
|
||||
|
||||
// The server blocks until either the request context is cancelled (the behaviour
|
||||
// under test: the phase timeout aborts the in-flight upload) or the test tears it
|
||||
// down. Without the phase timeout the upload would hang until the 30s client
|
||||
// timeout instead of releasing the cleanup budget. The release channel guarantees
|
||||
// the handler always returns so server.Close() cannot itself hang.
|
||||
release := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-release:
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
defer close(release)
|
||||
|
||||
ctx := context.Background()
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
rc := newJobSummaryRC(map[string]string{
|
||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
||||
"ACTIONS_RUNTIME_URL": server.URL,
|
||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, cm, 1)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("tryUploadJobSummary did not honour the phase timeout")
|
||||
}
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
runtimeToken := fakeRuntimeToken(34)
|
||||
|
||||
type upload struct {
|
||||
path string
|
||||
body string
|
||||
}
|
||||
var got []upload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
got = append(got, upload{r.URL.Path, string(body)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
cm := &containerMock{}
|
||||
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "first"}))),
|
||||
nil,
|
||||
).Once()
|
||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-1.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-1.md", body: ""}))),
|
||||
nil,
|
||||
).Once()
|
||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-2.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-2.md", body: "third"}))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
rc := newJobSummaryRC(map[string]string{
|
||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
||||
"ACTIONS_RUNTIME_URL": server.URL,
|
||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, cm, 3)
|
||||
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
|
||||
assert.Equal(t, []upload{
|
||||
{"/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", "first"},
|
||||
{"/_apis/pipelines/workflows/12/jobs/34/steps/2/summary", "third"},
|
||||
}, got)
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rc := newJobSummaryRC(map[string]string{
|
||||
"GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2",
|
||||
"ACTIONS_RUNTIME_URL": server.URL,
|
||||
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, &containerMock{}, 1)
|
||||
|
||||
tryUploadJobSummary(context.Background(), rc)
|
||||
|
||||
assert.Equal(t, 0, requests)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rc := newJobSummaryRC(map[string]string{
|
||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
||||
"ACTIONS_RUNTIME_URL": server.URL,
|
||||
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, &containerMock{}, 1)
|
||||
|
||||
tryUploadJobSummary(context.Background(), rc)
|
||||
|
||||
assert.Equal(t, 0, requests)
|
||||
}
|
||||
|
||||
func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
||||
assert.Equal(t, int64(42), extractJobIDFromRuntimeToken(fakeRuntimeToken(42)))
|
||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("not-a-jwt"))
|
||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("a.b.c"))
|
||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken(""))
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t,
|
||||
tarEntry{name: "workflow", typeflag: tar.TypeDir},
|
||||
tarEntry{name: "other.md", body: "wrong"},
|
||||
tarEntry{name: "SUMMARY.md", body: "right"},
|
||||
))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 1024)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []byte("right"), body)
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cm := &containerMock{}
|
||||
content := strings.Repeat("a", 300)
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
const maxBytes = 200
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
|
||||
|
||||
// Oversized summaries are truncated to the limit (reserving room for the marker)
|
||||
// rather than dropped entirely, and the truncation marker is appended.
|
||||
assert.True(t, ok)
|
||||
assert.LessOrEqual(t, len(body), maxBytes)
|
||||
keep := maxBytes - len(jobSummaryTruncationMarker)
|
||||
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
||||
if assert.Len(t, hook.Entries, 1) {
|
||||
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
||||
}
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
||||
nil,
|
||||
).Once()
|
||||
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
|
||||
|
||||
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []byte("abc"), body)
|
||||
assert.Empty(t, hook.Entries)
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
type tarEntry struct {
|
||||
name string
|
||||
body string
|
||||
typeflag byte
|
||||
}
|
||||
|
||||
func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
||||
t.Helper()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
for _, entry := range entries {
|
||||
typeflag := entry.typeflag
|
||||
if typeflag == 0 {
|
||||
typeflag = tar.TypeReg
|
||||
}
|
||||
header := &tar.Header{
|
||||
Name: entry.name,
|
||||
Typeflag: typeflag,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(entry.body)),
|
||||
}
|
||||
if typeflag == tar.TypeDir {
|
||||
header.Mode = 0o755
|
||||
header.Size = 0
|
||||
}
|
||||
require.NoError(t, tw.WriteHeader(header))
|
||||
if typeflag == tar.TypeReg {
|
||||
_, err := tw.Write([]byte(entry.body))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
require.NoError(t, tw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
||||
return &RunContext{
|
||||
Config: &Config{
|
||||
Workdir: ".",
|
||||
Platforms: map[string]string{
|
||||
"ubuntu-latest": "ubuntu-latest",
|
||||
},
|
||||
},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Env: map[string]string{},
|
||||
Matrix: matrix,
|
||||
Run: &model.Run{JobID: "job1", Workflow: wf},
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestRC(t *testing.T, jobYAML string) *RunContext {
|
||||
t.Helper()
|
||||
var job *model.Job
|
||||
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
|
||||
rc := newTestRC(&model.Workflow{
|
||||
Name: "workflow1",
|
||||
Jobs: map[string]*model.Job{"job1": job},
|
||||
}, nil)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
return rc
|
||||
}
|
||||
|
||||
func TestApplyJobTimeout(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantTimeout bool
|
||||
}{
|
||||
{"empty", "runs-on: ubuntu-latest", false},
|
||||
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
||||
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rc := makeTestRC(t, tc.yaml)
|
||||
ctx := context.Background()
|
||||
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
||||
defer cancel()
|
||||
_, hasDeadline := newCtx.Deadline()
|
||||
assert.Equal(t, tc.wantTimeout, hasDeadline)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobContinueOnError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want bool
|
||||
}{
|
||||
{"absent", "runs-on: ubuntu-latest", false},
|
||||
{"true", "continue-on-error: true\nruns-on: ubuntu-latest", true},
|
||||
{"false", "continue-on-error: false\nruns-on: ubuntu-latest", false},
|
||||
{"expression true", "continue-on-error: ${{ 'x' == 'x' }}\nruns-on: ubuntu-latest", true},
|
||||
{"expression false", "continue-on-error: ${{ 'x' != 'x' }}\nruns-on: ubuntu-latest", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rc := makeTestRC(t, tc.yaml)
|
||||
got := evaluateJobContinueOnError(context.Background(), rc, rc.Run.Job())
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSetContinueOnError(t *testing.T) {
|
||||
t.Run("first call true", func(t *testing.T) {
|
||||
j := &model.Job{}
|
||||
j.SetContinueOnError(true)
|
||||
assert.True(t, j.ContinueOnError)
|
||||
})
|
||||
t.Run("first call false", func(t *testing.T) {
|
||||
j := &model.Job{}
|
||||
j.SetContinueOnError(false)
|
||||
assert.False(t, j.ContinueOnError)
|
||||
})
|
||||
t.Run("true then false locks to false", func(t *testing.T) {
|
||||
j := &model.Job{}
|
||||
j.SetContinueOnError(true)
|
||||
j.SetContinueOnError(false)
|
||||
assert.False(t, j.ContinueOnError)
|
||||
})
|
||||
t.Run("false then true stays false", func(t *testing.T) {
|
||||
j := &model.Job{}
|
||||
j.SetContinueOnError(false)
|
||||
j.SetContinueOnError(true)
|
||||
assert.False(t, j.ContinueOnError)
|
||||
})
|
||||
t.Run("true then true stays true", func(t *testing.T) {
|
||||
j := &model.Job{}
|
||||
j.SetContinueOnError(true)
|
||||
j.SetContinueOnError(true)
|
||||
assert.True(t, j.ContinueOnError)
|
||||
})
|
||||
}
|
||||
|
||||
115
act/runner/job_hooks.go
Normal file
115
act/runner/job_hooks.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
)
|
||||
|
||||
// GitHub's job-hook variables, read as a fallback when the settings are unset.
|
||||
const (
|
||||
jobStartedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_STARTED"
|
||||
jobCompletedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED"
|
||||
)
|
||||
|
||||
// Kept apart from the per-step file-command files, which are truncated on every step.
|
||||
const (
|
||||
hookEnvFileCommand = "workflow/hook-envs.txt"
|
||||
hookPathFileCommand = "workflow/hook-path.txt"
|
||||
)
|
||||
|
||||
func (rc *RunContext) runJobStartedHook(ctx context.Context) error {
|
||||
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobStartedHook, rc.Config.Env[jobStartedHookEnv]), "job started")
|
||||
}
|
||||
|
||||
func (rc *RunContext) runJobCompletedHook(ctx context.Context) error {
|
||||
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobCompletedHook, rc.Config.Env[jobCompletedHookEnv]), "job completed")
|
||||
}
|
||||
|
||||
// runJobHook runs one hook in the job environment. Either hook failing fails the job, as
|
||||
// on GitHub, where the operator is responsible for the hook's own resilience.
|
||||
func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) error {
|
||||
if hookPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||
if shell != "" {
|
||||
rawLogger.Infof("shell: %s", shell)
|
||||
}
|
||||
|
||||
env := maps.Clone(rc.GetEnv())
|
||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||
maps.Copy(env, jobContainer.Env)
|
||||
}
|
||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||
rc.ApplyExtraPath(ctx, &env)
|
||||
|
||||
err := rc.setupHookFileCommands(ctx, env)
|
||||
if err == nil {
|
||||
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx)
|
||||
}
|
||||
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||
// failing still hands it to the job.
|
||||
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = fmt.Errorf("the %s hook %q failed: %w", name, hookPath, err)
|
||||
// Flip the job status the way a failing pre step does, so success()-default main steps
|
||||
// skip and the task is reported failed.
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// setupHookFileCommands points the hook at its GITHUB_ENV and GITHUB_PATH files, so it can
|
||||
// export to the job's steps, and truncates them so the second hook does not re-read what
|
||||
// the first one wrote.
|
||||
func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]string) error {
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
||||
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
||||
env["GITEA_ENV"] = env["GITHUB_ENV"]
|
||||
env["GITEA_PATH"] = env["GITHUB_PATH"]
|
||||
|
||||
return rc.JobContainer.Copy(actPath,
|
||||
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
||||
&container.FileEntry{Name: hookPathFileCommand, Mode: 0o666},
|
||||
)(ctx)
|
||||
}
|
||||
|
||||
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
||||
return err
|
||||
}
|
||||
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
||||
}
|
||||
|
||||
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||
// gives `run:` steps — a hook sets its own. See docs/adrs/1751-runner-job-hooks.md there.
|
||||
// The second return value is how the invocation is shown in the log, empty when the file is
|
||||
// executed directly.
|
||||
func hookCommand(hookPath string) (cmd []string, shell string) {
|
||||
switch strings.ToLower(path.Ext(hookPath)) {
|
||||
case ".sh":
|
||||
return []string{"bash", "-e", hookPath}, "bash -e {0}"
|
||||
case ".ps1":
|
||||
return []string{"pwsh", "-command", ". '" + hookPath + "'"}, `pwsh -command ". '{0}'"`
|
||||
default:
|
||||
return []string{hookPath}, ""
|
||||
}
|
||||
}
|
||||
162
act/runner/job_hooks_test.go
Normal file
162
act/runner/job_hooks_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// hookContainer records the command a hook was run with and answers with what the hook
|
||||
// wrote to its GITHUB_ENV and GITHUB_PATH files.
|
||||
type hookContainer struct {
|
||||
fakeContainer
|
||||
cmd []string
|
||||
env map[string]string
|
||||
err error
|
||||
envFile map[string]string
|
||||
pathTar []byte
|
||||
}
|
||||
|
||||
func (c *hookContainer) ToContainerPath(path string) string { return path }
|
||||
func (c *hookContainer) IsEnvironmentCaseInsensitive() bool { return false }
|
||||
|
||||
func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
|
||||
return map[string]any{"os": "Linux"}
|
||||
}
|
||||
|
||||
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
c.cmd, c.env = command, env
|
||||
return c.err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
maps.Copy(*env, c.envFile)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(c.pathTar)), nil
|
||||
}
|
||||
|
||||
// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is
|
||||
// silenced so the hook's job-log output does not reach the test output.
|
||||
func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) {
|
||||
// Env is left nil so that it is built from the config, as it is for a real job.
|
||||
rc := &RunContext{
|
||||
Config: config,
|
||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||
JobContainer: jobContainer,
|
||||
}
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
return rc, ctx
|
||||
}
|
||||
|
||||
func TestRunJobHook(t *testing.T) {
|
||||
t.Run("runs the hook with the job environment", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{
|
||||
JobStartedHook: "/hooks/started.sh",
|
||||
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
|
||||
})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
|
||||
// The setting wins over the environment variable.
|
||||
assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd)
|
||||
assert.Equal(t, "value", jobContainer.env["A_VAR"])
|
||||
// The github environment is there too, so a hook can tell which job it runs for.
|
||||
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
||||
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
||||
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||
})
|
||||
|
||||
// Each hook reads its own variable, so a swapped constant cannot pass.
|
||||
t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
|
||||
for name, hook := range map[string]struct {
|
||||
env string
|
||||
run func(*RunContext, context.Context) error
|
||||
}{
|
||||
"started": {jobStartedHookEnv, (*RunContext).runJobStartedHook},
|
||||
"completed": {jobCompletedHookEnv, (*RunContext).runJobCompletedHook},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{Env: map[string]string{hook.env: "/from/env.sh"}})
|
||||
|
||||
require.NoError(t, hook.run(rc, ctx))
|
||||
assert.Equal(t, []string{"bash", "-e", "/from/env.sh"}, jobContainer.cmd)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exports what the hook wrote to GITHUB_ENV and GITHUB_PATH", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{
|
||||
envFile: map[string]string{"FROM_HOOK": "1"},
|
||||
pathTar: tarArchive(t, tarEntry{name: "hook-path.txt", body: "/opt/tool/bin\n"}),
|
||||
}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
|
||||
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
|
||||
assert.Equal(t, []string{"/opt/tool/bin"}, rc.ExtraPath)
|
||||
})
|
||||
|
||||
t.Run("a failing hook fails the job", func(t *testing.T) {
|
||||
rc, ctx := newHookRunContext(&hookContainer{err: errors.New("boom")}, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||
|
||||
err := rc.runJobStartedHook(ctx)
|
||||
|
||||
require.ErrorContains(t, err, `the job started hook "/hooks/started.sh" failed`)
|
||||
require.ErrorContains(t, err, "boom")
|
||||
// The failure has to flip the job status, or success()-default steps would still
|
||||
// run and the task would be reported successful despite the missing setup.
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
require.ErrorContains(t, common.JobError(ctx), "boom")
|
||||
})
|
||||
|
||||
t.Run("is a no-op without a hook", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
require.NoError(t, rc.runJobCompletedHook(ctx))
|
||||
assert.Nil(t, jobContainer.cmd)
|
||||
})
|
||||
}
|
||||
|
||||
// actions/runner deliberately runs a hook without the flags it gives `run:` steps, and an
|
||||
// executable without a known extension speaks for itself through its shebang.
|
||||
func TestHookCommand(t *testing.T) {
|
||||
for hookPath, want := range map[string]struct {
|
||||
cmd []string
|
||||
shell string
|
||||
}{
|
||||
"/hooks/started.sh": {[]string{"bash", "-e", "/hooks/started.sh"}, "bash -e {0}"},
|
||||
"/hooks/started.PS1": {[]string{"pwsh", "-command", ". '/hooks/started.PS1'"}, `pwsh -command ". '{0}'"`},
|
||||
"/hooks/started": {[]string{"/hooks/started"}, ""},
|
||||
} {
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
assert.Equal(t, want.cmd, cmd, hookPath)
|
||||
assert.Equal(t, want.shell, shell, hookPath)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ package runner
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -167,6 +170,76 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
||||
|
||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||
|
||||
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
|
||||
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
|
||||
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
|
||||
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
|
||||
var secretValueEncoders = []func(string) string{
|
||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||
base64ShiftEncoder(1),
|
||||
base64ShiftEncoder(2),
|
||||
jsonStringEscape,
|
||||
jsonStringEscapeNoHTML,
|
||||
url.QueryEscape,
|
||||
url.PathEscape,
|
||||
}
|
||||
|
||||
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
||||
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
||||
const minShiftedBase64Len = 8
|
||||
|
||||
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
|
||||
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
|
||||
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
|
||||
// the base64 of the token alone only when the prefix length happens to be a multiple of
|
||||
// three; at the other two alignments the encoding of the whole value differs. Encoding
|
||||
// the secret behind shift filler bytes reproduces those alignments, which is what the
|
||||
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
|
||||
//
|
||||
// The leading group (filler mixed with the secret's first bytes) and the trailing group
|
||||
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
|
||||
// group-aligned middle that does appear verbatim in the log.
|
||||
func base64ShiftEncoder(shift int) func(string) string {
|
||||
return func(v string) string {
|
||||
buf := make([]byte, shift+len(v))
|
||||
copy(buf[shift:], v)
|
||||
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||
// Keep only the aligned middle, and only when enough of it is left to be a
|
||||
// distinctive pattern rather than a fragment that matches unrelated output.
|
||||
if len(encoded) < 8+minShiftedBase64Len {
|
||||
return ""
|
||||
}
|
||||
return encoded[4 : len(encoded)-4]
|
||||
}
|
||||
}
|
||||
|
||||
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
||||
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
||||
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||
// that do not. When v has none of those characters both forms are equal and deduplicated.
|
||||
func jsonStringEscape(v string) string {
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
return string(encoded[1 : len(encoded)-1])
|
||||
}
|
||||
|
||||
// jsonStringEscapeNoHTML is jsonStringEscape without HTML escaping, matching the JSON a
|
||||
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
|
||||
// masked in that form too.
|
||||
func jsonStringEscapeNoHTML(v string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return v
|
||||
}
|
||||
// Encode appends a newline; drop it along with the surrounding quotes.
|
||||
encoded := strings.TrimRight(buf.String(), "\n")
|
||||
return encoded[1 : len(encoded)-1]
|
||||
}
|
||||
|
||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||
ret := oldnew
|
||||
|
||||
@@ -175,9 +248,28 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||
if len(tm) > 1 {
|
||||
ret = append(ret, tm, "***")
|
||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||
if strings.ContainsAny(tm, "%\r\n") {
|
||||
ret = append(ret, escapeCommandData(tm), "***")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The encoded forms are derived from the whole value: a multi-line secret is
|
||||
// encoded as one string, not line by line.
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if len(trimmed) <= 1 {
|
||||
return ret
|
||||
}
|
||||
for _, encode := range secretValueEncoders {
|
||||
encoded := encode(trimmed)
|
||||
// An encoding that leaves the value unchanged is already masked above.
|
||||
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, encoded, "***")
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -190,6 +282,18 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
||||
}
|
||||
oldnew = slices.Clip(oldnew)
|
||||
defReplacer := strings.NewReplacer(oldnew...)
|
||||
|
||||
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
|
||||
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
|
||||
// length, instead of encoding every secret and mask again for each log line.
|
||||
var (
|
||||
mu sync.Mutex
|
||||
masksRef *[]string
|
||||
pairs []string
|
||||
masked int
|
||||
replacer *strings.Replacer
|
||||
)
|
||||
|
||||
return func(entry *logrus.Entry) *logrus.Entry {
|
||||
if insecureSecrets {
|
||||
return entry
|
||||
@@ -199,16 +303,27 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
||||
|
||||
if len(*masks) == 0 {
|
||||
entry.Message = defReplacer.Replace(entry.Message)
|
||||
} else {
|
||||
cmasker := oldnew
|
||||
|
||||
for _, v := range *masks {
|
||||
cmasker = AppendSecretMasker(cmasker, v)
|
||||
}
|
||||
|
||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
||||
return entry
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
// A composite action logs through the same masker with its own mask slice, so a
|
||||
// different slice starts the cache over.
|
||||
if masksRef != masks {
|
||||
masksRef, pairs, masked, replacer = masks, oldnew, 0, nil
|
||||
}
|
||||
if replacer == nil || masked != len(*masks) {
|
||||
for _, v := range (*masks)[masked:] {
|
||||
pairs = AppendSecretMasker(pairs, v)
|
||||
}
|
||||
masked = len(*masks)
|
||||
replacer = strings.NewReplacer(pairs...)
|
||||
}
|
||||
cmasker := replacer
|
||||
mu.Unlock()
|
||||
|
||||
entry.Message = cmasker.Replace(entry.Message)
|
||||
|
||||
return entry
|
||||
}
|
||||
}
|
||||
@@ -230,6 +345,11 @@ type jobLogFormatter struct {
|
||||
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
b := &bytes.Buffer{}
|
||||
|
||||
// the web renderer decodes command data, so this local view has to as well
|
||||
if _, _, _, ok := tryParseRawActionCommand(entry.Message + "\n"); ok {
|
||||
entry.Message = UnescapeCommandData(entry.Message)
|
||||
}
|
||||
|
||||
if f.isColored(entry) {
|
||||
f.printColored(b, entry)
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValueMasker(t *testing.T) {
|
||||
@@ -33,6 +37,12 @@ func TestValueMasker(t *testing.T) {
|
||||
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
||||
},
|
||||
{
|
||||
name: "Secret containing a percent sign",
|
||||
lines: "##[error]login failed for pass%25word",
|
||||
secrets: map[string]string{"TOKEN": "pass%word"},
|
||||
disallowed: []string{"pass%25word"},
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
@@ -50,3 +60,147 @@ func TestValueMasker(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||
secret := `p@ss w"rd/1`
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
line string
|
||||
}{
|
||||
{"verbatim", "the token is " + secret},
|
||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
assert.NotContains(t, entry.Message, secret)
|
||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret containing " together with <, > or & serializes to JSON differently depending
|
||||
// on the runtime: act's own toJSON (and Go) HTML-escape <>&, while a JavaScript
|
||||
// (JSON.stringify) or .NET action leaves them literal. The secret must be masked in either
|
||||
// form, so a JS-serialized JSON body does not leak it.
|
||||
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||
secret := `a"<b>&c`
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
form string
|
||||
}{
|
||||
{"html escaped (act toJSON / Go)", jsonStringEscape(secret)},
|
||||
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: `{"t":"` + tc.form + `"}`})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
assert.NotContains(t, entry.Message, tc.form)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
masks := []string{"s3cr3t value"}
|
||||
masker := valueMasker(false, nil)
|
||||
|
||||
entry := masker(&logrus.Entry{
|
||||
Context: WithMasks(t.Context(), &masks),
|
||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
||||
})
|
||||
|
||||
assert.Equal(t, "encoded: ***", entry.Message)
|
||||
}
|
||||
|
||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||
// own base64 only appears when the prefix length is a multiple of three. The other two
|
||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||
// the token to anyone who can decode the log.
|
||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
secret := "s3cr3t-token-value"
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
// The aligned middle of the secret must be gone, so the payload can no longer be
|
||||
// decoded back into the token.
|
||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||
if err == nil {
|
||||
assert.NotContains(t, string(decoded), secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The masker caches its replacer, so it has to notice both a mask appended to the same
|
||||
// slice and a composite action logging with a slice of its own.
|
||||
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"})
|
||||
mask := func(masks *[]string, message string) string {
|
||||
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
||||
}
|
||||
|
||||
job := []string{"first mask"}
|
||||
assert.Equal(t, "a *** and ***", mask(&job, "a first mask and secret-token"))
|
||||
|
||||
// ::add-mask:: appends to the same slice
|
||||
job = append(job, "second mask")
|
||||
assert.Equal(t, "*** and ***", mask(&job, "first mask and second mask"))
|
||||
|
||||
// a composite action brings its own slice
|
||||
composite := []string{"composite mask"}
|
||||
assert.Equal(t, "*** but first mask", mask(&composite, "composite mask but first mask"))
|
||||
|
||||
// and the job's masks still apply once it is back
|
||||
assert.Equal(t, "*** and *** but composite mask", mask(&job, "first mask and second mask but composite mask"))
|
||||
}
|
||||
|
||||
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")
|
||||
assert.Equal(t, []string{
|
||||
"plaintoken", "***",
|
||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
||||
// The two shifted alignments, each without its leading and trailing group.
|
||||
"YWludG9r", "***",
|
||||
"bGFpbnRv", "***",
|
||||
}, pairs)
|
||||
|
||||
// Too short to mask.
|
||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||
}
|
||||
|
||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
logger.Out = io.Discard
|
||||
format := func(message string) string {
|
||||
out, err := (&jobLogFormatter{}).Format(&logrus.Entry{Logger: logger, Message: message, Data: logrus.Fields{rawOutputField: true}})
|
||||
require.NoError(t, err)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
|
||||
// a plain line is not command data and keeps its literal escapes
|
||||
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
|
||||
Dir: targetDirectory,
|
||||
Token: token,
|
||||
OfflineMode: rc.Config.ActionOfflineMode,
|
||||
Depth: rc.Config.ActionCloneDepth,
|
||||
})(ctx)
|
||||
}
|
||||
}
|
||||
@@ -304,30 +305,45 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
|
||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
||||
// otherwise returns an empty string
|
||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
||||
if !shouldCloneURLUseToken(conf.GitHubInstance, cloneURL) {
|
||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
||||
return ""
|
||||
}
|
||||
return conf.GetToken()
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||
// 1. cloneURL is from the same Gitea instance that the runner is registered to
|
||||
// 2. the cloneURL does not have basic auth embedded
|
||||
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
|
||||
if !strings.HasPrefix(instanceURL, "http://") &&
|
||||
!strings.HasPrefix(instanceURL, "https://") {
|
||||
instanceURL = "https://" + instanceURL
|
||||
// trustedActionInstance returns the self-hosted DEFAULT_ACTIONS_URL host that may carry the
|
||||
// task token, or "" when actions resolve to github.com / a GithubMirror (never trusted).
|
||||
func (c Config) trustedActionInstance() string {
|
||||
if c.DefaultActionInstanceIsSelfHosted {
|
||||
return c.DefaultActionInstance
|
||||
}
|
||||
|
||||
u1, err1 := url.Parse(instanceURL)
|
||||
u2, err2 := url.Parse(cloneURL)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
if u2.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u1.Host == u2.Host
|
||||
return ""
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
||||
// 2. the cloneURL does not have basic auth embedded
|
||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
||||
u2, err := url.Parse(cloneURL)
|
||||
if err != nil || u2.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "http://") &&
|
||||
!strings.HasPrefix(candidate, "https://") {
|
||||
candidate = "https://" + candidate
|
||||
}
|
||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -136,12 +136,30 @@ func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
||||
// actions against AppURL on a different hostname for the same instance.
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.local",
|
||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
||||
DefaultActionInstanceIsSelfHosted: true,
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceURL string
|
||||
cloneURL string
|
||||
want bool
|
||||
name string
|
||||
instanceURL string
|
||||
trustedActionInstance string
|
||||
cloneURL string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same host with schemaless instance",
|
||||
@@ -173,11 +191,37 @@ func TestShouldCloneURLUseToken(t *testing.T) {
|
||||
cloneURL: "://gitea.example.net/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
||||
// registered instance: the token must still be attached.
|
||||
name: "self-hosted action instance on different host",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// embedded basic auth must still be rejected even when the host matches
|
||||
// the trusted action instance.
|
||||
name: "self-hosted action instance with embedded basic auth",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://user:pass@gitea.my-nas.lan/owner/action",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
||||
// empty in github mode, so an off-instance clone URL gets no token.
|
||||
name: "github mode does not trust mirror host",
|
||||
instanceURL: "gitea.local",
|
||||
cloneURL: "https://mirror.example.com/owner/action",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
|
||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,29 +22,35 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/opencontainers/selinux/go-selinux"
|
||||
)
|
||||
|
||||
// RunContext contains info about current job
|
||||
type RunContext struct {
|
||||
Name string
|
||||
Config *Config
|
||||
Matrix map[string]any
|
||||
Run *model.Run
|
||||
EventJSON string
|
||||
Env map[string]string
|
||||
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
||||
ExtraPath []string
|
||||
CurrentStep string
|
||||
Name string
|
||||
Config *Config
|
||||
Matrix map[string]any
|
||||
Run *model.Run
|
||||
EventJSON string
|
||||
Env map[string]string
|
||||
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
||||
ExtraPath []string
|
||||
CurrentStep string
|
||||
// CurrentStepIndex is the index of the top-level job step currently executing
|
||||
// (model.Step.Number). Composite sub-steps inherit the outer step's index by
|
||||
// walking the Parent chain; see topLevelRunContext.
|
||||
CurrentStepIndex int
|
||||
StepResults map[string]*model.StepResult
|
||||
IntraActionState map[string]map[string]string
|
||||
ExprEval ExpressionEvaluator
|
||||
@@ -57,10 +63,51 @@ type RunContext struct {
|
||||
Masks []string
|
||||
cleanUpJobContainer common.Executor
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||
// have already been created on the JobContainer. The runner sets up file-command files
|
||||
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||
// GITHUB_ENV/OUTPUT/STATE/PATH (consumed per phase) but wrong for GITHUB_STEP_SUMMARY,
|
||||
// which has accumulating semantics. We initialize each step's summary file exactly once
|
||||
// so writes from later phases and from composite sub-steps append to the same file.
|
||||
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
|
||||
summaryFileInitialized map[int]bool
|
||||
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
||||
// captured before execution so each matrix combo interpolates from the originals rather
|
||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
||||
outputTemplate map[string]string
|
||||
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
|
||||
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
|
||||
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
|
||||
jobCancelled bool
|
||||
// jobFailed records failures outside normal main-step results, such as action pre-step
|
||||
// failures. Those failures must still make success() false and failure() true for later
|
||||
// main-step if evaluation.
|
||||
jobFailed bool
|
||||
}
|
||||
|
||||
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
||||
// job status context observe the "cancelled" state.
|
||||
func (rc *RunContext) markCancelled() {
|
||||
rc.jobCancelled = true
|
||||
}
|
||||
|
||||
// markFailed flags the job as failed so subsequent step `if` evaluations observe
|
||||
// failure even when the error happened outside a main step result.
|
||||
func (rc *RunContext) markFailed() {
|
||||
rc.jobFailed = true
|
||||
}
|
||||
|
||||
// markInterrupted records the job's interruption status from a context error so later step `if` evaluations and the job result observe it,
|
||||
// keeping the timeout path symmetric with the cancel path:
|
||||
// - context.Canceled (server cancel) marks the job cancelled, matching GitHub's "only always()/cancelled() run on cancel".
|
||||
// - context.DeadlineExceeded (job timeout-minutes) marks the job failed, matching the "Timeout -> FAILURE" reporting semantics.
|
||||
func (rc *RunContext) markInterrupted(err error) {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
rc.markCancelled()
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
rc.markFailed()
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) AddMask(mask string) {
|
||||
@@ -93,7 +140,9 @@ func (rc *RunContext) GetEnv() map[string]string {
|
||||
}
|
||||
}
|
||||
}
|
||||
rc.Env["ACT"] = "true"
|
||||
if !rc.Config.DisableActEnv {
|
||||
rc.Env["ACT"] = "true"
|
||||
}
|
||||
|
||||
if !rc.Config.NoSkipCheckout {
|
||||
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
|
||||
@@ -157,49 +206,93 @@ func (rc *RunContext) validVolumes() []string {
|
||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||
}
|
||||
|
||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||
func (rc *RunContext) toolCache(fallback string) string {
|
||||
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
|
||||
return path
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
|
||||
// and friends report so the two cannot drift apart.
|
||||
func (rc *RunContext) runnerEnv(ctx context.Context) []string {
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
runnerContext := ext.GetRunnerContext(ctx)
|
||||
runnerContext["tool_cache"] = rc.toolCache(container.DefaultToolCache)
|
||||
|
||||
env := make([]string, 0, len(runnerContext))
|
||||
for key, value := range runnerContext {
|
||||
env = append(env, fmt.Sprintf("RUNNER_%s=%s", strings.ToUpper(key), value))
|
||||
}
|
||||
slices.Sort(env)
|
||||
return env
|
||||
}
|
||||
|
||||
// splitVolumes routes volume specs into binds and a source:target mount map, and returns the
|
||||
// container paths they mount onto. Only a plain source:target volume fits the map, everything
|
||||
// else (anonymous volumes, host binds, mount options) stays a bind.
|
||||
func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) {
|
||||
binds := []string{}
|
||||
mounts := map[string]string{}
|
||||
targets := map[string]bool{}
|
||||
|
||||
for _, spec := range specs {
|
||||
parsed, err := loader.ParseVolume(spec)
|
||||
if err != nil {
|
||||
binds = append(binds, spec) // let Docker report the malformed spec
|
||||
continue
|
||||
}
|
||||
targets[parsed.Target] = true
|
||||
if parsed.Type == string(mount.TypeVolume) && parsed.Source != "" && !parsed.ReadOnly {
|
||||
mounts[parsed.Source] = parsed.Target
|
||||
} else {
|
||||
binds = append(binds, spec)
|
||||
}
|
||||
}
|
||||
return binds, mounts, targets
|
||||
}
|
||||
|
||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
name := rc.jobContainerName()
|
||||
|
||||
binds := []string{}
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
}
|
||||
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
mounts := map[string]string{
|
||||
"act-toolcache": "/opt/hostedtoolcache",
|
||||
name + "-env": ext.GetActPath(),
|
||||
}
|
||||
|
||||
var volumes []string
|
||||
if job := rc.Run.Job(); job != nil {
|
||||
if container := job.Container(); container != nil {
|
||||
for _, v := range container.Volumes {
|
||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
||||
// Bind anonymous volume or host file.
|
||||
binds = append(binds, v)
|
||||
} else {
|
||||
// Mount existing volume.
|
||||
paths := strings.SplitN(v, ":", 2)
|
||||
mounts[paths[0]] = paths[1]
|
||||
if rc.ExprEval != nil {
|
||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||
}
|
||||
volumes = append(volumes, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// the runner's own mounts below yield to the targets the job claims
|
||||
binds, mounts, claimed := splitVolumes(volumes)
|
||||
|
||||
if rc.Config.BindWorkdir {
|
||||
bindModifiers := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
bindModifiers = ":delegated"
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||
}
|
||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||
mounts["act-toolcache"] = toolCache
|
||||
}
|
||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||
|
||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||
if rc.Config.BindWorkdir {
|
||||
bindModifiers := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
bindModifiers = ":delegated"
|
||||
}
|
||||
if selinux.GetEnabled() {
|
||||
bindModifiers = ":z"
|
||||
}
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||
} else {
|
||||
mounts[name] = workdir
|
||||
}
|
||||
if selinux.GetEnabled() {
|
||||
bindModifiers = ":z"
|
||||
}
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers))
|
||||
} else {
|
||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
||||
}
|
||||
|
||||
return binds, mounts
|
||||
@@ -233,7 +326,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
||||
return err
|
||||
}
|
||||
toolCache := filepath.Join(cacheDir, "tool_cache")
|
||||
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
|
||||
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||
return err
|
||||
}
|
||||
rc.JobContainer = &container.HostEnvironment{
|
||||
Path: path,
|
||||
TmpDir: runnerTmp,
|
||||
@@ -248,7 +344,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
}
|
||||
rc.cleanUpJobContainer = rc.JobContainer.Remove()
|
||||
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
|
||||
for k, v := range rc.getRunnerContext(ctx) {
|
||||
if v, ok := v.(string); ok {
|
||||
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||
}
|
||||
@@ -289,6 +385,9 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
|
||||
}
|
||||
}
|
||||
|
||||
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
|
||||
var newContainer = container.NewContainer
|
||||
|
||||
func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
@@ -315,10 +414,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
|
||||
envList := make([]string, 0)
|
||||
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
@@ -331,8 +427,17 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
|
||||
// add service containers
|
||||
for serviceID, spec := range rc.Run.Job().Services {
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||
if serviceImage == "" {
|
||||
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||
continue
|
||||
}
|
||||
// interpolate env
|
||||
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
||||
interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv))
|
||||
// a service reaches the internet the way the job does; its own env still wins
|
||||
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
||||
for k, v := range spec.Env {
|
||||
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
@@ -345,7 +450,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
for _, v := range spec.Cmd {
|
||||
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
|
||||
}
|
||||
username, password, err = rc.handleServiceCredentials(ctx, spec.Credentials)
|
||||
// keep these local: reusing username/password would overwrite the
|
||||
// credentials the job container is pulled with further down
|
||||
serviceUsername, servicePassword, err := rc.handleServiceCredentials(ctx, spec.Credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||
}
|
||||
@@ -366,12 +473,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
}
|
||||
|
||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||
c := container.NewContainer(&container.NewContainerInput{
|
||||
c := newContainer(&container.NewContainerInput{
|
||||
Name: serviceContainerName,
|
||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||
Image: rc.ExprEval.Interpolate(ctx, spec.Image),
|
||||
Username: username,
|
||||
Password: password,
|
||||
Image: serviceImage,
|
||||
Username: serviceUsername,
|
||||
Password: servicePassword,
|
||||
Cmd: interpolatedCmd,
|
||||
Env: envs,
|
||||
Mounts: serviceMounts,
|
||||
@@ -392,42 +499,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||
}
|
||||
|
||||
rc.cleanUpJobContainer = func(ctx context.Context) error {
|
||||
reuseJobContainer := func(ctx context.Context) bool {
|
||||
return rc.Config.ReuseContainers
|
||||
}
|
||||
|
||||
if rc.JobContainer != nil {
|
||||
return rc.JobContainer.Remove().IfNot(reuseJobContainer).
|
||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
|
||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer).
|
||||
Then(func(ctx context.Context) error {
|
||||
if len(rc.ServiceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// clean network if it has been created by act
|
||||
// if using service containers
|
||||
// it means that the network to which containers are connecting is created by `runner`,
|
||||
// so, we should remove the network at last.
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||
|
||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||
jobContainerNetwork := networkName
|
||||
|
||||
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
|
||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||
@@ -459,7 +536,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.pullServicesImages(rc.Config.ForcePull),
|
||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||
rc.stopJobContainer(),
|
||||
container.NewDockerNetworkCreateExecutor(networkName).IfBool(createAndDeleteNetwork),
|
||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||
IfBool(createAndDeleteNetwork),
|
||||
rc.startServiceContainers(networkName),
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
@@ -476,6 +554,41 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupJobResources removes everything the job created, continuing past failures.
|
||||
// Only job container and volume errors are returned, the rest are logged.
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
removeJobContainer := rc.JobContainer != nil && !rc.Config.ReuseContainers
|
||||
|
||||
var errs []error
|
||||
if removeJobContainer {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
}
|
||||
if len(rc.ServiceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if removeJobContainer {
|
||||
// after the containers using them, services can hold these via `--volumes-from`
|
||||
name := rc.jobContainerName()
|
||||
errs = append(errs,
|
||||
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
||||
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// last, once every container has detached
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return func(ctx context.Context) error {
|
||||
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
||||
@@ -604,13 +717,10 @@ func (rc *RunContext) ActionCacheDir() string {
|
||||
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
|
||||
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
|
||||
// per-directory AcquireCloneLock pattern).
|
||||
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
|
||||
var jobMutexes lock.Keyed[*model.Job]
|
||||
|
||||
func lockJob(job *model.Job) func() {
|
||||
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
return jobMutexes.Lock(job)
|
||||
}
|
||||
|
||||
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
@@ -704,6 +814,17 @@ func (rc *RunContext) steps() []*model.Step {
|
||||
return steps
|
||||
}
|
||||
|
||||
// topLevelRunContext walks the Parent chain to the outermost RunContext. Composite
|
||||
// actions create child RunContexts whose sub-steps need to share the outer job step's
|
||||
// summary file path so that nested writes accumulate under the right step_index.
|
||||
func (rc *RunContext) topLevelRunContext() *RunContext {
|
||||
top := rc
|
||||
for top.Parent != nil {
|
||||
top = top.Parent
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// Executor returns a pipeline executor for all the steps in the job
|
||||
func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
var executor common.Executor
|
||||
@@ -723,7 +844,13 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
return func(ctx context.Context) error {
|
||||
res, err := rc.isEnabled(ctx)
|
||||
if err != nil {
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure") // For Gitea
|
||||
// Record the failure so a job whose if-expression fails to evaluate
|
||||
// gets a result (and therefore a stop time) instead of being left
|
||||
// unfinished. rc.caller is only set for reusable workflows.
|
||||
rc.result("failure")
|
||||
if rc.caller != nil { // For Gitea
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if res {
|
||||
@@ -844,6 +971,20 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// proxyBuildArgs returns the job's proxy variables as docker build args. The docker CLI
|
||||
// pre-populates these from its own client configuration, but act builds through the API,
|
||||
// so without them a Dockerfile action's RUN steps have no network behind a proxy.
|
||||
func (rc *RunContext) proxyBuildArgs() map[string]*string {
|
||||
if len(rc.Config.ProxyEnv) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := make(map[string]*string, len(rc.Config.ProxyEnv))
|
||||
for name, value := range rc.Config.ProxyEnv {
|
||||
args[name] = &value
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func mergeMaps(maps ...map[string]string) map[string]string {
|
||||
rtnMap := make(map[string]string)
|
||||
for _, m := range maps {
|
||||
@@ -877,12 +1018,21 @@ func trimToLen(s string, l int) string {
|
||||
|
||||
func (rc *RunContext) getJobContext() *model.JobContext {
|
||||
jobStatus := "success"
|
||||
if rc.jobFailed {
|
||||
jobStatus = "failure"
|
||||
}
|
||||
for _, stepStatus := range rc.StepResults {
|
||||
if stepStatus.Conclusion == model.StepStatusFailure {
|
||||
jobStatus = "failure"
|
||||
break
|
||||
}
|
||||
}
|
||||
// A cancelled run takes precedence over success/failure so cancelled() is true and
|
||||
// success()/failure() are false, matching GitHub Actions: on cancellation only
|
||||
// always() and cancelled() steps run.
|
||||
if rc.jobCancelled {
|
||||
jobStatus = "cancelled"
|
||||
}
|
||||
return &model.JobContext{
|
||||
Status: jobStatus,
|
||||
}
|
||||
@@ -892,6 +1042,23 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
||||
return rc.StepResults
|
||||
}
|
||||
|
||||
// getRunnerContext returns the `runner` context: what the execution environment knows
|
||||
// (os, arch, temp, tool_cache) plus what only the runner process knows.
|
||||
func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
|
||||
runnerContext := map[string]any{}
|
||||
if rc.JobContainer != nil {
|
||||
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
|
||||
defaultToolCache, _ := runnerContext["tool_cache"].(string)
|
||||
runnerContext["tool_cache"] = rc.toolCache(defaultToolCache)
|
||||
}
|
||||
runnerContext["name"] = rc.Config.RunnerName
|
||||
runnerContext["environment"] = "self-hosted"
|
||||
if rc.Config.RunnerDebug() {
|
||||
runnerContext["debug"] = "1"
|
||||
}
|
||||
return runnerContext
|
||||
}
|
||||
|
||||
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
logger := common.Logger(ctx)
|
||||
ghc := &model.GithubContext{
|
||||
@@ -1076,7 +1243,7 @@ func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) map[string]string { //nolint:unparam // pre-existing issue from nektos/act
|
||||
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
|
||||
env["CI"] = "true"
|
||||
env["GITHUB_WORKFLOW"] = github.Workflow
|
||||
env["GITHUB_RUN_ID"] = github.RunID
|
||||
@@ -1118,23 +1285,71 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
|
||||
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
||||
}
|
||||
|
||||
env["RUNNER_NAME"] = rc.Config.RunnerName
|
||||
env["RUNNER_ENVIRONMENT"] = "self-hosted"
|
||||
if workspace := parentDir(github.Workspace); workspace != "" {
|
||||
env["RUNNER_WORKSPACE"] = workspace
|
||||
}
|
||||
if rc.Config.RunnerDebug() {
|
||||
env["RUNNER_DEBUG"] = "1"
|
||||
}
|
||||
|
||||
if rc.Config.ArtifactServerPath != "" {
|
||||
setActionRuntimeVars(rc, env)
|
||||
}
|
||||
|
||||
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
||||
if platformName != "" {
|
||||
if platformName == "ubuntu-latest" {
|
||||
// hardcode current ubuntu-latest since we have no way to check that 'on the fly'
|
||||
env["ImageOS"] = "ubuntu20"
|
||||
} else {
|
||||
platformName = strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0]
|
||||
env["ImageOS"] = platformName
|
||||
}
|
||||
}
|
||||
if imageOS := rc.imageOS(ctx); imageOS != "" {
|
||||
env["ImageOS"] = imageOS
|
||||
}
|
||||
}
|
||||
|
||||
// parentDir returns the directory containing p, or "" when p names no parent. Both
|
||||
// separators are accepted rather than filepath's, as p may describe a container while
|
||||
// the runner itself runs on Windows, or the other way round.
|
||||
func parentDir(p string) string {
|
||||
if slash := strings.LastIndexAny(p, `/\`); slash > 0 {
|
||||
return p[:slash]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// imageOS returns ImageOS, which setup-* actions use to tell one runner image release
|
||||
// from another. The resolved image tag is preferred over the runs-on label because it
|
||||
// still names a release when the label is a rolling one such as ubuntu-latest.
|
||||
func (rc *RunContext) imageOS(ctx context.Context) string {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
// A composite action runs on a synthetic job, and resolving its image would only
|
||||
// log that runs-on is missing.
|
||||
return ""
|
||||
}
|
||||
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" {
|
||||
return imageOS
|
||||
}
|
||||
|
||||
return env
|
||||
for _, platformName := range slices.Backward(rc.runsOnPlatformNames(ctx)) {
|
||||
if platformName == "ubuntu-latest" {
|
||||
// Rolling label whose image names no release either, so keep the historical value.
|
||||
return "ubuntu20"
|
||||
} else if platformName != "" {
|
||||
return strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// imageOSTag matches an image reference tagged with an OS family ImageOS can report plus
|
||||
// its release, such as "docker.gitea.com/runner-images:ubuntu-24.04". Anything else
|
||||
// ("ubuntu-latest", "app:22.04", "catthehacker/ubuntu:act-22.04", or a registry port) is
|
||||
// left to the runs-on label rather than turned into a bogus OS.
|
||||
var imageOSTag = regexp.MustCompile(`:(ubuntu|win|macos)-?([0-9]+)[^/]*$`)
|
||||
|
||||
// imageOSFromImage derives ImageOS from an image reference, e.g.
|
||||
// "docker.gitea.com/runner-images:ubuntu-24.04" yields "ubuntu24".
|
||||
func imageOSFromImage(image string) string {
|
||||
if match := imageOSTag.FindStringSubmatch(image); match != nil {
|
||||
return match[1] + match[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||
@@ -1152,21 +1367,18 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
|
||||
// TODO: remove below 2 lines when we can release act with breaking changes
|
||||
username := rc.Config.Secrets["DOCKER_USERNAME"]
|
||||
password := rc.Config.Secrets["DOCKER_PASSWORD"]
|
||||
|
||||
container := rc.Run.Job().Container()
|
||||
if container == nil || container.Credentials == nil {
|
||||
return username, password, nil
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
if container.Credentials != nil && len(container.Credentials) != 2 {
|
||||
if len(container.Credentials) != 2 {
|
||||
err := errors.New("invalid property count for key 'credentials:'")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ee := rc.NewExpressionEvaluator(ctx)
|
||||
var username, password string
|
||||
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
|
||||
err := errors.New("failed to interpolate container.credentials.username")
|
||||
return "", "", err
|
||||
@@ -1209,24 +1421,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
||||
|
||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||
binds := []string{}
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
binds, mounts, claimed := splitVolumes(svcVolumes)
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||
}
|
||||
|
||||
mounts := map[string]string{}
|
||||
|
||||
for _, v := range svcVolumes {
|
||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
||||
// Bind anonymous volume or host file.
|
||||
binds = append(binds, v)
|
||||
} else {
|
||||
// Mount existing volume.
|
||||
paths := strings.SplitN(v, ":", 2)
|
||||
mounts[paths[0]] = paths[1]
|
||||
}
|
||||
}
|
||||
|
||||
return binds, mounts
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package runner
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -14,9 +15,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
require "github.com/stretchr/testify/require"
|
||||
@@ -170,6 +173,202 @@ func TestRunContext_EvalBool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job:
|
||||
runs-on: ubuntu-latest
|
||||
steps: []
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
Env: map[string]string{},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: workflow,
|
||||
},
|
||||
}
|
||||
|
||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
|
||||
username, password, err := rc.handleCredentials(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, username)
|
||||
assert.Empty(t, password)
|
||||
}
|
||||
|
||||
// fakeContainer turns every container operation into a no-op, so startJobContainer
|
||||
// runs without a Docker daemon. The embedded interface is nil, so any method the
|
||||
// test does not exercise panics rather than silently doing the wrong thing.
|
||||
type fakeContainer struct {
|
||||
container.ExecutionsEnvironment
|
||||
}
|
||||
|
||||
func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||
func (fakeContainer) Create([]string, []string) common.Executor {
|
||||
return func(context.Context) error { return nil }
|
||||
}
|
||||
|
||||
func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor {
|
||||
return func(context.Context) error { return nil }
|
||||
}
|
||||
|
||||
// Regression test: a service without a `credentials:` block resolves to empty
|
||||
// credentials, which used to overwrite the job container's own credentials.
|
||||
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: registry.example/private:latest
|
||||
credentials:
|
||||
username: job-user
|
||||
password: job-password
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
db:
|
||||
image: postgres:latest
|
||||
credentials:
|
||||
username: db-user
|
||||
password: db-password
|
||||
steps: []
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var inputs []*container.NewContainerInput
|
||||
origNewContainer := newContainer
|
||||
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
inputs = append(inputs, input)
|
||||
return fakeContainer{}
|
||||
}
|
||||
t.Cleanup(func() { newContainer = origNewContainer })
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "test",
|
||||
Config: &Config{
|
||||
Workdir: "/tmp",
|
||||
// no daemon: an explicit network mode creates no network, and
|
||||
// reusing containers short-circuits the volume cleanup executors
|
||||
ContainerNetworkMode: "host",
|
||||
ReuseContainers: true,
|
||||
Env: map[string]string{},
|
||||
Secrets: map[string]string{},
|
||||
},
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: workflow,
|
||||
},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
|
||||
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||
|
||||
credentials := map[string][2]string{}
|
||||
for _, in := range inputs {
|
||||
credentials[in.Image] = [2]string{in.Username, in.Password}
|
||||
}
|
||||
|
||||
// the job container keeps its own credentials, whichever services exist
|
||||
require.Equal(t, [2]string{"job-user", "job-password"}, credentials["registry.example/private:latest"])
|
||||
// each service keeps its own, and a service without credentials gets none
|
||||
require.Equal(t, [2]string{"db-user", "db-password"}, credentials["postgres:latest"])
|
||||
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
||||
}
|
||||
|
||||
// A service container reaches the internet the same way the job does, so it inherits the
|
||||
// job's proxy; a service that sets the variable itself keeps its own value.
|
||||
func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: registry.example/job:latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
db:
|
||||
image: postgres:latest
|
||||
env:
|
||||
no_proxy: db-only.example
|
||||
steps: []
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var inputs []*container.NewContainerInput
|
||||
origNewContainer := newContainer
|
||||
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
inputs = append(inputs, input)
|
||||
return fakeContainer{}
|
||||
}
|
||||
t.Cleanup(func() { newContainer = origNewContainer })
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "test",
|
||||
Config: &Config{
|
||||
Workdir: "/tmp",
|
||||
ContainerNetworkMode: "host",
|
||||
ReuseContainers: true,
|
||||
Env: map[string]string{},
|
||||
ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"},
|
||||
Secrets: map[string]string{},
|
||||
},
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: workflow,
|
||||
},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
|
||||
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||
|
||||
env := map[string][]string{}
|
||||
for _, in := range inputs {
|
||||
env[in.Image] = in.Env
|
||||
}
|
||||
|
||||
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128")
|
||||
require.Contains(t, env["redis:latest"], "no_proxy=internal.example")
|
||||
// the service's own env wins over what the runner injected, without dropping the rest
|
||||
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example")
|
||||
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example")
|
||||
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128")
|
||||
}
|
||||
|
||||
// act builds Dockerfile actions through the API, which does not pre-populate the proxy
|
||||
// build args the docker CLI would, so the RUN steps would have no network behind a proxy.
|
||||
func TestProxyBuildArgs(t *testing.T) {
|
||||
rc := &RunContext{Config: &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128"}}}
|
||||
|
||||
args := rc.proxyBuildArgs()
|
||||
|
||||
require.Len(t, args, 1)
|
||||
require.Equal(t, "http://proxy:3128", *args["http_proxy"])
|
||||
|
||||
// a job without a proxy builds exactly as it does today
|
||||
require.Nil(t, (&RunContext{Config: &Config{}}).proxyBuildArgs())
|
||||
}
|
||||
|
||||
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rctemplate := &RunContext{
|
||||
Name: "TestRCName",
|
||||
@@ -242,8 +441,43 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
||||
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
||||
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
||||
{"MountExistingVolumeReadOnly", []string{"volume-id:/volume:ro"}, "volume-id:/volume:ro", map[string]string{}},
|
||||
{"BindRelativeHostPath", []string{"./relative:/volume"}, "./relative:/volume", map[string]string{}},
|
||||
{"OverridesToolCache", []string{"/host/tools:/opt/hostedtoolcache"}, "/host/tools:/opt/hostedtoolcache", map[string]string{}},
|
||||
{"OverridesDockerSocket", []string{"/host/docker.sock:/var/run/docker.sock"}, "/host/docker.sock:/var/run/docker.sock", map[string]string{}},
|
||||
}
|
||||
|
||||
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||
job := &model.Job{}
|
||||
err := job.RawContainer.Encode(map[string][]string{
|
||||
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "TestRCName",
|
||||
Run: &model.Run{
|
||||
Workflow: &model.Workflow{
|
||||
Name: "TestWorkflowName",
|
||||
},
|
||||
},
|
||||
Config: &Config{
|
||||
BindWorkdir: false,
|
||||
Secrets: map[string]string{
|
||||
"MAME": "/host/mame/roms",
|
||||
},
|
||||
},
|
||||
}
|
||||
rc.Run.JobID = "job1"
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||
})
|
||||
|
||||
for _, testcase := range tests {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
job := &model.Job{}
|
||||
@@ -266,15 +500,37 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.Run.JobID = "job1"
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
jobBinds, jobMounts := rc.GetBindsAndMounts()
|
||||
svcBinds, svcMounts := rc.GetServiceBindsAndMounts(testcase.volumes)
|
||||
// job and service containers classify volumes alike, only their own mounts differ
|
||||
for _, got := range []struct {
|
||||
binds []string
|
||||
mounts map[string]string
|
||||
}{{jobBinds, jobMounts}, {svcBinds, svcMounts}} {
|
||||
gotbind, gotmount := got.binds, got.mounts
|
||||
|
||||
if len(testcase.wantbind) > 0 {
|
||||
assert.Contains(t, gotbind, testcase.wantbind)
|
||||
}
|
||||
if len(testcase.wantbind) > 0 {
|
||||
assert.Contains(t, gotbind, testcase.wantbind)
|
||||
}
|
||||
|
||||
for k, v := range testcase.wantmount {
|
||||
assert.Contains(t, gotmount, k)
|
||||
assert.Equal(t, gotmount[k], v)
|
||||
for k, v := range testcase.wantmount {
|
||||
assert.Contains(t, gotmount, k)
|
||||
assert.Equal(t, gotmount[k], v)
|
||||
}
|
||||
|
||||
// Docker rejects a container with two mounts on one target, so the job's own
|
||||
// volumes must displace the runner's rather than pile up next to them.
|
||||
targets := map[string]bool{}
|
||||
for _, bind := range gotbind {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
|
||||
targets[parsed.Target] = true
|
||||
}
|
||||
for source, target := range gotmount {
|
||||
assert.NotContains(t, targets, target, "%s mounts an already mounted target", source)
|
||||
targets[target] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -300,6 +556,46 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
}
|
||||
|
||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
||||
require.NoError(t, err)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// cleanup used to bail out on a previous step's error and on a cancelled context
|
||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||
|
||||
jobContainer := &containerMock{}
|
||||
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
|
||||
service := &containerMock{}
|
||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: jobContainer,
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
|
||||
jobContainer.AssertExpectations(t)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
||||
@@ -748,3 +1044,117 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageOSFromImage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
image string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"docker.gitea.com/runner-images:ubuntu-24.04", "ubuntu24"},
|
||||
{"docker.gitea.com/runner-images:ubuntu-latest", ""},
|
||||
{"runner-images:ubuntu22.04", "ubuntu22"},
|
||||
{"node:20", ""},
|
||||
{"ubuntu:22.04", ""},
|
||||
{"ubuntu", ""},
|
||||
{"catthehacker/ubuntu:act-22.04", ""},
|
||||
{"myco/ubuntu:v2.1", ""},
|
||||
{"myco/ubuntu:v22.04", ""},
|
||||
{"app:release-1", ""},
|
||||
{"app:1.2.3", ""},
|
||||
{"app:build-2.1", ""},
|
||||
{"registry.example.com:5000/runner-images", ""},
|
||||
{"registry.example.com:5000/runner-images:ubuntu-24.04", "ubuntu24"},
|
||||
} {
|
||||
t.Run(tc.image, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, imageOSFromImage(tc.image))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
|
||||
return createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, "runs-on: "+runsOn, ""),
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunContextImageOS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.Platforms = map[string]string{
|
||||
"ubuntu-latest": "docker.gitea.com/runner-images:ubuntu-24.04",
|
||||
}
|
||||
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
||||
})
|
||||
|
||||
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
||||
rc.Config.Platforms = map[string]string{"ubuntu-22.04": "some-image"}
|
||||
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
||||
})
|
||||
|
||||
t.Run("keeps the historical value for a rolling label with no release", func(t *testing.T) {
|
||||
assert.Equal(t, "ubuntu20", createRunsOnRunContext(t, "ubuntu-latest").imageOS(ctx))
|
||||
})
|
||||
|
||||
t.Run("is empty for the synthetic job of a composite action", func(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{"job1": {}})
|
||||
assert.Empty(t, rc.imageOS(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunContextGetRunnerContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.RunnerName = "runner-1"
|
||||
|
||||
runnerContext := rc.getRunnerContext(ctx)
|
||||
assert.Equal(t, "runner-1", runnerContext["name"])
|
||||
assert.Equal(t, "self-hosted", runnerContext["environment"])
|
||||
assert.NotContains(t, runnerContext, "debug")
|
||||
})
|
||||
|
||||
t.Run("reports debug when step debugging is on", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||
|
||||
assert.Equal(t, "1", rc.getRunnerContext(ctx)["debug"])
|
||||
})
|
||||
|
||||
t.Run("keeps the execution environment values", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
|
||||
|
||||
runnerContext := rc.getRunnerContext(ctx)
|
||||
assert.Equal(t, "/tmp/act", runnerContext["temp"])
|
||||
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
|
||||
assert.NotEmpty(t, runnerContext["os"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestParentDir(t *testing.T) {
|
||||
assert.Empty(t, parentDir(""))
|
||||
assert.Empty(t, parentDir("repo"))
|
||||
assert.Empty(t, parentDir("/repo"))
|
||||
assert.Equal(t, "/workspace/owner", parentDir("/workspace/owner/repo"))
|
||||
assert.Equal(t, `C:\workspace\owner`, parentDir(`C:\workspace\owner\repo`))
|
||||
}
|
||||
|
||||
func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.RunnerName = "runner-1"
|
||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||
|
||||
env := map[string]string{}
|
||||
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
|
||||
|
||||
assert.Equal(t, "runner-1", env["RUNNER_NAME"])
|
||||
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
|
||||
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
||||
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
@@ -28,60 +29,80 @@ type Runner interface {
|
||||
|
||||
// Config contains the config for a new runner
|
||||
type Config struct {
|
||||
Actor string // the user that triggered the event
|
||||
Workdir string // path to working directory
|
||||
ActionCacheDir string // path used for caching action contents
|
||||
ActionOfflineMode bool // when offline, use cached action contents
|
||||
BindWorkdir bool // bind the workdir to the job container
|
||||
EventName string // name of event to run
|
||||
EventPath string // path to JSON file to use for event.json in containers
|
||||
DefaultBranch string // name of the main branch for this repository
|
||||
ReuseContainers bool // reuse containers to maintain state
|
||||
ForcePull bool // force pulling of the image, even if already present
|
||||
ForceRebuild bool // force rebuilding local docker image action
|
||||
LogOutput bool // log the output from docker run
|
||||
JSONLogger bool // use json or text logger
|
||||
LogPrefixJobID bool // switches from the full job name to the job id
|
||||
Env map[string]string // env for containers
|
||||
Inputs map[string]string // manually passed action inputs
|
||||
Secrets map[string]string // list of secrets
|
||||
Vars map[string]string // list of vars
|
||||
Token string // GitHub token
|
||||
InsecureSecrets bool // switch hiding output when printing to terminal
|
||||
Platforms map[string]string // list of platforms
|
||||
Privileged bool // use privileged mode
|
||||
UsernsMode string // user namespace to use
|
||||
ContainerArchitecture string // Desired OS/architecture platform for running containers
|
||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||
ContainerOptions string // Options for the job container
|
||||
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
||||
GitHubInstance string // GitHub instance to use, default "github.com"
|
||||
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||
AutoRemove bool // controls if the container is automatically removed upon workflow completion
|
||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||
ArtifactServerAddr string // the address the artifact server binds to
|
||||
ArtifactServerPort string // the port the artifact server binds to
|
||||
NoSkipCheckout bool // do not skip actions/checkout
|
||||
RemoteName string // remote name in local git repo config
|
||||
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||
Matrix map[string]map[string]bool // Matrix config to run
|
||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||
Actor string // the user that triggered the event
|
||||
Workdir string // path to working directory
|
||||
ActionCacheDir string // path used for caching action contents
|
||||
ActionOfflineMode bool // when offline, use cached action contents
|
||||
ActionCloneDepth int // limit history when cloning an action repo; 0 clones every branch in full
|
||||
BindWorkdir bool // bind the workdir to the job container
|
||||
EventName string // name of event to run
|
||||
EventPath string // path to JSON file to use for event.json in containers
|
||||
DefaultBranch string // name of the main branch for this repository
|
||||
ReuseContainers bool // reuse containers to maintain state
|
||||
ForcePull bool // force pulling of the image, even if already present
|
||||
ForceRebuild bool // force rebuilding local docker image action
|
||||
LogOutput bool // log the output from docker run
|
||||
JSONLogger bool // use json or text logger
|
||||
LogPrefixJobID bool // switches from the full job name to the job id
|
||||
Env map[string]string // env for containers
|
||||
Inputs map[string]string // manually passed action inputs
|
||||
Secrets map[string]string // list of secrets
|
||||
Vars map[string]string // list of vars
|
||||
Token string // GitHub token
|
||||
InsecureSecrets bool // switch hiding output when printing to terminal
|
||||
Platforms map[string]string // list of platforms
|
||||
Privileged bool // use privileged mode
|
||||
UsernsMode string // user namespace to use
|
||||
ContainerArchitecture string // Desired OS/architecture platform for running containers
|
||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||
ContainerOptions string // Options for the job container
|
||||
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
||||
GitHubInstance string // GitHub instance to use, default "github.com"
|
||||
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||
AutoRemove bool // controls if the container is automatically removed upon workflow completion
|
||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||
ArtifactServerAddr string // the address the artifact server binds to
|
||||
ArtifactServerPort string // the port the artifact server binds to
|
||||
NoSkipCheckout bool // do not skip actions/checkout
|
||||
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
||||
RemoteName string // remote name in local git repo config
|
||||
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||
Matrix map[string]map[string]bool // Matrix config to run
|
||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||
|
||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||
ContainerNamePrefix string // the prefix of container name
|
||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||
CleanWorkdir bool // remove host executor workdir on teardown
|
||||
DefaultActionInstance string // the default actions web site
|
||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||
JobLoggerLevel *log.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
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
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
|
||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||
ContainerNamePrefix string // the prefix of container name
|
||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||
CleanWorkdir bool // remove host executor workdir on teardown
|
||||
DefaultActionInstance string // the default actions web site
|
||||
// DefaultActionInstanceIsSelfHosted reports whether DefaultActionInstance is this
|
||||
// self-hosted Gitea (DEFAULT_ACTIONS_URL=self). It gates token trust: only then may the
|
||||
// task token be attached to action clone URLs on DefaultActionInstance's host, which can
|
||||
// differ from GitHubInstance when the runner registered with a different hostname than
|
||||
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||
DefaultActionInstanceIsSelfHosted bool
|
||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||
JobLoggerLevel *log.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
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||
// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env
|
||||
// is accepted for `exec` and for runners configured with it.
|
||||
func (c Config) RunnerDebug() bool {
|
||||
return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true"
|
||||
}
|
||||
|
||||
// GetToken: Adapt to Gitea
|
||||
@@ -127,6 +148,11 @@ func New(runnerConfig *Config) (Runner, error) {
|
||||
}
|
||||
|
||||
func (runner *runnerImpl) configure() (Runner, error) {
|
||||
if runner.config.RunnerName == "" {
|
||||
// Callers that do not register, such as `exec`, still get a `runner.name`.
|
||||
runner.config.RunnerName, _ = os.Hostname()
|
||||
}
|
||||
|
||||
runner.eventJSON = "{}"
|
||||
if runner.config.EventJSON != "" {
|
||||
runner.eventJSON = runner.config.EventJSON
|
||||
@@ -248,7 +274,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
||||
jobCtx := common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix))
|
||||
jobCtx, cancelTimeout := applyJobTimeout(jobCtx, rc, job)
|
||||
defer cancelTimeout()
|
||||
return executor(jobCtx)
|
||||
})
|
||||
}
|
||||
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
||||
@@ -303,7 +332,7 @@ func handleFailure(plan *model.Plan) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for _, stage := range plan.Stages {
|
||||
for _, run := range stage.Runs {
|
||||
if run.Job().Result == "failure" {
|
||||
if run.Job().Result == "failure" && !run.Job().ContinueOnError {
|
||||
return fmt.Errorf("Job '%s' failed", run.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMaxParallelConfig tests that MaxParallel config is properly set
|
||||
func TestMaxParallelConfig(t *testing.T) {
|
||||
t.Run("MaxParallel set to 2", func(t *testing.T) {
|
||||
config := &Config{
|
||||
Workdir: "testdata",
|
||||
MaxParallel: 2,
|
||||
}
|
||||
|
||||
runner, err := New(config)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, runner)
|
||||
|
||||
// Verify config is properly stored
|
||||
runnerImpl, ok := runner.(*runnerImpl)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 2, runnerImpl.config.MaxParallel)
|
||||
})
|
||||
|
||||
t.Run("MaxParallel set to 0 (no limit)", func(t *testing.T) {
|
||||
config := &Config{
|
||||
Workdir: "testdata",
|
||||
MaxParallel: 0,
|
||||
}
|
||||
|
||||
runner, err := New(config)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, runner)
|
||||
|
||||
runnerImpl, ok := runner.(*runnerImpl)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
||||
})
|
||||
|
||||
t.Run("MaxParallel not set (defaults to 0)", func(t *testing.T) {
|
||||
config := &Config{
|
||||
Workdir: "testdata",
|
||||
}
|
||||
|
||||
runner, err := New(config)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, runner)
|
||||
|
||||
runnerImpl, ok := runner.(*runnerImpl)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelConcurrencyTracking tests that max-parallel actually limits concurrent execution
|
||||
func TestMaxParallelConcurrencyTracking(t *testing.T) {
|
||||
// This is a unit test for the parallel executor logic
|
||||
// We test that when MaxParallel is set, it limits the number of workers
|
||||
|
||||
var mu sync.Mutex
|
||||
var maxConcurrent int
|
||||
var currentConcurrent int
|
||||
|
||||
// Create a function that tracks concurrent execution
|
||||
trackingFunc := func() {
|
||||
mu.Lock()
|
||||
currentConcurrent++
|
||||
if currentConcurrent > maxConcurrent {
|
||||
maxConcurrent = currentConcurrent
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Simulate work
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
currentConcurrent--
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Run multiple tasks with limited parallelism
|
||||
maxConcurrent = 0
|
||||
currentConcurrent = 0
|
||||
|
||||
// This simulates what NewParallelExecutor does with a semaphore
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 2) // Limit to 2 concurrent
|
||||
|
||||
for range 6 {
|
||||
wg.Go(func() {
|
||||
semaphore <- struct{}{} // Acquire
|
||||
defer func() { <-semaphore }() // Release
|
||||
trackingFunc()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// With a semaphore of 2, max concurrent should be <= 2
|
||||
assert.LessOrEqual(t, maxConcurrent, 2, "Maximum concurrent executions should not exceed limit")
|
||||
assert.GreaterOrEqual(t, maxConcurrent, 1, "Should have at least 1 concurrent execution")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -163,6 +164,12 @@ func TestGraphEvent(t *testing.T) {
|
||||
assert.Empty(t, plan.Stages)
|
||||
}
|
||||
|
||||
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
|
||||
var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"}
|
||||
|
||||
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
||||
var planSlots = make(chan struct{}, 4)
|
||||
|
||||
type TestJobFileInfo struct {
|
||||
workdir string
|
||||
workflowPath string
|
||||
@@ -182,12 +189,19 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
||||
|
||||
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
||||
runnerConfig := &Config{
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
EventName: j.eventName,
|
||||
EventPath: cfg.EventPath,
|
||||
Platforms: j.platforms,
|
||||
ReuseContainers: false,
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
EventName: j.eventName,
|
||||
EventPath: cfg.EventPath,
|
||||
Platforms: j.platforms,
|
||||
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
||||
ReuseContainers: false,
|
||||
// as the shipped runner does, else a fixture asserting a job failure keeps its
|
||||
// container, and its network, on the daemon forever
|
||||
AutoRemove: true,
|
||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||
MaxParallel: 2,
|
||||
ForceRebuild: true,
|
||||
Env: cfg.Env,
|
||||
Secrets: cfg.Secrets,
|
||||
@@ -210,7 +224,11 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
||||
plan, err := planner.PlanEvent(j.eventName)
|
||||
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
if err == nil && plan != nil {
|
||||
err = runner.NewPlanExecutor(plan)(ctx)
|
||||
err = func() error {
|
||||
planSlots <- struct{}{}
|
||||
defer func() { <-planSlots }()
|
||||
return runner.NewPlanExecutor(plan)(ctx)
|
||||
}()
|
||||
if j.errorMessage == "" {
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
} else {
|
||||
@@ -227,6 +245,7 @@ type TestConfig struct {
|
||||
|
||||
func TestRunEvent(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -303,6 +322,7 @@ func TestRunEvent(t *testing.T) {
|
||||
// services
|
||||
{workdir, "services", "push", "", platforms, secrets},
|
||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
||||
|
||||
// local remote action overrides
|
||||
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
||||
@@ -314,6 +334,9 @@ func TestRunEvent(t *testing.T) {
|
||||
// host /proc bind mounts are Linux-Docker-only
|
||||
requireLinuxDocker(t)
|
||||
}
|
||||
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
||||
t.Parallel()
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Secrets: table.secrets,
|
||||
@@ -444,6 +467,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDryrunEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Dryrun plans without containers or network (shells and local actions only).
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
@@ -463,6 +487,7 @@ func TestDryrunEvent(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
table.runTest(ctx, t, &Config{})
|
||||
})
|
||||
}
|
||||
@@ -473,33 +498,11 @@ func TestDryrunEvent(t *testing.T) {
|
||||
// workflow's outputs via `needs`).
|
||||
func TestReusableWorkflowCaller(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||
}
|
||||
|
||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
||||
requireDocker(t)
|
||||
requireNetwork(t) // force-pulls a docker action image
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
config := &Config{
|
||||
ForcePull: true,
|
||||
ForceRebuild: true,
|
||||
}
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-via-composite-dockerfile", "push", "", platforms, secrets},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
table.runTest(ctx, t, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type maskJobLoggerFactory struct {
|
||||
Output bytes.Buffer
|
||||
}
|
||||
@@ -512,6 +515,7 @@ func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
||||
}
|
||||
|
||||
func TestMaskValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
found := strings.Contains(text, "composite secret")
|
||||
if found {
|
||||
@@ -542,6 +546,7 @@ func TestMaskValues(t *testing.T) {
|
||||
|
||||
func TestRunEventSecrets(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
workflowPath := "secrets"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
@@ -597,6 +602,7 @@ func TestRunWithService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunActionInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
workflowPath := "input-from-cli"
|
||||
|
||||
@@ -616,6 +622,7 @@ func TestRunActionInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventPullRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
|
||||
workflowPath := "pull-request"
|
||||
@@ -632,6 +639,7 @@ func TestRunEventPullRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
workflowPath := "matrix-with-user-inclusions"
|
||||
|
||||
|
||||
@@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
if strings.Contains(stepString, "::add-mask::") {
|
||||
stepString = "add-mask command"
|
||||
}
|
||||
logger.Infof("Run %s %s", stage, stepString)
|
||||
if stage == stepStageMain {
|
||||
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
||||
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
||||
logger.Debugf("Run %s %s", stage, stepString)
|
||||
} else {
|
||||
logger.Infof("Run %s %s", stage, stepString)
|
||||
}
|
||||
|
||||
// Prepare and clean Runner File Commands
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
@@ -124,7 +130,12 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
envFileCommand := path.Join("workflow", "envs.txt")
|
||||
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
||||
|
||||
summaryFileCommand := path.Join("workflow", "SUMMARY.md")
|
||||
// Per-step summary file. Composite sub-steps share the outer job step's index
|
||||
// via the Parent chain so all writes from within a composite action accumulate
|
||||
// in the same file and upload under the outer step_index.
|
||||
topRC := rc.topLevelRunContext()
|
||||
stepSummaryIndex := topRC.CurrentStepIndex
|
||||
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
||||
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
||||
|
||||
{
|
||||
@@ -136,22 +147,23 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
||||
}
|
||||
|
||||
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{
|
||||
Name: outputFileCommand,
|
||||
Mode: 0o666,
|
||||
}, &container.FileEntry{
|
||||
Name: stateFileCommand,
|
||||
Mode: 0o666,
|
||||
}, &container.FileEntry{
|
||||
Name: pathFileCommand,
|
||||
Mode: 0o666,
|
||||
}, &container.FileEntry{
|
||||
Name: envFileCommand,
|
||||
Mode: 0o666,
|
||||
}, &container.FileEntry{
|
||||
Name: summaryFileCommand,
|
||||
Mode: 0o666,
|
||||
})(ctx)
|
||||
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
||||
// excluded here and initialized below at most once per step so writes from later
|
||||
// phases and from composite sub-steps accumulate instead of being truncated.
|
||||
files := []*container.FileEntry{
|
||||
{Name: outputFileCommand, Mode: 0o666},
|
||||
{Name: stateFileCommand, Mode: 0o666},
|
||||
{Name: pathFileCommand, Mode: 0o666},
|
||||
{Name: envFileCommand, Mode: 0o666},
|
||||
}
|
||||
if topRC.summaryFileInitialized == nil {
|
||||
topRC.summaryFileInitialized = map[int]bool{}
|
||||
}
|
||||
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
||||
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
||||
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
||||
}
|
||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||
|
||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||
defer cancelTimeOut()
|
||||
@@ -169,7 +181,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
}
|
||||
|
||||
if continueOnError {
|
||||
logger.Errorf("##[error]%v", err)
|
||||
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||
logger.Infof("Failed but continue next step")
|
||||
err = nil
|
||||
stepResult.Conclusion = model.StepStatusSuccess
|
||||
|
||||
@@ -69,6 +69,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
||||
}
|
||||
}
|
||||
// Actions served from the action cache are read out of a git object store rather than a
|
||||
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
||||
if sar.RunContext.Config.ActionCache != nil {
|
||||
cache := sar.RunContext.Config.ActionCache
|
||||
|
||||
@@ -112,23 +114,36 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
// empty token and clone the action anonymously (401 against the authenticated
|
||||
// instance). github.Token survives the composite config copy and matches the
|
||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||
token := ""
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||
token = github.Token
|
||||
}
|
||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
||||
URL: cloneURL,
|
||||
Ref: sar.remoteAction.Ref,
|
||||
Dir: actionDir,
|
||||
Token: token,
|
||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||
// printPrepareActions reports the download with its resolved commit.
|
||||
Quiet: true,
|
||||
|
||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||
})
|
||||
var ntErr common.Executor
|
||||
if err := gitClone(ctx); err != nil {
|
||||
if errors.Is(err, git.ErrShortRef) {
|
||||
var refErr *git.Error
|
||||
if errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef) {
|
||||
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||
sar.Step.Uses, sar.remoteAction.Ref, err.(*git.Error).Commit())
|
||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||
} else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||
} else {
|
||||
@@ -136,6 +151,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: the download report falls back to the ref alone when the commit is unknown.
|
||||
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
|
||||
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
|
||||
} else {
|
||||
sar.resolvedSha = sha
|
||||
}
|
||||
|
||||
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return func(filename string) (io.Reader, io.Closer, error) {
|
||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||
@@ -151,16 +173,28 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
// A stage of its own: it takes the same clone lock, and it has to land before
|
||||
// runAction copies the action into the job container.
|
||||
sar.patchActionToolkit,
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
|
||||
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
|
||||
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
|
||||
if sar.remoteAction == nil || sar.action == nil {
|
||||
return "", "", false
|
||||
}
|
||||
return sar.remoteAction.Reference(), sar.resolvedSha, true
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) pre() common.Executor {
|
||||
sar.env = map[string]string{}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) main() common.Executor {
|
||||
@@ -182,15 +216,51 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
|
||||
return sar.runAction(sar, actionDir, sar.remoteAction)(ctx)
|
||||
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) post() common.Executor {
|
||||
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
}
|
||||
|
||||
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
||||
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
||||
if sar.remoteAction == nil {
|
||||
return "", nil
|
||||
}
|
||||
dir := sar.actionDir()
|
||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||
}
|
||||
|
||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea, which lets the cache
|
||||
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
patchToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
||||
// runs it as shipped rather than repeating a failure the patch may have caused.
|
||||
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := exec(ctx)
|
||||
if err != nil {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
revertToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) actionDir() string {
|
||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||
@@ -241,7 +311,7 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
|
||||
@@ -303,6 +373,16 @@ func (ra *remoteAction) CloneURL(u string) string {
|
||||
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
||||
}
|
||||
|
||||
// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which
|
||||
// can be interpolated from a secret.
|
||||
func (ra *remoteAction) Reference() string {
|
||||
repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo)
|
||||
if ra.Path != "" {
|
||||
repo = fmt.Sprintf("%s/%s", repo, ra.Path)
|
||||
}
|
||||
return fmt.Sprintf("%s@%s", repo, ra.Ref)
|
||||
}
|
||||
|
||||
func (ra *remoteAction) IsCheckout() bool {
|
||||
if ra.Org == "actions" && ra.Repo == "checkout" {
|
||||
return true
|
||||
@@ -312,7 +392,7 @@ func (ra *remoteAction) IsCheckout() bool {
|
||||
|
||||
func newRemoteAction(action string) *remoteAction {
|
||||
// support http(s)://host/owner/repo@v3
|
||||
for _, schema := range []string{"https://", "http://"} {
|
||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
||||
if after, ok := strings.CutPrefix(action, schema); ok {
|
||||
splits := strings.SplitN(after, "/", 2)
|
||||
if len(splits) != 2 {
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -778,6 +781,32 @@ func Test_newRemoteAction(t *testing.T) {
|
||||
},
|
||||
wantCloneURL: "http://gitea.com/actions/aws",
|
||||
},
|
||||
{
|
||||
action: "ssh://git@gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it
|
||||
want: &remoteAction{
|
||||
URL: "ssh://git@gitea.com",
|
||||
Org: "actions",
|
||||
Repo: "heroku",
|
||||
Path: "",
|
||||
Ref: "main",
|
||||
},
|
||||
wantCloneURL: "ssh://git@gitea.com/actions/heroku",
|
||||
},
|
||||
{
|
||||
action: "ssh://git@gitea.com/actions/aws/ec2@main", // the ssh user is kept as part of the host segment
|
||||
want: &remoteAction{
|
||||
URL: "ssh://git@gitea.com",
|
||||
Org: "actions",
|
||||
Repo: "aws",
|
||||
Path: "ec2",
|
||||
Ref: "main",
|
||||
},
|
||||
wantCloneURL: "ssh://git@gitea.com/actions/aws",
|
||||
},
|
||||
{
|
||||
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.action, func(t *testing.T) {
|
||||
@@ -792,6 +821,97 @@ func Test_newRemoteAction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_remoteActionReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
uses string
|
||||
want string
|
||||
}{
|
||||
{uses: "actions/checkout@v7", want: "actions/checkout@v7"},
|
||||
{uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"},
|
||||
// The download source can be interpolated from a secret and must stay out of the log.
|
||||
{uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.uses, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local
|
||||
// git repository standing in for the actions instance, so the reported commit is the one the
|
||||
// clone actually checked out.
|
||||
func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
||||
instance := t.TempDir()
|
||||
actionDir := filepath.Join(instance, "actions", "setup-go")
|
||||
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"),
|
||||
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
|
||||
|
||||
// Supply an identity on the commit so the test does not depend on a
|
||||
// git identity being configured in the environment; a CI runner without
|
||||
// user.name/user.email would otherwise fail "commit" with exit code 128.
|
||||
for _, args := range [][]string{
|
||||
{"init", "--initial-branch=main", actionDir},
|
||||
{"-C", actionDir, "add", "action.yml"},
|
||||
{"-C", actionDir, "-c", "user.name=runner", "-c", "user.email=runner@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
require.NoError(t, cmd.Run(), "git %v", args)
|
||||
}
|
||||
out, err := exec.Command("git", "-C", actionDir, "rev-parse", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
wantSha := strings.TrimSpace(string(out))
|
||||
|
||||
sar := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "actions/setup-go@main"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: "https://gitea.example.com",
|
||||
DefaultActionInstance: instance,
|
||||
ActionCacheDir: t.TempDir(),
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||
},
|
||||
},
|
||||
readAction: readActionImpl,
|
||||
}
|
||||
|
||||
require.NoError(t, sar.prepareActionExecutor()(context.Background()))
|
||||
|
||||
reference, sha, ok := sar.actionDownloadInfo()
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "actions/setup-go@main", reference)
|
||||
assert.Equal(t, wantSha, sha)
|
||||
}
|
||||
|
||||
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||
sar := &stepActionRemote{
|
||||
remoteAction: newRemoteAction("actions/checkout@v7"),
|
||||
action: &model.Action{},
|
||||
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||
}
|
||||
|
||||
reference, sha, ok := sar.actionDownloadInfo()
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "actions/checkout@v7", reference)
|
||||
assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha)
|
||||
})
|
||||
|
||||
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||
// The local checkout of the workflow's own repository resolves no action.
|
||||
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
|
||||
|
||||
_, _, ok := sar.actionDownloadInfo()
|
||||
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_safeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
@@ -812,3 +932,83 @@ func Test_safeFilename(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a nested action in a composite cloned anonymously (401) because the
|
||||
// composite RunContext nils Config.Secrets. The token must come from github.Token,
|
||||
// which survives the config copy; the host gate must still withhold it cross-host.
|
||||
func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
||||
const wantToken = "job-token"
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
gitHubInstance string
|
||||
defaultActionInstance string
|
||||
wantCloneToken string
|
||||
}{
|
||||
{
|
||||
name: "same host forwards token despite nil secrets",
|
||||
gitHubInstance: "gitea.example.com",
|
||||
wantCloneToken: wantToken,
|
||||
},
|
||||
{
|
||||
name: "foreign host is not given the token",
|
||||
gitHubInstance: "gitea.example.com",
|
||||
defaultActionInstance: "github.com",
|
||||
wantCloneToken: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var capturedToken string
|
||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||
capturedToken = input.Token
|
||||
return func(ctx context.Context) error { return nil }
|
||||
}
|
||||
defer (func() {
|
||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
||||
})()
|
||||
|
||||
sarm := &stepActionRemoteMocks{}
|
||||
sar := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "org/repo@v1"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: tt.gitHubInstance,
|
||||
DefaultActionInstance: tt.defaultActionInstance,
|
||||
ActionCacheDir: "/tmp/test-cache",
|
||||
// Mirrors the state of a composite RunContext: job secrets are
|
||||
// stripped, but the job token is still reachable via Config.Token.
|
||||
Secrets: nil,
|
||||
Token: wantToken,
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{"1": {}},
|
||||
},
|
||||
},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
},
|
||||
readAction: sarm.readAction,
|
||||
}
|
||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
suffixMatcher := func(suffix string) any {
|
||||
return mock.MatchedBy(func(actionDir string) bool {
|
||||
return strings.HasSuffix(actionDir, suffix)
|
||||
})
|
||||
}
|
||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
|
||||
err := sar.prepareActionExecutor()(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCloneToken, capturedToken)
|
||||
|
||||
sarm.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
stepContainer.Start(true),
|
||||
).Finally(
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||
).Finally(stepContainer.Close())(ctx)
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
@@ -125,8 +122,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStepDockerMain(t *testing.T) {
|
||||
@@ -38,7 +39,12 @@ func TestStepDockerMain(t *testing.T) {
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Config: &Config{},
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
@@ -106,9 +112,50 @@ func TestStepDockerMain(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "node:14", input.Image)
|
||||
|
||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
|
||||
assert.Empty(t, input.Username)
|
||||
assert.Empty(t, input.Password)
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||
func TestStepDockerAutoRemove(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
autoRemove bool
|
||||
removes int
|
||||
}{
|
||||
{false, 2}, // stale + post-run
|
||||
{true, 1}, // post-run skipped
|
||||
} {
|
||||
cm := &containerMock{}
|
||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{AutoRemove: tc.autoRemove},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
|
||||
}
|
||||
|
||||
removes := 0
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, sd.runUsesContainer()(context.Background()))
|
||||
cm.AssertExpectations(t)
|
||||
assert.Equal(t, tc.removes, removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -63,7 +63,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
||||
|
||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||
|
||||
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
|
||||
rawLogger.Infof("::group::Run %s", escapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||
|
||||
if normalized != "" {
|
||||
for line := range strings.SplitSeq(normalized, "\n") {
|
||||
@@ -90,7 +90,7 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
||||
if step.Name != "" {
|
||||
title = step.Name
|
||||
}
|
||||
rawLogger.Infof("::group::Run %s", title)
|
||||
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
|
||||
|
||||
if len(step.With) > 0 {
|
||||
rawLogger.Infof("with:")
|
||||
|
||||
@@ -167,6 +167,9 @@ func TestSetupEnv(t *testing.T) {
|
||||
delete((env), "GITHUB_REPOSITORY")
|
||||
delete((env), "GITHUB_REPOSITORY_OWNER")
|
||||
delete((env), "GITHUB_ACTOR")
|
||||
// Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead.
|
||||
delete((env), "RUNNER_NAME")
|
||||
delete((env), "RUNNER_WORKSPACE")
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"ACT": "true",
|
||||
@@ -192,6 +195,7 @@ func TestSetupEnv(t *testing.T) {
|
||||
"GITHUB_WORKFLOW": "",
|
||||
"INPUT_STEP_WITH": "with-value",
|
||||
"RC_KEY": "rcvalue",
|
||||
"RUNNER_ENVIRONMENT": "self-hosted",
|
||||
"RUNNER_PERFLOG": "/dev/null",
|
||||
"RUNNER_TRACKING_ID": "",
|
||||
}, env)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
name: services-empty-image
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
db:
|
||||
image: ${{ false && 'postgres:16' || '' }}
|
||||
steps:
|
||||
- run: echo "empty-image service was skipped"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"inputs": {
|
||||
"required": "required input",
|
||||
"boolean": "true"
|
||||
"boolean": true
|
||||
}
|
||||
}
|
||||
|
||||
262
act/runner/toolkit_patch.go
Normal file
262
act/runner/toolkit_patch.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||
// from working against Gitea. Both are edited out of the bundle the runner downloaded.
|
||||
//
|
||||
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
|
||||
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
|
||||
// which is why the stock upload-artifact aborts here. The edit empties the last of the three
|
||||
// hostname tests, so `endsWith('.LOCALHOST')` becomes `endsWith(”)`, which every hostname
|
||||
// satisfies: one string literal, no call sites to resolve, and the same answer the toolkit's own
|
||||
// proposed ACTIONS_VENDOR switch would give. Gitea already makes this edit by hand in its fork
|
||||
// of upload-artifact.
|
||||
//
|
||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
// https://github.com/actions/toolkit/pull/2123 — an ACTIONS_VENDOR switch, naming Gitea
|
||||
// https://github.com/actions/toolkit/issues/2439 — treat ACTIONS_RESULTS_URL as the signal
|
||||
const (
|
||||
CacheServiceV2Env = "ACTIONS_CACHE_SERVICE_V2"
|
||||
cacheURLEnv = "ACTIONS_CACHE_URL"
|
||||
resultsURLEnv = "ACTIONS_RESULTS_URL"
|
||||
|
||||
// localhostHost is the suffix isGhes accepts; emptying the test is what opens the gate,
|
||||
// because every hostname ends with the empty string.
|
||||
localhostHost = ".LOCALHOST"
|
||||
|
||||
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
|
||||
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
|
||||
// runner has not looked at, and is left alone.
|
||||
artifactRefusal = "GHESNotSupportedError"
|
||||
|
||||
// sidecarSuffix names the directory of untouched copies, a sibling of the action directory
|
||||
// because that directory is copied wholesale into job containers.
|
||||
sidecarSuffix = ".toolkit-patch"
|
||||
|
||||
// skipMarker in the sidecar means a patched bundle already failed once here.
|
||||
skipMarker = "skip"
|
||||
|
||||
maxBundleSize = 64 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
// localhostTest matches the third hostname test of isGhes, in any quoting. The match is case
|
||||
// sensitive on purpose, and that is load-bearing: isGhes uppercases the hostname before
|
||||
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
|
||||
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
|
||||
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
|
||||
// covering every major version of sixteen actions.
|
||||
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
|
||||
|
||||
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
|
||||
// the cache URL and falls back to the results URL, the v2 branch just below reads the results
|
||||
// URL alone. That `||` pairing is the only place the two variables are read together, so
|
||||
// matching them as one expression is what keeps the edit inside this function rather than
|
||||
// anywhere they happen to sit near each other. The branches are 21 bytes apart minified and
|
||||
// 63 not, across every bundle measured.
|
||||
serviceURLBranches = regexp.MustCompile(`(` + envRead(cacheURLEnv) + `\s*\|\|\s*)(` +
|
||||
envRead(resultsURLEnv) + `)((?s).{0,256}?)(` + envRead(resultsURLEnv) + `)`)
|
||||
|
||||
// cacheURLFirst gives both reads the preference the v1 branch already had.
|
||||
cacheURLFirst = []byte(`${1}(process.env.` + cacheURLEnv + `||${2})${3}(process.env.` + cacheURLEnv + `||${4})`)
|
||||
)
|
||||
|
||||
func envRead(name string) string {
|
||||
return `process\s*\.\s*env\s*(?:\.\s*` + name + `\b|\[\s*` + quoted(name) + `\s*\])`
|
||||
}
|
||||
|
||||
// quoted matches a string literal in any of the three quote characters. RE2 has no
|
||||
// backreferences, so the pairs are spelled out.
|
||||
func quoted(pattern string) string {
|
||||
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
|
||||
}
|
||||
|
||||
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only
|
||||
// remote actions get here: a local one lives in the user's checkout, which the runner does not
|
||||
// rewrite.
|
||||
func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
if action == nil || !action.Runs.Using.IsNode() {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
|
||||
if script != "" {
|
||||
paths = append(paths, filepath.Join(dir, script))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// patchToolkit edits the toolkit in an action's bundles, keeping each original beside them. Every
|
||||
// failure is silent and leaves the bundle as it was, which costs the cache client the v2 API and
|
||||
// an artifact action nothing at all.
|
||||
func patchToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sidecarDir(actionDir), skipMarker)); err == nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
for _, script := range scripts {
|
||||
if err := patchBundle(script, originalFor(actionDir, script)); err != nil {
|
||||
common.Logger(ctx).Debugf("actions toolkit: %s left unpatched: %v", filepath.Base(script), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revertToolkit puts the originals back and stops this action being patched again, so the next job
|
||||
// runs it exactly as shipped. Called when a step failed with a patched bundle; it does not re-run
|
||||
// the step, because a step's outputs and env-file writes are already recorded by then.
|
||||
func revertToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(sidecarDir(actionDir)); err != nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
reverted := false
|
||||
for _, script := range scripts {
|
||||
original := originalFor(actionDir, script)
|
||||
if !isPatchOf(original, script) {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(original, script); err == nil {
|
||||
reverted = true
|
||||
}
|
||||
}
|
||||
if reverted {
|
||||
_ = os.WriteFile(filepath.Join(sidecarDir(actionDir), skipMarker), nil, 0o600)
|
||||
common.Logger(ctx).Warnf("actions toolkit: restored the original %s, it will not be patched again", filepath.Base(actionDir))
|
||||
}
|
||||
}
|
||||
|
||||
// sidecarDir holds an action's untouched bundles, and the marker that stops it being patched.
|
||||
func sidecarDir(actionDir string) string {
|
||||
return actionDir + sidecarSuffix
|
||||
}
|
||||
|
||||
// originalFor is where a script's untouched copy lives, or "" for a script the action's own
|
||||
// `runs` keys placed outside its directory, which is not this runner's to rewrite.
|
||||
func originalFor(actionDir, script string) string {
|
||||
rel, err := filepath.Rel(actionDir, script)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(sidecarDir(actionDir), rel)
|
||||
}
|
||||
|
||||
// patchBundle rewrites one entrypoint in place. The untouched copy kept beside it is what marks
|
||||
// the bundle as already patched.
|
||||
func patchBundle(script, original string) error {
|
||||
if original == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(original); err == nil {
|
||||
if isPatchOf(original, script) {
|
||||
return nil
|
||||
}
|
||||
// The action's ref moved and git checked the new bundle out over the patched one, so
|
||||
// the pair no longer belongs together. Patch afresh rather than keep an original that
|
||||
// would restore an older version of the action.
|
||||
if err := os.Remove(original); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(original), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// The copy is taken before the bundle is replaced, so a write that fails part way can put the
|
||||
// action back as it was. A crash needs no handling: the clone executor checks the action out
|
||||
// and hard resets it on every prepare, so a half-written bundle never outlives the job.
|
||||
if err := os.WriteFile(original, data, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(script, patched, info.Mode().Perm()); err != nil {
|
||||
_ = os.Rename(original, script)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPatchOf reports whether script is exactly what patching original produced. It is what proves
|
||||
// the two still belong together: an action whose ref moved is checked out over the patched bundle,
|
||||
// leaving an original that would restore the version before the move.
|
||||
func isPatchOf(original, script string) bool {
|
||||
data, err := os.ReadFile(original)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
return ok && bytes.Equal(patched, current)
|
||||
}
|
||||
|
||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||
// service at the cache server. A bundle this runner cannot account for comes back untouched.
|
||||
func patchedBundle(data []byte) ([]byte, bool) {
|
||||
if !localhostTest.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
switch {
|
||||
case bytes.Contains(data, []byte(CacheServiceV2Env)):
|
||||
// The cache toolkit: both edits or neither, because choosing v2 without redirecting the
|
||||
// URL would send the client to a results URL that serves no cache service.
|
||||
if !serviceURLBranches.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
case bytes.Contains(data, []byte(artifactRefusal)):
|
||||
// The artifact toolkit, where the gate is a plain refusal and there is no URL to move:
|
||||
// artifacts already go to Gitea, which implements that service.
|
||||
default:
|
||||
return data, false
|
||||
}
|
||||
|
||||
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
|
||||
// Drop the hostname from the test rather than rewriting the call, so the bundle's own
|
||||
// quoting survives and the result stays valid even inside a string literal.
|
||||
return bytes.Replace(test, []byte(localhostHost), nil, 1)
|
||||
})
|
||||
return serviceURLBranches.ReplaceAll(opened, cacheURLFirst), true
|
||||
}
|
||||
196
act/runner/toolkit_patch_e2e_test.go
Normal file
196
act/runner/toolkit_patch_e2e_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// actionsCacheRef pins the actions/cache release this is verified against. Bump it
|
||||
// deliberately: a new release is exactly what can stop the patch matching.
|
||||
const actionsCacheRef = "v6.1.0"
|
||||
|
||||
// bundleFromGitHub downloads one entrypoint, keeping it in the user cache dir so repeated runs
|
||||
// cost nothing. The bundles are megabytes, too large to vendor.
|
||||
func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
||||
t.Helper()
|
||||
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
require.NoError(t, err)
|
||||
dir := filepath.Join(cacheDir, "gitea-runner-test", strings.ReplaceAll(repo, "/", "-")+"-"+ref)
|
||||
bundle := filepath.Join(dir, strings.ReplaceAll(path, "/", "-"))
|
||||
if _, err := os.Stat(bundle); err == nil {
|
||||
return bundle
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
|
||||
url := "https://raw.githubusercontent.com/" + repo + "/" + ref + "/" + path
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Skipf("cannot reach %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
file, err := os.Create(bundle)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
require.NoError(t, file.Close())
|
||||
require.NoError(t, err)
|
||||
return bundle
|
||||
}
|
||||
|
||||
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
||||
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
||||
// so only the patch can make the client choose v2 and find the cache server.
|
||||
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
||||
t.Helper()
|
||||
state := filepath.Join(runnerTemp, "state")
|
||||
output := filepath.Join(runnerTemp, "output")
|
||||
for _, name := range []string{state, output} {
|
||||
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(t.Context(), "node", script)
|
||||
cmd.Dir = workspace
|
||||
cmd.Env = append(os.Environ(),
|
||||
"INPUT_PATH=to-cache",
|
||||
"INPUT_KEY="+key,
|
||||
"ACTIONS_RUNTIME_TOKEN="+token,
|
||||
"ACTIONS_CACHE_URL="+cacheURL+"/",
|
||||
// Unreachable on purpose: the artifact service lives here, the cache service must not.
|
||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||
"GITHUB_REF=refs/heads/main",
|
||||
"GITHUB_EVENT_NAME=push",
|
||||
"GITHUB_WORKSPACE="+workspace,
|
||||
"RUNNER_TEMP="+runnerTemp,
|
||||
"GITHUB_STATE="+state,
|
||||
"GITHUB_OUTPUT="+output,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
||||
require.NoError(t, err, "%s failed", script)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
||||
// to /private/var and the client derives archive paths relative to the workspace.
|
||||
func tempDirPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
return dir
|
||||
}
|
||||
|
||||
// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the
|
||||
// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it
|
||||
// save and restore through this runner's cache server. The unreachable results URL is the point,
|
||||
// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release
|
||||
// stops matching the patch the client falls back to v1 and this fails on the version line, which
|
||||
// is the signal to look at the new bundle.
|
||||
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
// A stand-in action directory, patched exactly as a downloaded one would be.
|
||||
actionDir := tempDirPath(t)
|
||||
scripts := map[string]string{}
|
||||
for _, stage := range []string{"restore", "save"} {
|
||||
body, err := os.ReadFile(bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/"+stage+"/index.js"))
|
||||
require.NoError(t, err)
|
||||
scripts[stage] = filepath.Join(actionDir, stage+".js")
|
||||
require.NoError(t, os.WriteFile(scripts[stage], body, 0o600))
|
||||
}
|
||||
patchToolkit(t.Context(), actionDir, []string{scripts["restore"], scripts["save"]})
|
||||
|
||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||
handler.RegisterJob(token, repo)
|
||||
|
||||
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
||||
content := []byte("cached through the patched gate")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
||||
|
||||
const key = "patched-gate-key"
|
||||
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
||||
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
||||
|
||||
saved := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||
|
||||
restored := tempDirPath(t)
|
||||
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, got)
|
||||
}
|
||||
|
||||
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||
// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is
|
||||
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||
// the two shapes and leaving every cache on v1.
|
||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||
for _, tc := range []struct{ repo, ref, path string }{
|
||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
||||
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
||||
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
||||
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||
// releases before it refuse in a shape the runner leaves alone.
|
||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js"},
|
||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
||||
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
||||
} {
|
||||
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := os.ReadFile(bundleFromGitHub(t, tc.repo, tc.ref, tc.path))
|
||||
require.NoError(t, err)
|
||||
|
||||
out, patched := patchedBundle(data)
|
||||
assert.True(t, patched, "the version gate was not patched")
|
||||
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
||||
|
||||
if !strings.Contains(string(data), CacheServiceV2Env) {
|
||||
return // the artifact toolkit: a refusal to open, and no URL to move
|
||||
}
|
||||
// Only the reads inside getCacheServiceURL are rewritten. The others, such as the
|
||||
// feature-availability check, must be left as they are.
|
||||
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
|
||||
"the cache service URL was not redirected")
|
||||
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
|
||||
"a read of the results URL was lost, it must stay as the fallback")
|
||||
})
|
||||
}
|
||||
}
|
||||
328
act/runner/toolkit_patch_test.go
Normal file
328
act/runner/toolkit_patch_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The three shapes real bundlers emit, reduced to the bytes that matter: the version gate, and
|
||||
// the URL getter that follows it. tsc keeps the names, webpack prefixes them, esbuild mangles
|
||||
// them, writes ternaries in place of the switch, and records the real name in the export
|
||||
// assignment. Each carries both reads of the results URL, as the real getter does.
|
||||
const (
|
||||
urlTSC = `function getCacheServiceURL() {` + "\n" + ` switch (getCacheServiceVersion()) {` + "\n" + ` case 'v1':` + "\n" + ` return (process.env['ACTIONS_CACHE_URL'] || process.env['ACTIONS_RESULTS_URL'] || '');` + "\n" + ` case 'v2':` + "\n" + ` return process.env['ACTIONS_RESULTS_URL'] || '';` + "\n" + ` }` + "\n" + `}`
|
||||
urlEsbuild = `function YK(){let e=XK();return e==="v1"?process.env.ACTIONS_CACHE_URL||process.env.ACTIONS_RESULTS_URL||"":e==="v2"?process.env.ACTIONS_RESULTS_URL||"":""}`
|
||||
|
||||
isGhesTSC = `function isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}`
|
||||
gateTSC = isGhesTSC + "\n" + `function getCacheServiceVersion() {` + "\n" + ` if (isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||
gateWebpack = `function config_isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}` + "\n" + `function config_getCacheServiceVersion() {` + "\n" + ` if (config_isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||
gateEsbuild = `vu.isGhes=$K;vu.getCacheServiceVersion=XK;function $K(){let e=new URL(process.env.GITHUB_SERVER_URL||"https://github.com").hostname.toUpperCase(),r=e==="GITHUB.COM",n=e.endsWith(".GHE.COM"),i=e.endsWith(".LOCALHOST");return!r&&!n&&!i}function XK(){return $K()?"v1":process.env.ACTIONS_CACHE_SERVICE_V2?"v2":"v1"}` + urlEsbuild
|
||||
)
|
||||
|
||||
func TestPatchedBundle(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
wantPatched bool
|
||||
}{
|
||||
{"tsc keeps the names", gateTSC, true},
|
||||
{"webpack prefixes them", gateWebpack, true},
|
||||
{"esbuild mangles and minifies them", gateEsbuild, true},
|
||||
// A bundler picks its own quoting; gateTSC is single-quoted already.
|
||||
{"double-quoted", requoted(`"`), true},
|
||||
{"backtick-quoted", requoted("`"), true},
|
||||
// sccache-action sets the variable itself; there is no gate to open.
|
||||
{"mentions the variable without the gate", `core.exportVariable("ACTIONS_CACHE_SERVICE_V2","on")`, false},
|
||||
// Both edits or neither: a gate patched without the URL would send the client to a
|
||||
// results URL that serves no cache service.
|
||||
{"gate without a recognisable url getter", strings.TrimSuffix(gateTSC, "\n"+urlTSC), false},
|
||||
// And the other way round: an action that reads both variables but has no gate to open.
|
||||
{"url getter without a gate", urlTSC, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, patched := patchedBundle([]byte(tc.body))
|
||||
assert.Equal(t, tc.wantPatched, patched)
|
||||
if !tc.wantPatched {
|
||||
assert.Equal(t, tc.body, string(out), "an unpatched bundle must come back byte for byte")
|
||||
return
|
||||
}
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
// The other two hostname tests are left alone, so a host that really is GitHub or
|
||||
// GHES is still recognised as such.
|
||||
assert.NotContains(t, string(out), ".LOCALHOST", "the localhost test is the one that opens")
|
||||
assert.Contains(t, string(out), ".GHE.COM")
|
||||
|
||||
// Every read of the results URL now prefers the cache URL, and none was lost: the
|
||||
// results URL stays the fallback, so a runner not serving the cache still works.
|
||||
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"), strings.Count(string(out), "ACTIONS_RESULTS_URL"))
|
||||
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"),
|
||||
strings.Count(string(out), "(process.env.ACTIONS_CACHE_URL||process.env"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// undici, bundled into every one of these actions, decides whether to trust a URL with a
|
||||
// lowercase test that reads almost the same. Opening it would tell the HTTP client that every URL
|
||||
// is trustworthy, so the uppercase the toolkit produces is what separates them.
|
||||
func TestPatchedBundleLeavesTrustworthyURLCheckAlone(t *testing.T) {
|
||||
const undici = `if(n.hostname==="localhost"||n.hostname.includes("localhost.")||n.hostname.endsWith(".localhost")){return true}`
|
||||
|
||||
out, patched := patchedBundle([]byte(undici + gateTSC))
|
||||
require.True(t, patched)
|
||||
assert.Contains(t, string(out), undici, "the trustworthy-URL check must survive byte for byte")
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
}
|
||||
|
||||
// The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so
|
||||
// opening it is what lets the stock upload-artifact work against Gitea instead of aborting.
|
||||
func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) {
|
||||
const artifact = isGhesTSC + "\n" + `uploadArtifact(){if(isGhes()){throw new GHESNotSupportedError()}}`
|
||||
|
||||
out, patched := patchedBundle([]byte(artifact))
|
||||
assert.True(t, patched)
|
||||
assert.True(t, gateOpened(string(out)))
|
||||
assert.Contains(t, string(out), "GHESNotSupportedError", "the refusal itself is left in place, it just stops firing")
|
||||
|
||||
// A bundle using the gate for something this runner has not accounted for is not touched.
|
||||
unknown := strings.Replace(artifact, "GHESNotSupportedError", "SomeOtherError", 1)
|
||||
out, patched = patchedBundle([]byte(unknown))
|
||||
assert.False(t, patched)
|
||||
assert.Equal(t, unknown, string(out))
|
||||
}
|
||||
|
||||
// requoted respells gateTSC's string literals with another quote character.
|
||||
func requoted(quote string) string {
|
||||
gate := strings.ReplaceAll(gateTSC, `'.LOCALHOST'`, quote+".LOCALHOST"+quote)
|
||||
gate = strings.ReplaceAll(gate, `['ACTIONS_RESULTS_URL']`, "["+quote+"ACTIONS_RESULTS_URL"+quote+"]")
|
||||
return strings.ReplaceAll(gate, `['ACTIONS_CACHE_URL']`, "["+quote+"ACTIONS_CACHE_URL"+quote+"]")
|
||||
}
|
||||
|
||||
// gateOpened reports whether the hostname test was emptied, in whatever quoting the bundle used.
|
||||
func gateOpened(body string) bool {
|
||||
return strings.Contains(body, "endsWith(") && !strings.Contains(body, ".LOCALHOST")
|
||||
}
|
||||
|
||||
// The patched bundle must still be JavaScript, and must resolve the way the runner needs: v2 for
|
||||
// an ordinary Gitea host, the cache server for the service URL, and the results URL when there is
|
||||
// no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing.
|
||||
func TestPatchedBundleBehavesInNode(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
eval := func(t *testing.T, bundle, prelude, cacheURL string) string {
|
||||
t.Helper()
|
||||
|
||||
script := prelude + bundle + "\nprocess.stdout.write(getCacheServiceVersion()+' '+getCacheServiceURL())"
|
||||
cmd := exec.CommandContext(t.Context(), "node", "-e", script)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||
"ACTIONS_CACHE_URL="+cacheURL,
|
||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||
"GITHUB_SERVER_URL=https://gitea.example",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "%s", out)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, bundle, prelude string }{
|
||||
{"tsc", gateTSC, ""},
|
||||
{"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"},
|
||||
{"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Unpatched, a Gitea host is taken for GHES: v1, whose branch already reads the
|
||||
// cache URL. The patch has to move the version without moving that.
|
||||
assert.Equal(t, "v1 http://cache:8088/", eval(t, tc.bundle, tc.prelude, "http://cache:8088/"))
|
||||
|
||||
patched, ok := patchedBundle([]byte(tc.bundle))
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "v2 http://cache:8088/", eval(t, string(patched), tc.prelude, "http://cache:8088/"))
|
||||
assert.Equal(t, "v2 https://gitea.example", eval(t, string(patched), tc.prelude, ""),
|
||||
"with no cache server the results URL is still the fallback")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A bundler that embeds module sources as strings, such as webpack with devtool: eval, carries
|
||||
// the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument
|
||||
// would end that string early and leave the bundle unparseable.
|
||||
func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
||||
requireHostTools(t, "node")
|
||||
|
||||
escaped := strings.ReplaceAll(gateTSC, `"`, `\"`)
|
||||
embedded := `eval("` + strings.ReplaceAll(escaped, "\n", `\n`) + `");`
|
||||
out, patched := patchedBundle([]byte(embedded))
|
||||
require.True(t, patched)
|
||||
|
||||
file := filepath.Join(t.TempDir(), "bundle.js")
|
||||
require.NoError(t, os.WriteFile(file, out, 0o600))
|
||||
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
|
||||
require.NoError(t, err, "%s", checked)
|
||||
}
|
||||
|
||||
func TestPatchBundleKeepsTheOriginal(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
original := originalFor(dir, script)
|
||||
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
patched, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gateOpened(string(patched)))
|
||||
|
||||
kept, err := os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(kept), "the untouched bundle is kept outside the action tree")
|
||||
assert.NotContains(t, original, dir+string(filepath.Separator), "originals must not ship into job containers")
|
||||
|
||||
// Patching again must not stack, and must not overwrite the kept original.
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
again, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(patched), string(again))
|
||||
kept, err = os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(kept))
|
||||
}
|
||||
|
||||
// A bundle with nothing to patch is left exactly as it was, with no original kept beside it.
|
||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||
dir, script := bundleFile(t, `console.log("checkout")`)
|
||||
original := originalFor(dir, script)
|
||||
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||
_, err = os.Stat(original)
|
||||
assert.True(t, os.IsNotExist(err), "no original is kept for a bundle that was not patched")
|
||||
}
|
||||
|
||||
// bundleFile writes one entrypoint into a fresh action directory.
|
||||
func bundleFile(t *testing.T, body string) (dir, script string) {
|
||||
t.Helper()
|
||||
|
||||
dir = t.TempDir()
|
||||
script = filepath.Join(dir, "index.js")
|
||||
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
||||
return dir, script
|
||||
}
|
||||
|
||||
func TestActionScriptPaths(t *testing.T) {
|
||||
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "dist/restore/index.js", Post: "dist/save/index.js"}}
|
||||
assert.Equal(t, []string{"/a/dist/restore/index.js", "/a/dist/save/index.js"}, actionScriptPaths("/a", node))
|
||||
|
||||
// Only a node action has a bundle to patch.
|
||||
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
||||
assert.Nil(t, actionScriptPaths("/a", nil))
|
||||
}
|
||||
|
||||
// A step that fails with a patched bundle gets the untouched bundle back, and the action is not
|
||||
// patched again, so later jobs run it exactly as its author shipped it.
|
||||
func TestRevertToolkit(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
scripts := []string{script}
|
||||
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(body)), "precondition: the bundle is patched")
|
||||
|
||||
revertToolkit(t.Context(), dir, scripts)
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body), "the original bundle is back")
|
||||
|
||||
// The skip marker survives, so the action stays unpatched from now on.
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body), "a reverted action stays unpatched")
|
||||
}
|
||||
|
||||
// An action whose ref moves is checked out over the patched bundle. The kept original then
|
||||
// belongs to the version before the move, and must not be restored over the new one.
|
||||
func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
||||
dir, script := bundleFile(t, gateTSC)
|
||||
original := originalFor(dir, script)
|
||||
scripts := []string{script}
|
||||
|
||||
patchToolkit(t.Context(), dir, scripts)
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateWebpack), 0o600)) // the new version lands
|
||||
|
||||
// Reverting must not roll the action back to the version the original came from.
|
||||
revertToolkit(t.Context(), dir, scripts)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateWebpack, string(body))
|
||||
|
||||
// Nothing was reverted, so the action is not marked off either: the new version is patched
|
||||
// in its own right, and keeps its own original.
|
||||
require.NoFileExists(t, filepath.Join(sidecarDir(dir), skipMarker))
|
||||
require.NoError(t, patchBundle(script, original))
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gateOpened(string(body)))
|
||||
kept, err := os.ReadFile(original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateWebpack, string(kept))
|
||||
}
|
||||
|
||||
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
||||
// that fails gets them back. The action's path inside its repository is part of where they live.
|
||||
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
||||
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
||||
t.Helper()
|
||||
|
||||
sar := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||
RunContext: &RunContext{
|
||||
Env: env,
|
||||
Config: &Config{ActionCacheDir: t.TempDir()},
|
||||
},
|
||||
}
|
||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||
return sar, script
|
||||
}
|
||||
|
||||
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
||||
sar, script := newStep(t, map[string]string{})
|
||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body))
|
||||
})
|
||||
|
||||
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
||||
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(body)))
|
||||
|
||||
failed := errors.New("the step failed")
|
||||
require.ErrorIs(t, sar.revertToolkitOnFailure(func(context.Context) error { return failed })(t.Context()), failed)
|
||||
|
||||
body, err = os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(body))
|
||||
})
|
||||
}
|
||||
70
docs/job-hooks.md
Normal file
70
docs/job-hooks.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Job hooks
|
||||
|
||||
Job hooks are operator-provided scripts that run **inside the job environment**, before the job's first step and after its last one. They are the equivalent of GitHub's [job hooks](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts) and are configured under `runner.hooks` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)):
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
hooks:
|
||||
job_started: /hooks/started.sh
|
||||
job_completed: /hooks/completed.sh
|
||||
```
|
||||
|
||||
| Setting | Runs |
|
||||
| --- | --- |
|
||||
| `runner.hooks.job_started` | Before the job's first step, before any action is downloaded |
|
||||
| `runner.hooks.job_completed` | After the job's last post step, while the job environment is still up |
|
||||
|
||||
`ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` are read from the runner's environment (`runner.envs`, `runner.env_file`) when the settings are unset, so a configuration carried over from actions/runner keeps working. The settings take precedence. A workflow cannot point the runner at a different hook: the variables are only read from the runner's own environment, never from the job's.
|
||||
|
||||
Both hooks are **synchronous** and block the job while they run, and a non-zero exit from either one fails the job. There is no `continue-on-error` and no per-hook timeout — the job's own `runner.timeout` is the only bound. The operator is responsible for the hook's resilience; run anything long in the background from within the hook.
|
||||
|
||||
## Where they run
|
||||
|
||||
The hooks run in the same place as the job's steps: inside the job container, or on the host in host mode. The paths are resolved *there*, so the script has to exist in the job image or on the host — a path that only exists on the runner host is not visible to a containerized job. For host-wide cleanup that runs after the job environment is gone, use the [post-task script](post-task-script.md) instead.
|
||||
|
||||
> This is a deliberate difference from actions/runner, which runs its job hooks on the host, outside any container the job declares. Running them where the steps run is what lets a hook prepare the environment the steps actually see.
|
||||
|
||||
The script is run according to its extension:
|
||||
|
||||
| Extension | Command |
|
||||
| --- | --- |
|
||||
| `.sh` | `bash -e <path>` |
|
||||
| `.ps1` | `pwsh -command . '<path>'` |
|
||||
| anything else | the file itself, which needs its own shebang and executable bit |
|
||||
|
||||
As on GitHub, the shell flags applied to `run:` steps are **not** applied to a hook — set `pipefail` or anything else you want inside the script.
|
||||
|
||||
### Docker-in-Docker and Docker-out-of-Docker
|
||||
|
||||
The hook is executed and its files are exchanged over the Docker API, addressed by container ID, so no path is translated between the runner and the daemon. Both setups work unchanged, but they differ in where the hook file has to be:
|
||||
|
||||
- **DinD** — the daemon has its own filesystem. Bake the hook into the job image; a path from the runner's filesystem is not visible to it.
|
||||
- **DooD** — the job container is created by the host's daemon, so a bind mount in `container.options` is resolved against the **host**, not against the runner container. Either bake the hook into the job image, or mount a host directory and add it to `container.valid_volumes`.
|
||||
|
||||
A hook path that does not exist inside the job environment fails the job with `No such file or directory`, naming the path.
|
||||
|
||||
## Environment
|
||||
|
||||
A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go.
|
||||
|
||||
Its stdout is part of the job log, inside a collapsible group, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job, `::set-env::` and `::add-path::` apply to the steps that follow.
|
||||
|
||||
`$GITHUB_ENV` and `$GITHUB_PATH` point at files that are read back after the hook exits, so the file-command form works too:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "REGISTRY_TOKEN=$(fetch-token)" >> "$GITHUB_ENV"
|
||||
echo "/opt/tooling/bin" >> "$GITHUB_PATH"
|
||||
```
|
||||
|
||||
Both files are the hook's own, separate from the per-step ones, so nothing a hook writes is truncated by the first step.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- Keep hooks **fast** and return the right exit code: they are on the critical path of every job, and nothing bounds them.
|
||||
- Use **idempotent** operations, and expect `job_completed` to run after success, failure, and cancellation alike.
|
||||
- Mask anything secret the hook prints or exports with `::add-mask::`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Post-task script](post-task-script.md) — host-side cleanup after the job environment is torn down.
|
||||
156
docs/post-task-script.md
Normal file
156
docs/post-task-script.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Post-task script
|
||||
|
||||
The post-task script is an optional host hook that runs **once after every task**, after the runner has already finished its normal per-task cleanup. Typical uses include pruning Docker images, vacuuming ephemeral disks, or resetting VM state between jobs.
|
||||
|
||||
It is configured under `runner.post_task_script` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)).
|
||||
|
||||
## When it runs
|
||||
|
||||
For each task, execution order is:
|
||||
|
||||
1. Workflow runs (steps, actions, containers).
|
||||
2. In-job cleanup (action `post:` steps, container stop/remove).
|
||||
3. Job outputs are reported to Gitea.
|
||||
4. Bind-workdir workspace removal, when `container.bind_workdir` is enabled.
|
||||
5. **Post-task script** (this hook).
|
||||
6. Final task acknowledgement to Gitea (`reporter.Close()`).
|
||||
|
||||
The script is **additive**: it does not replace any built-in cleanup. When `container.bind_workdir` is enabled, the task workspace directory has usually already been deleted before the script starts. `GITEA_WORKSPACE` is still set to the path the job used, for reference.
|
||||
|
||||
## Runner stays offline until the script finishes
|
||||
|
||||
This is the most important operational detail.
|
||||
|
||||
When the post-task script starts, the runner **stops sending task heartbeats** to Gitea (the same mechanism used during cancel/cleanup). From Gitea's perspective, the runner is **not available for new work** until:
|
||||
|
||||
1. The script exits (success or failure), **and**
|
||||
2. The runner sends the final task flush to Gitea.
|
||||
|
||||
While the script runs:
|
||||
|
||||
- **Gitea will not assign another task** to this runner for the current job slot (heartbeats are stopped).
|
||||
- **The runner capacity slot stays occupied** locally — with `capacity: 1`, the poller will not start another task until the script completes.
|
||||
- **Runner shutdown** (`shutdown_timeout`) counts this phase as part of the in-flight task; a long or stuck script delays graceful shutdown.
|
||||
|
||||
If the script **never exits**, the runner remains in this state until `runner.post_task_script_timeout` elapses (default **5 minutes** when a script is configured). The runner then kills the script process and proceeds to the final acknowledgement. Until that timeout fires, **the runner effectively stays offline**.
|
||||
|
||||
Set `post_task_script_timeout` to a value that matches how long your housekeeping is allowed to take — not how long you wish it could take. Prefer short, bounded scripts.
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Keep scripts **fast and bounded** (seconds, not minutes).
|
||||
- Avoid interactive prompts, blocking network calls without timeouts, or waiting on user input.
|
||||
- Use **idempotent** operations (the script may run after success, failure, or cancellation).
|
||||
- Test failure modes: hung script, non-zero exit, missing executable.
|
||||
- Watch the **runner process log** for script output (it is not written to the Gitea job log).
|
||||
- On shutdown, ensure scripts respond to process termination within `post_task_script_timeout`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
# Path to an executable on the host. Empty or omitted disables the hook.
|
||||
post_task_script: /usr/local/bin/gitea-post-task.sh
|
||||
|
||||
# Hard limit on script runtime. Default when post_task_script is set: 5m.
|
||||
# If the script exceeds this, it is killed and the runner continues.
|
||||
post_task_script_timeout: 2m
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `runner.post_task_script` | *(disabled)* | Host path to the script or binary. Relative paths are resolved from the runner process working directory. |
|
||||
| `runner.post_task_script_timeout` | `5m` (only when script is set) | Maximum time the script may run before the runner kills it and moves on. |
|
||||
|
||||
The script must be **executable** on the host (shebang on Linux/macOS, or a native `.exe` / `.bat` / `.cmd` on Windows). **PowerShell (`.ps1`) is not supported yet** as the value of `post_task_script`; the runner executes the configured path directly and does not invoke `powershell.exe` for you.
|
||||
|
||||
`gitea-runner exec` does **not** load runner YAML and will not run this hook.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The script receives `runner.envs` / `runner.env_file` values plus:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `GITEA_TASK_ID` | Numeric task ID. |
|
||||
| `GITEA_RUN_ID` | Workflow run ID, when provided by the server. |
|
||||
| `GITEA_REPOSITORY` | Repository slug (`owner/name`). |
|
||||
| `GITEA_WORKSPACE` | Workspace path used for the job (may already be deleted). |
|
||||
| `GITEA_JOB_RESULT` | `success`, `failure`, `cancelled`, `skipped`, or `unknown`. |
|
||||
|
||||
The script environment is **not** a full copy of the job container environment. System variables such as `PATH` are only present if you define them in `runner.envs` or `runner.env_file`.
|
||||
|
||||
## Output and errors
|
||||
|
||||
- **Stdout/stderr** are written to the **runner process log** (logrus), prefixed with `post-task script stdout:` / `post-task script stderr:`.
|
||||
- **Non-zero exit codes** are logged as warnings only. They do **not** change the job result already reported to Gitea.
|
||||
- **Timeouts and start failures** are logged as warnings; the runner still completes the task acknowledgement.
|
||||
|
||||
## Interaction with other timeouts
|
||||
|
||||
| Timeout | Effect on post-task script |
|
||||
| --- | --- |
|
||||
| `runner.post_task_script_timeout` | Kills the script if it runs too long. This is the **only** timeout that bounds the script. |
|
||||
| `runner.timeout` | Caps the task **up to** the script. The script detaches from the task deadline, so a job near the runner timeout limit does **not** cut the script short — it still gets its full `post_task_script_timeout`. |
|
||||
| `runner.shutdown_timeout` | On SIGINT/SIGTERM, bounds how long the runner waits for the **task** to finish. The post-task script detaches from cancellation, so it is **not** interrupted by this window and may extend shutdown until its own `post_task_script_timeout` elapses. |
|
||||
|
||||
## Examples
|
||||
|
||||
### Linux — prune dangling Docker resources
|
||||
|
||||
`/usr/local/bin/gitea-post-task.sh`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter 'until=24h'
|
||||
```
|
||||
|
||||
`config.yaml`:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
post_task_script: /usr/local/bin/gitea-post-task.sh
|
||||
post_task_script_timeout: 3m
|
||||
```
|
||||
|
||||
### Windows — batch file (`.cmd`)
|
||||
|
||||
Use a `.cmd` or `.bat` file. PowerShell scripts are **not supported yet** as `post_task_script`; call PowerShell from a batch wrapper if needed:
|
||||
|
||||
`C:\gitea-runner\scripts\post-task.cmd`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
post_task_script: C:\gitea-runner\scripts\post-task.cmd
|
||||
post_task_script_timeout: 3m
|
||||
```
|
||||
|
||||
PowerShell workaround until native `.ps1` support exists:
|
||||
|
||||
`C:\gitea-runner\scripts\post-task.cmd`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0post-task.ps1"
|
||||
```
|
||||
|
||||
## Windows notes
|
||||
|
||||
- Supported as `post_task_script`: `.exe`, `.bat`, `.cmd`.
|
||||
- **Not supported yet:** `.ps1` as the configured path (use a `.cmd` wrapper; see above).
|
||||
- `.sh` files require a Unix shell on the PATH unless you point `post_task_script` at the interpreter.
|
||||
- Use backslashes or forward slashes in YAML paths; both work in Go on Windows.
|
||||
|
||||
## See also
|
||||
|
||||
- [Job hooks](job-hooks.md) — scripts running inside the job environment, around its steps
|
||||
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
||||
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
||||
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user