mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
47 Commits
v2.0.0
...
41c72216bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -18,8 +18,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- 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@v7
|
||||
- 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@v7
|
||||
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@v7
|
||||
- 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@v7
|
||||
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@v7
|
||||
- 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"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
!/act/runner/testdata/secrets/.env
|
||||
.runner
|
||||
coverage.txt
|
||||
.tmp/
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains
|
||||
@@ -12,3 +13,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 }}"
|
||||
|
||||
@@ -17,14 +17,14 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.0-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.6.0-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
|
||||
|
||||
19
Makefile
19
Makefile
@@ -21,6 +21,8 @@ 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
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
STATIC ?=
|
||||
EXTLDFLAGS ?=
|
||||
ifneq ($(STATIC),)
|
||||
@@ -110,6 +112,9 @@ 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-go-windows ## lint everything
|
||||
|
||||
@@ -131,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
|
||||
@@ -148,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)
|
||||
@@ -218,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
|
||||
|
||||
108
README.md
108
README.md
@@ -85,6 +85,8 @@ 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.
|
||||
@@ -121,6 +123,8 @@ Two processes have to run side by side here (the Docker daemon and the runner),
|
||||
|
||||
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
|
||||
@@ -143,23 +147,97 @@ 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.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
--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.
|
||||
@@ -175,6 +253,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
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:
|
||||
@@ -189,6 +268,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
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.
|
||||
@@ -219,6 +299,16 @@ On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not su
|
||||
|
||||
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
|
||||
|
||||
Check out the [examples](examples) directory for sample deployment types.
|
||||
|
||||
@@ -70,6 +70,7 @@ type Handler struct {
|
||||
storage *Storage
|
||||
router *httprouter.Router
|
||||
listener net.Listener
|
||||
port int
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
|
||||
@@ -177,6 +178,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 +201,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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
@@ -261,6 +259,10 @@ type NewGitCloneExecutorInput struct {
|
||||
// 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
|
||||
}
|
||||
@@ -347,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)()
|
||||
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -50,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)
|
||||
@@ -96,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()
|
||||
|
||||
@@ -381,6 +407,44 @@ 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()
|
||||
@@ -545,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,7 @@ type NewDockerBuildExecutorInput struct {
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
BuildArgs map[string]*string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor 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)
|
||||
}
|
||||
@@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,7 +35,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"
|
||||
@@ -43,7 +42,6 @@ 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"
|
||||
)
|
||||
|
||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||
@@ -57,6 +55,12 @@ const drainGracePeriod = 2 * time.Second
|
||||
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
|
||||
}
|
||||
|
||||
@@ -137,6 +141,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(
|
||||
@@ -232,11 +241,12 @@ 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.
|
||||
@@ -376,6 +386,11 @@ func (cr *containerReference) find() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict).
|
||||
func isContainerGone(err error) bool {
|
||||
return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err)
|
||||
}
|
||||
|
||||
func (cr *containerReference) remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
@@ -387,7 +402,7 @@ func (cr *containerReference) remove() common.Executor {
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !isContainerGone(err) {
|
||||
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
||||
}
|
||||
|
||||
@@ -406,17 +421,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.
|
||||
@@ -460,8 +471,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
|
||||
@@ -471,6 +481,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.")
|
||||
}
|
||||
@@ -1094,6 +1107,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
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
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"
|
||||
@@ -116,6 +117,11 @@ 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)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
@@ -381,6 +387,40 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// 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}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantLogs bool
|
||||
}{
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")},
|
||||
{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"), wantLogs: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
assert.Empty(t, cr.id)
|
||||
|
||||
if tc.wantLogs {
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||
// daemon with a torn-down container.
|
||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
@@ -621,3 +661,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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -43,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 {
|
||||
@@ -310,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
|
||||
@@ -324,11 +348,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||
|
||||
// Kill the step's whole process tree on cancellation (a step often launches a
|
||||
// shell that spawns further background or GUI children) and bound the post-exit
|
||||
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
|
||||
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
|
||||
// override SysProcAttr, but never touches Cancel/WaitDelay.
|
||||
// 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
|
||||
@@ -360,6 +381,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
@@ -407,13 +433,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
// removeAll is the filesystem delete used by removeAllWithContext. A package
|
||||
// var so tests can substitute a blocking stub without patching os.RemoveAll.
|
||||
// removeAll is a var so tests can substitute a blocking stub.
|
||||
var removeAll = os.RemoveAll
|
||||
|
||||
// removeAllWithContext runs removeAll in a goroutine and returns once it
|
||||
// finishes or ctx is cancelled. On cancellation the goroutine is left running —
|
||||
// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout).
|
||||
// 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) }()
|
||||
@@ -455,17 +480,12 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
||||
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 {
|
||||
@@ -501,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)
|
||||
@@ -530,21 +549,24 @@ 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 filesystem-teardown phase of the host
|
||||
// environment so a single stalled delete cannot wedge the runner slot forever.
|
||||
// A var (not const) so tests can shrink it.
|
||||
// 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 runs fn in a goroutine and returns once it finishes or timeout
|
||||
// elapses, whichever comes first. On timeout the goroutine is left running — an
|
||||
// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an
|
||||
// unresponsive network mount, a dying disk) cannot be interrupted — and
|
||||
// context.DeadlineExceeded is returned. Leaking the goroutine and the scratch
|
||||
// state it was deleting is strictly better than blocking the caller forever and
|
||||
// permanently losing the runner's capacity slot; the leaked scratch dir is
|
||||
// reclaimed later by the runner's idle stale-dir sweep.
|
||||
// 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() {
|
||||
@@ -565,14 +587,15 @@ func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Ensure any lingering child processes are ended before attempting
|
||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
||||
// 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.
|
||||
// Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll,
|
||||
// and a delete stalled by a filesystem filter driver would otherwise hang
|
||||
// the job forever at "Cleaning up container" and hold the capacity slot.
|
||||
// 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 {
|
||||
logger.Debugf("running host environment cleanup callback")
|
||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||
@@ -603,8 +626,7 @@ func (e *HostEnvironment) Remove() common.Executor {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
// Bounded teardown timed out; warnings already logged above. Do not
|
||||
// fail job completion — leaked scratch is reclaimed by the idle sweep.
|
||||
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -443,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.
|
||||
@@ -455,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
|
||||
@@ -465,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
|
||||
@@ -482,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,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"
|
||||
|
||||
@@ -58,6 +59,190 @@ func TestJobNeedsResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -482,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
|
||||
|
||||
@@ -490,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
|
||||
|
||||
@@ -498,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
|
||||
|
||||
@@ -515,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": {
|
||||
@@ -907,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")
|
||||
@@ -945,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
|
||||
}
|
||||
|
||||
@@ -129,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()
|
||||
@@ -147,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
|
||||
}
|
||||
@@ -207,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
|
||||
@@ -305,7 +314,7 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -351,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
|
||||
}
|
||||
@@ -364,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.
|
||||
@@ -386,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(
|
||||
@@ -405,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()
|
||||
@@ -455,10 +481,7 @@ 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()
|
||||
@@ -559,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 _, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
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
|
||||
@@ -609,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)
|
||||
@@ -622,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
|
||||
@@ -693,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 != "")
|
||||
}
|
||||
@@ -704,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 {
|
||||
@@ -740,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
|
||||
@@ -426,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:
|
||||
@@ -502,3 +541,86 @@ func TestDockerActionImageTag(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -283,3 +283,30 @@ func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -154,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 {
|
||||
|
||||
@@ -214,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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -56,17 +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.
|
||||
// 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) {
|
||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
||||
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 {
|
||||
@@ -113,9 +178,13 @@ 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 {
|
||||
@@ -157,6 +226,16 @@ 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
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"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"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
)
|
||||
|
||||
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},
|
||||
@@ -45,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{})
|
||||
})
|
||||
}
|
||||
@@ -111,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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,10 @@ 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), "***")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +234,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,13 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValueMasker(t *testing.T) {
|
||||
@@ -33,6 +35,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 +58,17 @@ func TestValueMasker(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -305,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,15 +22,17 @@ 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"
|
||||
)
|
||||
|
||||
@@ -138,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"
|
||||
@@ -202,52 +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 rc.ExprEval != nil {
|
||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||
}
|
||||
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]
|
||||
}
|
||||
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
|
||||
@@ -281,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,
|
||||
@@ -296,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
|
||||
}
|
||||
@@ -337,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)
|
||||
@@ -363,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{}
|
||||
@@ -379,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)
|
||||
}
|
||||
@@ -393,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)
|
||||
}
|
||||
@@ -414,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,
|
||||
@@ -440,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),
|
||||
@@ -525,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)
|
||||
@@ -653,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 {
|
||||
@@ -783,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 {
|
||||
@@ -904,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 {
|
||||
@@ -961,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{
|
||||
@@ -1145,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
|
||||
@@ -1187,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) {
|
||||
@@ -1275,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"
|
||||
@@ -202,6 +205,170 @@ jobs:
|
||||
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",
|
||||
@@ -274,6 +441,10 @@ 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) {
|
||||
@@ -329,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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -363,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.
|
||||
@@ -811,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"])
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type Config struct {
|
||||
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.
|
||||
@@ -72,19 +73,36 @@ type Config struct {
|
||||
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
|
||||
@@ -130,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
|
||||
|
||||
@@ -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()
|
||||
@@ -175,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
|
||||
|
||||
@@ -121,7 +121,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||
token := ""
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, cloneURL) {
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||
token = github.Token
|
||||
}
|
||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
@@ -131,14 +131,17 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
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 {
|
||||
@@ -146,6 +149,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))
|
||||
@@ -165,6 +175,15 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
@@ -313,6 +332,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
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -818,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
@@ -118,6 +119,43 @@ func TestStepDockerMain(t *testing.T) {
|
||||
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 +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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -150,6 +150,7 @@ powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0po
|
||||
|
||||
## 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
|
||||
|
||||
@@ -6,6 +6,11 @@ NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `r
|
||||
|
||||
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
|
||||
|
||||
Each example persists **two** things, and it is worth knowing which is which:
|
||||
|
||||
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
||||
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
||||
|
||||
Files in this directory:
|
||||
|
||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
||||
@@ -13,3 +18,6 @@ Files in this directory:
|
||||
|
||||
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
||||
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||
|
||||
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
|
||||
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Holds the runner's working directory (/data): the .runner registration file
|
||||
# and, optionally, the config file.
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
@@ -10,6 +12,21 @@ spec:
|
||||
storage: 1Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
# Holds the Docker daemon's data root (/var/lib/docker), i.e. the images pulled
|
||||
# for jobs. Without it, the image cache is lost whenever the pod is recreated
|
||||
# and every job re-pulls its images. Size it for the images you expect to cache.
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: docker-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
@@ -45,6 +62,9 @@ spec:
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: runner-vol
|
||||
- name: docker-data
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-vol
|
||||
initContainers:
|
||||
- name: docker
|
||||
image: docker:28.2.2-dind
|
||||
@@ -53,6 +73,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: docker-socket
|
||||
mountPath: /var/run
|
||||
- name: docker-data
|
||||
mountPath: /var/lib/docker
|
||||
startupProbe:
|
||||
exec:
|
||||
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Holds the runner's working directory (/data): the .runner registration file
|
||||
# and, optionally, the config file.
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
@@ -10,6 +12,21 @@ spec:
|
||||
storage: 1Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
# Holds the rootless Docker daemon's data root, i.e. the images pulled for jobs.
|
||||
# Without it, the image cache is lost whenever the pod is recreated and every job
|
||||
# re-pulls its images. Size it for the images you expect to cache.
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: docker-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
@@ -43,7 +60,12 @@ spec:
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: runner-vol
|
||||
- name: docker-data
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-vol
|
||||
securityContext:
|
||||
# The dind-rootless image runs as the `rootless` user (UID/GID 1000);
|
||||
# fsGroup makes both volumes writable for it.
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: runner
|
||||
@@ -68,4 +90,7 @@ spec:
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
# The rootless daemon keeps its images here, not under /data.
|
||||
- name: docker-data
|
||||
mountPath: /home/rootless/.local/share/docker
|
||||
|
||||
|
||||
96
examples/kubernetes/statefulset-dind.yaml
Normal file
96
examples/kubernetes/statefulset-dind.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
# StatefulSet variant of the dind example.
|
||||
#
|
||||
# Unlike the Deployment, a StatefulSet gives each replica a stable identity and,
|
||||
# via volumeClaimTemplates, its own persistent volume. That means every runner
|
||||
# pod keeps its own `.runner` registration file across restarts and reschedules,
|
||||
# so it re-attaches to the server instead of trying to register again.
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: runner-secret
|
||||
type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
labels:
|
||||
app: runner
|
||||
name: runner
|
||||
spec:
|
||||
serviceName: runner
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: runner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
emptyDir: {}
|
||||
initContainers:
|
||||
- name: docker
|
||||
image: docker:28.2.2-dind
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: docker-socket
|
||||
mountPath: /var/run
|
||||
# Keeps the images pulled for jobs across restarts. Without this, the
|
||||
# daemon's data root is ephemeral and every job re-pulls its images.
|
||||
- name: docker-data
|
||||
mountPath: /var/lib/docker
|
||||
startupProbe:
|
||||
exec:
|
||||
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||
restartPolicy: Always
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/runner:nightly
|
||||
env:
|
||||
- name: GITEA_INSTANCE_URL
|
||||
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: runner-secret
|
||||
key: token
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
- name: docker-socket
|
||||
mountPath: /var/run
|
||||
volumeClaimTemplates:
|
||||
# The runner's working directory: the .runner registration file and, optionally,
|
||||
# the config file.
|
||||
- metadata:
|
||||
name: runner-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: standard
|
||||
# The Docker daemon's data root: the images pulled for jobs. Size it for the
|
||||
# images you expect to cache.
|
||||
- metadata:
|
||||
name: docker-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
storageClassName: standard
|
||||
34
examples/systemd/README.md
Normal file
34
examples/systemd/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Running the runner as a systemd service
|
||||
|
||||
[`gitea-runner.service`](./gitea-runner.service) is an example unit for running
|
||||
the runner as a background service on a systemd host.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
|
||||
2. Create a dedicated user and working directory:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner
|
||||
```
|
||||
|
||||
3. Generate a config and register the runner (as the service user), so the
|
||||
`.runner` file ends up in the working directory:
|
||||
|
||||
```bash
|
||||
sudo -u gitea-runner gitea-runner generate-config > /etc/gitea-runner/config.yaml
|
||||
cd /var/lib/gitea-runner
|
||||
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
|
||||
```
|
||||
|
||||
4. Install and enable the unit:
|
||||
|
||||
```bash
|
||||
sudo cp gitea-runner.service /etc/systemd/system/gitea-runner.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now gitea-runner
|
||||
```
|
||||
|
||||
Adjust the binary path, config path, working directory and user to match your
|
||||
installation. If jobs use the host's Docker daemon, uncomment the
|
||||
`docker.service` dependencies in the unit.
|
||||
30
examples/systemd/gitea-runner.service
Normal file
30
examples/systemd/gitea-runner.service
Normal file
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions runner
|
||||
Documentation=https://gitea.com/gitea/runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# Uncomment when jobs use the local Docker daemon:
|
||||
# After=docker.service
|
||||
# Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Adjust the binary path, config path and working directory to your setup.
|
||||
# The working directory is where the .runner registration file is read from
|
||||
# unless runner.file is set to an absolute path in the config.
|
||||
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
|
||||
WorkingDirectory=/var/lib/gitea-runner
|
||||
User=gitea-runner
|
||||
Group=gitea-runner
|
||||
|
||||
# Restart automatically so the runner survives transient failures, e.g. the
|
||||
# Gitea instance being temporarily unreachable at startup.
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Allow running jobs to finish before the runner is stopped. Keep this in sync
|
||||
# with runner.shutdown_timeout in the config.
|
||||
TimeoutStopSec=3h
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
32
go.mod
32
go.mod
@@ -11,7 +11,7 @@ require (
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v29.6.0+incompatible
|
||||
github.com/docker/cli v29.6.2+incompatible
|
||||
github.com/docker/go-connections v0.7.0
|
||||
github.com/go-git/go-billy/v5 v5.9.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
@@ -20,7 +20,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mattn/go-isatty v0.0.23
|
||||
github.com/moby/go-archive v0.2.0
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.0
|
||||
@@ -28,7 +28,8 @@ require (
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/opencontainers/selinux v1.15.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_golang v1.24.0
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/rhysd/actionlint v1.7.12
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -37,8 +38,10 @@ require (
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.40.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gotest.tools/v3 v3.5.2
|
||||
tags.cncf.io/container-device-interface v1.1.0
|
||||
@@ -71,7 +74,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
@@ -84,9 +87,8 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
@@ -97,16 +99,14 @@ require (
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
72
go.sum
72
go.sum
@@ -47,10 +47,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
|
||||
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
|
||||
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
@@ -104,8 +102,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -119,8 +117,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||
@@ -129,12 +127,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
@@ -161,14 +155,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
||||
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -213,8 +207,6 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
|
||||
@@ -222,34 +214,34 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -258,14 +250,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
31
internal/app/cmd/bug_report.go
Normal file
31
internal/app/cmd/bug_report.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// loadBugReportCmd prints environment details that are useful when opening a
|
||||
// bug report, so users can paste them straight into an issue.
|
||||
func loadBugReportCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "bug-report",
|
||||
Short: "Print information useful when filing a bug report",
|
||||
Args: cobra.MaximumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "Runner version: %s\n", ver.Version())
|
||||
fmt.Fprintf(w, "Go version: %s\n", runtime.Version())
|
||||
fmt.Fprintf(w, "OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
fmt.Fprintf(w, "NumCPU: %d\n", runtime.NumCPU())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
|
||||
secret := cfg.Cache.ExternalSecret
|
||||
if secret == "" {
|
||||
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
}
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
|
||||
@@ -35,7 +35,8 @@ func Execute(ctx context.Context) {
|
||||
}
|
||||
registerCmd.Flags().BoolVar(®Args.NoInteractive, "no-interactive", false, "Disable interactive mode")
|
||||
registerCmd.Flags().StringVar(®Args.InstanceAddr, "instance", "", "Gitea instance address")
|
||||
registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token")
|
||||
registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token (or set the GITEA_RUNNER_REGISTRATION_TOKEN envvar)")
|
||||
registerCmd.Flags().StringVar(®Args.TokenFile, "token-file", "", "Path to a file containing the runner token")
|
||||
registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name")
|
||||
registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated")
|
||||
registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
|
||||
@@ -50,11 +51,15 @@ func Execute(ctx context.Context) {
|
||||
RunE: runDaemon(ctx, &daemArgs, &configFile),
|
||||
}
|
||||
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
|
||||
daemonCmd.Flags().StringVar(&daemArgs.Labels, "labels", os.Getenv("GITEA_RUNNER_LABELS"), "Runner labels, comma separated. Overrides the labels of an already registered runner")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
|
||||
// ./gitea-runner exec
|
||||
rootCmd.AddCommand(loadExecCmd(ctx))
|
||||
|
||||
// ./gitea-runner bug-report
|
||||
rootCmd.AddCommand(loadBugReportCmd())
|
||||
|
||||
// ./gitea-runner config
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "generate-config",
|
||||
|
||||
@@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
return fmt.Errorf("failed to load registration file: %w", err)
|
||||
}
|
||||
|
||||
lbls := reg.Labels
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
lbls = cfg.Runner.Labels
|
||||
}
|
||||
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
|
||||
|
||||
ls := labels.Labels{}
|
||||
for _, l := range lbls {
|
||||
@@ -67,6 +64,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
log.Warn("no labels configured, runner may not be able to pick up jobs")
|
||||
}
|
||||
|
||||
// Before the first Docker API call: the standard library resolves the proxy
|
||||
// environment once. Ungated because host labels still reach the daemon.
|
||||
if dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost); err == nil {
|
||||
run.BypassProxyForDockerHost(dockerSocketPath)
|
||||
} else {
|
||||
log.Debugf("cannot resolve the docker socket path, so Docker API calls are not exempted from the proxy: %v", err)
|
||||
}
|
||||
|
||||
if ls.RequireDocker() || cfg.Container.RequireDocker {
|
||||
// Wait for dockerd be ready
|
||||
if timeout := cfg.Container.DockerTimeout; timeout > 0 {
|
||||
@@ -104,6 +109,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
}
|
||||
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
|
||||
os.Setenv("DOCKER_HOST", dockerSocketPath)
|
||||
run.WarnIfDaemonHasNoProxy(ctx)
|
||||
// empty cfg.Container.DockerHost means runner need to find an available docker host automatically
|
||||
// and assign the path to cfg.Container.DockerHost
|
||||
if cfg.Container.DockerHost == "" {
|
||||
@@ -150,17 +156,19 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
}
|
||||
runner.SetCapabilitiesFromDeclare(resp)
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
|
||||
if cfg.Metrics.Enabled {
|
||||
metrics.Init()
|
||||
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
||||
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
||||
metrics.RegisterUptimeFunc(time.Now())
|
||||
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
||||
metrics.StartServer(ctx, cfg.Metrics.Addr)
|
||||
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
|
||||
return poller.Ready(cfg.Metrics.ReadinessGrace)
|
||||
})
|
||||
}
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
|
||||
if daemArgs.Once || reg.Ephemeral {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -176,7 +184,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
} else {
|
||||
go poller.Poll()
|
||||
|
||||
<-ctx.Done()
|
||||
// Stop either on an external cancellation or when the poller shuts
|
||||
// itself down (e.g. after the runner has been unregistered).
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-poller.Done():
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
|
||||
@@ -189,12 +202,39 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
||||
}
|
||||
|
||||
if poller.Unregistered() {
|
||||
return errors.New("runner is no longer registered with the server; please register it again")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type daemonArgs struct {
|
||||
Once bool
|
||||
Once bool
|
||||
Labels string
|
||||
}
|
||||
|
||||
// resolveLabels picks the labels to run with: --labels/GITEA_RUNNER_LABELS > config > .runner.
|
||||
// The flag lets a registered runner change its labels without deleting the .runner file.
|
||||
func resolveLabels(argLabels string, cfgLabels, regLabels []string) []string {
|
||||
if lbls := splitLabels(argLabels); len(lbls) > 0 {
|
||||
return lbls
|
||||
}
|
||||
if len(cfgLabels) > 0 {
|
||||
return cfgLabels
|
||||
}
|
||||
return regLabels
|
||||
}
|
||||
|
||||
func splitLabels(s string) []string {
|
||||
var lbls []string
|
||||
for l := range strings.SplitSeq(s, ",") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lbls = append(lbls, l)
|
||||
}
|
||||
}
|
||||
return lbls
|
||||
}
|
||||
|
||||
// initLogging setup the global logrus logger.
|
||||
|
||||
66
internal/app/cmd/daemon_test.go
Normal file
66
internal/app/cmd/daemon_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveLabels(t *testing.T) {
|
||||
var (
|
||||
cfgLabels = []string{"cfg:host"}
|
||||
regLabels = []string{"reg:host"}
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
arg string
|
||||
cfg []string
|
||||
reg []string
|
||||
want []string
|
||||
}{
|
||||
{"flag wins", "flag:host,other", cfgLabels, regLabels, []string{"flag:host", "other"}},
|
||||
{"config wins over registration", "", cfgLabels, regLabels, cfgLabels},
|
||||
{"registration is the fallback", "", nil, regLabels, regLabels},
|
||||
{"blank flag is ignored", " , ", cfgLabels, regLabels, cfgLabels},
|
||||
{"nothing configured", "", nil, nil, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, resolveLabels(tt.arg, tt.cfg, tt.reg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
|
||||
got, err := getDockerSocketPath("tcp://docker.example:2376")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "tcp://docker.example:2376", got)
|
||||
|
||||
t.Setenv("DOCKER_HOST", "unix:///tmp/docker.sock")
|
||||
got, err = getDockerSocketPath("-")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "unix:///tmp/docker.sock", got)
|
||||
}
|
||||
|
||||
func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
|
||||
oldLevel := log.GetLevel()
|
||||
oldReportCaller := log.StandardLogger().ReportCaller
|
||||
t.Cleanup(func() {
|
||||
log.SetLevel(oldLevel)
|
||||
log.SetReportCaller(oldReportCaller)
|
||||
})
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Log.Level = "debug"
|
||||
initLogging(cfg)
|
||||
|
||||
require.Equal(t, log.DebugLevel, log.GetLevel())
|
||||
require.True(t, log.StandardLogger().ReportCaller)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
@@ -34,6 +35,7 @@ type executeArgs struct {
|
||||
runList bool
|
||||
job string
|
||||
event string
|
||||
eventpath string
|
||||
workdir string
|
||||
workflowsPath string
|
||||
noWorkflowRecurse bool
|
||||
@@ -414,6 +416,11 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
|
||||
|
||||
// no service aliases: exec builds one config for the whole plan
|
||||
run.BypassProxyForDockerHost(os.Getenv("DOCKER_HOST"))
|
||||
proxyEnv := run.JobProxyEnv(env, env["ACTIONS_CACHE_URL"], nil)
|
||||
maps.Copy(env, proxyEnv)
|
||||
|
||||
// run the plan
|
||||
config := &runner.Config{
|
||||
Workdir: execArgs.Workdir(),
|
||||
@@ -424,6 +431,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
LogOutput: true,
|
||||
JSONLogger: execArgs.jsonLogger,
|
||||
Env: env,
|
||||
ProxyEnv: proxyEnv,
|
||||
Vars: execArgs.LoadVars(),
|
||||
Secrets: execArgs.LoadSecrets(),
|
||||
InsecureSecrets: execArgs.insecureSecrets,
|
||||
@@ -441,12 +449,14 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
ArtifactServerPort: execArgs.artifactServerPort,
|
||||
ArtifactServerAddr: execArgs.artifactServerAddr,
|
||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||
EventPath: execArgs.resolve(execArgs.eventpath),
|
||||
// PresetGitHubContext: preset,
|
||||
// EventJSON: string(eventJSON),
|
||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
||||
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return execArgs.image
|
||||
},
|
||||
@@ -495,8 +505,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
||||
}
|
||||
|
||||
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
|
||||
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
|
||||
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID; when several workflow files define that job, also pass --workflows/-W to select the file")
|
||||
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
|
||||
execCmd.Flags().StringVarP(&execArg.eventpath, "eventpath", "e", "", "path to a JSON event payload file exposed as the event that triggered the workflow")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
|
||||
|
||||
220
internal/app/cmd/exec_test.go
Normal file
220
internal/app/cmd/exec_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestExecuteArgsResolve(t *testing.T) {
|
||||
workdir := t.TempDir()
|
||||
args := &executeArgs{workdir: workdir}
|
||||
|
||||
require.Empty(t, args.resolve(""))
|
||||
require.Equal(t, filepath.Join(workdir, "sub", "file"), args.resolve("sub/file"))
|
||||
|
||||
abs := filepath.Join(workdir, "abs")
|
||||
require.Equal(t, abs, args.resolve(abs))
|
||||
}
|
||||
|
||||
func TestExecuteArgsPaths(t *testing.T) {
|
||||
workdir := t.TempDir()
|
||||
args := &executeArgs{
|
||||
workdir: workdir,
|
||||
workflowsPath: ".gitea/workflows",
|
||||
envfile: ".env",
|
||||
}
|
||||
|
||||
require.Equal(t, filepath.Join(workdir, ".gitea/workflows"), args.WorkflowsPath())
|
||||
require.Equal(t, filepath.Join(workdir, ".env"), args.Envfile())
|
||||
require.Equal(t, workdir, args.Workdir())
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadVars(t *testing.T) {
|
||||
require.Empty(t, (&executeArgs{}).LoadVars())
|
||||
|
||||
args := &executeArgs{vars: []string{"FOO=bar", "EMPTY", "WITH=eq=sign"}}
|
||||
require.Equal(t, map[string]string{
|
||||
"FOO": "bar",
|
||||
"EMPTY": "",
|
||||
"WITH": "eq=sign",
|
||||
}, args.LoadVars())
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadSecrets(t *testing.T) {
|
||||
t.Setenv("FROMENV", "from-env-value")
|
||||
|
||||
args := &executeArgs{secrets: []string{"token=abc", "fromenv"}}
|
||||
require.Equal(t, map[string]string{
|
||||
"TOKEN": "abc",
|
||||
"FROMENV": "from-env-value",
|
||||
}, args.LoadSecrets())
|
||||
}
|
||||
|
||||
func TestReadEnvs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envFile := filepath.Join(dir, ".env")
|
||||
require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||
|
||||
envs := map[string]string{"EXISTING": "keep"}
|
||||
require.True(t, readEnvs(envFile, envs))
|
||||
require.Equal(t, map[string]string{
|
||||
"EXISTING": "keep",
|
||||
"FOO": "bar",
|
||||
"BAZ": "qux",
|
||||
}, envs)
|
||||
|
||||
missing := map[string]string{}
|
||||
require.False(t, readEnvs(filepath.Join(dir, "does-not-exist"), missing))
|
||||
require.Empty(t, missing)
|
||||
}
|
||||
|
||||
func TestRunExecListUsesJobEventAndAllPlans(t *testing.T) {
|
||||
planner := &fakeWorkflowPlanner{
|
||||
events: []string{"push", "pull_request"},
|
||||
plans: map[string]*model.Plan{
|
||||
"job:build": listPlan("build", "Build", "push"),
|
||||
"event:push": listPlan("test", "Test", "push"),
|
||||
"all": listPlan("lint", "Lint", "push"),
|
||||
},
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
require.NoError(t, runExecList(planner, &executeArgs{job: "build"}))
|
||||
require.NoError(t, runExecList(planner, &executeArgs{event: "push"}))
|
||||
require.NoError(t, runExecList(planner, &executeArgs{autodetectEvent: true}))
|
||||
require.NoError(t, runExecList(planner, &executeArgs{}))
|
||||
})
|
||||
|
||||
require.Contains(t, out, "Build")
|
||||
require.Contains(t, out, "Test")
|
||||
require.Contains(t, out, "Lint")
|
||||
require.Equal(t, []string{"job:build", "event:push", "event:push", "all"}, planner.calls)
|
||||
}
|
||||
|
||||
func TestPrintListReportsDuplicateJobIDs(t *testing.T) {
|
||||
workflowA := workflowForList("A", "a.yml", "push", "build", "Build A")
|
||||
workflowB := workflowForList("B", "b.yml", "pull_request", "build", "Build B")
|
||||
plan := &model.Plan{Stages: []*model.Stage{{
|
||||
Runs: []*model.Run{
|
||||
{Workflow: workflowA, JobID: "build"},
|
||||
{Workflow: workflowB, JobID: "build"},
|
||||
},
|
||||
}}}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
printList(plan)
|
||||
})
|
||||
|
||||
require.Contains(t, out, "Workflow file")
|
||||
require.Contains(t, out, "Build A")
|
||||
require.Contains(t, out, "Build B")
|
||||
require.Contains(t, out, "Detected multiple jobs with the same job name")
|
||||
}
|
||||
|
||||
func TestLoadExecCmdDefinesExpectedFlags(t *testing.T) {
|
||||
cmd := loadExecCmd(context.Background())
|
||||
|
||||
for _, name := range []string{
|
||||
"list",
|
||||
"job",
|
||||
"event",
|
||||
"workflows",
|
||||
"directory",
|
||||
"env",
|
||||
"secret",
|
||||
"var",
|
||||
"dryrun",
|
||||
"image",
|
||||
"gitea-instance",
|
||||
} {
|
||||
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
|
||||
t.Fatalf("expected flag %q to be registered", name)
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, "exec", cmd.Use)
|
||||
require.NoError(t, cmd.Args(cmd, strings.Split("a b c", " ")))
|
||||
require.Error(t, cmd.Args(cmd, strings.Fields(strings.Repeat("arg ", 21))))
|
||||
}
|
||||
|
||||
type fakeWorkflowPlanner struct {
|
||||
events []string
|
||||
plans map[string]*model.Plan
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (p *fakeWorkflowPlanner) PlanEvent(eventName string) (*model.Plan, error) {
|
||||
p.calls = append(p.calls, "event:"+eventName)
|
||||
return p.plans["event:"+eventName], nil
|
||||
}
|
||||
|
||||
func (p *fakeWorkflowPlanner) PlanJob(jobName string) (*model.Plan, error) {
|
||||
p.calls = append(p.calls, "job:"+jobName)
|
||||
return p.plans["job:"+jobName], nil
|
||||
}
|
||||
|
||||
func (p *fakeWorkflowPlanner) PlanAll() (*model.Plan, error) {
|
||||
p.calls = append(p.calls, "all")
|
||||
return p.plans["all"], nil
|
||||
}
|
||||
|
||||
func (p *fakeWorkflowPlanner) GetEvents() []string {
|
||||
return p.events
|
||||
}
|
||||
|
||||
func listPlan(jobID, jobName, event string) *model.Plan {
|
||||
workflow := workflowForList("Workflow "+jobID, jobID+".yml", event, jobID, jobName)
|
||||
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: jobID}}}}}
|
||||
}
|
||||
|
||||
func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
|
||||
return &model.Workflow{
|
||||
Name: name,
|
||||
File: file,
|
||||
RawOn: rawOnNode(event),
|
||||
Jobs: map[string]*model.Job{
|
||||
jobID: {Name: jobName},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rawOnNode(event string) yaml.Node {
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return *node.Content[0]
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
return buf.String()
|
||||
}
|
||||
@@ -75,6 +75,7 @@ type registerArgs struct {
|
||||
NoInteractive bool
|
||||
InstanceAddr string
|
||||
Token string
|
||||
TokenFile string
|
||||
RunnerName string
|
||||
Labels string
|
||||
Ephemeral bool
|
||||
@@ -93,6 +94,8 @@ const (
|
||||
StageExit
|
||||
)
|
||||
|
||||
const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
||||
|
||||
var defaultLabels = []string{
|
||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||
@@ -207,10 +210,27 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co
|
||||
return StageUnknown
|
||||
}
|
||||
|
||||
func initInputs(regArgs *registerArgs) *registerInputs {
|
||||
func initInputs(regArgs *registerArgs) (*registerInputs, error) {
|
||||
var token string
|
||||
switch {
|
||||
case regArgs.TokenFile != "":
|
||||
tokenBytes, err := os.ReadFile(regArgs.TokenFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read the token file: %s, %v", regArgs.TokenFile, err)
|
||||
}
|
||||
token = string(tokenBytes)
|
||||
case regArgs.Token != "":
|
||||
token = regArgs.Token
|
||||
default:
|
||||
envToken, ok := os.LookupEnv(registerTokenEnvVar)
|
||||
if !ok || envToken == "" {
|
||||
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
|
||||
}
|
||||
token = envToken
|
||||
}
|
||||
inputs := ®isterInputs{
|
||||
InstanceAddr: regArgs.InstanceAddr,
|
||||
Token: regArgs.Token,
|
||||
Token: token,
|
||||
RunnerName: regArgs.RunnerName,
|
||||
Ephemeral: regArgs.Ephemeral,
|
||||
}
|
||||
@@ -219,7 +239,7 @@ func initInputs(regArgs *registerArgs) *registerInputs {
|
||||
if regArgs.Labels != "" {
|
||||
inputs.Labels = strings.Split(regArgs.Labels, ",")
|
||||
}
|
||||
return inputs
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
|
||||
@@ -235,7 +255,10 @@ func registerInteractive(ctx context.Context, configFile string, regArgs *regist
|
||||
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
|
||||
stage = StageOverwriteLocalConfig
|
||||
}
|
||||
inputs := initInputs(regArgs)
|
||||
inputs, err := initInputs(regArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
cmdString := inputs.stageValue(stage)
|
||||
@@ -292,7 +315,10 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs := initInputs(regArgs)
|
||||
inputs, err := initInputs(regArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// specify labels in config file.
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
if regArgs.Labels != "" {
|
||||
@@ -361,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
||||
|
||||
ls := make([]string, len(reg.Labels))
|
||||
for i, v := range reg.Labels {
|
||||
l, _ := labels.Parse(v)
|
||||
l, err := labels.Parse(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse label %q: %w", v, err)
|
||||
}
|
||||
ls[i] = l.Name
|
||||
}
|
||||
// register new runner.
|
||||
|
||||
@@ -4,16 +4,245 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
|
||||
err := registerNoInteractive(t.Context(), "", ®isterArgs{
|
||||
Labels: "label:invalid",
|
||||
Labels: "ubuntu:host,,broken",
|
||||
Token: "token",
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
})
|
||||
assert.Error(t, err, "unsupported schema: invalid")
|
||||
assert.ErrorContains(t, err, "empty label")
|
||||
}
|
||||
|
||||
func TestRegisterInputsValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputs registerInputs
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty instance address",
|
||||
inputs: registerInputs{Token: "token"},
|
||||
wantErr: "instance address is empty",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
inputs: registerInputs{InstanceAddr: "http://localhost:3000"},
|
||||
wantErr: "token is empty",
|
||||
},
|
||||
{
|
||||
name: "invalid label",
|
||||
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}},
|
||||
wantErr: "empty label",
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:host"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.inputs.validate()
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLabels(t *testing.T) {
|
||||
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
||||
// a colon that is not a supported schema is part of the label name
|
||||
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
||||
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
||||
}
|
||||
|
||||
func TestRegisterInputsStageValue(t *testing.T) {
|
||||
inputs := ®isterInputs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
Token: "token",
|
||||
RunnerName: "runner",
|
||||
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
|
||||
}
|
||||
require.Equal(t, "http://localhost:3000", inputs.stageValue(StageInputInstance))
|
||||
require.Equal(t, "token", inputs.stageValue(StageInputToken))
|
||||
require.Equal(t, "runner", inputs.stageValue(StageInputRunnerName))
|
||||
require.Equal(t, "ubuntu:host,ubuntu:docker://node:18", inputs.stageValue(StageInputLabels))
|
||||
require.Empty(t, (®isterInputs{}).stageValue(StageInputLabels))
|
||||
require.Empty(t, inputs.stageValue(StageWaitingForRegistration))
|
||||
}
|
||||
|
||||
func TestRegisterInputsAssignToNext(t *testing.T) {
|
||||
emptyCfg := &config.Config{}
|
||||
|
||||
t.Run("instance and token stay on empty value", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageInputInstance, inputs.assignToNext(StageInputInstance, "", emptyCfg))
|
||||
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputToken, "", emptyCfg))
|
||||
})
|
||||
|
||||
t.Run("instance then token then runner name", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputInstance, "http://localhost:3000", emptyCfg))
|
||||
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||
require.Equal(t, StageInputRunnerName, inputs.assignToNext(StageInputToken, "token", emptyCfg))
|
||||
require.Equal(t, "token", inputs.Token)
|
||||
})
|
||||
|
||||
t.Run("empty runner name falls back to hostname", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputRunnerName, "", emptyCfg))
|
||||
hostname, _ := os.Hostname()
|
||||
require.Equal(t, hostname, inputs.RunnerName)
|
||||
})
|
||||
|
||||
t.Run("labels from config skip the labels stage", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"}
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
|
||||
require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("blank labels input uses defaults", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "", emptyCfg))
|
||||
require.Equal(t, defaultLabels, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("invalid labels input loops back", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:host,,bad", emptyCfg))
|
||||
require.Nil(t, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("labels containing a colon are accepted", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "pool:e57e18d4,ubuntu:host", emptyCfg))
|
||||
require.Equal(t, []string{"pool:e57e18d4", "ubuntu:host"}, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("overwrite local config", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
|
||||
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "y", emptyCfg))
|
||||
require.Equal(t, StageExit, inputs.assignToNext(StageOverwriteLocalConfig, "n", emptyCfg))
|
||||
})
|
||||
|
||||
t.Run("unknown stage", func(t *testing.T) {
|
||||
inputs := ®isterInputs{}
|
||||
require.Equal(t, StageUnknown, inputs.assignToNext(StageWaitingForRegistration, "x", emptyCfg))
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitInputs(t *testing.T) {
|
||||
t.Run("missing token", func(t *testing.T) {
|
||||
_, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
|
||||
})
|
||||
|
||||
t.Run("empty token", func(t *testing.T) {
|
||||
t.Setenv(registerTokenEnvVar, "")
|
||||
_, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
Token: "",
|
||||
TokenFile: "",
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
|
||||
})
|
||||
|
||||
t.Run("invalid token file", func(t *testing.T) {
|
||||
t.Setenv(registerTokenEnvVar, "from-env")
|
||||
_, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
TokenFile: "/tmp/nonexistent",
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory")
|
||||
})
|
||||
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
t.Setenv(registerTokenEnvVar, "from-env")
|
||||
inputs, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
Token: "from-plain-arg",
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||
require.Equal(t, "from-plain-arg", inputs.Token)
|
||||
require.Equal(t, "runner", inputs.RunnerName)
|
||||
require.True(t, inputs.Ephemeral)
|
||||
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("valid token file", func(t *testing.T) {
|
||||
t.Setenv(registerTokenEnvVar, "from-env")
|
||||
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
|
||||
require.NoError(t, createErr)
|
||||
defer tokenFile.Close()
|
||||
_, writeErr := tokenFile.WriteString("from-file")
|
||||
require.NoError(t, writeErr)
|
||||
_ = tokenFile.Sync()
|
||||
|
||||
inputs, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
TokenFile: tokenFile.Name(),
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||
require.Equal(t, "from-file", inputs.Token)
|
||||
require.Equal(t, "runner", inputs.RunnerName)
|
||||
require.True(t, inputs.Ephemeral)
|
||||
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("token from environment variable", func(t *testing.T) {
|
||||
t.Setenv(registerTokenEnvVar, "from-env")
|
||||
inputs, err := initInputs(®isterArgs{
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
RunnerName: "runner",
|
||||
Ephemeral: true,
|
||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||
require.Equal(t, "from-env", inputs.Token)
|
||||
require.Equal(t, "runner", inputs.RunnerName)
|
||||
require.True(t, inputs.Ephemeral)
|
||||
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||
})
|
||||
|
||||
t.Run("empty labels", func(t *testing.T) {
|
||||
inputs, _ := initInputs(®isterArgs{
|
||||
Token: "from-plain-arg",
|
||||
Labels: " ",
|
||||
})
|
||||
require.Nil(t, inputs.Labels)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ type IdleRunner interface {
|
||||
OnIdle(ctx context.Context)
|
||||
}
|
||||
|
||||
// AvailabilityRunner can temporarily pause task fetching for local resource
|
||||
// conditions such as low disk space without changing server-side scheduling.
|
||||
type AvailabilityRunner interface {
|
||||
CanAcceptTask(ctx context.Context) (bool, string)
|
||||
}
|
||||
|
||||
type Poller struct {
|
||||
client client.Client
|
||||
runner TaskRunner
|
||||
@@ -45,6 +51,15 @@ type Poller struct {
|
||||
shutdownJobs context.CancelFunc
|
||||
|
||||
done chan struct{}
|
||||
|
||||
// unregistered is set when the server rejects the runner with an
|
||||
// Unauthenticated response, meaning the runner is no longer registered.
|
||||
unregistered atomic.Bool
|
||||
lastHealthyPoll atomic.Int64
|
||||
lastPollFailed atomic.Bool
|
||||
availabilityMu sync.Mutex
|
||||
availabilityReady bool
|
||||
availabilityReason string
|
||||
}
|
||||
|
||||
// workerState holds the single poller's backoff state. Consecutive empty or
|
||||
@@ -66,7 +81,7 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
return &Poller{
|
||||
p := &Poller{
|
||||
client: client,
|
||||
runner: runner,
|
||||
cfg: cfg,
|
||||
@@ -79,6 +94,10 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
|
||||
|
||||
done: done,
|
||||
}
|
||||
p.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||
p.availabilityReady = true
|
||||
p.availabilityReason = "ok"
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Poller) Poll() {
|
||||
@@ -98,6 +117,17 @@ func (p *Poller) Poll() {
|
||||
return
|
||||
}
|
||||
|
||||
ready, reason := p.localAvailability(p.pollingCtx)
|
||||
p.reportAvailability(ready, reason)
|
||||
if !ready {
|
||||
p.runIdleMaintenance()
|
||||
<-sem
|
||||
if !p.waitBackoff(s) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
task, ok := p.fetchTask(p.pollingCtx, s)
|
||||
if !ok {
|
||||
p.runIdleMaintenance()
|
||||
@@ -123,6 +153,15 @@ func (p *Poller) PollOnce() {
|
||||
defer close(p.done)
|
||||
s := &workerState{}
|
||||
for {
|
||||
ready, reason := p.localAvailability(p.pollingCtx)
|
||||
p.reportAvailability(ready, reason)
|
||||
if !ready {
|
||||
p.runIdleMaintenance()
|
||||
if !p.waitBackoff(s) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
task, ok := p.fetchTask(p.pollingCtx, s)
|
||||
if !ok {
|
||||
p.runIdleMaintenance()
|
||||
@@ -137,6 +176,61 @@ func (p *Poller) PollOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed once polling has fully stopped,
|
||||
// allowing callers to react when the poller shuts itself down (e.g. after the
|
||||
// runner has been unregistered) rather than only on an external cancellation.
|
||||
func (p *Poller) Done() <-chan struct{} {
|
||||
return p.done
|
||||
}
|
||||
|
||||
// Unregistered reports whether polling stopped because the server rejected the
|
||||
// runner as unregistered (an Unauthenticated response).
|
||||
func (p *Poller) Unregistered() bool {
|
||||
return p.unregistered.Load()
|
||||
}
|
||||
|
||||
// Ready reports whether the daemon can currently communicate with Gitea and
|
||||
// accept work, reusing the availability the poll loop last observed rather than
|
||||
// re-running the check. Transient transport failures are tolerated for grace.
|
||||
func (p *Poller) Ready(grace time.Duration) (bool, string) {
|
||||
if p.unregistered.Load() {
|
||||
return false, "runner is no longer registered"
|
||||
}
|
||||
p.availabilityMu.Lock()
|
||||
ready, reason := p.availabilityReady, p.availabilityReason
|
||||
p.availabilityMu.Unlock()
|
||||
if !ready {
|
||||
return false, reason
|
||||
}
|
||||
if !p.lastPollFailed.Load() {
|
||||
return true, "ok"
|
||||
}
|
||||
if time.Since(time.Unix(0, p.lastHealthyPoll.Load())) <= grace {
|
||||
return true, "polling errors within grace period"
|
||||
}
|
||||
return false, "unable to poll Gitea"
|
||||
}
|
||||
|
||||
func (p *Poller) localAvailability(ctx context.Context) (bool, string) {
|
||||
if available, ok := p.runner.(AvailabilityRunner); ok {
|
||||
return available.CanAcceptTask(ctx)
|
||||
}
|
||||
return true, "ok"
|
||||
}
|
||||
|
||||
func (p *Poller) reportAvailability(ready bool, reason string) {
|
||||
p.availabilityMu.Lock()
|
||||
defer p.availabilityMu.Unlock()
|
||||
switch {
|
||||
case !ready && p.availabilityReady:
|
||||
log.Warnf("runner temporarily unavailable: %s", reason)
|
||||
case ready && !p.availabilityReady:
|
||||
log.Info("runner local health recovered, resuming task polling")
|
||||
}
|
||||
p.availabilityReady = ready
|
||||
p.availabilityReason = reason
|
||||
}
|
||||
|
||||
func (p *Poller) runIdleMaintenance() {
|
||||
if idleRunner, ok := p.runner.(IdleRunner); ok {
|
||||
idleRunner.OnIdle(p.jobsCtx)
|
||||
@@ -256,6 +350,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
||||
// found no work within FetchTimeout. Treat it as an empty response and do
|
||||
// not record the duration — the timeout value would swamp the histogram.
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
p.markHealthyPoll()
|
||||
s.consecutiveEmpty++
|
||||
s.consecutiveErrors = 0 // timeout is a healthy idle response
|
||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
|
||||
@@ -264,12 +359,23 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
||||
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
|
||||
|
||||
if err != nil {
|
||||
// An Unauthenticated response means the server no longer knows this
|
||||
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
|
||||
// polling and let the daemon exit with an error instead of spinning.
|
||||
if connect.CodeOf(err) == connect.CodeUnauthenticated {
|
||||
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
|
||||
p.unregistered.Store(true)
|
||||
p.shutdownPolling()
|
||||
return nil, false
|
||||
}
|
||||
log.WithError(err).Error("failed to fetch task")
|
||||
p.lastPollFailed.Store(true)
|
||||
s.consecutiveErrors++
|
||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
|
||||
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc()
|
||||
return nil, false
|
||||
}
|
||||
p.markHealthyPoll()
|
||||
|
||||
// Successful response — reset error counter.
|
||||
s.consecutiveErrors = 0
|
||||
@@ -296,3 +402,8 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
|
||||
return resp.Msg.Task, true
|
||||
}
|
||||
|
||||
func (p *Poller) markHealthyPoll() {
|
||||
p.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||
p.lastPollFailed.Store(false)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,35 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
|
||||
assert.Equal(t, int64(0), s.consecutiveEmpty)
|
||||
}
|
||||
|
||||
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
|
||||
// response marks the runner as unregistered and cancels the polling context so
|
||||
// the daemon can exit instead of retrying forever.
|
||||
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
|
||||
client := mocks.NewClient(t)
|
||||
client.On("FetchTask", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
|
||||
return nil, connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner"))
|
||||
},
|
||||
)
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
p := New(cfg, client, nil)
|
||||
|
||||
s := &workerState{}
|
||||
_, ok := p.fetchTask(context.Background(), s)
|
||||
require.False(t, ok)
|
||||
|
||||
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
|
||||
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
|
||||
|
||||
select {
|
||||
case <-p.pollingCtx.Done():
|
||||
default:
|
||||
t.Fatal("expected polling context to be cancelled after an Unauthenticated response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPoller_CalculateInterval verifies the exponential backoff math is
|
||||
// correctly driven by the workerState counters.
|
||||
func TestPoller_CalculateInterval(t *testing.T) {
|
||||
@@ -130,6 +159,81 @@ type idleAwareRunner struct {
|
||||
idleCalls atomic.Int64
|
||||
}
|
||||
|
||||
type availabilityRunner struct {
|
||||
mockRunner
|
||||
ready atomic.Bool
|
||||
reason string
|
||||
}
|
||||
|
||||
func (r *availabilityRunner) CanAcceptTask(_ context.Context) (bool, string) {
|
||||
if r.ready.Load() {
|
||||
return true, "ok"
|
||||
}
|
||||
return false, r.reason
|
||||
}
|
||||
|
||||
func TestPollerReady(t *testing.T) {
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
poller := New(cfg, nil, &availabilityRunner{})
|
||||
|
||||
// /readyz reuses the availability the poll loop last recorded.
|
||||
ready, reason := poller.Ready(time.Second)
|
||||
assert.True(t, ready)
|
||||
assert.Equal(t, "ok", reason)
|
||||
|
||||
poller.reportAvailability(false, "low disk space")
|
||||
ready, reason = poller.Ready(time.Second)
|
||||
assert.False(t, ready)
|
||||
assert.Equal(t, "low disk space", reason)
|
||||
|
||||
poller.reportAvailability(true, "ok")
|
||||
poller.lastPollFailed.Store(true)
|
||||
poller.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||
ready, _ = poller.Ready(time.Second)
|
||||
assert.True(t, ready, "transient polling errors should remain ready during grace")
|
||||
|
||||
poller.lastHealthyPoll.Store(time.Now().Add(-2 * time.Second).UnixNano())
|
||||
ready, reason = poller.Ready(time.Second)
|
||||
assert.False(t, ready)
|
||||
assert.Equal(t, "unable to poll Gitea", reason)
|
||||
|
||||
poller.unregistered.Store(true)
|
||||
ready, reason = poller.Ready(time.Second)
|
||||
assert.False(t, ready)
|
||||
assert.Equal(t, "runner is no longer registered", reason)
|
||||
}
|
||||
|
||||
func TestPollerPausesAndResumesForLocalAvailability(t *testing.T) {
|
||||
var fetches atomic.Int64
|
||||
cli := mocks.NewClient(t)
|
||||
cli.On("FetchTask", mock.Anything, mock.Anything).Maybe().Run(func(mock.Arguments) {
|
||||
fetches.Add(1)
|
||||
}).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil)
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Runner.FetchInterval = 10 * time.Millisecond
|
||||
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
|
||||
runner := &availabilityRunner{reason: "low disk space"}
|
||||
poller := New(cfg, cli, runner)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(poller.Poll)
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
assert.Zero(t, fetches.Load(), "an unavailable runner must not fetch a task")
|
||||
|
||||
runner.ready.Store(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return fetches.Load() > 0
|
||||
}, time.Second, 10*time.Millisecond, "polling should resume after local recovery")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, poller.Shutdown(shutdownCtx))
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
|
||||
atomicMax(&m.maxConcurrent, m.running.Add(1))
|
||||
select {
|
||||
|
||||
12
internal/app/run/disk_other.go
Normal file
12
internal/app/run/disk_other.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||
|
||||
package run
|
||||
|
||||
import "fmt"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
return 0, fmt.Errorf("free disk space checks are not supported for %s", path)
|
||||
}
|
||||
53
internal/app/run/disk_test.go
Normal file
53
internal/app/run/disk_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCanAcceptTaskDiskGuard(t *testing.T) {
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Host.WorkdirParent = t.TempDir()
|
||||
cfg.HealthCheck.Enabled = true
|
||||
r := &Runner{cfg: cfg}
|
||||
|
||||
ready, _ := r.CanAcceptTask(t.Context())
|
||||
assert.True(t, ready)
|
||||
|
||||
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
|
||||
ready, reason := r.CanAcceptTask(t.Context())
|
||||
assert.False(t, ready)
|
||||
assert.Contains(t, reason, "low disk space")
|
||||
}
|
||||
|
||||
func TestDiskCheckDeferredWhileJobRuns(t *testing.T) {
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.HealthCheck.Enabled = true
|
||||
cfg.HealthCheck.MinFreeDiskSpaceMB = 1
|
||||
cfg.Host.WorkdirParent = t.TempDir()
|
||||
r := &Runner{cfg: cfg}
|
||||
|
||||
ready, reason := r.CanAcceptTask(t.Context())
|
||||
assert.True(t, ready)
|
||||
assert.Equal(t, "ok", reason)
|
||||
|
||||
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
|
||||
r.runningCount.Store(1)
|
||||
ready, reason = r.CanAcceptTask(t.Context())
|
||||
assert.True(t, ready, "the disk check must not run while a job is active")
|
||||
assert.Equal(t, "ok", reason)
|
||||
|
||||
r.runningCount.Store(0)
|
||||
ready, reason = r.CanAcceptTask(t.Context())
|
||||
assert.False(t, ready)
|
||||
assert.Contains(t, reason, "low disk space")
|
||||
}
|
||||
16
internal/app/run/disk_unix.go
Normal file
16
internal/app/run/disk_unix.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package run
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Bavail) * uint64(stat.Bsize), nil //nolint:unconvert // Bavail/Bsize signedness differs by platform
|
||||
}
|
||||
20
internal/app/run/disk_windows.go
Normal file
20
internal/app/run/disk_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package run
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var available uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user