25 Commits

Author SHA1 Message Date
Bo-Yi Wu
e5e53c732e perf(config): lower default fetch_interval_max from 60s to 5s (#875)
## Summary

- Lower the default `fetch_interval_max` from 60s to 5s to reduce job pickup latency for common single-runner deployments
- PR #819 optimized defaults for 200-runner fleets, regressing single-runner pickup time from ~2s to ~65s
- Most deployments use few runners; large fleets can still tune this value higher in their config

## Impact

| `fetch_interval_max` | 1 runner pickup | 200 runners idle |
| -------------------- | --------------- | ---------------- |
| 60s (previous)       | up to **65s**   | 3.3 req/s        |
| **5s (new default)** | up to **5s**    | 40 req/s         |

Closes https://gitea.com/gitea/act_runner/issues/869

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/875
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-30 17:40:35 +00:00
silverwind
2516573592 chore: clean up nolint directives in act package (#864)
Removes 88 `nolint` directives (386 → 298) via mechanical, zero-regression cleanups:

- **38 `bodyclose`** in `act/artifactcache/handler_test.go`: replaced by `defer resp.Body.Close()` after each HTTP call.
- **21 dead directives** (`gocyclo`, `dogsled`, `contextcheck`): none of these linters are enabled in `.golangci.yml`, so the directives were doing nothing.
- **29 `testifylint`** directives whose underlying issues were addressed by mechanical rewrites:
  - `assert.Nil(t, err)` → `assert.NoError(t, err)`
  - `assert.NotNil(t, err)` → `assert.Error(t, err)`
  - `assert.Equal(t, true/false, x)` → `assert.True/False(t, x)`
  - `assert.Equal(t, 0, len(x))` → `assert.Empty(t, x)`
  - `assert.Equal(t, N, len(x))` → `assert.Len(t, x, N)`
  - `assert.Len(t, x, 0)` → `assert.Empty(t, x)`

Many `testifylint` directives still apply because they flag `require-error` (i.e. testifylint wants `require.NoError` instead of `assert.NoError` for early bail-out). That's a behavior change (fail-fast vs continue) and out of scope for this purely mechanical cleanup — those can be addressed in a follow-up. Same for `expected-actual`, `equal-values`, `error-is-as`, and the remaining `nilnil` / `unparam` / `forbidigo` / `staticcheck` / `goheader` / `dupl` directives.

`golangci-lint run` is clean. Tests pass for all touched packages.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/864
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-committed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-04-29 18:32:55 +00:00
Renovate Bot
35834bf817 fix(deps): update module github.com/moby/patternmatcher to v0.6.1 (#868)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/moby/patternmatcher](https://github.com/moby/patternmatcher) | `v0.6.0` → `v0.6.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fpatternmatcher/v0.6.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fpatternmatcher/v0.6.0/v0.6.1?slim=true) |

---

### Release Notes

<details>
<summary>moby/patternmatcher (github.com/moby/patternmatcher)</summary>

### [`v0.6.1`](https://github.com/moby/patternmatcher/releases/tag/v0.6.1)

[Compare Source](https://github.com/moby/patternmatcher/compare/v0.6.0...v0.6.1)

#### What's Changed

- fix panic /  nil pointer dereference on invalid patterns [#&#8203;9](https://github.com/moby/patternmatcher/pull/9)
- ci: update actions and test against "oldest", "oldstable" and "stable" [#&#8203;8](https://github.com/moby/patternmatcher/pull/8)

**Full Changelog**: <https://github.com/moby/patternmatcher/compare/v0.6.0...v0.6.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/868
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-29 00:47:19 +00:00
Renovate Bot
11a5dc8936 fix(deps): update module github.com/docker/docker to v25.0.15+incompatible (#867)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/docker](https://github.com/docker/docker) | `v25.0.14+incompatible` → `v25.0.15+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fdocker/v25.0.15+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fdocker/v25.0.14+incompatible/v25.0.15+incompatible?slim=true) |

---

### Release Notes

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

### [`v25.0.15+incompatible`](https://github.com/docker/docker/compare/v25.0.14...v25.0.15)

[Compare Source](https://github.com/docker/docker/compare/v25.0.14...v25.0.15)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/867
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-29 00:46:57 +00:00
Zettat123
f09fafcb0a Clone different git repos in parallel via per-directory locks (#866)
Old `cloneLock` is a package-level `sync.Mutex` that serialized every action clone across all goroutines, regardless of target directory.
This PR replaces it with a `sync.Map` of per-directory mutexes keyed by `input.Dir`. Same-directory operations still serialize; different directories now clone in parallel.

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/866
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
2026-04-28 21:39:41 +00:00
Morgan Peyre
801e5cf4d5 fix: avoid 'filename too long' on matrix jobs by hashing container names (#853)
## Bug fixes

- Fixed "file name too long" errors when running matrix jobs with many or
  long-valued matrix parameters. Docker volume and container names are now
  bounded to a safe length by hashing the full name with SHA-256, keeping
  the result well within the filesystem `NAME_MAX` limit (255 bytes) even
  after Docker appends its own suffixes (`-env`, `-network`, etc.).

## Upgrade notes

This change renames the Docker containers, volumes and networks created by
the runner. Resources created by a previous version will **not** be cleaned
up automatically after upgrade and will become orphans.

After upgrading, you can remove the legacy resources with:

```sh
# volumes
docker volume ls -q | grep -E '^GITEA-ACTIONS-TASK-[0-9]+_' | xargs -r docker volume rm

# networks
docker network ls --format '{{.Name}}' | grep -E '^GITEA-ACTIONS-TASK-[0-9]+_.*-network$' | xargs -r docker network rm
```

> **Note:** If multiple act_runner instances share the same Docker daemon,
> make sure no runner using the old version is running before executing the
> cleanup commands above.

Fixes #686

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/853
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Morgan Peyre <195218+peyremorgan@noreply.gitea.com>
Co-committed-by: Morgan Peyre <195218+peyremorgan@noreply.gitea.com>
2026-04-28 00:30:27 +00:00
Renovate Bot
3f05040438 fix(deps): update module github.com/mattn/go-isatty to v0.0.22 (#863)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) | `v0.0.20` → `v0.0.22` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmattn%2fgo-isatty/v0.0.22?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmattn%2fgo-isatty/v0.0.20/v0.0.22?slim=true) |

---

### Release Notes

<details>
<summary>mattn/go-isatty (github.com/mattn/go-isatty)</summary>

### [`v0.0.22`](https://github.com/mattn/go-isatty/compare/v0.0.21...v0.0.22)

[Compare Source](https://github.com/mattn/go-isatty/compare/v0.0.21...v0.0.22)

### [`v0.0.21`](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.21)

[Compare Source](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.21)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/863
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-28 00:15:16 +00:00
Renovate Bot
59d90bff26 fix(deps): update module github.com/docker/docker to v25.0.14+incompatible (#862)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/docker](https://github.com/docker/docker) | `v25.0.13+incompatible` → `v25.0.14+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fdocker/v25.0.14+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fdocker/v25.0.13+incompatible/v25.0.14+incompatible?slim=true) |

---

### Release Notes

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

### [`v25.0.14+incompatible`](https://github.com/docker/docker/compare/v25.0.13...v25.0.14)

[Compare Source](https://github.com/docker/docker/compare/v25.0.13...v25.0.14)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/862
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-28 00:14:44 +00:00
silverwind
5edc4ba550 Authenticate cache requests via ACTIONS_RUNTIME_TOKEN and scope by repo (#849)
Closes #848. Addresses [GHSA-82g9-637c-2fx2](https://github.com/go-gitea/gitea/security/advisories/GHSA-82g9-637c-2fx2) and the follow-up points raised by @ChristopherHX and @haroutp in that thread.

The change is breaking only for `cache.external_server` which uses auth via a pre-shared secret.

## How auth works now

1. **Runner starts** → opens the embedded cache server on `:port`. Loads / creates a 32-byte HMAC signing key in `<cache-dir>/.secret`.
2. **Runner receives a task** → calls `handler.RegisterJob(ACTIONS_RUNTIME_TOKEN, repository)` before the job runs, defers a revoker that removes the credential on completion. Registrations are reference-counted so a stray re-register cannot revoke a live job.
3. **Job container runs `actions/cache`** → the toolkit sends `Authorization: Bearer $ACTIONS_RUNTIME_TOKEN` on every management call (`reserve`, `upload`, `commit`, `find`, `clean`). The cache server's middleware looks the token up in the registered-jobs map: miss → 401; hit → the job's repository is injected into the request context.
4. **Repository scoping** — every cache entry is stamped with `Repo` on reserve; `find`, `upload`, `commit` all verify the caller's repo matches. A job in repo A cannot see or poison a cache entry owned by repo B, even when both reach the server over the same docker bridge. GC dedup also groups by `(Repo, Key, Version)` so one repo can't age out another.
5. **Archive downloads** — `@actions/cache` does not attach Authorization when downloading `archiveLocation`, so the `find` response is a short-lived HMAC-signed URL: `…/artifacts/:id?exp=<unix>&sig=<hmac>`, 10-minute TTL, signature binds `cacheID:exp`. Tampered, expired, or foreign-secret URLs get 401.
6. **Defence-in-depth** — `ACTIONS_RUNTIME_TOKEN` is added to `task.Secrets` so the runner's log masker scrubs it from step output.

## `cache.external_server` (standalone `act_runner cache-server`)

Operators set `cache.external_secret` to the same value on the runner config and the `act_runner cache-server` config. The `cache-server` then runs with bearer auth on the cache API and exposes a control-plane at `POST /_internal/{register,revoke}` (gated by the shared secret). The runner pre-registers each task's `ACTIONS_RUNTIME_TOKEN` with the remote server before the job runs and revokes it on completion. Same per-job auth + repo scoping as the embedded handler, just over the network.

`cache-server` refuses to start without `cache.external_secret`; runner config load also fails when `cache.external_server` is set without `cache.external_secret`.

## User-facing changes

- **One-time cache miss after upgrade.** Pre-existing entries in `bolt.db` have no `Repo` stamp and won't match any job — they'll be evicted by the normal GC. First job per cache key rebuilds its cache.
- **`cache.external_server` deployments must add `cache.external_secret`.** Breaking change for anyone running a standalone `act_runner cache-server`: set the same `cache.external_secret` in both the runner config and the cache-server config. Without it neither side starts.
- **No config changes required for the default setup.** Runners using the embedded cache server (the common case) keep working without any yaml edits; the auth mechanism is invisible to workflows.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/849
Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
2026-04-27 23:59:20 +00:00
Nicolas
547a0ff297 feat: show run command, shell and env in collapsible group before step output (#847)
## Summary

Mirrors the GitHub Actions runner behaviour where each `run:` step shows a collapsible **"Run \<command\>"** section containing the script, shell command, and environment variables before the actual step output.

### What changes

- **`pkg/runner/step_run.go`**: In `stepRun.main()`, two new executors are added to the pipeline:
  1. `logRunGroupHeader()` — runs after `setupShellCommandExecutor()` (so `sr.cmdline` is already resolved). Emits a `::group::Run <step>` log entry followed by the interpolated script, the full shell command line, and the step's env vars (sorted, internal vars filtered out).
  2. The existing execution function now has `defer rawLogger.Infof("::endgroup::")` so the group is closed after the step finishes, regardless of success or failure.

### Env var filtering

Internal runner vars are hidden (`GITHUB_*`, `GITEA_*`, `RUNNER_*`, `INPUT_*`, `PATH`, `HOME`) — only user-relevant vars are shown, matching what GitHub Actions displays.

### Example output

```
▼ Run cargo build
  cargo build
  shell: bash --noprofile --norc -e -o pipefail {0}
  env:
    CARGO_HOME: /home/runner/.cargo
    CARGO_INCREMENTAL: 0
    CARGO_TERM_COLOR: always
  <actual build output>
```

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/847
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
2026-04-27 16:31:56 +00:00
Mirko Sekulic
f2b4dbf05f run docker step in host mode (#857)
## Problem
In host executor mode, uses: docker://<image> step actions fail because
act/runner/step_docker.go always attaches the step container to the job
container's network namespace, which doesn't exist in host mode.

### Example
Run following job in host runner

```yaml
jobs:
  test:
    runs-on: ubuntu-latest-host
    steps:
      - uses: docker://alpine:3.20
        with:
          args: echo hello
```
```
Error:
  failed to start container: Error response from daemon:
    joining network namespace of container:
    No such container: xxxxxx
```

This pr allows the docker step in the host mode

## Testing
I tested following steps on host runner and it worked

```yaml

 - name: Test azure cli action in host mode
   uses: azure/cli@v2
   env:
     RUNNER_OS: Linux
   with:
     inlineScript: echo "hello from azure cli"
 
 - uses: docker://alpine:3.20
   with:
     args: echo hello

```

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/857
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Mirko Sekulic <misha.sekulic@gmail.com>
Co-committed-by: Mirko Sekulic <misha.sekulic@gmail.com>
2026-04-27 15:26:31 +00:00
silverwind
bad4239d18 chore(deps): drop unused distribution/reference replace directive (#858)
The `replace github.com/distribution/reference v0.6.0 => v0.5.0` was added defensively in [c4b57fbc](c4b57fbcb2) (#775) against `github.com/docker/distribution v2.8.3`, whose `reference_deprecated.go` calls the now-removed `reference.SplitHostname`. However, nothing in act_runner's build graph imports `github.com/docker/distribution/reference`, so the file is never compiled and the replace has no effect.

Verified locally: removing the directive followed by `go mod tidy` keeps `distribution/reference` at v0.6.0, drops `docker/distribution` from the graph entirely, and `go build ./...` / `go vet ./...` both pass.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/858
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-27 15:14:36 +00:00
Renovate Bot
589db33e70 fix(deps): update module github.com/docker/cli to v25.0.7+incompatible (#855)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v25.0.3+incompatible` → `v25.0.7+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v25.0.7+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v25.0.3+incompatible/v25.0.7+incompatible?slim=true) |

---

### Release Notes

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

### [`v25.0.7+incompatible`](https://github.com/docker/cli/compare/v25.0.6...v25.0.7)

[Compare Source](https://github.com/docker/cli/compare/v25.0.6...v25.0.7)

### [`v25.0.6+incompatible`](https://github.com/docker/cli/compare/v25.0.5...v25.0.6)

[Compare Source](https://github.com/docker/cli/compare/v25.0.5...v25.0.6)

### [`v25.0.5+incompatible`](https://github.com/docker/cli/compare/v25.0.4...v25.0.5)

[Compare Source](https://github.com/docker/cli/compare/v25.0.4...v25.0.5)

### [`v25.0.4+incompatible`](https://github.com/docker/cli/compare/v25.0.3...v25.0.4)

[Compare Source](https://github.com/docker/cli/compare/v25.0.3...v25.0.4)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/855
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-27 01:01:35 +00:00
Renovate Bot
1032f857a1 fix(deps): update module connectrpc.com/connect to v1.19.2 (#854)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [connectrpc.com/connect](https://github.com/connectrpc/connect-go) | `v1.19.1` → `v1.19.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/connectrpc.com%2fconnect/v1.19.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/connectrpc.com%2fconnect/v1.19.1/v1.19.2?slim=true) |

---

### Release Notes

<details>
<summary>connectrpc/connect-go (connectrpc.com/connect)</summary>

### [`v1.19.2`](https://github.com/connectrpc/connect-go/releases/tag/v1.19.2)

[Compare Source](https://github.com/connectrpc/connect-go/compare/v1.19.1...v1.19.2)

#### What's Changed

##### Governance

- Add [@&#8203;timostamm](https://github.com/timostamm) as a maintainer in [#&#8203;905](https://github.com/connectrpc/connect-go/pull/905) 🎉

##### Bugfixes

- Use 'deadline\_exceeded' instead of 'canceled' on HTTP/2 cancelation when appropriate by [@&#8203;jhump](https://github.com/jhump) in [#&#8203;904](https://github.com/connectrpc/connect-go/pull/904)
- Fix nil pointer deref in duplexHTTPCall under concurrent Send + CloseAndReceive by [@&#8203;simonferquel](https://github.com/simonferquel) in [#&#8203;919](https://github.com/connectrpc/connect-go/pull/919)

##### Other changes

- Refactor memhttptest to work with Go 1.25 synctest by [@&#8203;codefromthecrypt](https://github.com/codefromthecrypt) in [#&#8203;881](https://github.com/connectrpc/connect-go/pull/881)
- Doc clarifications by [@&#8203;emcfarlane](https://github.com/emcfarlane) ([#&#8203;911](https://github.com/connectrpc/connect-go/issues/911), [#&#8203;912](https://github.com/connectrpc/connect-go/issues/912)) and [@&#8203;stefanvanburen](https://github.com/stefanvanburen) ([#&#8203;906](https://github.com/connectrpc/connect-go/issues/906))

#### New Contributors

- [@&#8203;codefromthecrypt](https://github.com/codefromthecrypt) made their first contribution in [#&#8203;881](https://github.com/connectrpc/connect-go/pull/881)
- [@&#8203;simonferquel](https://github.com/simonferquel) made their first contribution in [#&#8203;919](https://github.com/connectrpc/connect-go/pull/919)

**Full Changelog**:

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

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

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/854
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-27 01:01:16 +00:00
silverwind
e56b984c04 fix: wait for docker supervise dir before s6-svwait (#851)
`s6-svscan` starts services in parallel, so `act_runner/run` could invoke `s6-svwait` before s6 had created the docker service's `supervise/` directory, failing with `s6-svwait: fatal: unable to s6_svstatus_read: No such file or directory`. Poll for the directory before waiting.

Fixes #760

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/851
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-26 22:50:48 +00:00
silverwind
fa5334eb24 fix: Heartbeat ReportState for long-running silent jobs (#852)
Fixes #826.

Regressed in f2d54556 (#819, "perf: reduce runner-to-server connection load with adaptive reporting and polling"). That change added an early-return in `ReportState` whenever there was no state change and no pending outputs, so jobs that produce no log output and no step transitions for many minutes (e.g. a Linux kernel build) stop heartbeating. The server eventually marks the task as orphaned and cancels it while the runner is still executing.

The fix tracks the last successful `UpdateTask` time in an atomic and keeps the no-op skip only while the previous report is younger than `stateReportInterval`. The periodic state ticker fires at exactly `stateReportInterval`, so silent jobs now heartbeat each tick; redundant sends from a `stateNotify` firing right after a tick are still suppressed, preserving the perf intent of #819.

Test added: `TestReporter_StateHeartbeat` asserts the skip path within the interval and the heartbeat path after the interval elapses.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/852
Reviewed-by: Nicolas <bircni@icloud.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-26 22:49:39 +00:00
Michael Hoang
7c6f1261d4 fix: fetch when other refs get force-pushed (#846)
Due to `NewGitCloneExecutor` fetching all the refs (rather than `GoGitActionCache`), if any refs move in a non-fast-forward fashion, this causes the entire action update to fail. As GitHub has special refs like `pull/N/merge` which are guaranteed to move in a non-fast-forward fashion, this leads actions from GitHub usually failing to update.

```
  ☁  git clone 'https://github.com/Mic92/update-flake-inputs-gitea' # ref=main
  cloning https://github.com/Mic92/update-flake-inputs-gitea to /var/lib/gitea-runner/nix0/.cache/act/9b0155f2957ac84c749f9ecc8afaec823af5ef2e67a104ac655623aee12ca5b2
Non-terminating error while running 'git clone': some refs were not updated
```

With the repo https://github.com/Mic92/update-flake-inputs-gitea, you can notice that it only has a `main` branch that moves in a fast-forward fashion and no tags that could've been force pushed.

Fixes #726

---------

Co-authored-by: Michael Hoang <enzime@users.noreply.github.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/846
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Michael Hoang <194829+enzime@noreply.gitea.com>
Co-committed-by: Michael Hoang <194829+enzime@noreply.gitea.com>
2026-04-26 11:08:23 +00:00
silverwind
fbd6316928 Merge pull request 'Align root files with gitea' (#844) from silverwind/act_runner:align-makefile-gitea into main
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/844
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
2026-04-24 12:59:45 +00:00
silverwind
ade5b8202e Merge branch 'main' into align-makefile-gitea 2026-04-24 12:23:17 +00:00
silverwind
a31f3962c0 Add .dockerignore and align .editorconfig
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-24 13:08:16 +02:00
silverwind
04244fc3f7 Add AGENTS.md and CLAUDE.md
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-24 13:04:47 +02:00
silverwind
cb58492678 Merge pull request 'upgrade go git and yaml' (#842) from lunny/upgrade_dep into main
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/842
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-04-24 11:03:23 +00:00
silverwind
9faadad0ce Add help target and target descriptions
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-24 13:03:23 +02:00
silverwind
352096c5bf Align Makefile with gitea
- Fix `GXZ_PAGAGE` typo to `GXZ_PACKAGE`.
- Move `gxz`/`xgo` tool installs from `deps-backend` to `deps-tools`
  and add `golangci-lint` there; `deps-backend` is just `go mod download`.
- Use `--color=always` + `printf` in `tidy-check` to match `fmt-check`.
- Use STATIC-gated `EXTLDFLAGS` instead of a uname-based toggle, and
  move `-s -w` out of `EXTLDFLAGS` to match gitea's `-ldflags` layout.
- Pass `-s -w -linkmode external -extldflags "-static"` explicitly for
  release-linux / release-windows; add `-s -w` to release-darwin.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-24 13:00:37 +02:00
Lunny Xiao
b5c50bb3ab upgrade go git 2026-04-23 14:38:25 -07:00
61 changed files with 2175 additions and 307 deletions

52
.dockerignore Normal file
View File

@@ -0,0 +1,52 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# IntelliJ
.idea
# Goland's output filename can not be set manually
/go_build_*
# MS VSCode
.vscode
__debug_bin*
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
*coverage.out
coverage.all
coverage.txt
cpu.out
*.db
*.log
/act_runner
/debug
/bin
/dist
/.env
/.runner
/config.yaml
/Dockerfile
.DS_Store

View File

@@ -12,5 +12,8 @@ insert_final_newline = true
[*.{go}] [*.{go}]
indent_style = tab indent_style = tab
[go.*]
indent_style = tab
[Makefile] [Makefile]
indent_style = tab indent_style = tab

10
AGENTS.md Normal file
View File

@@ -0,0 +1,10 @@
- Use `make help` to find available development targets
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
- Run `make tidy` after any `go.mod` changes
- Run single go unit tests with `go test -run '^TestName$' ./modulepath/`
- Add the current year into the copyright header of new `.go` files
- Ensure no trailing whitespace in edited files
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
- Include authorship attribution in issue and pull request comments
- Add `Co-Authored-By` lines to all commits, indicating name and model used

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
XGO_VERSION := go-1.26.x XGO_VERSION := go-1.26.x
GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
LINUX_ARCHS ?= linux/amd64,linux/arm64 LINUX_ARCHS ?= linux/amd64,linux/arm64
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64 DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
@@ -21,10 +21,10 @@ DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
ifneq ($(shell uname), Darwin) STATIC ?=
EXTLDFLAGS = -extldflags "-static" $(null) EXTLDFLAGS ?=
else ifneq ($(STATIC),)
EXTLDFLAGS = EXTLDFLAGS = -extldflags "-static"
endif endif
ifeq ($(HAS_GO), GO) ifeq ($(HAS_GO), GO)
@@ -69,10 +69,15 @@ endif
TAGS ?= TAGS ?=
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)" LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
.PHONY: all
all: build all: build
.PHONY: help
help: Makefile ## print Makefile help information.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile
.PHONY: fmt .PHONY: fmt
fmt: fmt: ## format the Go code
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
.PHONY: go-check .PHONY: go-check
@@ -96,10 +101,14 @@ fmt-check: fmt
.PHONY: deps-tools .PHONY: deps-tools
deps-tools: ## install tool dependencies deps-tools: ## install tool dependencies
$(GO) install $(GOVULNCHECK_PACKAGE) $(GO) install $(GOLANGCI_LINT_PACKAGE) & \
$(GO) install $(GXZ_PACKAGE) & \
$(GO) install $(XGO_PACKAGE) & \
$(GO) install $(GOVULNCHECK_PACKAGE) & \
wait
.PHONY: lint .PHONY: lint
lint: lint-go lint: lint-go ## lint everything
.PHONY: lint-go .PHONY: lint-go
lint-go: ## lint go files lint-go: ## lint go files
@@ -114,58 +123,59 @@ security-check: deps-tools
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy .PHONY: tidy
tidy: tidy: ## run go mod tidy
$(GO) mod tidy $(GO) mod tidy
.PHONY: tidy-check .PHONY: tidy-check
tidy-check: tidy tidy-check: tidy
@diff=$$(git diff -- go.mod go.sum); \ @diff=$$(git diff --color=always -- go.mod go.sum); \
if [ -n "$$diff" ]; then \ if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \ echo "Please run 'make tidy' and commit the result:"; \
echo "$${diff}"; \ printf "%s" "$${diff}"; \
exit 1; \ exit 1; \
fi fi
test: fmt-check security-check .PHONY: test
test: fmt-check security-check ## test everything
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 @$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
install: $(GOFILES) .PHONY: install
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' install: $(GOFILES) ## install the act_runner binary via `go install`
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
build: go-check $(EXECUTABLE) .PHONY: build
build: go-check $(EXECUTABLE) ## build the act_runner binary
$(EXECUTABLE): $(GOFILES) $(EXECUTABLE): $(GOFILES)
$(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@ $(GO) build -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
.PHONY: deps-backend .PHONY: deps-backend
deps-backend: deps-backend: ## install backend dependencies
$(GO) mod download $(GO) mod download
$(GO) install $(GXZ_PAGAGE)
$(GO) install $(XGO_PACKAGE)
.PHONY: release .PHONY: release
release: release-windows release-linux release-darwin release-copy release-compress release-check release: release-windows release-linux release-darwin release-copy release-compress release-check ## build release artifacts
$(DIST_DIRS): $(DIST_DIRS):
mkdir -p $(DIST_DIRS) mkdir -p $(DIST_DIRS)
.PHONY: release-windows .PHONY: release-windows
release-windows: | $(DIST_DIRS) release-windows: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true) ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/ cp -r /build/* $(DIST)/binaries/
endif endif
.PHONY: release-linux .PHONY: release-linux
release-linux: | $(DIST_DIRS) release-linux: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true) ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/ cp -r /build/* $(DIST)/binaries/
endif endif
.PHONY: release-darwin .PHONY: release-darwin
release-darwin: | $(DIST_DIRS) release-darwin: | $(DIST_DIRS)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '$(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w $(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
ifeq ($(CI),true) ifeq ($(CI),true)
cp -r /build/* $(DIST)/binaries/ cp -r /build/* $(DIST)/binaries/
endif endif
@@ -180,18 +190,20 @@ release-check: | $(DIST_DIRS)
.PHONY: release-compress .PHONY: release-compress
release-compress: | $(DIST_DIRS) release-compress: | $(DIST_DIRS)
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PAGAGE) -k -9 $${file}; done; cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done;
.PHONY: docker .PHONY: docker
docker: docker: ## build the docker image
if ! docker buildx version >/dev/null 2>&1; then \ if ! docker buildx version >/dev/null 2>&1; then \
ARG_DISABLE_CONTENT_TRUST=--disable-content-trust=false; \ ARG_DISABLE_CONTENT_TRUST=--disable-content-trust=false; \
fi; \ fi; \
docker build $${ARG_DISABLE_CONTENT_TRUST} -t $(DOCKER_REF) . docker build $${ARG_DISABLE_CONTENT_TRUST} -t $(DOCKER_REF) .
clean: .PHONY: clean
clean: ## delete binary and coverage files
$(GO) clean -x -i ./... $(GO) clean -x -i ./...
rm -rf coverage.txt $(EXECUTABLE) $(DIST) rm -rf coverage.txt $(EXECUTABLE) $(DIST)
version: .PHONY: version
version: ## print the version
@echo $(VERSION) @echo $(VERSION)

View File

@@ -5,17 +5,24 @@
package artifactcache package artifactcache
import ( import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -28,9 +35,36 @@ import (
) )
const ( const (
urlBase = "/_apis/artifactcache" apiPath = "/_apis/artifactcache"
internalPath = "/_internal"
// artifactURLTTL bounds how long a signed artifactLocation URL stays valid.
// Short enough that a leaked URL is near-worthless; long enough to let the
// @actions/cache client download a big blob that was returned from /cache.
artifactURLTTL = 10 * time.Minute
) )
type credKey struct{}
// JobCredential ties a per-job bearer token (ACTIONS_RUNTIME_TOKEN) to the
// repository that owns it. Every cache entry is stamped with Repo on
// reserve/commit and checked on read/write so one repo can never observe or
// poison another repo's cache, even from inside a container that reaches the
// cache server over the docker bridge network.
type JobCredential struct {
Repo string
}
// credEntry holds a registered job's credential along with an active
// registration count. RegisterJob is reference-counted so that if two tasks
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
// after a crash before the old registration is revoked — the first task's
// revoker does not cut the second task's auth out from under it.
type credEntry struct {
cred JobCredential
refs int
}
type Handler struct { type Handler struct {
dir string dir string
storage *Storage storage *Storage
@@ -43,10 +77,36 @@ type Handler struct {
gcAt time.Time gcAt time.Time
outboundIP string outboundIP string
// internalSecret guards /_internal/{register,revoke}. When set, a remote
// runner can use these endpoints to pre-register per-job
// ACTIONS_RUNTIME_TOKENs against this server, enabling the same
// per-job auth and repo scoping as the embedded handler over the
// network. Empty disables the control-plane entirely.
internalSecret string
// secret signs short-lived artifact download URLs. The @actions/cache
// toolkit does not send Authorization on the download request, so blob
// GETs authenticate via a per-URL HMAC signature with expiry rather than
// via the bearer token used for management endpoints.
secret []byte
credMu sync.RWMutex
creds map[string]*credEntry
} }
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) { // StartHandler opens the on-disk cache store and starts the HTTP server.
h := &Handler{} //
// internalSecret, when non-empty, enables a control-plane API at
// /_internal/{register,revoke} that lets a remote runner pre-register the
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
// embedded in-process handler leaves it empty and registers tokens via the
// in-process RegisterJob method directly.
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{
creds: make(map[string]*credEntry),
internalSecret: internalSecret,
}
if logger == nil { if logger == nil {
discard := logrus.New() discard := logrus.New()
@@ -83,19 +143,37 @@ func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger
h.outboundIP = ip.String() h.outboundIP = ip.String()
} }
secret, err := loadOrCreateSecret(dir)
if err != nil {
return nil, err
}
h.secret = secret
router := httprouter.New() router := httprouter.New()
router.GET(urlBase+"/cache", h.middleware(h.find)) router.GET(apiPath+"/cache", h.bearerAuth(h.find))
router.POST(urlBase+"/caches", h.middleware(h.reserve)) router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload)) router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
router.POST(urlBase+"/caches/:id", h.middleware(h.commit)) router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get)) router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
router.POST(urlBase+"/clean", h.middleware(h.clean)) // Artifact GET is signed via query-string HMAC because @actions/cache
// does not attach Authorization when downloading archiveLocation.
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
// Control-plane: a remote runner registers/revokes per-job tokens so the
// cache API can authenticate them. Always wired so the routes exist; the
// handlers themselves 401 when internalSecret is unset.
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
h.router = router h.router = router
h.gcCache() h.gcCache()
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces // Listen on all interfaces. Binding to outboundIP only would give no real
// security benefit (it is the LAN/internet-facing address either way) and
// can break Docker Desktop variants where the host's outbound IP is not
// routable from inside the container network. Authentication is enforced
// by the bearer middleware and per-repo scoping, not by reachability.
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -121,6 +199,91 @@ func (h *Handler) ExternalURL() string {
h.listener.Addr().(*net.TCPAddr).Port) h.listener.Addr().(*net.TCPAddr).Port)
} }
// RegisterJob makes token a valid bearer credential for cache requests from
// the given repository and returns a function that removes it. The runner
// calls this at job start and defers the returned func so that the credential
// is only accepted while the job is running.
//
// Registrations are reference-counted: if a token is already registered, the
// existing repo is kept and the refcount is incremented. The entry is
// removed only when every revoker returned by RegisterJob has been called.
// This keeps a stray re-registration from silently revoking a live job.
func (h *Handler) RegisterJob(token, repo string) func() {
if h == nil || token == "" {
return func() {}
}
h.credMu.Lock()
if existing, ok := h.creds[token]; ok {
existing.refs++
} else {
h.creds[token] = &credEntry{
cred: JobCredential{Repo: repo},
refs: 1,
}
}
h.credMu.Unlock()
return func() {
h.credMu.Lock()
if entry, ok := h.creds[token]; ok {
entry.refs--
if entry.refs <= 0 {
delete(h.creds, token)
}
}
h.credMu.Unlock()
}
}
// RevokeJob explicitly revokes one registration of token, mirroring one call
// of the closure returned by RegisterJob. Used by the control-plane endpoint
// so a remote runner can revoke without holding the closure.
func (h *Handler) RevokeJob(token string) {
if h == nil || token == "" {
return
}
h.credMu.Lock()
if entry, ok := h.creds[token]; ok {
entry.refs--
if entry.refs <= 0 {
delete(h.creds, token)
}
}
h.credMu.Unlock()
}
func (h *Handler) lookupCredential(token string) (JobCredential, bool) {
h.credMu.RLock()
entry, ok := h.creds[token]
h.credMu.RUnlock()
if !ok {
return JobCredential{}, false
}
return entry.cred, true
}
// loadOrCreateSecret returns the 32-byte HMAC signing key for artifact URLs,
// persisted in dir/.secret so signed URLs handed out before a restart stay
// valid across the restart and so the standalone cache-server can be pointed
// at by config.Cache.ExternalServer without the URL rotating.
func loadOrCreateSecret(dir string) ([]byte, error) {
path := filepath.Join(dir, ".secret")
if data, err := os.ReadFile(path); err == nil {
if secret, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(secret) >= 32 {
return secret, nil
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read cache secret: %w", err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate cache secret: %w", err)
}
if err := os.WriteFile(path, []byte(hex.EncodeToString(secret)), 0o600); err != nil {
return nil, fmt.Errorf("write cache secret: %w", err)
}
return secret, nil
}
func (h *Handler) Close() error { func (h *Handler) Close() error {
if h == nil { if h == nil {
return nil return nil
@@ -160,6 +323,7 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
// GET /_apis/artifactcache/cache // GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",") keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive // cache keys are case insensitive
for i, key := range keys { for i, key := range keys {
@@ -174,7 +338,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
} }
defer db.Close() defer db.Close()
cache, err := findCache(db, keys, version) cache, err := findCache(db, cred.Repo, keys, version)
if err != nil { if err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return return
@@ -194,13 +358,14 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
} }
h.responseJSON(w, r, 200, map[string]any{ h.responseJSON(w, r, 200, map[string]any{
"result": "hit", "result": "hit",
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID), "archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
"cacheKey": cache.Key, "cacheKey": cache.Key,
}) })
} }
// POST /_apis/artifactcache/caches // POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
api := &Request{} api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil { if err := json.NewDecoder(r.Body).Decode(api); err != nil {
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
@@ -210,6 +375,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
api.Key = strings.ToLower(api.Key) api.Key = strings.ToLower(api.Key)
cache := api.ToCache() cache := api.ToCache()
cache.Repo = cred.Repo
db, err := h.openDB() db, err := h.openDB()
if err != nil { if err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
@@ -231,6 +397,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
// PATCH /_apis/artifactcache/caches/:id // PATCH /_apis/artifactcache/caches/:id
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
cred := credFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64) id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil { if err != nil {
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
@@ -253,6 +420,11 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete { if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key)) h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return return
@@ -272,6 +444,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
// POST /_apis/artifactcache/caches/:id // POST /_apis/artifactcache/caches/:id
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
cred := credFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64) id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil { if err != nil {
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
@@ -294,6 +467,11 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete { if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key)) h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return return
@@ -326,6 +504,10 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
} }
// GET /_apis/artifactcache/artifacts/:id // GET /_apis/artifactcache/artifacts/:id
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
// @actions/cache toolkit downloads archiveLocation without Authorization.
// Repository scoping is already enforced at find() time; the signature binds
// the URL to the specific cache ID and an expiry.
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseInt(params.ByName("id"), 10, 64) id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil { if err != nil {
@@ -344,21 +526,158 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
h.responseJSON(w, r, 200) h.responseJSON(w, r, 200)
} }
func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle { // bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
// registered jobs. A match attaches the job's JobCredential to the request
// context; a miss returns 401 before the handler body runs.
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.RequestURI) h.logger.Debugf("%s %s", r.Method, r.URL.Path)
token := bearerToken(r)
if token == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
return
}
cred, ok := h.lookupCredential(token)
if !ok {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
return
}
ctx := context.WithValue(r.Context(), credKey{}, cred)
handler(w, r.WithContext(ctx), params)
go h.gcCache()
}
}
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
expStr := r.URL.Query().Get("exp")
sig := r.URL.Query().Get("sig")
if expStr == "" || sig == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing signature"))
return
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("invalid expiry"))
return
}
if time.Now().Unix() > exp {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
return
}
expected := h.computeSignature(id, exp)
if !hmac.Equal([]byte(sig), []byte(expected)) {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
return
}
handler(w, r, params) handler(w, r, params)
go h.gcCache() go h.gcCache()
} }
} }
// internalAuth gates the control-plane endpoints. The bearer must
// constant-time-equal the configured internalSecret. If the secret is empty,
// the control-plane is disabled and every request gets 404 — which matches
// the upstream nektos/act behavior of "the route does not exist".
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
if h.internalSecret == "" {
http.NotFound(w, r)
return
}
token := bearerToken(r)
if token == "" || !hmac.Equal([]byte(token), []byte(h.internalSecret)) {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
return
}
handler(w, r, params)
}
}
type internalRegisterBody struct {
Token string `json:"token"`
Repo string `json:"repo"`
}
type internalRevokeBody struct {
Token string `json:"token"`
}
// POST /_internal/register
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRegisterBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if body.Token == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
return
}
h.RegisterJob(body.Token, body.Repo)
h.responseJSON(w, r, http.StatusOK)
}
// POST /_internal/revoke
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRevokeBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if body.Token == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
return
}
h.RevokeJob(body.Token)
h.responseJSON(w, r, http.StatusOK)
}
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
return auth[len(prefix):]
}
return ""
}
func credFromContext(ctx context.Context) JobCredential {
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok {
return cred
}
return JobCredential{}
}
func (h *Handler) computeSignature(cacheID, exp int64) string {
mac := hmac.New(sha256.New, h.secret)
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
return hex.EncodeToString(mac.Sum(nil))
}
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
expUnix := exp.Unix()
sig := h.computeSignature(int64(cacheID), expUnix)
q := url.Values{}
q.Set("exp", strconv.FormatInt(expUnix, 10))
q.Set("sig", sig)
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
}
// if not found, return (nil, nil) instead of an error. // if not found, return (nil, nil) instead of an error.
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) { func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
cache := &Cache{} cache := &Cache{}
for _, prefix := range keys { for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches // if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache, if err := db.FindOne(cache,
bolthold.Where("Key").Eq(prefix). bolthold.Where("Repo").Eq(repo).
And("Key").Eq(prefix).
And("Version").Eq(version). And("Version").Eq(version).
And("Complete").Eq(true). And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) { SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
@@ -373,7 +692,8 @@ func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error
continue continue
} }
if err := db.FindOne(cache, if err := db.FindOne(cache,
bolthold.Where("Key").RegExp(re). bolthold.Where("Repo").Eq(repo).
And("Key").RegExp(re).
And("Version").Eq(version). And("Version").Eq(version).
And("Complete").Eq(true). And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil { SortBy("CreatedAt").Reverse()); err != nil {
@@ -419,7 +739,6 @@ const (
keepOld = 5 * time.Minute keepOld = 5 * time.Minute
) )
//nolint:gocyclo // function handles many cases
func (h *Handler) gcCache() { func (h *Handler) gcCache() {
if h.gcing.Load() { if h.gcing.Load() {
return return
@@ -494,12 +813,16 @@ func (h *Handler) gcCache() {
} }
} }
// Remove the old caches with the same key and version, keep the latest one. // Remove the old caches with the same key and version within the same
// repository, keep the latest one. Aggregation must include Repo so two
// repos that happen to share a (key, version) do not evict each other —
// otherwise per-repo scoping holds for reads but one repo can age
// another out after keepOld.
// Also keep the olds which have been used recently for a while in case of the cache is still in use. // Also keep the olds which have been used recently for a while in case of the cache is still in use.
if results, err := db.FindAggregate( if results, err := db.FindAggregate(
&Cache{}, &Cache{},
bolthold.Where("Complete").Eq(true), bolthold.Where("Complete").Eq(true),
"Key", "Version", "Repo", "Key", "Version",
); err != nil { ); err != nil {
h.logger.Warnf("find aggregate caches: %v", err) h.logger.Warnf("find aggregate caches: %v", err)
} else { } else {
@@ -533,7 +856,7 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
if len(v) == 0 || v[0] == nil { if len(v) == 0 || v[0] == nil {
data, _ = json.Marshal(struct{}{}) data, _ = json.Marshal(struct{}{})
} else if err, ok := v[0].(error); ok { } else if err, ok := v[0].(error); ok {
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err) h.logger.Errorf("%v %v: %v", r.Method, r.URL.Path, err)
data, _ = json.Marshal(map[string]any{ data, _ = json.Marshal(map[string]any{
"error": err.Error(), "error": err.Error(),
}) })

View File

@@ -22,12 +22,38 @@ import (
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
// testToken is registered with the cache server in every test that needs to
// make authenticated requests; testClient then attaches it as the
// Authorization: Bearer header. testRepo is the repository scope used when
// registering it; cross-repo isolation is exercised in its own test.
const (
testToken = "test-runtime-token"
testRepo = "owner/repo"
)
type bearerTransport struct{ token string }
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+b.token)
return http.DefaultTransport.RoundTrip(r)
}
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
// signArtifactURL builds a signed download URL the same way the server does;
// tests use it to reach the get handler directly without going through a
// find/cache-hit round trip.
func signArtifactURL(h *Handler, id int64) string {
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
}
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache") dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil) handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err) require.NoError(t, err)
handler.RegisterJob(testToken, testRepo)
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase) base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
defer func() { defer func() {
t.Run("inpect db", func(t *testing.T) { t.Run("inpect db", func(t *testing.T) {
@@ -45,7 +71,10 @@ func TestHandler(t *testing.T) {
require.NoError(t, handler.Close()) require.NoError(t, handler.Close())
assert.Nil(t, handler.server) assert.Nil(t, handler.server)
assert.Nil(t, handler.listener) assert.Nil(t, handler.listener)
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
if err == nil {
resp.Body.Close()
}
assert.Error(t, err) assert.Error(t, err)
}) })
}() }()
@@ -53,8 +82,9 @@ func TestHandler(t *testing.T) {
t.Run("get not exist", func(t *testing.T) { t.Run("get not exist", func(t *testing.T) {
key := strings.ToLower(t.Name()) key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 204, resp.StatusCode) require.Equal(t, 204, resp.StatusCode)
}) })
@@ -68,16 +98,18 @@ func TestHandler(t *testing.T) {
}) })
t.Run("clean", func(t *testing.T) { t.Run("clean", func(t *testing.T) {
resp, err := http.Post(base+"/clean", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/clean", "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
}) })
t.Run("reserve with bad request", func(t *testing.T) { t.Run("reserve with bad request", func(t *testing.T) {
body := []byte(`invalid json`) body := []byte(`invalid json`)
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
}) })
@@ -94,8 +126,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
@@ -108,8 +141,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
@@ -125,8 +159,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
}) })
@@ -136,8 +171,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
}) })
@@ -155,8 +191,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
@@ -171,13 +208,15 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
@@ -186,8 +225,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
} }
}) })
@@ -206,8 +246,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
@@ -222,24 +263,27 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes xx-99/*") req.Header.Set("Content-Range", "bytes xx-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
} }
}) })
t.Run("commit with bad id", func(t *testing.T) { t.Run("commit with bad id", func(t *testing.T) {
{ {
resp, err := http.Post(base+"/caches/invalid_id", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches/invalid_id", "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
} }
}) })
t.Run("commit with not exist id", func(t *testing.T) { t.Run("commit with not exist id", func(t *testing.T) {
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
} }
}) })
@@ -258,8 +302,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
@@ -274,18 +319,21 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode) assert.Equal(t, 400, resp.StatusCode)
} }
}) })
@@ -304,8 +352,9 @@ func TestHandler(t *testing.T) {
Size: 100, Size: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
@@ -320,32 +369,37 @@ func TestHandler(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-59/*") req.Header.Set("Content-Range", "bytes 0-59/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 500, resp.StatusCode) assert.Equal(t, 500, resp.StatusCode)
} }
}) })
t.Run("get with bad id", func(t *testing.T) { t.Run("get with bad id", func(t *testing.T) {
resp, err := http.Get(base + "/artifacts/invalid_id") //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(base + "/artifacts/invalid_id")
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 400, resp.StatusCode) require.Equal(t, 400, resp.StatusCode)
}) })
t.Run("get with not exist id", func(t *testing.T) { t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(signArtifactURL(handler, 100))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode) require.Equal(t, 404, resp.StatusCode)
}) })
t.Run("get with not exist id", func(t *testing.T) { t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(signArtifactURL(handler, 100))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode) require.Equal(t, 404, resp.StatusCode)
}) })
@@ -375,8 +429,9 @@ func TestHandler(t *testing.T) {
key + "_a", key + "_a",
}, ",") }, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
/* /*
@@ -395,8 +450,9 @@ func TestHandler(t *testing.T) {
assert.Equal(t, "hit", got.Result) assert.Equal(t, "hit", got.Result)
assert.Equal(t, keys[except], got.CacheKey) assert.Equal(t, keys[except], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act contentResp, err := testClient.Get(got.ArchiveLocation)
require.NoError(t, err) require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode) require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body) content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err) require.NoError(t, err)
@@ -413,8 +469,9 @@ func TestHandler(t *testing.T) {
{ {
reqKey := key + "_aBc" reqKey := key + "_aBc"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
Result string `json:"result"` Result string `json:"result"`
@@ -452,8 +509,9 @@ func TestHandler(t *testing.T) {
key + "_a_b", key + "_a_b",
}, ",") }, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
/* /*
@@ -470,8 +528,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey) assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act contentResp, err := testClient.Get(got.ArchiveLocation)
require.NoError(t, err) require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode) require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body) content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err) require.NoError(t, err)
@@ -504,8 +563,9 @@ func TestHandler(t *testing.T) {
key + "_a_b", key + "_a_b",
}, ",") }, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
/* /*
@@ -523,8 +583,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey) assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act contentResp, err := testClient.Get(got.ArchiveLocation)
require.NoError(t, err) require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode) require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body) content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err) require.NoError(t, err)
@@ -541,8 +602,9 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
Size: int64(len(content)), Size: int64(len(content)),
}) })
require.NoError(t, err) require.NoError(t, err)
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
@@ -557,19 +619,22 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
require.NoError(t, err) require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*") req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
{ {
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
} }
var archiveLocation string var archiveLocation string
{ {
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
got := struct { got := struct {
Result string `json:"result"` Result string `json:"result"`
@@ -582,8 +647,9 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
archiveLocation = got.ArchiveLocation archiveLocation = got.ArchiveLocation
} }
{ {
resp, err := http.Get(archiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act resp, err := testClient.Get(archiveLocation)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
got, err := io.ReadAll(resp.Body) got, err := io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
@@ -593,7 +659,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
func TestHandler_gcCache(t *testing.T) { func TestHandler_gcCache(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache") dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil) handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err) require.NoError(t, err)
defer func() { defer func() {
@@ -699,3 +765,421 @@ func TestHandler_gcCache(t *testing.T) {
} }
require.NoError(t, db.Close()) require.NoError(t, db.Close())
} }
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
// unauthenticated access to management endpoints is now refused with 401.
func TestHandler_RejectsMissingBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL() + apiPath
for _, tc := range []struct {
name string
method string
path string
body string
}{
{"find", http.MethodGet, "/cache?keys=x&version=y", ""},
{"reserve", http.MethodPost, "/caches", "{}"},
{"upload", http.MethodPatch, "/caches/1", ""},
{"commit", http.MethodPost, "/caches/1", ""},
{"clean", http.MethodPost, "/clean", ""},
} {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(tc.method, base+tc.path, strings.NewReader(tc.body))
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
}
// TestHandler_RejectsUnknownBearer verifies that a bearer token is only
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
func TestHandler_RejectsUnknownBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL() + apiPath
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer not-a-registered-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestHandler_UnregisterRevokes ensures that the function returned by
// RegisterJob invalidates the credential, so a token leaked at job time stops
// working the moment the job ends instead of living for the runner's lifetime.
func TestHandler_UnregisterRevokes(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
unregister := handler.RegisterJob("tmp-token", testRepo)
base := handler.ExternalURL() + apiPath
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer tmp-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
unregister()
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestHandler_CrossRepoIsolation addresses the intra-runner poisoning vector
// raised in GHSA-82g9-637c-2fx2: job containers can reach the cache server
// over the docker bridge, so IP allowlisting alone does not stop a malicious
// PR run from another repo. A cache entry created under repoA must be
// invisible to queries scoped to repoB.
func TestHandler_CrossRepoIsolation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("token-a", "owner/repoA")
handler.RegisterJob("token-b", "owner/repoB")
base := handler.ExternalURL() + apiPath
key := "shared-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
content := []byte("repoA-payload")
clientA := &http.Client{Transport: &bearerTransport{token: "token-a"}}
clientB := &http.Client{Transport: &bearerTransport{token: "token-b"}}
// repoA reserves + uploads + commits.
reserveBody, err := json.Marshal(&Request{Key: key, Version: version, Size: int64(len(content))})
require.NoError(t, err)
resp, err := clientA.Post(base+"/caches", "application/json", bytes.NewReader(reserveBody))
require.NoError(t, err)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
resp.Body.Close()
require.NotZero(t, reserved.CacheID)
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err = clientA.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", nil)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// repoB with a matching key and version must NOT see repoA's cache.
resp, err = clientB.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// repoA still sees its own cache.
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// repoB cannot upload to repoA's reserved id either (forbidden, not 401).
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader([]byte("poison")))
require.NoError(t, err)
req.Header.Set("Content-Range", "bytes 0-5/*")
resp, err = clientB.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
// TestHandler_ArtifactSignature verifies that archive downloads reject
// missing / tampered / expired signatures, so a leaked archiveLocation stops
// working after artifactURLTTL even if the bearer token is still registered.
func TestHandler_ArtifactSignature(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
base := handler.ExternalURL() + apiPath
t.Run("missing signature", func(t *testing.T) {
resp, err := testClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 1))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("tampered signature", func(t *testing.T) {
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
bad := good[:len(good)-4] + "dead"
resp, err := testClient.Get(bad)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("expired signature", func(t *testing.T) {
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
resp, err := testClient.Get(expired)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("signature from a different server", func(t *testing.T) {
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
other, err := StartHandler(dir2, "", 0, "", nil)
require.NoError(t, err)
defer other.Close()
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
// Rewrite the host so the request still lands on our handler, but
// the signature was computed with a different secret.
parts := strings.SplitN(otherURL, apiPath, 2)
forged := base + parts[1]
resp, err := testClient.Get(forged)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
// act_runner cache-server be pointed at via cfg.Cache.ExternalServer: a
// restart must not invalidate signed URLs the handler has already issued
// (within their expiry window).
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
exp := time.Now().Add(artifactURLTTL).Unix()
sig := first.computeSignature(42, exp)
require.NoError(t, first.Close())
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer second.Close()
assert.Equal(t, sig, second.computeSignature(42, exp))
}
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
// ensures a real reserve/upload/commit/find/download flow still works after
// the auth refactor.
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
base := handler.ExternalURL() + apiPath
key := "download-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
content := []byte("hello")
uploadCacheNormally(t, base, key, version, content)
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
resp.Body.Close()
require.Contains(t, hit.ArchiveLocation, "sig=")
require.Contains(t, hit.ArchiveLocation, "exp=")
// Download without any Authorization header — the signature alone must
// be enough, because @actions/cache downloads archiveLocation unauth'd.
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
body, err := io.ReadAll(dl.Body)
dl.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, dl.StatusCode)
assert.Equal(t, content, body)
}
// TestHandler_RegisterJob_RefCounted verifies that a duplicate RegisterJob
// for the same token does not silently revoke the first registration on the
// first revoker call. This matters if a runner ever re-registers a token
// (restart mid-task, retry), which must not kill the live job's auth.
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
first := handler.RegisterJob("shared", testRepo)
second := handler.RegisterJob("shared", testRepo)
base := handler.ExternalURL() + apiPath
probe := func() int {
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer shared")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
require.NotEqual(t, http.StatusUnauthorized, probe())
first()
assert.NotEqual(t, http.StatusUnauthorized, probe(),
"token must stay valid while another registration holds the refcount")
second()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token is revoked only after every revoker has run")
}
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
// another repo's entry. Two repos reserve the same (key, version); after the
// keepOld window, GC must keep the one from each repo.
func TestHandler_GC_PerRepoDedup(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("tok-a", "owner/repoA")
handler.RegisterJob("tok-b", "owner/repoB")
key := "shared-dedup-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
// Seed one completed cache per repo directly via the DB, bypassing the
// HTTP round trip so we can precisely control UsedAt.
db, err := handler.openDB()
require.NoError(t, err)
now := time.Now().Unix()
stale := time.Now().Add(-keepOld - time.Minute).Unix()
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
require.NoError(t, insertCache(db, a))
require.NoError(t, insertCache(db, b))
// Write the backing blobs so the dedup deletion has something to remove.
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
_, err = handler.storage.Commit(a.ID, 1)
require.NoError(t, err)
require.NoError(t, handler.storage.Write(b.ID, 0, strings.NewReader("b")))
_, err = handler.storage.Commit(b.ID, 1)
require.NoError(t, err)
require.NoError(t, db.Close())
// Force GC to run regardless of the cooldown.
handler.gcAt = time.Time{}
handler.gcCache()
db, err = handler.openDB()
require.NoError(t, err)
defer db.Close()
var after []Cache
require.NoError(t, db.Find(&after, bolthold.Where("Key").Eq(key).And("Version").Eq(version)))
repos := make(map[string]bool)
for _, c := range after {
repos[c.Repo] = true
}
assert.True(t, repos["owner/repoA"], "repoA's cache must survive dedup against repoB")
assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA")
}
// TestHandler_InternalAPI_Disabled verifies that without an internalSecret
// the control-plane routes are 404 — operators can't accidentally hit
// register/revoke when the feature is off.
func TestHandler_InternalAPI_Disabled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
for _, ep := range []string{"/_internal/register", "/_internal/revoke"} {
resp, err := http.Post(handler.ExternalURL()+ep, "application/json", strings.NewReader(`{}`))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode, ep)
}
}
// TestHandler_InternalAPI_AuthAndUsage covers the control-plane: bad/missing
// secret → 401, malformed body → 400, happy path round-trips a token through
// register → cache-API accepts it → revoke → cache-API rejects it.
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
const secret = "internal-secret"
handler, err := StartHandler(dir, "", 0, secret, nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL()
post := func(path, bearer, body string) int {
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(body))
require.NoError(t, err)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
t.Run("missing secret 401", func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "", `{"token":"x","repo":"r"}`))
})
t.Run("wrong secret 401", func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "wrong", `{"token":"x","repo":"r"}`))
})
t.Run("malformed body 400", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `not json`))
})
t.Run("missing token 400", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `{"repo":"r"}`))
})
t.Run("register then revoke round-trip", func(t *testing.T) {
probe := func(token string) int {
req, _ := http.NewRequest(http.MethodGet, base+apiPath+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo"}`))
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
})
}

View File

@@ -29,6 +29,7 @@ func (c *Request) ToCache() *Cache {
type Cache struct { type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"` ID uint64 `json:"id" boltholdKey:"ID"`
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"` Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"` Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"` Size int64 `json:"cacheSize"`

View File

@@ -202,7 +202,7 @@ func TestListArtifactContainer(t *testing.T) {
panic(err) panic(err)
} }
assert.Equal(1, len(response.Value)) //nolint:testifylint // pre-existing issue from nektos/act assert.Len(response.Value, 1)
assert.Equal("some/file", response.Value[0].Path) assert.Equal("some/file", response.Value[0].Path)
assert.Equal("file", response.Value[0].ItemType) assert.Equal("file", response.Value[0].ItemType)
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation) assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
@@ -283,7 +283,7 @@ func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
} }
workdir, err := filepath.Abs(tjfi.workdir) workdir, err := filepath.Abs(tjfi.workdir)
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath) fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
runnerConfig := &runner.Config{ runnerConfig := &runner.Config{
Workdir: workdir, Workdir: workdir,
@@ -299,16 +299,16 @@ func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
} }
runner, err := runner.New(runnerConfig) runner, err := runner.New(runnerConfig)
assert.Nil(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true) planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
plan, err := planner.PlanEvent(tjfi.eventName) plan, err := planner.PlanEvent(tjfi.eventName)
if err == nil { if err == nil {
err = runner.NewPlanExecutor(plan)(ctx) err = runner.NewPlanExecutor(plan)(ctx)
if tjfi.errorMessage == "" { if tjfi.errorMessage == "" {
assert.Nil(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 {
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
} }

View File

@@ -35,9 +35,9 @@ func TestCartesianProduct(t *testing.T) {
"baz": {false, true}, "baz": {false, true},
} }
output = CartesianProduct(input) output = CartesianProduct(input)
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(output)
input = map[string][]any{} input = map[string][]any{}
output = CartesianProduct(input) output = CartesianProduct(input)
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(output)
} }

View File

@@ -21,11 +21,11 @@ func TestNewWorkflow(t *testing.T) {
// empty // empty
emptyWorkflow := NewPipelineExecutor() emptyWorkflow := NewPipelineExecutor()
assert.Nil(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// error case // error case
errorWorkflow := NewErrorExecutor(errors.New("test error")) errorWorkflow := NewErrorExecutor(errors.New("test error"))
assert.NotNil(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// multiple success case // multiple success case
runcount := 0 runcount := 0
@@ -38,7 +38,7 @@ func TestNewWorkflow(t *testing.T) {
runcount++ runcount++
return nil return nil
}) })
assert.Nil(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(2, runcount) assert.Equal(2, runcount)
} }
@@ -60,7 +60,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil return nil
})(ctx) })(ctx)
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(0, trueCount) assert.Equal(0, trueCount)
assert.Equal(1, falseCount) assert.Equal(1, falseCount)
@@ -74,7 +74,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil return nil
})(ctx) })(ctx)
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(1, trueCount) assert.Equal(1, trueCount)
assert.Equal(1, falseCount) assert.Equal(1, falseCount)
} }
@@ -105,7 +105,7 @@ func TestNewParallelExecutor(t *testing.T) {
assert.Equal(int32(3), count.Load(), "should run all 3 executors") assert.Equal(int32(3), count.Load(), "should run all 3 executors")
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel") assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// Reset to test running the executor with 0 parallelism // Reset to test running the executor with 0 parallelism
count.Store(0) count.Store(0)
@@ -116,7 +116,7 @@ func TestNewParallelExecutor(t *testing.T) {
assert.Equal(int32(3), count.Load(), "should run all 3 executors") 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.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
assert.Nil(errSingle) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(errSingle)
} }
func TestNewParallelExecutorFailed(t *testing.T) { func TestNewParallelExecutorFailed(t *testing.T) {

View File

@@ -32,12 +32,21 @@ var (
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`) githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`) githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
cloneLock sync.Mutex cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
ErrShortRef = errors.New("short SHA references are not supported") ErrShortRef = errors.New("short SHA references are not supported")
ErrNoRepo = errors.New("unable to find git repo") ErrNoRepo = errors.New("unable to find git repo")
) )
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
func acquireCloneLock(dir string) func() {
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
type Error struct { type Error struct {
err error err error
commit string commit string
@@ -277,6 +286,7 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) { func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"} fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
fetchOptions.Force = true
pullOptions.Force = true pullOptions.Force = true
if token != "" { if token != "" {
@@ -292,16 +302,13 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
} }
// NewGitCloneExecutor creates an executor to clone git repos // NewGitCloneExecutor creates an executor to clone git repos
//
//nolint:gocyclo // function handles many cases
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref) logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Debugf(" cloning %s to %s", input.URL, input.Dir) logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
cloneLock.Lock() defer acquireCloneLock(input.Dir)()
defer cloneLock.Unlock()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref) refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, err := CloneIfRequired(ctx, refName, input, logger) r, err := CloneIfRequired(ctx, refName, input, logger)

View File

@@ -10,8 +10,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"sync"
"syscall" "syscall"
"testing" "testing"
"time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -220,6 +223,62 @@ func TestGitCloneExecutor(t *testing.T) {
} }
} }
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
gitConfig()
// Create a bare "remote" repo with an initial commit on main and a feature branch.
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
// We need a working clone to push commits from.
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
// Create a feature branch (simulates refs/pull/N/head).
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
// First clone via the executor — should succeed and cache the repo.
cloneDir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "main",
Dir: cloneDir,
})
require.NoError(t, clone(context.Background()))
// Now force-push the feature branch to a non-fast-forward commit (simulates
// a PR rebase). This makes refs/heads/feature non-fast-forward.
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
// Also advance main so we can verify the clone picks up the new commit.
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
// Second clone to the same directory — before the fix this returned ErrForceNeeded
// and left the working tree at the old commit.
err := clone(context.Background())
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
// Verify the working tree was actually updated to the latest main commit.
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
require.NoError(t, err)
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
}
func gitConfig() { func gitConfig() {
if os.Getenv("GITHUB_ACTIONS") == "true" { if os.Getenv("GITHUB_ACTIONS") == "true" {
var err error var err error
@@ -246,3 +305,61 @@ func gitCmd(args ...string) error {
} }
return nil return nil
} }
func TestAcquireCloneLock(t *testing.T) {
t.Run("same directory serializes", func(t *testing.T) {
dir := t.TempDir()
unlock1 := acquireCloneLock(dir)
secondAcquired := make(chan struct{})
go func() {
unlock := acquireCloneLock(dir)
close(secondAcquired)
unlock()
}()
select {
case <-secondAcquired:
t.Fatal("second acquire should block while first holds the lock")
case <-time.After(50 * time.Millisecond):
}
unlock1()
select {
case <-secondAcquired:
case <-time.After(time.Second):
t.Fatal("second acquire should proceed after first releases the lock")
}
})
t.Run("different directories do not block", func(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
unlockA := acquireCloneLock(dirA)
defer unlockA()
done := make(chan struct{})
go func() {
unlock := acquireCloneLock(dirB)
unlock()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("acquire on a different directory must not block")
}
})
t.Run("same directory reuses the same mutex", func(t *testing.T) {
dir := t.TempDir()
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
require.Same(t, v1, v2)
})
}

View File

@@ -324,8 +324,6 @@ type containerConfig struct {
// parse parses the args for the specified command and generates a Config, // parse parses the args for the specified command and generates a Config,
// a HostConfig and returns them with the specified command. // a HostConfig and returns them with the specified command.
// If the specified args are not valid, it will return an error. // If the specified args are not valid, it will return an error.
//
//nolint:gocyclo // function handles many cases
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) {
var ( var (
attachStdin = copts.attach.Get("stdin") attachStdin = copts.attach.Get("stdin")

View File

@@ -194,7 +194,6 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
} }
} }
//nolint:gocyclo // function handles many cases
func TestParseWithVolumes(t *testing.T) { func TestParseWithVolumes(t *testing.T) {
// A single volume // A single volume
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`}) arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
@@ -632,7 +631,7 @@ func TestParseModes(t *testing.T) {
} }
// uts ko // uts ko
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers _, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"})
assert.ErrorContains(t, err, "--uts: invalid UTS mode") assert.ErrorContains(t, err, "--uts: invalid UTS mode")
// uts ok // uts ok
@@ -693,7 +692,7 @@ func TestParseRestartPolicy(t *testing.T) {
func TestParseRestartPolicyAutoRemove(t *testing.T) { func TestParseRestartPolicyAutoRemove(t *testing.T) {
expected := "Conflicting options: --restart and --rm" expected := "Conflicting options: --restart and --rm"
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers _, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"})
if err == nil || err.Error() != expected { if err == nil || err.Error() != expected {
t.Fatalf("Expected error %v, but got none", expected) t.Fatalf("Expected error %v, but got none", expected)
} }

View File

@@ -29,17 +29,17 @@ func TestImageExistsLocally(t *testing.T) {
// Test if image exists with specific tag // Test if image exists with specific tag
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64") invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, invalidImageTag) //nolint:testifylint // pre-existing issue from nektos/act assert.False(t, invalidImageTag)
// Test if image exists with specific architecture (image platform) // Test if image exists with specific architecture (image platform)
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64") invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, invalidImagePlatform) //nolint:testifylint // pre-existing issue from nektos/act assert.False(t, invalidImagePlatform)
// pull an image // pull an image
cli, err := client.NewClientWithOpts(client.FromEnv) cli, err := client.NewClientWithOpts(client.FromEnv)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cli.NegotiateAPIVersion(context.Background()) cli.NegotiateAPIVersion(context.Background())
// Chose alpine latest because it's so small // Chose alpine latest because it's so small
@@ -47,25 +47,25 @@ func TestImageExistsLocally(t *testing.T) {
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{ readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/amd64", Platform: "linux/amd64",
}) })
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer readerDefault.Close() defer readerDefault.Close()
_, err = io.ReadAll(readerDefault) _, err = io.ReadAll(readerDefault)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64") imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageDefaultArchExists) //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, imageDefaultArchExists)
// Validate if another architecture platform can be pulled // Validate if another architecture platform can be pulled
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{ readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/arm64", Platform: "linux/arm64",
}) })
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer readerArm64.Close() defer readerArm64.Close()
_, err = io.ReadAll(readerArm64) _, err = io.ReadAll(readerArm64)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64") imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageArm64Exists) //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, imageArm64Exists)
} }

View File

@@ -43,7 +43,7 @@ func TestGetImagePullOptions(t *testing.T) {
config.SetDir("/non-existent/docker") config.SetDir("/non-existent/docker")
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{}) options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{ options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
@@ -51,7 +51,7 @@ func TestGetImagePullOptions(t *testing.T) {
Username: "username", Username: "username",
Password: "password", Password: "password",
}) })
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided") assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
config.SetDir("testdata/docker-pull-options") config.SetDir("testdata/docker-pull-options")
@@ -59,6 +59,6 @@ func TestGetImagePullOptions(t *testing.T) {
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{ options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
Image: "nektos/act", Image: "nektos/act",
}) })
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config") assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
} }

View File

@@ -29,7 +29,7 @@ func TestGetSocketAndHostWithSocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI) ret, err := GetSocketAndHost(socketURI)
// Assert // Assert
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret) assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
} }
@@ -42,7 +42,7 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("") ret, err := GetSocketAndHost("")
// Assert // Assert
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret) assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
} }
@@ -57,8 +57,8 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI) ret, err := GetSocketAndHost(socketURI)
// Assert // Assert
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, defaultSocketFound, "Expected to find default socket") //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, defaultSocketFound, "Expected to find default socket")
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location") assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location") assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
} }
@@ -73,7 +73,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
ret, err := GetSocketAndHost("-") ret, err := GetSocketAndHost("-")
// Assert // Assert
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret) assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
} }
@@ -87,8 +87,8 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("") ret, err := GetSocketAndHost("")
// Assert // Assert
assert.Equal(t, true, found, "Expected a default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, found, "Expected a default socket to be found")
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location") assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
} }
@@ -112,8 +112,8 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Assert // Assert
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location") assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
assert.Equal(t, true, found, "Expected default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, found, "Expected default socket to be found")
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location") assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
} }
@@ -128,7 +128,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
ret, err := GetSocketAndHost(mySocket) ret, err := GetSocketAndHost(mySocket)
// Assert // Assert
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act assert.False(t, found, "Expected no default socket to be found")
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location") assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
assert.Error(t, err, "Expected an error in invalid state") assert.Error(t, err, "Expected an error in invalid state")
@@ -147,8 +147,8 @@ func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Assert // Assert
// Default socket locations // Default socket locations
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act assert.False(t, found, "Expected no default socket to be found")
// Sane default // Sane default
assert.Nil(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket") assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
} }

View File

@@ -43,7 +43,7 @@ func TestFunctionContains(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -72,7 +72,7 @@ func TestFunctionStartsWith(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -101,7 +101,7 @@ func TestFunctionEndsWith(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -128,7 +128,7 @@ func TestFunctionJoin(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -154,7 +154,7 @@ func TestFunctionToJSON(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -177,7 +177,7 @@ func TestFunctionFromJSON(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -205,9 +205,9 @@ func TestFunctionHashFiles(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
workdir, err := filepath.Abs("testdata") workdir, err := filepath.Abs("testdata")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -248,7 +248,7 @@ func TestFunctionFormat(t *testing.T) {
if tt.error != nil { if tt.error != nil {
assert.Equal(t, tt.error, err.Error()) assert.Equal(t, tt.error, err.Error())
} else { } else {
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
} }
}) })

View File

@@ -156,7 +156,6 @@ func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, er
} }
} }
//nolint:gocyclo // function handles many cases
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) { func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
switch strings.ToLower(variableNode.Name) { switch strings.ToLower(variableNode.Name) {
case "github": case "github":
@@ -584,7 +583,6 @@ func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.Logi
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind()) return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
} }
//nolint:gocyclo // function handles many cases
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) { func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
args := make([]reflect.Value, 0) args := make([]reflect.Value, 0)

View File

@@ -35,7 +35,7 @@ func TestLiterals(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -105,10 +105,10 @@ func TestOperators(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != "" { if tt.error != "" {
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.error, err.Error()) assert.Equal(t, tt.error, err.Error())
} else { } else {
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
} }
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@@ -157,7 +157,7 @@ func TestOperatorsCompare(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })
@@ -520,7 +520,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) { if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
assert.True(t, math.IsNaN(output.(float64))) assert.True(t, math.IsNaN(output.(float64)))
@@ -624,7 +624,7 @@ func TestContexts(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
}) })

View File

@@ -128,7 +128,6 @@ func (*DefaultFs) Readlink(path string) (string, error) {
return os.Readlink(path) return os.Readlink(path)
} }
//nolint:gocyclo // function handles many cases
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc { func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...))) i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
return func(file string, fi os.FileInfo, err error) error { return func(file string, fi os.FileInfo, err error) error {

View File

@@ -61,8 +61,6 @@ type WorkflowFiles struct {
} }
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories // NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
//
//nolint:gocyclo // function handles many cases
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) { func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
path, err := filepath.Abs(path) path, err := filepath.Abs(path)
if err != nil { if err != nil {

View File

@@ -57,11 +57,11 @@ func TestWorkflow(t *testing.T) {
// Check that an invalid job id returns error // Check that an invalid job id returns error
result, err := createStages(&workflow, "invalid_job_id") result, err := createStages(&workflow, "invalid_job_id")
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, result) assert.Nil(t, result)
// Check that an valid job id returns non-error // Check that an valid job id returns non-error
result, err = createStages(&workflow, "valid_job") result, err = createStages(&workflow, "valid_job")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, result) assert.NotNil(t, result)
} }

View File

@@ -440,8 +440,6 @@ func (j *Job) Matrix() map[string][]any {
// GetMatrixes returns the matrix cross product // GetMatrixes returns the matrix cross product
// It skips includes and hard fails excludes for non-existing keys // It skips includes and hard fails excludes for non-existing keys
//
//nolint:gocyclo // function handles many cases
func (j *Job) GetMatrixes() ([]map[string]any, error) { func (j *Job) GetMatrixes() ([]map[string]any, error) {
matrixes := make([]map[string]any, 0) matrixes := make([]map[string]any, 0)
if j.Strategy != nil { if j.Strategy != nil {

View File

@@ -56,7 +56,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule() newSchedules = workflow.OnSchedule()
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, newSchedules)
yaml = ` yaml = `
name: local-action-docker-url name: local-action-docker-url
@@ -74,7 +74,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule() newSchedules = workflow.OnSchedule()
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, newSchedules)
yaml = ` yaml = `
name: local-action-docker-url name: local-action-docker-url
@@ -91,7 +91,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule() newSchedules = workflow.OnSchedule()
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, newSchedules)
} }
func TestReadWorkflow_StringEvent(t *testing.T) { func TestReadWorkflow_StringEvent(t *testing.T) {
@@ -870,7 +870,7 @@ jobs:
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy") assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
} else { } else {
assert.NotNil(t, matrix, "matrix should not be nil") assert.NotNil(t, matrix, "matrix should not be nil")
assert.Equal(t, tt.wantLen, len(matrix), "matrix should have expected number of keys") //nolint:testifylint // pre-existing issue from nektos/act assert.Len(t, matrix, tt.wantLen, "matrix should have expected number of keys")
if tt.checkFn != nil { if tt.checkFn != nil {
tt.checkFn(t, matrix) tt.checkFn(t, matrix)
} }

View File

@@ -265,8 +265,6 @@ func removeGitIgnore(ctx context.Context, directory string) error {
} }
// TODO: break out parts of function to reduce complexicity // TODO: break out parts of function to reduce complexicity
//
//nolint:gocyclo // function handles many cases
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error { func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc := step.getRunContext() rc := step.getRunContext()
@@ -429,7 +427,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Image: image, Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"], Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"], Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID), Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList, Env: envList,
Mounts: mounts, Mounts: mounts,
NetworkMode: networkMode, NetworkMode: networkMode,

View File

@@ -137,7 +137,7 @@ runs:
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile) action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, action) assert.Equal(t, tt.expected, action)
closerMock.AssertExpectations(t) closerMock.AssertExpectations(t)
@@ -247,7 +247,7 @@ func TestActionRunner(t *testing.T) {
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx) err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t) cm.AssertExpectations(t)
}) })
} }

View File

@@ -405,7 +405,6 @@ func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
} }
//nolint:gocyclo // function handles many cases
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in, nil return in, nil
@@ -472,7 +471,6 @@ func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (str
return out, nil return out, nil
} }
//nolint:gocyclo // function handles many cases
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any { func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
inputs := map[string]any{} inputs := map[string]any{}

View File

@@ -24,7 +24,6 @@ type jobInfo interface {
result(result string) result(result string)
} }
//nolint:contextcheck,gocyclo // composes many step executors
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor { func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := make([]common.Executor, 0) steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0) preSteps := make([]common.Executor, 0)
@@ -157,7 +156,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
pipeline = append(pipeline, steps...) pipeline = append(pipeline, steps...)
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...). return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
Finally(func(ctx context.Context) error { //nolint:contextcheck // intentionally detaches from canceled parent Finally(func(ctx context.Context) error {
var cancel context.CancelFunc var cancel context.CancelFunc
if ctx.Err() == context.Canceled { if ctx.Err() == context.Canceled {
// in case of an aborted run, we still should execute the // in case of an aborted run, we still should execute the

View File

@@ -331,7 +331,7 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc) executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx) err := executor(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.executedSteps, executorOrder) assert.Equal(t, tt.executedSteps, executorOrder)
jim.AssertExpectations(t) jim.AssertExpectations(t)

View File

@@ -30,6 +30,11 @@ const (
gray = 37 gray = 37
) )
const (
rawOutputField = "raw_output"
scriptLineCyanField = "script_line_cyan"
)
var ( var (
colors []int colors []int
nextColor int nextColor int
@@ -161,6 +166,8 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry type entryProcessor func(entry *logrus.Entry) *logrus.Entry
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
// raw_output (command/stream) lines; there is no bypass by field.
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor { func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
return func(entry *logrus.Entry) *logrus.Entry { return func(entry *logrus.Entry) *logrus.Entry {
if insecureSecrets { if insecureSecrets {
@@ -227,8 +234,12 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
debugFlag = "[DEBUG] " debugFlag = "[DEBUG] "
} }
if entry.Data["raw_output"] == true { if entry.Data[rawOutputField] == true {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message) if entry.Data[scriptLineCyanField] == true {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
} else {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
}
} else if entry.Data["dryrun"] == true { } else if entry.Data["dryrun"] == true {
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message) fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
} else { } else {
@@ -251,7 +262,7 @@ func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
debugFlag = "[DEBUG] " debugFlag = "[DEBUG] "
} }
if entry.Data["raw_output"] == true { if entry.Data[rawOutputField] == true {
fmt.Fprintf(b, "[%s] | %s", job, entry.Message) fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
} else if entry.Data["dryrun"] == true { } else if entry.Data["dryrun"] == true {
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message) fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)

View File

@@ -60,7 +60,7 @@ func TestMaxParallelStrategy(t *testing.T) {
matrixes, err := job.GetMatrixes() matrixes, err := job.GetMatrixes()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, matrixes) assert.NotNil(t, matrixes)
assert.Equal(t, 5, len(matrixes)) //nolint:testifylint // pre-existing issue from nektos/act assert.Len(t, matrixes, 5)
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel) assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
}) })
} }

View File

@@ -101,8 +101,7 @@ func (rc *RunContext) jobContainerName() string {
if rc.caller != nil { if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName) nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
} }
// return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name) return createContainerName(nameParts...) // For Gitea
return createSimpleContainerName(nameParts...) // For Gitea
} }
// networkNameForGitea return the name of the network // networkNameForGitea return the name of the network
@@ -260,7 +259,6 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
} }
} }
//nolint:gocyclo // function handles many cases
func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
@@ -769,7 +767,6 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap return rtnMap
} }
// Deprecated: use createSimpleContainerName
func createContainerName(parts ...string) string { func createContainerName(parts ...string) string {
name := strings.Join(parts, "-") name := strings.Join(parts, "-")
pattern := regexp.MustCompile("[^a-zA-Z0-9]") pattern := regexp.MustCompile("[^a-zA-Z0-9]")
@@ -783,22 +780,6 @@ func createContainerName(parts ...string) string {
return fmt.Sprintf("%s-%x", trimmedName, hash) return fmt.Sprintf("%s-%x", trimmedName, hash)
} }
func createSimpleContainerName(parts ...string) string {
pattern := regexp.MustCompile("[^a-zA-Z0-9-]")
name := make([]string, 0, len(parts))
for _, v := range parts {
v = pattern.ReplaceAllString(v, "-")
v = strings.Trim(v, "-")
for strings.Contains(v, "--") {
v = strings.ReplaceAll(v, "--", "-")
}
if v != "" {
name = append(name, v)
}
}
return strings.Join(name, "_")
}
func trimToLen(s string, l int) string { func trimToLen(s string, l int) string {
if l < 0 { if l < 0 {
l = 0 l = 0
@@ -826,7 +807,6 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults return rc.StepResults
} }
//nolint:gocyclo // function handles many cases
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{

View File

@@ -282,7 +282,7 @@ func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
cwd, err := os.Getwd() cwd, err := os.Getwd()
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
rc := &RunContext{ rc := &RunContext{
Config: &Config{ Config: &Config{
@@ -622,23 +622,16 @@ func TestRunContextGetEnv(t *testing.T) {
} }
} }
func Test_createSimpleContainerName(t *testing.T) { func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
tests := []struct { longMatrixValue := strings.Repeat("os=ubuntu-latest-go=1.24-node=22-", 20)
parts []string name := createContainerName(
want string "gitea",
}{ "WORKFLOW-super-long-workflow-name",
{ "JOB-build-matrix-"+longMatrixValue,
parts: []string{"a--a", "BB正", "c-C"}, )
want: "a-a_BB_c-C",
}, assert.LessOrEqual(t, len(name), 128)
{ assert.LessOrEqual(t, len(name+"-env"), 255)
parts: []string{"a-a", "", "-"}, assert.LessOrEqual(t, len(name+"-network"), 255)
want: "a-a", assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
},
}
for _, tt := range tests {
t.Run(strings.Join(tt.parts, " "), func(t *testing.T) {
assert.Equalf(t, tt.want, createSimpleContainerName(tt.parts...), "createSimpleContainerName(%v)", tt.parts)
})
}
} }

View File

@@ -137,8 +137,6 @@ func (runner *runnerImpl) configure() (Runner, error) {
} }
// NewPlanExecutor ... // NewPlanExecutor ...
//
//nolint:gocyclo // function handles many cases
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
maxJobNameLen := 0 maxJobNameLen := 0

View File

@@ -87,7 +87,7 @@ func TestGraphMissingEvent(t *testing.T) {
plan, err := planner.PlanEvent("push") plan, err := planner.PlanEvent("push")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan) assert.NotNil(t, plan)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, plan.Stages)
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml") assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
log.SetOutput(out) log.SetOutput(out)
@@ -100,7 +100,7 @@ func TestGraphMissingFirst(t *testing.T) {
plan, err := planner.PlanEvent("push") plan, err := planner.PlanEvent("push")
assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan) assert.NotNil(t, plan)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, plan.Stages)
} }
func TestGraphWithMissing(t *testing.T) { func TestGraphWithMissing(t *testing.T) {
@@ -114,7 +114,7 @@ func TestGraphWithMissing(t *testing.T) {
plan, err := planner.PlanEvent("push") plan, err := planner.PlanEvent("push")
assert.NotNil(t, plan) assert.NotNil(t, plan)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, plan.Stages)
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)") assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
log.SetOutput(out) log.SetOutput(out)
@@ -134,7 +134,7 @@ func TestGraphWithSomeMissing(t *testing.T) {
plan, err := planner.PlanAll() plan, err := planner.PlanAll()
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan) assert.NotNil(t, plan)
assert.Equal(t, 1, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act assert.Len(t, plan.Stages, 1)
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)") assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)") assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
log.SetOutput(out) log.SetOutput(out)
@@ -159,7 +159,7 @@ func TestGraphEvent(t *testing.T) {
plan, err = planner.PlanEvent("release") plan, err = planner.PlanEvent("release")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan) assert.NotNil(t, plan)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act assert.Empty(t, plan.Stages)
} }
type TestJobFileInfo struct { type TestJobFileInfo struct {
@@ -177,7 +177,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
log.SetLevel(logLevel) log.SetLevel(logLevel)
workdir, err := filepath.Abs(j.workdir) workdir, err := filepath.Abs(j.workdir)
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
fullWorkflowPath := filepath.Join(workdir, j.workflowPath) fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
runnerConfig := &Config{ runnerConfig := &Config{
@@ -197,17 +197,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
} }
runner, err := New(runnerConfig) runner, err := New(runnerConfig)
assert.Nil(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true) planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
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 = runner.NewPlanExecutor(plan)(ctx)
if j.errorMessage == "" { if j.errorMessage == "" {
assert.Nil(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 {
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
} }

View File

@@ -44,6 +44,10 @@ func (sal *stepActionLocal) main() common.Executor {
return nil return nil
} }
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses) actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
localReader := func(ctx context.Context) actionYamlReader { localReader := func(ctx context.Context) actionYamlReader {

View File

@@ -97,10 +97,10 @@ func TestStepActionLocalTest(t *testing.T) {
}) })
err := sal.pre()(ctx) err := sal.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sal.main()(ctx) err = sal.main()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t) cm.AssertExpectations(t)
salm.AssertExpectations(t) salm.AssertExpectations(t)

View File

@@ -39,7 +39,6 @@ type stepActionRemote struct {
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
//nolint:gocyclo // function handles many cases
func (sar *stepActionRemote) prepareActionExecutor() common.Executor { func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if sar.remoteAction != nil && sar.action != nil { if sar.remoteAction != nil && sar.action != nil {
@@ -166,6 +165,10 @@ func (sar *stepActionRemote) main() common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
sar.prepareActionExecutor(), sar.prepareActionExecutor(),
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error { runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
github := sar.getGithubContext(ctx) github := sar.getGithubContext(ctx)
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
if sar.RunContext.Config.BindWorkdir { if sar.RunContext.Config.BindWorkdir {

View File

@@ -272,8 +272,8 @@ func TestStepActionRemotePre(t *testing.T) {
err := sar.pre()(ctx) err := sar.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, clonedAction)
sarm.AssertExpectations(t) sarm.AssertExpectations(t)
}) })
@@ -343,8 +343,8 @@ func TestStepActionRemotePreThroughAction(t *testing.T) {
err := sar.pre()(ctx) err := sar.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, clonedAction)
sarm.AssertExpectations(t) sarm.AssertExpectations(t)
}) })
@@ -419,7 +419,7 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
err := sar.pre()(ctx) err := sar.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Verify that the clone was called (URL should be redirected to github.com) // Verify that the clone was called (URL should be redirected to github.com)
assert.True(t, actualURL != "", "Expected clone to be called") //nolint:testifylint // pre-existing issue from nektos/act assert.True(t, actualURL != "", "Expected clone to be called") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "https://github.com/org/repo", actualURL, "URL should be redirected to github.com") assert.Equal(t, "https://github.com/org/repo", actualURL, "URL should be redirected to github.com")

View File

@@ -116,6 +116,10 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
binds, mounts := rc.GetBindsAndMounts() binds, mounts := rc.GetBindsAndMounts()
networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) {
networkMode = "default"
}
stepContainer := ContainerNewContainer(&container.NewContainerInput{ stepContainer := ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd, Cmd: cmd,
Entrypoint: entrypoint, Entrypoint: entrypoint,
@@ -123,10 +127,10 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Image: image, Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"], Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"], Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID), Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Env: envList, Env: envList,
Mounts: mounts, Mounts: mounts,
NetworkMode: "container:" + rc.jobContainerName(), NetworkMode: networkMode,
Binds: binds, Binds: binds,
Stdout: logWriter, Stdout: logWriter,
Stderr: logWriter, Stderr: logWriter,

View File

@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"context" "context"
"io" "io"
"strings"
"testing" "testing"
"gitea.com/gitea/act_runner/act/container" "gitea.com/gitea/act_runner/act/container"
@@ -101,7 +102,7 @@ func TestStepDockerMain(t *testing.T) {
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
err := sd.main()(ctx) err := sd.main()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "node:14", input.Image) assert.Equal(t, "node:14", input.Image)
@@ -113,8 +114,86 @@ func TestStepDockerPrePost(t *testing.T) {
sd := &stepDocker{} sd := &stepDocker{}
err := sd.pre()(ctx) err := sd.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sd.post()(ctx) err = sd.post()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err)
}
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
cases := []struct {
name string
platform string
expectDefault bool
}{
{
name: "docker mode attaches to job container network",
platform: "node:14",
expectDefault: false,
},
{
name: "host mode uses default network",
platform: "-self-hosted",
expectDefault: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
platform := tc.platform
sd := &stepDocker{
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
Config: &Config{
PlatformPicker: func(_ []string) string {
return platform
},
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
JobContainer: cm,
},
Step: &model.Step{
ID: "1",
Uses: "docker://alpine:3.20",
},
}
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
"IsHostEnv mismatch for platform %q", tc.platform)
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode,
"host-mode step container must use 'default' network, got %q",
captured.NetworkMode)
} else {
assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"),
"docker-mode step container must attach to job container network, got %q",
captured.NetworkMode)
}
})
}
} }

View File

@@ -67,7 +67,7 @@ func TestStepFactoryNewStep(t *testing.T) {
step, err := sf.newStep(tt.model, &RunContext{}) step, err := sf.newStep(tt.model, &RunContext{})
assert.True(t, tt.check((step))) assert.True(t, tt.check((step)))
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err)
}) })
} }
} }

View File

@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"runtime" "runtime"
"slices"
"strings" "strings"
"gitea.com/gitea/act_runner/act/common" "gitea.com/gitea/act_runner/act/common"
@@ -17,15 +18,18 @@ import (
"gitea.com/gitea/act_runner/act/model" "gitea.com/gitea/act_runner/act/model"
"github.com/kballard/go-shellquote" "github.com/kballard/go-shellquote"
yaml "go.yaml.in/yaml/v4"
) )
type stepRun struct { type stepRun struct {
Step *model.Step Step *model.Step
RunContext *RunContext RunContext *RunContext
cmd []string cmd []string
cmdline string cmdline string
env map[string]string env map[string]string
WorkingDirectory string WorkingDirectory string
interpolatedScript string
shellCommand string
} }
func (sr *stepRun) pre() common.Executor { func (sr *stepRun) pre() common.Executor {
@@ -39,15 +43,154 @@ func (sr *stepRun) main() common.Executor {
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor( return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
sr.setupShellCommandExecutor(), sr.setupShellCommandExecutor(),
func(ctx context.Context) error { func(ctx context.Context) error {
sr.getRunContext().ApplyExtraPath(ctx, &sr.env) rc := sr.getRunContext()
if he, ok := sr.getRunContext().JobContainer.(*container.HostEnvironment); ok && he != nil { // Apply ::add-path:: effects before printing so PATH is accurate in the env: block.
rc.ApplyExtraPath(ctx, &sr.env)
sr.printRunScriptActionDetails(ctx)
if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil {
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx) return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
} }
return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx) return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
}, },
)) ))
} }
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
// for script steps.
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
if normalized != "" {
for line := range strings.SplitSeq(normalized, "\n") {
scriptLineLogger.Info(line)
}
}
rawLogger.Infof("shell: %s", sr.shellCommand)
printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext())
rawLogger.Infof("::endgroup::")
}
// printRunActionHeader mirrors actions/runner's "Run <action>" header for `uses:` steps,
// including the with: inputs and the step-level env: block. The caller is responsible
// for emitting ::endgroup:: after the action finishes.
func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
if step == nil {
return
}
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
title := step.Uses
if step.Name != "" {
title = step.Name
}
rawLogger.Infof("::group::Run %s", title)
if len(step.With) > 0 {
rawLogger.Infof("with:")
for _, k := range slices.Sorted(maps.Keys(step.With)) {
rawLogger.Infof(" %s: %s", k, step.With[k])
}
}
printStepEnvBlock(ctx, step, env, rc)
}
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
// shared by the run: and uses: "Run" headers.
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
var visible []string
for _, k := range stepDeclaredEnvKeysInOrder(step) {
if !isInternalEnvKey(k, caseInsensitive) {
visible = append(visible, k)
}
}
if len(visible) == 0 {
return
}
rawLogger.Infof("env:")
envLookup := env
if caseInsensitive {
envLookup = make(map[string]string, len(env))
for k, v := range env {
envLookup[strings.ToUpper(k)] = v
}
}
for _, k := range visible {
lookupKey := k
if caseInsensitive {
lookupKey = strings.ToUpper(k)
}
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
}
}
// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden
// from the "Run" header's env: block because they are injected by the runner itself.
func isInternalEnvKey(k string, caseInsensitive bool) bool {
upper := k
if caseInsensitive {
upper = strings.ToUpper(k)
}
switch upper {
case "PATH", "HOME", "CI":
return true
}
return strings.HasPrefix(upper, "GITHUB_") ||
strings.HasPrefix(upper, "GITEA_") ||
strings.HasPrefix(upper, "RUNNER_") ||
strings.HasPrefix(upper, "INPUT_")
}
func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string {
trimmed := strings.TrimLeft(normalizedScript, " \t\r\n")
if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 {
trimmed = trimmed[:idx]
}
if trimmed != "" {
return trimmed
}
if sr.Step != nil {
if sr.Step.Name != "" {
return sr.Step.Name
}
return sr.Step.ID
}
return ""
}
// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in
// the order the workflow author wrote them; step.Environment() decodes into a Go map
// and loses ordering.
func stepDeclaredEnvKeysInOrder(step *model.Step) []string {
if step == nil || step.Env.Kind != yaml.MappingNode {
return nil
}
content := step.Env.Content
keys := make([]string, 0, len(content)/2)
seen := make(map[string]struct{}, len(content)/2)
for i := 0; i+1 < len(content); i += 2 {
k := content[i]
if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" {
continue
}
if _, dup := seen[k.Value]; dup {
continue
}
seen[k.Value] = struct{}{}
keys = append(keys, k.Value)
}
return keys
}
func (sr *stepRun) post() common.Executor { func (sr *stepRun) post() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
return nil return nil
@@ -111,8 +254,10 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
step := sr.Step step := sr.Step
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run) script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
sr.interpolatedScript = script
scCmd := step.ShellCommand() scCmd := step.ShellCommand()
sr.shellCommand = scCmd
name = getScriptName(sr.RunContext, step) name = getScriptName(sr.RunContext, step)

View File

@@ -0,0 +1,182 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2026 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"bytes"
"context"
"strings"
"testing"
"gitea.com/gitea/act_runner/act/common"
"gitea.com/gitea/act_runner/act/model"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4"
)
func TestRunScriptGroupTitle(t *testing.T) {
sr := &stepRun{Step: &model.Step{Name: "Build"}}
assert.Equal(t, "make build", sr.runScriptGroupTitle("make build"))
assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two"))
assert.Equal(t, "Build", sr.runScriptGroupTitle(""))
sr = &stepRun{Step: &model.Step{ID: "s1"}}
assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n"))
}
func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) {
raw := `id: s1
run: "echo 1"
env:
GITHUB_TOKEN: tok
PATH: /custom/bin
MY_VAR: hello
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step))
}
func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) {
assert.Nil(t, stepDeclaredEnvKeysInOrder(nil))
assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{}))
}
func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) {
doc := `
common: &common
COMMON_A: a
COMMON_B: b
step:
env:
LOCAL_BEFORE: before
<<: *common
COMMON_B: overridden
LOCAL_AFTER: after
`
var root struct {
Step model.Step `yaml:"step"`
}
require.NoError(t, yaml.Unmarshal([]byte(doc), &root))
keys := stepDeclaredEnvKeysInOrder(&root.Step)
assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys)
}
func TestPrintRunScriptActionDetailsGolden(t *testing.T) {
raw := `id: s1
name: Build
run: |
echo one
echo two
shell: pwsh
env:
PATH_PREFIX: /custom/bin
GITHUB_TOKEN: tok
GREETING: hello
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetLevel(logrus.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(logrus.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
sr := &stepRun{
Step: &step,
RunContext: &RunContext{},
shellCommand: "pwsh -command . '{0}'",
interpolatedScript: "echo one\necho two\n",
env: map[string]string{
"PATH_PREFIX": "/custom/bin",
"GITHUB_TOKEN": "tok",
"GREETING": "hello",
},
}
sr.printRunScriptActionDetails(ctx)
want := strings.Join([]string{
"[j1] | ::group::Run echo one",
"[j1] | echo one",
"[j1] | echo two",
"[j1] | shell: pwsh -command . '{0}'",
"[j1] | env:",
"[j1] | PATH_PREFIX: /custom/bin",
"[j1] | GREETING: hello",
"[j1] | ::endgroup::",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestPrintRunActionHeaderGolden(t *testing.T) {
raw := `id: s1
uses: actions/checkout@v4
with:
fetch-depth: "0"
token: secret
env:
CUSTOM: value
GITHUB_TOKEN: tok
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetLevel(logrus.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(logrus.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
want := strings.Join([]string{
"[j1] | ::group::Run actions/checkout@v4",
"[j1] | with:",
"[j1] | fetch-depth: 0",
"[j1] | token: secret",
"[j1] | env:",
"[j1] | CUSTOM: value",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestIsInternalEnvKey(t *testing.T) {
for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} {
assert.True(t, isInternalEnvKey(k, false), k)
}
for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} {
assert.False(t, isInternalEnvKey(k, false), k)
}
assert.True(t, isInternalEnvKey("path", true))
assert.False(t, isInternalEnvKey("path", false))
}
func TestPrintColoredScriptLineCyan(t *testing.T) {
f := &jobLogFormatter{color: cyan}
entry := &logrus.Entry{
Level: logrus.InfoLevel,
Message: "echo one",
Data: logrus.Fields{
"job": "j1",
rawOutputField: true,
scriptLineCyanField: true,
},
}
buf := &bytes.Buffer{}
f.printColored(buf, entry)
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
}

View File

@@ -81,7 +81,7 @@ func TestStepRun(t *testing.T) {
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
err := sr.main()(ctx) err := sr.main()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
@@ -91,8 +91,8 @@ func TestStepRunPrePost(t *testing.T) {
sr := &stepRun{} sr := &stepRun{}
err := sr.pre()(ctx) err := sr.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sr.post()(ctx) err = sr.post()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err)
} }

View File

@@ -156,7 +156,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
err := setupEnv(context.Background(), sm) err := setupEnv(context.Background(), sm)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// These are commit or system specific // These are commit or system specific
delete((env), "GITHUB_REF") delete((env), "GITHUB_REF")
@@ -318,35 +318,35 @@ func TestIsContinueOnError(t *testing.T) {
step := createTestStep(t, "name: test") step := createTestStep(t, "name: test")
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError) assertObject.False(continueOnError)
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// explcit true // explcit true
step = createTestStep(t, "continue-on-error: true") step = createTestStep(t, "continue-on-error: true")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.True(continueOnError) assertObject.True(continueOnError)
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// explicit false // explicit false
step = createTestStep(t, "continue-on-error: false") step = createTestStep(t, "continue-on-error: false")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError) assertObject.False(continueOnError)
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression true // expression true
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}") step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.True(continueOnError) assertObject.True(continueOnError)
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression false // expression false
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}") step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError) assertObject.False(continueOnError)
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression parse error // expression parse error
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}") step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain) continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError) assertObject.False(continueOnError)
assertObject.NotNil(err) //nolint:testifylint // pre-existing issue from nektos/act assertObject.Error(err)
} }

View File

@@ -38,7 +38,6 @@ func CompilePattern(rawpattern string) (*WorkflowPattern, error) {
}, nil }, nil
} }
//nolint:gocyclo // function handles many cases
func PatternToRegex(pattern string) (string, error) { func PatternToRegex(pattern string) (string, error) {
var rpattern strings.Builder var rpattern strings.Builder
rpattern.WriteString("^") rpattern.WriteString("^")

20
go.mod
View File

@@ -4,11 +4,11 @@ go 1.26.0
require ( require (
code.gitea.io/actions-proto-go v0.4.1 code.gitea.io/actions-proto-go v0.4.1
connectrpc.com/connect v1.19.1 connectrpc.com/connect v1.19.2
github.com/avast/retry-go/v4 v4.7.0 github.com/avast/retry-go/v4 v4.7.0
github.com/docker/docker v25.0.13+incompatible github.com/docker/docker v25.0.15+incompatible
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.22
github.com/sirupsen/logrus v1.9.4 github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@@ -16,7 +16,7 @@ require (
golang.org/x/term v0.40.0 golang.org/x/term v0.40.0
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 gotest.tools/v3 v3.5.2
) )
@@ -24,16 +24,16 @@ require (
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0 github.com/distribution/reference v0.6.0
github.com/docker/cli v25.0.3+incompatible github.com/docker/cli v25.0.7+incompatible
github.com/docker/go-connections v0.6.0 github.com/docker/go-connections v0.6.0
github.com/go-git/go-billy/v5 v5.7.0 github.com/go-git/go-billy/v5 v5.8.0
github.com/go-git/go-git/v5 v5.16.5 github.com/go-git/go-git/v5 v5.18.0
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
github.com/imdario/mergo v0.3.16 github.com/imdario/mergo v0.3.16
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/moby/buildkit v0.13.2 github.com/moby/buildkit v0.13.2
github.com/moby/patternmatcher v0.6.0 github.com/moby/patternmatcher v0.6.1
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.13.1 github.com/opencontainers/selinux v1.13.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@@ -114,7 +114,3 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
) )
// Remove after github.com/docker/distribution is updated to support distribution/reference v0.6.0
// (pulled in via moby/buildkit, breaks on undefined: reference.SplitHostname)
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0

28
go.sum
View File

@@ -1,7 +1,7 @@
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls= code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o= cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -49,12 +49,16 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.5.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 v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284= github.com/docker/cli v25.0.7+incompatible h1:scW/AbGafKmANsonsFckFHTwpz2QypoPA/zpoLnDs/E=
github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v25.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU= github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.14+incompatible h1:+HNue3fKbqiDHYFAriyiMjfS5u25zB0E2/R8f42lOMc=
github.com/docker/docker v25.0.14+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.15+incompatible h1:JhRD6vZdk0Ms3SEMztefBISJL13NbxudQnGix6l+T5M=
github.com/docker/docker v25.0.15+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -73,12 +77,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -131,6 +135,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
@@ -141,6 +147,8 @@ github.com/moby/buildkit v0.13.2 h1:nXNszM4qD9E7QtG7bFWPnDI1teUQFQglBzon/IU3SzI=
github.com/moby/buildkit v0.13.2/go.mod h1:2cyVOv9NoHM7arphK9ZfHIWKn9YVZRFd1wXB8kKmEzY= github.com/moby/buildkit v0.13.2/go.mod h1:2cyVOv9NoHM7arphK9ZfHIWKn9YVZRFd1wXB8kKmEzY=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=

View File

@@ -4,6 +4,7 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
@@ -47,10 +48,15 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
port = cacheArgs.Port port = cacheArgs.Port
} }
secret := cfg.Cache.ExternalSecret
if secret == "" {
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
}
cacheHandler, err := artifactcache.StartHandler( cacheHandler, err := artifactcache.StartHandler(
dir, dir,
host, host,
port, port,
secret,
log.StandardLogger().WithField("module", "cache_request"), log.StandardLogger().WithField("module", "cache_request"),
) )
if err != nil { if err != nil {

View File

@@ -6,6 +6,8 @@ package cmd
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
@@ -368,7 +370,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
} }
// init a cache server // init a cache server
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request")) handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
if err != nil { if err != nil {
return err return err
} }
@@ -393,6 +395,25 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
execArgs.artifactServerPath = tempDir execArgs.artifactServerPath = tempDir
} }
// Register ACTIONS_RUNTIME_TOKEN against local cache server
env := execArgs.LoadEnvs()
const actionsRuntimeTokenEnvName = "ACTIONS_RUNTIME_TOKEN"
actionsRuntimeToken := env[actionsRuntimeTokenEnvName]
if actionsRuntimeToken == "" {
actionsRuntimeToken = os.Getenv(actionsRuntimeTokenEnvName)
}
if actionsRuntimeToken == "" {
tmpBranch := make([]byte, 12)
if _, err := rand.Read(tmpBranch); err != nil {
actionsRuntimeToken = "token"
} else {
actionsRuntimeToken = hex.EncodeToString(tmpBranch)
}
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
}
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
// run the plan // run the plan
config := &runner.Config{ config := &runner.Config{
Workdir: execArgs.Workdir(), Workdir: execArgs.Workdir(),
@@ -402,7 +423,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ForceRebuild: execArgs.forceRebuild, ForceRebuild: execArgs.forceRebuild,
LogOutput: true, LogOutput: true,
JSONLogger: execArgs.jsonLogger, JSONLogger: execArgs.jsonLogger,
Env: execArgs.LoadEnvs(), Env: env,
Vars: execArgs.LoadVars(), Vars: execArgs.LoadVars(),
Secrets: execArgs.LoadSecrets(), Secrets: execArgs.LoadSecrets(),
InsecureSecrets: execArgs.insecureSecrets, InsecureSecrets: execArgs.insecureSecrets,

View File

@@ -4,10 +4,12 @@
package run package run
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps" "maps"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -38,9 +40,10 @@ type Runner struct {
cfg *config.Config cfg *config.Config
client client.Client client client.Client
labels labels.Labels labels labels.Labels
envs map[string]string envs map[string]string
cacheHandler *artifactcache.Handler
runningTasks sync.Map runningTasks sync.Map
runningCount atomic.Int64 runningCount atomic.Int64
@@ -55,21 +58,24 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
} }
envs := make(map[string]string, len(cfg.Runner.Envs)) envs := make(map[string]string, len(cfg.Runner.Envs))
maps.Copy(envs, cfg.Runner.Envs) maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled { if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
if cfg.Cache.ExternalServer != "" { if cfg.Cache.ExternalServer != "" {
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
} else { } else {
cacheHandler, err := artifactcache.StartHandler( handler, err := artifactcache.StartHandler(
cfg.Cache.Dir, cfg.Cache.Dir,
cfg.Cache.Host, cfg.Cache.Host,
cfg.Cache.Port, cfg.Cache.Port,
"",
log.StandardLogger().WithField("module", "cache_request"), log.StandardLogger().WithField("module", "cache_request"),
) )
if err != nil { if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err) log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on // go on
} else { } else {
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/" cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
} }
} }
} }
@@ -84,11 +90,12 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version() envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version()
return &Runner{ return &Runner{
name: reg.Name, name: reg.Name,
cfg: cfg, cfg: cfg,
client: cli, client: cli,
labels: ls, labels: ls,
envs: envs, envs: envs,
cacheHandler: cacheHandler,
} }
} }
@@ -199,6 +206,21 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
giteaRuntimeToken = preset.Token giteaRuntimeToken = preset.Token
} }
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
// Mask the runtime token so it cannot be echoed in user step output; it is
// now also the cache server's bearer credential and leaking it would let
// any reader of the log impersonate this job against the cache.
if giteaRuntimeToken != "" {
task.Secrets["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
}
// Register this job's runtime token with the local cache server so that
// cache requests from the job container can authenticate. The credential
// is removed when the task finishes, so a leaked token stops working as
// soon as the job ends rather than remaining valid for the runner's
// lifetime. Only applies to the embedded cache server; when the operator
// points the runner at an external cache via cfg.Cache.ExternalServer, it
// is that server's responsibility to authenticate requests.
defer r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)()
eventJSON, err := json.Marshal(preset.Event) eventJSON, err := json.Marshal(preset.Event)
if err != nil { if err != nil {
@@ -278,6 +300,82 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr return execErr
} }
// registerCacheForTask tells the cache server to accept requests authenticated
// with the given runtime token for the duration of this task. Returns a
// function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes.
//
// Three modes:
// - Embedded handler: register in-process via RegisterJob.
// - external_server + external_secret: POST to the remote server's
// /_internal/register, defer a POST to /_internal/revoke. This is what
// enables full per-job auth and 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).
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
if token == "" {
return func() {}
}
if r.cacheHandler != nil {
return r.cacheHandler.RegisterJob(token, repo)
}
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, repo, reporter)
}
return func() {}
}
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
// Failures are logged but not fatal: if registration fails, the cache will
// 401 the job's requests — better than failing the whole task for a cache
// outage. The warning is mirrored to the job log so users can see why their
// cache calls 401, instead of having to read the runner daemon's stderr.
func (r *Runner) registerExternalCacheJob(token, repo string, reporter *report.Reporter) func() {
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret,
map[string]string{"token": token, "repo": repo}); err != nil {
log.Warnf("cache external_server register failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
}
}
return func() {
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
map[string]string{"token": token}); err != nil {
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
}
}
}
}
func postInternalCache(url, secret string, body map[string]string) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
func (r *Runner) RunningCount() int64 { func (r *Runner) RunningCount() int64 {
return r.runningCount.Load() return r.runningCount.Load()
} }

View File

@@ -0,0 +1,239 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"gitea.com/gitea/act_runner/act/artifactcache"
"gitea.com/gitea/act_runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
token := "run-token-123"
unregister := r.registerCacheForTask(token, "owner/repo", nil)
base := handler.ExternalURL() + "/_apis/artifactcache"
probe := func() int {
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
assert.NotEqual(t, http.StatusUnauthorized, probe(),
"token should be accepted while task is registered")
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
}
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
t.Run("nil cacheHandler", func(t *testing.T) {
r := &Runner{cfg: emptyCfg()}
unregister := r.registerCacheForTask("tok", "owner/repo", nil)
require.NotNil(t, unregister)
unregister()
})
t.Run("empty token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
unregister := r.registerCacheForTask("", "owner/repo", nil)
require.NotNil(t, unregister)
unregister()
})
}
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
// /find, no auth on the signed archiveLocation download.
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
token := "full-flow-token"
unregister := r.registerCacheForTask(token, "owner/repo", nil)
defer unregister()
base := handler.ExternalURL() + "/_apis/artifactcache"
do := func(method, url, contentType, contentRange, body string) *http.Response {
req, err := http.NewRequest(method, url, strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if contentRange != "" {
req.Header.Set("Content-Range", contentRange)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
key := "toolkit-flow"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
body := `hello-cache-body`
// reserve
resp := do(http.MethodPost, base+"/caches", "application/json", "",
fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body)))
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
// upload
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
"application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// commit
resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// find — @actions/cache always sends comma-separated keys here
resp = do(http.MethodGet,
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}
require.NoError(t, decodeJSON(resp, &hit))
require.Equal(t, key, hit.CacheKey)
require.NotEmpty(t, hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough.
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode)
got := make([]byte, 64)
n, _ := dl.Body.Read(got)
assert.Equal(t, body, string(got[:n]))
}
func decodeJSON(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(v)
}
// End-to-end against a remote cache-server: token unknown → 401, register →
// reserve/upload/commit/find/download all OK, revoke → 401 again.
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
dir := filepath.Join(t.TempDir(), "remote-cache")
const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
require.NoError(t, err)
defer remote.Close()
r := &Runner{cfg: &config.Config{Cache: config.Cache{
ExternalServer: remote.ExternalURL(),
ExternalSecret: secret,
}}}
token := "external-task-token"
repo := "owner/repoX"
base := remote.ExternalURL() + "/_apis/artifactcache"
probe := func() int {
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration")
unregister := r.registerCacheForTask(token, repo, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask")
// Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, against the remote (external) server.
body := []byte("payload-from-task")
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, decodeJSON(resp, &hit))
require.NotEmpty(t, hit.ArchiveLocation)
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode)
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
}

View File

@@ -35,7 +35,7 @@ runner:
# The maximum interval for fetching the job from the Gitea instance. # The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum. # The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff. # Set to 0 or same as fetch_interval to disable backoff.
fetch_interval_max: 60s fetch_interval_max: 5s
# The base interval for periodic log flush to the Gitea instance. # The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size # Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row. # or if log_report_max_latency expires after the first buffered row.
@@ -81,7 +81,12 @@ cache:
# The external cache server URL. Valid only when enable is true. # The external cache server URL. Valid only when enable is true.
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself. # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
# The URL should generally end with "/". # The URL should generally end with "/".
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
external_server: "" external_server: ""
# Shared secret between this runner and the external `act_runner cache-server`. Required when external_server
# (or `act_runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
external_secret: ""
container: container:
# Specifies the network to which the container will connect. # Specifies the network to which the container will connect.

View File

@@ -4,6 +4,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"maps" "maps"
"os" "os"
@@ -12,7 +13,7 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3" "go.yaml.in/yaml/v4"
) )
// Log represents the configuration for logging. // Log represents the configuration for logging.
@@ -47,6 +48,7 @@ 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 act_runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
} }
// Container represents the configuration for the container. // Container represents the configuration for the container.
@@ -135,6 +137,9 @@ func LoadDefault(file string) (*Config, error) {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache") cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
} }
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 act_runner cache-server")
}
} }
if cfg.Container.WorkdirParent == "" { if cfg.Container.WorkdirParent == "" {
cfg.Container.WorkdirParent = "workspace" cfg.Container.WorkdirParent = "workspace"
@@ -150,7 +155,7 @@ func LoadDefault(file string) (*Config, error) {
cfg.Runner.FetchInterval = 2 * time.Second cfg.Runner.FetchInterval = 2 * time.Second
} }
if cfg.Runner.FetchIntervalMax <= 0 { if cfg.Runner.FetchIntervalMax <= 0 {
cfg.Runner.FetchIntervalMax = 60 * time.Second cfg.Runner.FetchIntervalMax = 5 * time.Second
} }
if cfg.Runner.LogReportInterval <= 0 { if cfg.Runner.LogReportInterval <= 0 {
cfg.Runner.LogReportInterval = 5 * time.Second cfg.Runner.LogReportInterval = 5 * time.Second

View File

@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadDefault_RejectsExternalServerWithoutSecret(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/"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "external_secret")
}
func TestLoadDefault_AcceptsExternalServerWithSecret(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: "shh"
`), 0o600))
_, err := LoadDefault(path)
require.NoError(t, err)
}

View File

@@ -10,6 +10,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"gitea.com/gitea/act_runner/internal/pkg/client" "gitea.com/gitea/act_runner/internal/pkg/client"
@@ -48,6 +49,10 @@ type Reporter struct {
outputs sync.Map outputs sync.Map
daemon chan struct{} daemon chan struct{}
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
// guard in ReportState reads it without contending stateMu.
lastReportedAtNanos atomic.Int64
// Adaptive batching control // Adaptive batching control
logReportInterval time.Duration logReportInterval time.Duration
logReportMaxLatency time.Duration logReportMaxLatency time.Duration
@@ -489,8 +494,12 @@ func (r *Reporter) ReportState(reportResult bool) error {
// Consume stateChanged atomically with the snapshot; restored on error // Consume stateChanged atomically with the snapshot; restored on error
// below so a concurrent Fire() during UpdateTask isn't silently lost. // below so a concurrent Fire() during UpdateTask isn't silently lost.
// Heartbeat at stateReportInterval even when nothing changed, so the server
// doesn't time out long-running silent jobs as orphaned (#826).
last := r.lastReportedAtNanos.Load()
withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval
r.stateMu.Lock() r.stateMu.Lock()
if !reportResult && !r.stateChanged && len(outputs) == 0 { if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval {
r.stateMu.Unlock() r.stateMu.Unlock()
return nil return nil
} }
@@ -517,6 +526,7 @@ func (r *Reporter) ReportState(reportResult bool) error {
return err return err
} }
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc() metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
r.lastReportedAtNanos.Store(time.Now().UnixNano())
for _, k := range resp.Msg.SentOutputs { for _, k := range resp.Msg.SentOutputs {
r.outputs.Store(k, struct{}{}) r.outputs.Store(k, struct{}{})

View File

@@ -597,3 +597,45 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
}, 500*time.Millisecond, 10*time.Millisecond, }, 500*time.Millisecond, 10*time.Millisecond,
"step transition should have triggered immediate state flush via stateNotify") "step transition should have triggered immediate state flush via stateNotify")
} }
// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat
// UpdateTask once stateReportInterval has elapsed since the last successful
// report, even when nothing has changed. Without this, long-running silent
// jobs (no log output, no step transitions) cause the server to time the
// task out and cancel it (#826).
func TestReporter_StateHeartbeat(t *testing.T) {
var updateTaskCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
updateTaskCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
cfg.Runner.StateReportInterval = 50 * time.Millisecond
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
// First call has no prior report — sends to seed lastReportedAt.
reporter.stateMu.Lock()
reporter.stateChanged = true
reporter.stateMu.Unlock()
require.NoError(t, reporter.ReportState(false))
require.Equal(t, int64(1), updateTaskCalls.Load())
// Second call immediately after with nothing changed — must skip.
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(1), updateTaskCalls.Load(), "no-op ReportState within stateReportInterval must skip")
// After stateReportInterval elapses, a heartbeat must fire even with no changes.
time.Sleep(2 * cfg.Runner.StateReportInterval)
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change")
}

View File

@@ -1,5 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
s6-svwait -U /etc/s6/docker s6-svwait -U /etc/s6/docker
exec run.sh exec run.sh