mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
16 Commits
v2.2.0
...
0192861155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 | ||
|
|
fc0e03e5a9 | ||
|
|
333eb17d19 | ||
|
|
c3b39e0d99 | ||
|
|
78a74f78f8 | ||
|
|
8c519ce318 | ||
|
|
de43c84203 | ||
|
|
3c5ef1721a | ||
|
|
94ab020204 | ||
|
|
b4a64b97dd | ||
|
|
26f9fb12af | ||
|
|
c9c4957e38 | ||
|
|
b1a02cdd5d | ||
|
|
0fd8602ac3 |
@@ -18,8 +18,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
|
|||||||
@@ -17,14 +17,26 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release --nightly
|
args: release --nightly
|
||||||
@@ -35,6 +47,10 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
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"
|
GORELEASER_FORCE_TOKEN: "gitea"
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -58,18 +74,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
@@ -83,7 +99,7 @@ jobs:
|
|||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -9,21 +9,33 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
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:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
passphrase: ${{ secrets.PASSPHRASE }}
|
passphrase: ${{ secrets.PASSPHRASE }}
|
||||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release
|
args: release
|
||||||
@@ -34,6 +46,10 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
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"
|
GORELEASER_FORCE_TOKEN: "gitea"
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||||
@@ -61,25 +77,25 @@ jobs:
|
|||||||
DOCKER_LATEST: latest
|
DOCKER_LATEST: latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_ORG }}/runner
|
${{ env.DOCKER_ORG }}/runner
|
||||||
@@ -92,7 +108,7 @@ jobs:
|
|||||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ jobs:
|
|||||||
# to ~/.docker with the stale credentials.
|
# to ~/.docker with the stale credentials.
|
||||||
DOCKER_CONFIG: /tmp/docker-noauth
|
DOCKER_CONFIG: /tmp/docker-noauth
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
- name: prepare anonymous docker config
|
- name: prepare anonymous docker config
|
||||||
@@ -33,6 +33,8 @@ jobs:
|
|||||||
done
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
|
- name: checks
|
||||||
|
run: make checks
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
|
|||||||
@@ -93,6 +93,37 @@ blobs:
|
|||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- 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:
|
archives:
|
||||||
- format: binary
|
- format: binary
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.1-dind AS dind
|
FROM docker:29.6.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.1-dind-rootless AS dind-rootless
|
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
11
Makefile
11
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
|
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
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||||
|
|
||||||
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
STATIC ?=
|
STATIC ?=
|
||||||
EXTLDFLAGS ?=
|
EXTLDFLAGS ?=
|
||||||
ifneq ($(STATIC),)
|
ifneq ($(STATIC),)
|
||||||
@@ -110,6 +112,9 @@ deps-tools: ## install tool dependencies
|
|||||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
.PHONY: checks
|
||||||
|
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-go-windows ## lint everything
|
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
|
@node ./tools/lint-pr-title.ts
|
||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check: deps-tools
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
@@ -148,8 +153,8 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: ## 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
|
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: coverage-report
|
.PHONY: coverage-report
|
||||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -224,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
|||||||
dir: /data/actcache
|
dir: /data/actcache
|
||||||
port: 8088
|
port: 8088
|
||||||
external_secret: "replace-with-a-strong-random-secret"
|
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:
|
2. Start the server:
|
||||||
@@ -238,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
|||||||
cache:
|
cache:
|
||||||
external_server: "http://<cache-server-host>:8088/"
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
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.
|
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||||
@@ -268,6 +270,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.
|
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
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
|
|||||||
@@ -445,13 +445,6 @@ func TestHandler(t *testing.T) {
|
|||||||
require.Equal(t, 404, resp.StatusCode)
|
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) {
|
t.Run("get with multiple keys", func(t *testing.T) {
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
@@ -469,7 +462,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
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{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -554,7 +548,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
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{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -607,7 +602,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
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{
|
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
|
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
var id uint64
|
var id uint64
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -82,44 +82,45 @@ func TestNewConditionalExecutor(t *testing.T) {
|
|||||||
assert.Equal(1, falseCount)
|
assert.Equal(1, falseCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutor(t *testing.T) {
|
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||||
assert := assert.New(t)
|
// 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()
|
return func(ctx context.Context) error {
|
||||||
|
counted.Add(1)
|
||||||
var count, activeCount, maxCount atomic.Int32
|
running := active.Add(1)
|
||||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
|
||||||
count.Add(1)
|
|
||||||
|
|
||||||
active := activeCount.Add(1)
|
|
||||||
for {
|
for {
|
||||||
m := maxCount.Load()
|
seen := peak.Load()
|
||||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
if running >= wantActive {
|
||||||
activeCount.Add(-1)
|
once.Do(func() { close(reached) })
|
||||||
|
}
|
||||||
|
<-reached
|
||||||
|
active.Add(-1)
|
||||||
return nil
|
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")
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
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
|
// parallelism below 1 falls back to a single worker
|
||||||
count.Store(0)
|
exec, count, maxActive = concurrencyProbe(1)
|
||||||
activeCount.Store(0)
|
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||||
maxCount.Store(0)
|
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")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
@@ -173,6 +174,23 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
|||||||
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
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) {
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var calls []string
|
var calls []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
|
||||||
|
}
|
||||||
61
act/container/docker_create_flags_test.go
Normal file
61
act/container/docker_create_flags_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// 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 := NewContainer(input).(*containerReference)
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ import (
|
|||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
"github.com/gobwas/glob"
|
"github.com/gobwas/glob"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kballard/go-shellquote"
|
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
@@ -43,7 +42,6 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
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
|
// 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 {
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
cr := new(containerReference)
|
cr := new(containerReference)
|
||||||
cr.input = input
|
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
|
return cr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +141,11 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) Pull(forcePull 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.
|
return common.
|
||||||
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
||||||
Then(
|
Then(
|
||||||
@@ -235,6 +244,7 @@ type containerReference struct {
|
|||||||
cli client.APIClient
|
cli client.APIClient
|
||||||
id string
|
id string
|
||||||
input *NewContainerInput
|
input *NewContainerInput
|
||||||
|
pullPolicy string
|
||||||
UID int
|
UID int
|
||||||
GID int
|
GID int
|
||||||
// attachDone is closed by the attach() streaming goroutine once it has
|
// attachDone is closed by the attach() streaming goroutine once it has
|
||||||
@@ -411,17 +421,13 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse configuration from CLI container.options
|
// parse configuration from CLI container.options
|
||||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
flags, copts, cf, err := parseContainerOptions(input.Options)
|
||||||
copts := addFlags(flags)
|
|
||||||
|
|
||||||
optionsArgs, err := shellquote.Split(input.Options)
|
|
||||||
if err != nil {
|
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 := cf.validate(); err != nil {
|
||||||
if err != nil {
|
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||||
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
||||||
@@ -465,8 +471,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
|
|
||||||
hostConfig.Binds = append(hostConfig.Binds, containerConfig.HostConfig.Binds...)
|
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||||
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
|
|
||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
networkMode := hostConfig.NetworkMode
|
networkMode := hostConfig.NetworkMode
|
||||||
@@ -476,6 +481,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
}
|
}
|
||||||
hostConfig.Binds = binds
|
hostConfig.Binds = binds
|
||||||
hostConfig.Mounts = mounts
|
hostConfig.Mounts = mounts
|
||||||
|
if cf.name != "" {
|
||||||
|
logger.Warn("--name in the options will be ignored.")
|
||||||
|
}
|
||||||
if len(copts.netMode.Value()) > 0 {
|
if len(copts.netMode.Value()) > 0 {
|
||||||
logger.Warn("--network and --net in the options will be ignored.")
|
logger.Warn("--network and --net in the options will be ignored.")
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1107,34 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
return config, hostConfig
|
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 {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []glob.Glob
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -660,3 +661,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.Empty(t, hostConf.Binds)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
} else {
|
} else {
|
||||||
wd = e.Path
|
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)
|
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
|||||||
return strings.Join(paths, ":")
|
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 {
|
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"os": "Linux",
|
"os": "Linux",
|
||||||
"arch": RunnerArch(ctx),
|
"arch": RunnerArch(ctx),
|
||||||
"temp": "/tmp",
|
"temp": "/tmp",
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
"tool_cache": DefaultToolCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ type ActionRuns struct {
|
|||||||
Post string `yaml:"post"`
|
Post string `yaml:"post"`
|
||||||
PostIf string `yaml:"post-if"`
|
PostIf string `yaml:"post-if"`
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image"`
|
||||||
|
PreEntrypoint string `yaml:"pre-entrypoint"`
|
||||||
Entrypoint string `yaml:"entrypoint"`
|
Entrypoint string `yaml:"entrypoint"`
|
||||||
|
PostEntrypoint string `yaml:"post-entrypoint"`
|
||||||
Args []string `yaml:"args"`
|
Args []string `yaml:"args"`
|
||||||
Steps []Step `yaml:"steps"`
|
Steps []Step `yaml:"steps"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,22 @@ runs:
|
|||||||
t.Fatalf("error = %q, want invalid value", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
if remoteAction == nil {
|
if remoteAction == nil {
|
||||||
location = containerActionDir
|
location = containerActionDir
|
||||||
}
|
}
|
||||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
|
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -305,7 +305,7 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: break out parts of function to reduce complexicity
|
// 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)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
@@ -386,17 +386,10 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
cmd = action.Runs.Args
|
cmd = action.Runs.Args
|
||||||
evalDockerArgs(ctx, step, action, &cmd)
|
evalDockerArgs(ctx, step, action, &cmd)
|
||||||
}
|
}
|
||||||
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]))
|
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||||
if len(entrypoint) == 0 {
|
|
||||||
if action.Runs.Entrypoint != "" {
|
|
||||||
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
entrypoint = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
@@ -409,6 +402,30 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
).Finally(stepContainer.Close())(ctx)
|
).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) {
|
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
@@ -455,10 +472,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", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
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"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
@@ -559,44 +573,57 @@ func hasPreStep(step actionStep) common.Conditional {
|
|||||||
return action.Runs.Using.IsComposite() ||
|
return action.Runs.Using.IsComposite() ||
|
||||||
(action.Runs.Using.IsNode() &&
|
(action.Runs.Using.IsNode() &&
|
||||||
action.Runs.Pre != "") ||
|
action.Runs.Pre != "") ||
|
||||||
|
(action.Runs.Using.IsDocker() &&
|
||||||
|
action.Runs.PreEntrypoint != "") ||
|
||||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||||
action.Runs.Pre != "")
|
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 {
|
func runPreStep(step actionStep) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("run pre step for '%s'", step.getStepModel())
|
logger.Debugf("run pre step for '%s'", step.getStepModel())
|
||||||
|
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||||
|
|
||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
switch {
|
switch {
|
||||||
case x.IsNode():
|
case x.IsNode():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
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 {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -609,6 +636,12 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
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():
|
case x.IsComposite():
|
||||||
if step.getCompositeSteps() == nil {
|
if step.getCompositeSteps() == nil {
|
||||||
step.getCompositeRunContext(ctx)
|
step.getCompositeRunContext(ctx)
|
||||||
@@ -622,25 +655,6 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
case x == model.ActionRunsUsingGo:
|
case x == model.ActionRunsUsingGo:
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
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 {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -693,6 +707,8 @@ func hasPostStep(step actionStep) common.Conditional {
|
|||||||
return action.Runs.Using.IsComposite() ||
|
return action.Runs.Using.IsComposite() ||
|
||||||
(action.Runs.Using.IsNode() &&
|
(action.Runs.Using.IsNode() &&
|
||||||
action.Runs.Post != "") ||
|
action.Runs.Post != "") ||
|
||||||
|
(action.Runs.Using.IsDocker() &&
|
||||||
|
action.Runs.PostEntrypoint != "") ||
|
||||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||||
action.Runs.Post != "")
|
action.Runs.Post != "")
|
||||||
}
|
}
|
||||||
@@ -704,28 +720,9 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
logger.Debugf("run post step for '%s'", step.getStepModel())
|
logger.Debugf("run post step for '%s'", step.getStepModel())
|
||||||
|
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
// todo: refactor into step
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(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)
|
|
||||||
|
|
||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
switch {
|
switch {
|
||||||
@@ -740,6 +737,11 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
|
case x.IsDocker():
|
||||||
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
|
||||||
|
return execDockerActionStage(ctx, step, stepStagePost)
|
||||||
|
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func TestExecAsDockerAutoRemove(t *testing.T) {
|
|||||||
cm.On("Start", true).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 })
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false))
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain))
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
assert.Equal(t, tc.removes, removes)
|
assert.Equal(t, tc.removes, removes)
|
||||||
}
|
}
|
||||||
@@ -465,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
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 {
|
select {
|
||||||
case <-innerEntered:
|
case <-innerEntered:
|
||||||
@@ -541,3 +541,86 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
dockerActionImageTag("owner/repo", "./sub", true),
|
dockerActionImageTag("owner/repo", "./sub", true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
||||||
|
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
||||||
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||||
|
orig := ContainerNewContainer
|
||||||
|
defer func() { ContainerNewContainer = orig }()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
stage stepStage
|
||||||
|
wantEntrypoint []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "main stage prefers the entrypoint input",
|
||||||
|
stage: stepStageMain,
|
||||||
|
wantEntrypoint: []string{"input.sh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pre stage uses runs.pre-entrypoint",
|
||||||
|
stage: stepStagePre,
|
||||||
|
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "post stage uses runs.post-entrypoint",
|
||||||
|
stage: stepStagePost,
|
||||||
|
wantEntrypoint: []string{"post.sh"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cm := &containerMock{}
|
||||||
|
var input *container.NewContainerInput
|
||||||
|
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
input = in
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
step := &stepActionRemote{
|
||||||
|
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
|
JobContainer: cm,
|
||||||
|
},
|
||||||
|
action: &model.Action{Runs: model.ActionRuns{
|
||||||
|
Using: "docker",
|
||||||
|
Image: "docker://node:14",
|
||||||
|
PreEntrypoint: "pre.sh --verbose",
|
||||||
|
Entrypoint: "main.sh",
|
||||||
|
PostEntrypoint: "post.sh",
|
||||||
|
Args: []string{"hello"},
|
||||||
|
Env: map[string]string{"MY_VAR": "world"},
|
||||||
|
}},
|
||||||
|
env: map[string]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Remove").Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||||
|
require.NotNil(t, input)
|
||||||
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||||
|
assert.Equal(t, []string{"hello"}, input.Cmd)
|
||||||
|
assert.Contains(t, input.Env, "MY_VAR=world")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
||||||
|
newStep := func(runs model.ActionRuns) actionStep {
|
||||||
|
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||||
|
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||||
|
|
||||||
|
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
|
||||||
|
assert.True(t, hasPreStep(newStep(withStages))(ctx))
|
||||||
|
assert.True(t, hasPostStep(newStep(withStages))(ctx))
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,30 +154,25 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
return rtn
|
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 {
|
func UnescapeCommandData(arg string) string {
|
||||||
escapeMap := map[string]string{
|
return commandDataUnescaper.Replace(arg)
|
||||||
"%25": "%",
|
|
||||||
"%0D": "\r",
|
|
||||||
"%0A": "\n",
|
|
||||||
}
|
|
||||||
for k, v := range escapeMap {
|
|
||||||
arg = strings.ReplaceAll(arg, k, v)
|
|
||||||
}
|
|
||||||
return arg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unescapeCommandProperty(arg string) string {
|
func unescapeCommandProperty(arg string) string {
|
||||||
escapeMap := map[string]string{
|
return commandPropertyUnescaper.Replace(arg)
|
||||||
"%25": "%",
|
|
||||||
"%0D": "\r",
|
|
||||||
"%0A": "\n",
|
|
||||||
"%3A": ":",
|
|
||||||
"%2C": ",",
|
|
||||||
}
|
|
||||||
for k, v := range escapeMap {
|
|
||||||
arg = strings.ReplaceAll(arg, k, v)
|
|
||||||
}
|
|
||||||
return arg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
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"])
|
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,
|
Inputs: inputs,
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
if rc.JobContainer != nil {
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
|
|
||||||
}
|
|
||||||
return expressionEvaluator{
|
return expressionEvaluator{
|
||||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||||
Run: rc.Run,
|
Run: rc.Run,
|
||||||
@@ -149,9 +147,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
|
|||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
if rc.JobContainer != nil {
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
|
|
||||||
}
|
|
||||||
return expressionEvaluator{
|
return expressionEvaluator{
|
||||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||||
Run: rc.Run,
|
Run: rc.Run,
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"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
|
// 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.
|
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
func requireHostTools(t *testing.T, tools ...string) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
|||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
common.Logger(ctx).Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
rc.markFailed()
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
@@ -226,11 +226,16 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The setup section of the job log: download the actions, run the pre steps, then name the job.
|
// 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, printPrepareActions(rc, preparers))
|
||||||
preSteps = append(preSteps, stepPreSteps...)
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
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 {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||||
@@ -46,6 +47,7 @@ func TestJobExecutor(t *testing.T) {
|
|||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||||
if len(tm) > 1 {
|
if len(tm) > 1 {
|
||||||
ret = append(ret, tm, "***")
|
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) {
|
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||||
b := &bytes.Buffer{}
|
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) {
|
if f.isColored(entry) {
|
||||||
f.printColored(b, entry)
|
f.printColored(b, entry)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValueMasker(t *testing.T) {
|
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"},
|
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
||||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_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 {
|
for _, entry := range table {
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,41 +206,81 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
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
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
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{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
mounts := map[string]string{
|
var volumes []string
|
||||||
"act-toolcache": "/opt/hostedtoolcache",
|
|
||||||
name + "-env": ext.GetActPath(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if job := rc.Run.Job(); job != nil {
|
if job := rc.Run.Job(); job != nil {
|
||||||
if container := job.Container(); container != nil {
|
if container := job.Container(); container != nil {
|
||||||
for _, v := range container.Volumes {
|
for _, v := range container.Volumes {
|
||||||
if rc.ExprEval != nil {
|
if rc.ExprEval != nil {
|
||||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||||
}
|
}
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
volumes = append(volumes, 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]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// the runner's own mounts below yield to the targets the job claims
|
||||||
|
binds, mounts, claimed := splitVolumes(volumes)
|
||||||
|
|
||||||
|
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 {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
@@ -247,9 +289,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if selinux.GetEnabled() {
|
if selinux.GetEnabled() {
|
||||||
bindModifiers = ":z"
|
bindModifiers = ":z"
|
||||||
}
|
}
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers))
|
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||||
} else {
|
} else {
|
||||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
mounts[name] = workdir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
@@ -283,7 +326,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
||||||
return err
|
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{
|
rc.JobContainer = &container.HostEnvironment{
|
||||||
Path: path,
|
Path: path,
|
||||||
TmpDir: runnerTmp,
|
TmpDir: runnerTmp,
|
||||||
@@ -298,7 +344,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
}
|
}
|
||||||
rc.cleanUpJobContainer = rc.JobContainer.Remove()
|
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 {
|
if v, ok := v.(string); ok {
|
||||||
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||||
}
|
}
|
||||||
@@ -368,10 +414,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
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, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
@@ -454,37 +497,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.cleanUpJobContainer = func(ctx context.Context) error {
|
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||||
jobContainerNetwork := networkName
|
jobContainerNetwork := networkName
|
||||||
@@ -539,6 +552,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
|
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 func(ctx context.Context) error {
|
||||||
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
||||||
@@ -981,6 +1029,23 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
|||||||
return rc.StepResults
|
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 {
|
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ghc := &model.GithubContext{
|
ghc := &model.GithubContext{
|
||||||
@@ -1165,7 +1230,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["CI"] = "true"
|
||||||
env["GITHUB_WORKFLOW"] = github.Workflow
|
env["GITHUB_WORKFLOW"] = github.Workflow
|
||||||
env["GITHUB_RUN_ID"] = github.RunID
|
env["GITHUB_RUN_ID"] = github.RunID
|
||||||
@@ -1207,23 +1272,71 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
|
|||||||
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
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 != "" {
|
if rc.Config.ArtifactServerPath != "" {
|
||||||
setActionRuntimeVars(rc, env)
|
setActionRuntimeVars(rc, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
if imageOS := rc.imageOS(ctx); imageOS != "" {
|
||||||
if platformName != "" {
|
env["ImageOS"] = imageOS
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||||
@@ -1295,24 +1408,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
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
binds := []string{}
|
binds, mounts, claimed := splitVolumes(svcVolumes)
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/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
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
@@ -362,6 +364,10 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
||||||
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/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"}},
|
{"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) {
|
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||||
@@ -417,7 +423,14 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
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 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -427,6 +440,21 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
assert.Contains(t, gotmount, k)
|
assert.Contains(t, gotmount, k)
|
||||||
assert.Equal(t, gotmount[k], v)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -451,6 +479,46 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
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
|
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
// *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.
|
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
||||||
@@ -899,3 +967,117 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
|
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"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ type Config struct {
|
|||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
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)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
|
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
|
// GetToken: Adapt to Gitea
|
||||||
@@ -137,6 +147,11 @@ func New(runnerConfig *Config) (Runner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (runner *runnerImpl) configure() (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 = "{}"
|
runner.eventJSON = "{}"
|
||||||
if runner.config.EventJSON != "" {
|
if runner.config.EventJSON != "" {
|
||||||
runner.eventJSON = 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"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -163,6 +164,12 @@ func TestGraphEvent(t *testing.T) {
|
|||||||
assert.Empty(t, plan.Stages)
|
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 {
|
type TestJobFileInfo struct {
|
||||||
workdir string
|
workdir string
|
||||||
workflowPath string
|
workflowPath string
|
||||||
@@ -187,7 +194,14 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventName: j.eventName,
|
EventName: j.eventName,
|
||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
Platforms: j.platforms,
|
||||||
|
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||||
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
||||||
ReuseContainers: false,
|
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,
|
ForceRebuild: true,
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
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)
|
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
|
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 {
|
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 == "" {
|
if j.errorMessage == "" {
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
} else {
|
} else {
|
||||||
@@ -227,6 +245,7 @@ type TestConfig struct {
|
|||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -315,6 +334,9 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// host /proc bind mounts are Linux-Docker-only
|
// host /proc bind mounts are Linux-Docker-only
|
||||||
requireLinuxDocker(t)
|
requireLinuxDocker(t)
|
||||||
}
|
}
|
||||||
|
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
||||||
|
t.Parallel()
|
||||||
|
}
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
@@ -445,6 +467,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
// Dryrun plans without containers or network (shells and local actions only).
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
@@ -464,6 +487,7 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -474,33 +498,11 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
// workflow's outputs via `needs`).
|
// workflow's outputs via `needs`).
|
||||||
func TestReusableWorkflowCaller(t *testing.T) {
|
func TestReusableWorkflowCaller(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
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 {
|
type maskJobLoggerFactory struct {
|
||||||
Output bytes.Buffer
|
Output bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -513,6 +515,7 @@ func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskValues(t *testing.T) {
|
func TestMaskValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
found := strings.Contains(text, "composite secret")
|
found := strings.Contains(text, "composite secret")
|
||||||
if found {
|
if found {
|
||||||
@@ -543,6 +546,7 @@ func TestMaskValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
workflowPath := "secrets"
|
workflowPath := "secrets"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -598,6 +602,7 @@ func TestRunWithService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
func TestRunActionInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "input-from-cli"
|
workflowPath := "input-from-cli"
|
||||||
|
|
||||||
@@ -617,6 +622,7 @@ func TestRunActionInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
|
||||||
workflowPath := "pull-request"
|
workflowPath := "pull-request"
|
||||||
@@ -633,6 +639,7 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
workflowPath := "matrix-with-user-inclusions"
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if continueOnError {
|
if continueOnError {
|
||||||
logger.Errorf("##[error]%v", err)
|
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||||
logger.Infof("Failed but continue next step")
|
logger.Infof("Failed but continue next step")
|
||||||
err = nil
|
err = nil
|
||||||
stepResult.Conclusion = model.StepStatusSuccess
|
stepResult.Conclusion = model.StepStatusSuccess
|
||||||
|
|||||||
@@ -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", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
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"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
|||||||
|
|
||||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
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 != "" {
|
if normalized != "" {
|
||||||
for line := range strings.SplitSeq(normalized, "\n") {
|
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 != "" {
|
if step.Name != "" {
|
||||||
title = step.Name
|
title = step.Name
|
||||||
}
|
}
|
||||||
rawLogger.Infof("::group::Run %s", title)
|
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
|
||||||
|
|
||||||
if len(step.With) > 0 {
|
if len(step.With) > 0 {
|
||||||
rawLogger.Infof("with:")
|
rawLogger.Infof("with:")
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
delete((env), "GITHUB_REPOSITORY")
|
delete((env), "GITHUB_REPOSITORY")
|
||||||
delete((env), "GITHUB_REPOSITORY_OWNER")
|
delete((env), "GITHUB_REPOSITORY_OWNER")
|
||||||
delete((env), "GITHUB_ACTOR")
|
delete((env), "GITHUB_ACTOR")
|
||||||
|
// Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead.
|
||||||
|
delete((env), "RUNNER_NAME")
|
||||||
|
delete((env), "RUNNER_WORKSPACE")
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
assert.Equal(t, map[string]string{
|
||||||
"ACT": "true",
|
"ACT": "true",
|
||||||
@@ -192,6 +195,7 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
"GITHUB_WORKFLOW": "",
|
"GITHUB_WORKFLOW": "",
|
||||||
"INPUT_STEP_WITH": "with-value",
|
"INPUT_STEP_WITH": "with-value",
|
||||||
"RC_KEY": "rcvalue",
|
"RC_KEY": "rcvalue",
|
||||||
|
"RUNNER_ENVIRONMENT": "self-hosted",
|
||||||
"RUNNER_PERFLOG": "/dev/null",
|
"RUNNER_PERFLOG": "/dev/null",
|
||||||
"RUNNER_TRACKING_ID": "",
|
"RUNNER_TRACKING_ID": "",
|
||||||
}, env)
|
}, env)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
FROM ubuntu:24.04
|
FROM ubuntu:26.04
|
||||||
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
|
## 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`
|
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
||||||
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
- [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
|
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
||||||
|
|||||||
19
go.mod
19
go.mod
@@ -28,7 +28,7 @@ require (
|
|||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/pkg/errors v0.9.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/prometheus/client_model v0.6.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
github.com/rhysd/actionlint v1.7.12
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
@@ -73,7 +73,7 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
@@ -86,8 +86,8 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.70.0 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
@@ -98,15 +98,14 @@ require (
|
|||||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.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 v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
48
go.sum
48
go.sum
@@ -47,8 +47,6 @@ 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/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 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8=
|
|
||||||
github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
|
||||||
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
|
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/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 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||||
@@ -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/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 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
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.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -119,8 +117,6 @@ 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/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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
@@ -159,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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
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 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
||||||
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
|
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=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
@@ -218,34 +214,32 @@ 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/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 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
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 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
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 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
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.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
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 h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
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.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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
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/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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -262,8 +256,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
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/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.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 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
|||||||
|
|
||||||
secret := cfg.Cache.ExternalSecret
|
secret := cfg.Cache.ExternalSecret
|
||||||
if secret == "" {
|
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(
|
cacheHandler, err := artifactcache.StartHandler(
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
if cfg.Cache.ExternalServer != "" {
|
if cfg.Cache.ExternalServer != "" {
|
||||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
||||||
} else {
|
} else {
|
||||||
|
warnIgnoredCacheSecret(cfg)
|
||||||
handler, err := artifactcache.StartHandler(
|
handler, err := artifactcache.StartHandler(
|
||||||
cfg.Cache.Dir,
|
cfg.Cache.Dir,
|
||||||
cfg.Cache.Host,
|
cfg.Cache.Host,
|
||||||
@@ -470,9 +471,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
DefaultActionInstance: r.getDefaultActionsURL(task),
|
DefaultActionInstance: r.getDefaultActionsURL(task),
|
||||||
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
|
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
|
||||||
PlatformPicker: r.labels.PickPlatform,
|
PlatformPicker: r.labels.PickPlatform,
|
||||||
|
JobStartedHook: r.cfg.Runner.Hooks.JobStarted,
|
||||||
|
JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted,
|
||||||
Vars: task.Vars,
|
Vars: task.Vars,
|
||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
|
RunnerName: r.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
rr, err := runner.New(runnerConfig)
|
rr, err := runner.New(runnerConfig)
|
||||||
@@ -512,14 +516,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// function the caller must invoke (typically via defer) to revoke the
|
// function the caller must invoke (typically via defer) to revoke the
|
||||||
// credential when the task finishes.
|
// credential when the task finishes.
|
||||||
//
|
//
|
||||||
// Three modes:
|
// Two modes:
|
||||||
// - Embedded handler: register in-process via RegisterJob.
|
// - Embedded handler: register in-process via RegisterJob.
|
||||||
// - external_server + external_secret: POST to the remote server's
|
// - external_server: POST to the remote server's /_internal/register, defer a
|
||||||
// /_internal/register, defer a POST to /_internal/revoke. This is what
|
// POST to /_internal/revoke. This is what enables full per-job auth and
|
||||||
// enables full per-job auth and repo scoping over the network.
|
// repo scoping over the network.
|
||||||
// - external_server alone (no secret): no-op revoker. The remote server is
|
|
||||||
// in legacy openMode and ignores the runtime token; trust is at the
|
|
||||||
// network layer.
|
|
||||||
//
|
//
|
||||||
// Safe with an empty token (older Gitea did not issue one).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
||||||
@@ -532,6 +533,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
|||||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||||
return r.registerExternalCacheJob(token, repo, reporter)
|
return r.registerExternalCacheJob(token, repo, reporter)
|
||||||
}
|
}
|
||||||
|
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,3 +657,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
|
|||||||
Capabilities: RunnerCapabilities(),
|
Capabilities: RunnerCapabilities(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
|
||||||
|
func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||||
|
if cfg.Cache.ExternalServer != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not using an external cache server, so any configured secret is ignored.
|
||||||
|
if cfg.Cache.ExternalSecret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
|
||||||
|
key := "cache.external_secret"
|
||||||
|
if cfg.Cache.ExternalSecretFile != "" {
|
||||||
|
key = "cache.external_secret_file"
|
||||||
|
}
|
||||||
|
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ runner:
|
|||||||
post_task_script: ''
|
post_task_script: ''
|
||||||
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
||||||
post_task_script_timeout: 5m
|
post_task_script_timeout: 5m
|
||||||
|
# Scripts run inside the job environment before the job's first step and after its last
|
||||||
|
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
|
||||||
|
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
|
||||||
|
# resolved inside the job environment. Either one failing fails the job.
|
||||||
|
# Full guide: docs/job-hooks.md
|
||||||
|
hooks:
|
||||||
|
job_started: ''
|
||||||
|
job_completed: ''
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||||
@@ -132,6 +140,11 @@ cache:
|
|||||||
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
external_secret: ""
|
external_secret: ""
|
||||||
|
# Path to a file containing the shared secret, as an alternative to external_secret.
|
||||||
|
# Use this to keep the secret out of this file.
|
||||||
|
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
||||||
|
# Setting both external_secret and external_secret_file is an error.
|
||||||
|
external_secret_file: ""
|
||||||
# When true, reuse a cached action instead of fetching from the remote on every job.
|
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||||
# until its cache entry expires or is manually removed.
|
# until its cache entry expires or is manually removed.
|
||||||
@@ -154,7 +167,11 @@ container:
|
|||||||
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
||||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||||
privileged: false
|
privileged: false
|
||||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
# Any other options to be used when the container is started, for example:
|
||||||
|
# options: --add-host=my.gitea.url:host-gateway
|
||||||
|
# A volume declared here replaces the one the runner mounts on the same container path, so the
|
||||||
|
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
|
||||||
|
# options: --volume /host/toolcache:/opt/hostedtoolcache
|
||||||
options:
|
options:
|
||||||
# The parent directory of a job's working directory.
|
# The parent directory of a job's working directory.
|
||||||
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -53,6 +55,13 @@ type Runner struct {
|
|||||||
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
||||||
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
|
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
|
||||||
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
|
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
|
||||||
|
Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps.
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunnerHooks represents the scripts run inside the job environment around the job's steps.
|
||||||
|
type RunnerHooks struct {
|
||||||
|
JobStarted string `yaml:"job_started"` // JobStarted is the path of a script run before the job's first step. Falls back to ACTIONS_RUNNER_HOOK_JOB_STARTED; a failure fails the job.
|
||||||
|
JobCompleted string `yaml:"job_completed"` // JobCompleted is the path of a script run after the job's last step, while the job environment is still up. Falls back to ACTIONS_RUNNER_HOOK_JOB_COMPLETED; a failure fails the job.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache represents the configuration for caching.
|
// Cache represents the configuration for caching.
|
||||||
@@ -62,7 +71,8 @@ type Cache struct {
|
|||||||
Host string `yaml:"host"` // Host specifies the caching host.
|
Host string `yaml:"host"` // Host specifies the caching host.
|
||||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||||
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
|
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it.
|
||||||
|
ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error.
|
||||||
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +144,7 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if err := yaml.Unmarshal(content, cfg); err != nil {
|
if err := yaml.Unmarshal(content, cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
||||||
}
|
}
|
||||||
|
warnUnknownKeys(file, content)
|
||||||
definedRunnerKeys, err = definedRunnerConfigKeys(content)
|
definedRunnerKeys, err = definedRunnerConfigKeys(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err)
|
return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err)
|
||||||
@@ -177,6 +188,10 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
b := true
|
b := true
|
||||||
cfg.Cache.Enabled = &b
|
cfg.Cache.Enabled = &b
|
||||||
}
|
}
|
||||||
|
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
|
||||||
|
if err := resolveCacheExternalSecret(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if *cfg.Cache.Enabled {
|
if *cfg.Cache.Enabled {
|
||||||
if cfg.Cache.Dir == "" {
|
if cfg.Cache.Dir == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -186,7 +201,7 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||||
}
|
}
|
||||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||||
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server")
|
return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.Container.WorkdirParent == "" {
|
if cfg.Container.WorkdirParent == "" {
|
||||||
@@ -275,6 +290,21 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnUnknownKeys reports keys the config does not define, which are otherwise ignored
|
||||||
|
// without a trace. It only warns, so a config carrying keys from another runner version
|
||||||
|
// still loads.
|
||||||
|
func warnUnknownKeys(file string, content []byte) {
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
|
||||||
|
var typeErr *yaml.TypeError
|
||||||
|
if err := decoder.Decode(&Config{}); errors.As(err, &typeErr) {
|
||||||
|
for _, message := range typeErr.Errors {
|
||||||
|
log.Warnf("config file %q: %s, it will be ignored", file, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
||||||
var root yaml.Node
|
var root yaml.Node
|
||||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||||
@@ -301,3 +331,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
|||||||
|
|
||||||
return defined, nil
|
return defined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file,
|
||||||
|
// so deployments can mount the secret instead of committing it to the config file.
|
||||||
|
func resolveCacheExternalSecret(cfg *Config) error {
|
||||||
|
if cfg.Cache.ExternalSecretFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.Cache.ExternalSecret != "" {
|
||||||
|
return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them")
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(cfg.Cache.ExternalSecretFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err)
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(string(content))
|
||||||
|
if secret == "" {
|
||||||
|
return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile)
|
||||||
|
}
|
||||||
|
cfg.Cache.ExternalSecret = secret
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -139,9 +140,6 @@ runner:
|
|||||||
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
|
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
|
|
||||||
// invalid YAML to the canonical "parse config file" message rather than the
|
|
||||||
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
|
|
||||||
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
|
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
@@ -170,6 +168,25 @@ runner:
|
|||||||
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_LoadsJobHooks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
runner:
|
||||||
|
hooks:
|
||||||
|
job_started: /hooks/started.sh
|
||||||
|
job_completed: /hooks/completed.sh
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/hooks/started.sh", cfg.Runner.Hooks.JobStarted)
|
||||||
|
assert.Equal(t, "/hooks/completed.sh", cfg.Runner.Hooks.JobCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
|
||||||
|
// invalid YAML to the canonical "parse config file" message rather than the
|
||||||
|
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
|
||||||
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
@@ -181,6 +198,21 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
|||||||
assert.NotContains(t, err.Error(), "defaults metadata")
|
assert.NotContains(t, err.Error(), "defaults metadata")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("container:\n volumes:\n - /host:/ctr\n privileged: true\n"), 0o600))
|
||||||
|
|
||||||
|
hook := test.NewGlobal()
|
||||||
|
defer hook.Reset()
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.Container.Privileged)
|
||||||
|
require.Len(t, hook.Entries, 1)
|
||||||
|
assert.Contains(t, hook.LastEntry().Message, "field volumes not found")
|
||||||
|
}
|
||||||
|
|
||||||
func TestContainerNetworkCreateOptions(t *testing.T) {
|
func TestContainerNetworkCreateOptions(t *testing.T) {
|
||||||
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
|
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
|
||||||
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
|
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
|
||||||
@@ -227,3 +259,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) {
|
|||||||
assert.Nil(t, opts.EnableIPv6)
|
assert.Nil(t, opts.EnableIPv6)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretPath := filepath.Join(dir, "cache.secret")
|
||||||
|
require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600))
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
external_server: "http://cache.invalid/"
|
||||||
|
external_secret_file: "`+secretPath+`"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretPath := filepath.Join(dir, "cache.secret")
|
||||||
|
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
|
||||||
|
|
||||||
|
// the file has to be resolved even when cache is disabled
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
external_secret_file: "`+secretPath+`"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretPath := filepath.Join(dir, "cache.secret")
|
||||||
|
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
external_server: "http://cache.invalid/"
|
||||||
|
external_secret: "inline"
|
||||||
|
external_secret_file: "`+secretPath+`"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
_, err := LoadDefault(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "both set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
external_server: "http://cache.invalid/"
|
||||||
|
external_secret_file: "`+filepath.Join(dir, "absent.secret")+`"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
_, err := LoadDefault(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read cache.external_secret_file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretPath := filepath.Join(dir, "cache.secret")
|
||||||
|
require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600))
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
external_server: "http://cache.invalid/"
|
||||||
|
external_secret_file: "`+secretPath+`"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
_, err := LoadDefault(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "contains no secret")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,11 @@
|
|||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"local>gitea/renovate-config"
|
"local>gitea/renovate-config"
|
||||||
]
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["go.yaml.in/yaml/v4"],
|
||||||
|
"allowedVersions": "<4.0.0-rc.4", // rc.4 removes the error types actionlint builds against
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
112
scripts/upload-r2.sh
Executable file
112
scripts/upload-r2.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# upload-r2.sh uploads a single local file to a single object key in a
|
||||||
|
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
|
||||||
|
# S3-API compatible).
|
||||||
|
#
|
||||||
|
# This is the R2 half of the release process's parallel S3+R2 upload
|
||||||
|
# period: goreleaser's `blobs:` pipe still uploads every release
|
||||||
|
# artifact to AWS S3, and this script is invoked once per artifact
|
||||||
|
# (via a goreleaser `publishers:` entry) to mirror the same artifact
|
||||||
|
# into R2. Once the migration away from S3 is complete, the `blobs:`
|
||||||
|
# block and the AWS_* secrets can be dropped without touching this
|
||||||
|
# script.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# upload-r2.sh <local-file> <remote-key>
|
||||||
|
# upload-r2.sh --check-config
|
||||||
|
#
|
||||||
|
# The second form only validates that the required environment
|
||||||
|
# variables below are set (it does not touch the network or the
|
||||||
|
# filesystem beyond that), and is meant to be run as an early
|
||||||
|
# preflight step in CI: goreleaser custom publishers run as the very
|
||||||
|
# last step of the publish pipeline, so without a preflight check a
|
||||||
|
# missing R2_* secret would only be discovered after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded to S3.
|
||||||
|
#
|
||||||
|
# Required environment variables:
|
||||||
|
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
|
||||||
|
# https://<account>.r2.cloudflarestorage.com
|
||||||
|
# R2_BUCKET Destination bucket name.
|
||||||
|
# R2_ACCESS_KEY_ID R2 access key id.
|
||||||
|
# R2_SECRET_ACCESS_KEY R2 secret access key.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# check_env validates that all required R2_* environment variables are
|
||||||
|
# set and non-empty, printing a single "missing required environment
|
||||||
|
# variable(s): ..." message and exiting non-zero otherwise. Used by
|
||||||
|
# both the normal upload mode and --check-config, so the validation
|
||||||
|
# logic only exists in one place.
|
||||||
|
check_env() {
|
||||||
|
missing=""
|
||||||
|
|
||||||
|
if [ -z "${R2_ENDPOINT:-}" ]; then
|
||||||
|
missing="$missing R2_ENDPOINT"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_BUCKET:-}" ]; then
|
||||||
|
missing="$missing R2_BUCKET"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
|
||||||
|
missing="$missing R2_ACCESS_KEY_ID"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
|
||||||
|
missing="$missing R2_SECRET_ACCESS_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
|
||||||
|
check_env
|
||||||
|
echo "upload-r2.sh: R2 configuration OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$#" -ne 2 ]; then
|
||||||
|
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
|
||||||
|
echo " upload-r2.sh --check-config" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local_file="$1"
|
||||||
|
remote_key="$2"
|
||||||
|
|
||||||
|
if [ ! -f "$local_file" ]; then
|
||||||
|
echo "upload-r2.sh: local file not found: $local_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_env
|
||||||
|
|
||||||
|
# Strip a single trailing slash from the endpoint, if present, so that
|
||||||
|
# building the path-style URL below never produces a double slash.
|
||||||
|
endpoint="${R2_ENDPOINT%/}"
|
||||||
|
url="$endpoint/$R2_BUCKET/$remote_key"
|
||||||
|
|
||||||
|
# Credentials are passed to curl through a config file read from
|
||||||
|
# stdin rather than as a command-line argument, so they never show up
|
||||||
|
# in `ps` output.
|
||||||
|
#
|
||||||
|
# --fail-with-body (instead of plain --fail) still exits non-zero on
|
||||||
|
# HTTP errors, but also prints R2's XML error body, which is where the
|
||||||
|
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
|
||||||
|
# AccessDenied, ...); with plain --fail that body is discarded and the
|
||||||
|
# failure is silent. --retry 3 (without --retry-all-errors) still
|
||||||
|
# retries the transient cases (5xx, 408, 429, connection failures);
|
||||||
|
# --retry-all-errors would additionally retry permanent 4xx responses
|
||||||
|
# three times with backoff, which only delays an inevitable failure.
|
||||||
|
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
|
||||||
|
--config - \
|
||||||
|
--fail-with-body \
|
||||||
|
--silent \
|
||||||
|
--show-error \
|
||||||
|
--retry 3 \
|
||||||
|
--aws-sigv4 "aws:amz:auto:s3" \
|
||||||
|
--upload-file "$local_file" \
|
||||||
|
"$url"
|
||||||
Reference in New Issue
Block a user