Compare commits

...

9 Commits

Author SHA1 Message Date
Bo-Yi Wu
3b4b070c8f perf(config): lower default fetch_interval_max from 60s to 5s
- Reduce default maximum polling backoff to improve job pickup time
  for common single-runner deployments (from ~65s to ~5s worst case)
- Large fleets can still tune this value higher in their config

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

Co-Authored-By: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 11:36:27 +08: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
49 changed files with 1497 additions and 246 deletions

View File

@@ -5,17 +5,24 @@
package artifactcache
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -28,9 +35,36 @@ import (
)
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 {
dir string
storage *Storage
@@ -43,10 +77,36 @@ type Handler struct {
gcAt time.Time
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) {
h := &Handler{}
// StartHandler opens the on-disk cache store and starts the HTTP server.
//
// 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 {
discard := logrus.New()
@@ -83,19 +143,37 @@ func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger
h.outboundIP = ip.String()
}
secret, err := loadOrCreateSecret(dir)
if err != nil {
return nil, err
}
h.secret = secret
router := httprouter.New()
router.GET(urlBase+"/cache", h.middleware(h.find))
router.POST(urlBase+"/caches", h.middleware(h.reserve))
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
router.POST(urlBase+"/clean", h.middleware(h.clean))
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
// Artifact GET is signed via query-string HMAC because @actions/cache
// does not attach Authorization when downloading archiveLocation.
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
// 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.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 {
return nil, err
}
@@ -121,6 +199,91 @@ func (h *Handler) ExternalURL() string {
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 {
if h == nil {
return nil
@@ -160,6 +323,7 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
// GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
@@ -174,7 +338,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
defer db.Close()
cache, err := findCache(db, keys, version)
cache, err := findCache(db, cred.Repo, keys, version)
if err != nil {
h.responseJSON(w, r, 500, err)
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{
"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,
})
}
// POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
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)
cache := api.ToCache()
cache.Repo = cred.Repo
db, err := h.openDB()
if err != nil {
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
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)
if err != nil {
h.responseJSON(w, r, 400, err)
@@ -253,6 +420,11 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
return
}
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
@@ -272,6 +444,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
// POST /_apis/artifactcache/caches/:id
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)
if err != nil {
h.responseJSON(w, r, 400, err)
@@ -294,6 +467,11 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
return
}
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
@@ -326,6 +504,10 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
}
// 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) {
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
@@ -344,21 +526,158 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
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) {
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)
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.
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{}
for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache,
bolthold.Where("Key").Eq(prefix).
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(prefix).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
@@ -373,7 +692,8 @@ func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error
continue
}
if err := db.FindOne(cache,
bolthold.Where("Key").RegExp(re).
bolthold.Where("Repo").Eq(repo).
And("Key").RegExp(re).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
@@ -419,7 +739,6 @@ const (
keepOld = 5 * time.Minute
)
//nolint:gocyclo // function handles many cases
func (h *Handler) gcCache() {
if h.gcing.Load() {
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.
if results, err := db.FindAggregate(
&Cache{},
bolthold.Where("Complete").Eq(true),
"Key", "Version",
"Repo", "Key", "Version",
); err != nil {
h.logger.Warnf("find aggregate caches: %v", err)
} else {
@@ -533,7 +856,7 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
if len(v) == 0 || v[0] == nil {
data, _ = json.Marshal(struct{}{})
} 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{
"error": err.Error(),
})

View File

@@ -22,12 +22,38 @@ import (
"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) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
handler, err := StartHandler(dir, "", 0, "", nil)
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() {
t.Run("inpect db", func(t *testing.T) {
@@ -45,7 +71,10 @@ func TestHandler(t *testing.T) {
require.NoError(t, handler.Close())
assert.Nil(t, handler.server)
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)
})
}()
@@ -53,8 +82,9 @@ func TestHandler(t *testing.T) {
t.Run("get not exist", func(t *testing.T) {
key := strings.ToLower(t.Name())
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)
defer resp.Body.Close()
require.Equal(t, 204, resp.StatusCode)
})
@@ -68,16 +98,18 @@ func TestHandler(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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
})
t.Run("reserve with bad request", func(t *testing.T) {
body := []byte(`invalid json`)
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -94,8 +126,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
@@ -108,8 +141,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
@@ -125,8 +159,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -136,8 +171,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -155,8 +191,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -171,13 +208,15 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
@@ -186,8 +225,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -206,8 +246,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -222,24 +263,27 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -258,8 +302,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -274,18 +319,21 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
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)
defer resp.Body.Close()
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)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -304,8 +352,9 @@ func TestHandler(t *testing.T) {
Size: 100,
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -320,32 +369,37 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
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)
defer resp.Body.Close()
assert.Equal(t, 500, resp.StatusCode)
}
})
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)
defer resp.Body.Close()
require.Equal(t, 400, resp.StatusCode)
})
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)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode)
})
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)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode)
})
@@ -375,8 +429,9 @@ func TestHandler(t *testing.T) {
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -395,8 +450,9 @@ func TestHandler(t *testing.T) {
assert.Equal(t, "hit", got.Result)
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)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -413,8 +469,9 @@ func TestHandler(t *testing.T) {
{
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
@@ -452,8 +509,9 @@ func TestHandler(t *testing.T) {
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -470,8 +528,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
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)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -504,8 +563,9 @@ func TestHandler(t *testing.T) {
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -523,8 +583,9 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
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)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -541,8 +602,9 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
Size: int64(len(content)),
})
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -557,19 +619,22 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
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)
defer resp.Body.Close()
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)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
@@ -582,8 +647,9 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
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)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got, err := io.ReadAll(resp.Body)
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) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer func() {
@@ -699,3 +765,421 @@ func TestHandler_gcCache(t *testing.T) {
}
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 {
ID uint64 `json:"id" boltholdKey:"ID"`
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`

View File

@@ -202,7 +202,7 @@ func TestListArtifactContainer(t *testing.T) {
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("file", response.Value[0].ItemType)
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)
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)
runnerConfig := &runner.Config{
Workdir: workdir,
@@ -299,16 +299,16 @@ func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
}
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)
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)
if err == nil {
err = runner.NewPlanExecutor(plan)(ctx)
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 {
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},
}
output = CartesianProduct(input)
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
assert.Empty(output)
input = map[string][]any{}
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
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
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
runcount := 0
@@ -38,7 +38,7 @@ func TestNewWorkflow(t *testing.T) {
runcount++
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)
}
@@ -60,7 +60,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil
})(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(1, falseCount)
@@ -74,7 +74,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil
})(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, falseCount)
}
@@ -105,7 +105,7 @@ func TestNewParallelExecutor(t *testing.T) {
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.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
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(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) {

View File

@@ -32,12 +32,21 @@ var (
githubHTTPRegex = regexp.MustCompile(`^https?://.*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")
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 {
err error
commit string
@@ -293,16 +302,13 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
}
// NewGitCloneExecutor creates an executor to clone git repos
//
//nolint:gocyclo // function handles many cases
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
cloneLock.Lock()
defer cloneLock.Unlock()
defer acquireCloneLock(input.Dir)()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, err := CloneIfRequired(ctx, refName, input, logger)

View File

@@ -11,8 +11,10 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -303,3 +305,61 @@ func gitCmd(args ...string) error {
}
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,
// a HostConfig and returns them with the specified command.
// 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) {
var (
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) {
// A single volume
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
@@ -632,7 +631,7 @@ func TestParseModes(t *testing.T) {
}
// 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")
// uts ok
@@ -693,7 +692,7 @@ func TestParseRestartPolicy(t *testing.T) {
func TestParseRestartPolicyAutoRemove(t *testing.T) {
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 {
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
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.Equal(t, false, invalidImageTag) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, invalidImageTag)
// Test if image exists with specific architecture (image platform)
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, invalidImagePlatform) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, invalidImagePlatform)
// pull an image
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())
// 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{
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()
_, 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")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageDefaultArchExists) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageDefaultArchExists)
// Validate if another architecture platform can be pulled
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
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()
_, 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")
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageArm64Exists) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //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")
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
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
@@ -51,7 +51,7 @@ func TestGetImagePullOptions(t *testing.T) {
Username: "username",
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")
config.SetDir("testdata/docker-pull-options")
@@ -59,6 +59,6 @@ func TestGetImagePullOptions(t *testing.T) {
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
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")
}

View File

@@ -29,7 +29,7 @@ func TestGetSocketAndHostWithSocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI)
// 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)
}
@@ -42,7 +42,7 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("")
// 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)
}
@@ -57,8 +57,8 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI)
// Assert
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.NoError(t, err, "Expected no error from GetSocketAndHost") //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, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
}
@@ -73,7 +73,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
ret, err := GetSocketAndHost("-")
// 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)
}
@@ -87,8 +87,8 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("")
// Assert
assert.Equal(t, true, found, "Expected a default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, found, "Expected a default socket to be found")
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")
}
@@ -112,8 +112,8 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Assert
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.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, found, "Expected default socket to be found")
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")
}
@@ -128,7 +128,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
ret, err := GetSocketAndHost(mySocket)
// 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, SocketAndHost{}, ret, "Expected to match default socket location")
assert.Error(t, err, "Expected an error in invalid state")
@@ -147,8 +147,8 @@ func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Assert
// 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, 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
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")
}

View File

@@ -43,7 +43,7 @@ func TestFunctionContains(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -72,7 +72,7 @@ func TestFunctionStartsWith(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -101,7 +101,7 @@ func TestFunctionEndsWith(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -128,7 +128,7 @@ func TestFunctionJoin(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -154,7 +154,7 @@ func TestFunctionToJSON(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -177,7 +177,7 @@ func TestFunctionFromJSON(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -205,9 +205,9 @@ func TestFunctionHashFiles(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
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)
})
@@ -248,7 +248,7 @@ func TestFunctionFormat(t *testing.T) {
if tt.error != nil {
assert.Equal(t, tt.error, err.Error())
} 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)
}
})

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) {
switch strings.ToLower(variableNode.Name) {
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())
}
//nolint:gocyclo // function handles many cases
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
args := make([]reflect.Value, 0)

View File

@@ -35,7 +35,7 @@ func TestLiterals(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -105,10 +105,10 @@ func TestOperators(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
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())
} 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)
@@ -157,7 +157,7 @@ func TestOperatorsCompare(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})
@@ -520,7 +520,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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) {
assert.True(t, math.IsNaN(output.(float64)))
@@ -624,7 +624,7 @@ func TestContexts(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
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)
})

View File

@@ -128,7 +128,6 @@ func (*DefaultFs) Readlink(path string) (string, error) {
return os.Readlink(path)
}
//nolint:gocyclo // function handles many cases
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
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
//
//nolint:gocyclo // function handles many cases
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
path, err := filepath.Abs(path)
if err != nil {

View File

@@ -57,11 +57,11 @@ func TestWorkflow(t *testing.T) {
// Check that an invalid job id returns error
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)
// Check that an valid job id returns non-error
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)
}

View File

@@ -440,8 +440,6 @@ func (j *Job) Matrix() map[string][]any {
// GetMatrixes returns the matrix cross product
// 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) {
matrixes := make([]map[string]any, 0)
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
newSchedules = workflow.OnSchedule()
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
assert.Empty(t, newSchedules)
yaml = `
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
newSchedules = workflow.OnSchedule()
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
assert.Empty(t, newSchedules)
yaml = `
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
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) {
@@ -870,7 +870,7 @@ jobs:
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
} else {
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 {
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
//
//nolint:gocyclo // function handles many cases
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
@@ -429,7 +427,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,

View File

@@ -137,7 +137,7 @@ runs:
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)
closerMock.AssertExpectations(t)
@@ -247,7 +247,7 @@ func TestActionRunner(t *testing.T) {
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)
})
}

View File

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

View File

@@ -24,7 +24,6 @@ type jobInfo interface {
result(result string)
}
//nolint:contextcheck,gocyclo // composes many step executors
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := 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...)
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
if ctx.Err() == context.Canceled {
// 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)
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)
jim.AssertExpectations(t)

View File

@@ -60,7 +60,7 @@ func TestMaxParallelStrategy(t *testing.T) {
matrixes, err := job.GetMatrixes()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
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)
})
}

View File

@@ -101,8 +101,7 @@ func (rc *RunContext) jobContainerName() string {
if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
}
// return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name)
return createSimpleContainerName(nameParts...) // For Gitea
return createContainerName(nameParts...) // For Gitea
}
// 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 {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
@@ -769,7 +767,6 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap
}
// Deprecated: use createSimpleContainerName
func createContainerName(parts ...string) string {
name := strings.Join(parts, "-")
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
@@ -783,22 +780,6 @@ func createContainerName(parts ...string) string {
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 {
if l < 0 {
l = 0
@@ -826,7 +807,6 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults
}
//nolint:gocyclo // function handles many cases
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx)
ghc := &model.GithubContext{

View File

@@ -282,7 +282,7 @@ func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel)
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{
Config: &Config{
@@ -622,23 +622,16 @@ func TestRunContextGetEnv(t *testing.T) {
}
}
func Test_createSimpleContainerName(t *testing.T) {
tests := []struct {
parts []string
want string
}{
{
parts: []string{"a--a", "BB正", "c-C"},
want: "a-a_BB_c-C",
},
{
parts: []string{"a-a", "", "-"},
want: "a-a",
},
}
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)
})
}
func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
longMatrixValue := strings.Repeat("os=ubuntu-latest-go=1.24-node=22-", 20)
name := createContainerName(
"gitea",
"WORKFLOW-super-long-workflow-name",
"JOB-build-matrix-"+longMatrixValue,
)
assert.LessOrEqual(t, len(name), 128)
assert.LessOrEqual(t, len(name+"-env"), 255)
assert.LessOrEqual(t, len(name+"-network"), 255)
assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
}

View File

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

View File

@@ -87,7 +87,7 @@ func TestGraphMissingEvent(t *testing.T) {
plan, err := planner.PlanEvent("push")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
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")
log.SetOutput(out)
@@ -100,7 +100,7 @@ func TestGraphMissingFirst(t *testing.T) {
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.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) {
@@ -114,7 +114,7 @@ func TestGraphWithMissing(t *testing.T) {
plan, err := planner.PlanEvent("push")
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.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
log.SetOutput(out)
@@ -134,7 +134,7 @@ func TestGraphWithSomeMissing(t *testing.T) {
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.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 no first (no-first.yml)")
log.SetOutput(out)
@@ -159,7 +159,7 @@ func TestGraphEvent(t *testing.T) {
plan, err = planner.PlanEvent("release")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
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 {
@@ -177,7 +177,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
log.SetLevel(logLevel)
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)
runnerConfig := &Config{
@@ -197,17 +197,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
}
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)
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)
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
if err == nil && plan != nil {
err = runner.NewPlanExecutor(plan)(ctx)
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 {
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -97,10 +97,10 @@ func TestStepActionLocalTest(t *testing.T) {
})
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)
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)
salm.AssertExpectations(t)

View File

@@ -39,7 +39,6 @@ type stepActionRemote struct {
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
//nolint:gocyclo // function handles many cases
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return func(ctx context.Context) error {
if sar.remoteAction != nil && sar.action != nil {

View File

@@ -272,8 +272,8 @@ func TestStepActionRemotePre(t *testing.T) {
err := sar.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, clonedAction)
sarm.AssertExpectations(t)
})
@@ -343,8 +343,8 @@ func TestStepActionRemotePreThroughAction(t *testing.T) {
err := sar.pre()(ctx)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, clonedAction)
sarm.AssertExpectations(t)
})
@@ -419,7 +419,7 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
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)
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")

View File

@@ -127,7 +127,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,

View File

@@ -102,7 +102,7 @@ func TestStepDockerMain(t *testing.T) {
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
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)
@@ -114,10 +114,10 @@ func TestStepDockerPrePost(t *testing.T) {
sd := &stepDocker{}
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)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NoError(t, err)
}
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {

View File

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

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)
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)
}
@@ -91,8 +91,8 @@ func TestStepRunPrePost(t *testing.T) {
sr := &stepRun{}
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)
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)
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
delete((env), "GITHUB_REF")
@@ -318,35 +318,35 @@ func TestIsContinueOnError(t *testing.T) {
step := createTestStep(t, "name: test")
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
step = createTestStep(t, "continue-on-error: true")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
step = createTestStep(t, "continue-on-error: false")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
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
}
//nolint:gocyclo // function handles many cases
func PatternToRegex(pattern string) (string, error) {
var rpattern strings.Builder
rpattern.WriteString("^")

6
go.mod
View File

@@ -6,9 +6,9 @@ require (
code.gitea.io/actions-proto-go v0.4.1
connectrpc.com/connect v1.19.2
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/mattn/go-isatty v0.0.20
github.com/mattn/go-isatty v0.0.22
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@@ -33,7 +33,7 @@ require (
github.com/julienschmidt/httprouter v1.3.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
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/selinux v1.13.1
github.com/pkg/errors v0.9.1

8
go.sum
View File

@@ -55,6 +55,10 @@ github.com/docker/cli v25.0.7+incompatible h1:scW/AbGafKmANsonsFckFHTwpz2QypoPA/
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/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/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -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-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.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/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/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.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/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=

View File

@@ -4,6 +4,7 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/signal"
@@ -47,10 +48,15 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
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(
dir,
host,
port,
secret,
log.StandardLogger().WithField("module", "cache_request"),
)
if err != nil {

View File

@@ -6,6 +6,8 @@ package cmd
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"maps"
@@ -368,7 +370,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
}
// 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 {
return err
}
@@ -393,6 +395,25 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
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
config := &runner.Config{
Workdir: execArgs.Workdir(),
@@ -402,7 +423,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ForceRebuild: execArgs.forceRebuild,
LogOutput: true,
JSONLogger: execArgs.jsonLogger,
Env: execArgs.LoadEnvs(),
Env: env,
Vars: execArgs.LoadVars(),
Secrets: execArgs.LoadSecrets(),
InsecureSecrets: execArgs.insecureSecrets,

View File

@@ -4,10 +4,12 @@
package run
import (
"bytes"
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"os"
"path/filepath"
"strings"
@@ -38,9 +40,10 @@ type Runner struct {
cfg *config.Config
client client.Client
labels labels.Labels
envs map[string]string
client client.Client
labels labels.Labels
envs map[string]string
cacheHandler *artifactcache.Handler
runningTasks sync.Map
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))
maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
if cfg.Cache.ExternalServer != "" {
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
} else {
cacheHandler, err := artifactcache.StartHandler(
handler, err := artifactcache.StartHandler(
cfg.Cache.Dir,
cfg.Cache.Host,
cfg.Cache.Port,
"",
log.StandardLogger().WithField("module", "cache_request"),
)
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} 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()
return &Runner{
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
cacheHandler: cacheHandler,
}
}
@@ -199,6 +206,21 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
giteaRuntimeToken = preset.Token
}
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)
if err != nil {
@@ -278,6 +300,82 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
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 {
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 runner uses exponential backoff when idle, increasing the interval up to this maximum.
# 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.
# 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.
@@ -81,7 +81,12 @@ cache:
# 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.
# 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: ""
# 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:
# Specifies the network to which the container will connect.

View File

@@ -4,6 +4,7 @@
package config
import (
"errors"
"fmt"
"maps"
"os"
@@ -47,6 +48,7 @@ type Cache struct {
Host string `yaml:"host"` // Host specifies the caching host.
Port uint16 `yaml:"port"` // Port specifies the caching port.
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.
@@ -135,6 +137,9 @@ func LoadDefault(file string) (*Config, error) {
home, _ := os.UserHomeDir()
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 == "" {
cfg.Container.WorkdirParent = "workspace"
@@ -150,7 +155,7 @@ func LoadDefault(file string) (*Config, error) {
cfg.Runner.FetchInterval = 2 * time.Second
}
if cfg.Runner.FetchIntervalMax <= 0 {
cfg.Runner.FetchIntervalMax = 60 * time.Second
cfg.Runner.FetchIntervalMax = 5 * time.Second
}
if cfg.Runner.LogReportInterval <= 0 {
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)
}