Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,85 @@ echo "SCCACHE_S3_KEY_PREFIX=cache/sccache" >> $GITHUB_ENV
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
```

### `cache` / `path`

Only available for Linux runners, on jobs with a `snap=<size>[:<name>]` label (sticky disk).

Persists package manager caches across jobs by bind-mounting them onto the job's sticky disk — a dedicated EBS volume that is snapshotted at job completion and restored (per repo, name, architecture, and branch) on the next job. No tarball upload/download: caches are available at native disk speed, with no size penalty on job duration.

Example:

```yaml
jobs:
build:
runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/snap=20gb
steps:
- uses: actions/checkout@v4
- uses: runs-on/action@v2
with:
cache: |
go
node
```

Supported cache modes and the directories they persist:

| Mode | Aliases | Cached paths |
|---|---|---|
| `go` | `golang` | `~/.cache/go-build`, `~/go/pkg/mod` |
| `node` | `npm` | `~/.npm` |
| `yarn` | | `~/.cache/yarn` |
| `pnpm` | | `~/.pnpm-store` |
| `ruby` | `bundler` | `~/.bundle`, `vendor/bundle` |
| `rust` | `cargo` | `~/.cargo/registry`, `~/.cargo/git` |
| `python` | `pip` | `~/.cache/pip` |
| `uv` | | `~/.cache/uv` |
| `poetry` | | `~/.cache/pypoetry` |
| `apt` | | `/var/cache/apt/archives` |
| `buildkit` | `buildx` | BuildKit layer cache (dedicated `buildkitd` with state on the sticky disk, registered as the current buildx builder) |
| `git` | `checkout` | Git repository mirrors (local git proxy serving github.com fetches from the sticky disk) |
| `gradle` | | `~/.gradle/caches`, `~/.gradle/wrapper` |
| `maven` | | `~/.m2/repository` |
| `playwright` | | `~/.cache/ms-playwright` |

The `buildkit` mode follows the dedicated-builder pattern: it starts a `buildkitd` daemon whose state (and the buildkit binaries themselves) live on the sticky disk, and registers it as the current buildx builder. The docker daemon is never touched. Use `docker buildx build` (or `docker/build-push-action`); add `--load` when you need the built image available to the local docker daemon (e.g. for `docker run`). `docker pull` and plain `docker build` on the default builder are not cached by this mode.

#### `git` mode (fast checkouts)

The `git` mode accelerates `actions/checkout` — and any other `git fetch`/`git clone` of a github.com repository during the job — without changing your checkout step. It starts a local git proxy whose bare repository mirrors live on the sticky disk, and rewrites `https://github.com/` fetch URLs to it (global `url.insteadOf`). The mirror syncs once per repo per job (only new objects cross the network); the fetch itself, including shallow `fetch-depth: 1` packs, is served at disk speed by `git upload-pack` on the runner.

Unlike other modes, the `git` mode must run **before** `actions/checkout`:

```yaml
jobs:
build:
runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/snap=20gb
steps:
- uses: runs-on/action@v2
with:
cache: git
- uses: actions/checkout@v4
```

To combine it with workspace-relative `path:` caching (which must run after checkout), run the action twice — the second invocation reuses the already-running proxy.

Notes and limitations:

* Mirror syncs authenticate with the `token` input (default: `${{ github.token }}`). Checking out **other private repositories** requires passing a PAT with access to them, since the rewritten URLs no longer match the credentials configured by `actions/checkout`.
* `git push` is pinned to upstream (`pushInsteadOf`) and never goes through the proxy; Git LFS and anything else the proxy cannot serve is transparently forwarded to github.com. If mirroring fails for any reason, fetches fall back to upstream — the mode never breaks a build.
* SSH remotes (`git@github.com:`) are not rewritten, and container jobs (`container:`) are not accelerated (the proxy listens on the host's loopback). GitHub Enterprise Server is not supported. Linux only.

Use the `path` input to persist arbitrary additional paths (newline separated). Relative paths are resolved against the workspace, so run this action **after** `actions/checkout` when caching workspace-relative paths (e.g. `vendor/bundle`).

**Disk pressure:** the buildkit cache is bounded by BuildKit's own garbage collection (capped at ~75% of the volume, keeping 20% free; least-recently-used layers are pruned automatically during builds). Other cache modes have no native GC: when the volume drops below 20% free space (or 10% free inodes), the post step emits a warning and a job summary with a per-cache breakdown — increase the `snap=` label size to fix. If a volume is ever critically full at job start (<5% free space or inodes), all caches on it are automatically reset so the job runs cold instead of failing with "no space left on device", and the next snapshot starts clean.

Other related inputs:

* `wait_timeout` - how long to wait for the sticky disk to be ready (default `5m`)
* `fail_on_missing` - fail the step when no sticky disk is available instead of continuing without cache (default `false`)

The action sets a `cache-hit` output: `true` when every requested path was restored from a previous snapshot.

## Development

Make your source code changes in a commit, then rebuild and commit the generated binaries and JS files:
Expand Down
26 changes: 25 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,28 @@ inputs:
sccache:
description: 'Enable sccache. Can take either "s3" (RunsOn S3 cache bucket) or be empty (disabled). You still need to setup sccache in your workflow, for instance with mozilla-actions/sccache-action.'
required: false
default: ''
default: ''
cache:
description: 'Newline/comma separated list of cache modes to persist on the sticky disk (go, node, yarn, pnpm, ruby, rust, python, uv, poetry, apt, buildkit, git, gradle, maven, playwright). Requires a snap=<size>[:<name>] label on the job. Linux and Windows (apt, buildkit and git modes are Linux only). Run this action after actions/checkout when caching workspace-relative paths. The git mode is the exception: it must run BEFORE actions/checkout, since it accelerates github.com fetches with git mirrors persisted on the sticky disk (combine both by running the action twice).'
required: false
default: ''
token:
description: 'GitHub token used by the git cache mode to sync repository mirrors from github.com. Supply a PAT if the job checks out private repositories other than the workflow repository.'
required: false
default: ${{ github.token }}
path:
description: 'Newline separated list of arbitrary paths to persist on the sticky disk. Requires a snap=<size>[:<name>] label on the job. Linux and Windows.'
required: false
default: ''
wait_timeout:
description: 'How long to wait for the sticky disk to be ready (Go duration, e.g. "5m")'
required: false
default: '5m'
fail_on_missing:
description: 'Fail the step if no sticky disk is available (no snap= label, or not ready before wait_timeout)'
required: false
default: 'false'

outputs:
cache-hit:
description: 'Set to "true" when every requested cache path was restored from a previous snapshot, "false" otherwise'
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
golang.org/x/sync v0.22.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ github.com/guptarohit/asciigraph v0.8.1 h1:JBeHTGj2ntBODnZxLQhp+GQZdlZ/48S/m7J1i
github.com/guptarohit/asciigraph v0.8.1/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
github.com/sethvargo/go-githubactions v1.3.2 h1:gkibLr/QjosgNWoCf1V58rTMRZw7xZtSB7dY4atbl1Y=
github.com/sethvargo/go-githubactions v1.3.2/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
61 changes: 61 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"runtime"
"strconv"
"strings"
"time"

"github.com/sethvargo/go-githubactions"
)
Expand All @@ -17,6 +18,10 @@ type Config struct {
NetworkInterface string
DiskDevice string
Sccache string
Cache []string
CachePaths []string
CacheWaitTimeout time.Duration
CacheFailOnMissing bool
ZctionsResultsURL string
ZctionsCacheURL string
ActionsResultsURL string
Expand Down Expand Up @@ -63,6 +68,40 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) {

cfg.Sccache = action.GetInput("sccache")

cacheInput := action.GetInput("cache")
if cacheInput != "" {
cfg.Cache = splitList(cacheInput)
}

pathInput := action.GetInput("path")
if pathInput != "" {
for _, entry := range strings.Split(pathInput, "\n") {
entry = strings.TrimSpace(entry)
if entry != "" {
cfg.CachePaths = append(cfg.CachePaths, entry)
}
}
}

cfg.CacheWaitTimeout = 5 * time.Minute
waitTimeoutStr := action.GetInput("wait_timeout")
if waitTimeoutStr != "" {
if timeout, err := time.ParseDuration(waitTimeoutStr); err == nil {
cfg.CacheWaitTimeout = timeout
} else {
action.Warningf("Error parsing 'wait_timeout' input '%s': %v. Using default 5m.", waitTimeoutStr, err)
}
}

failOnMissingStr := action.GetInput("fail_on_missing")
if failOnMissingStr != "" {
var err error
cfg.CacheFailOnMissing, err = strconv.ParseBool(failOnMissingStr)
if err != nil {
action.Warningf("Error parsing 'fail_on_missing' input '%s': %v. Assuming false.", failOnMissingStr, err)
}
}

cfg.ZctionsResultsURL = os.Getenv("ZCTIONS_RESULTS_URL")
cfg.ZctionsCacheURL = os.Getenv("ZCTIONS_CACHE_URL")
cfg.ActionsResultsURL = os.Getenv("ACTIONS_RESULTS_URL")
Expand All @@ -74,6 +113,8 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) {
action.Infof("Input 'network_interface': %s", cfg.NetworkInterface)
action.Infof("Input 'disk_device': %s", cfg.DiskDevice)
action.Infof("Input 'sccache': %s", cfg.Sccache)
action.Infof("Input 'cache': %v", cfg.Cache)
action.Infof("Input 'path': %v", cfg.CachePaths)

if cfg.ZctionsResultsURL != "" {
action.Infof("ZCTIONS_RESULTS_URL is set: %s", cfg.ZctionsResultsURL)
Expand Down Expand Up @@ -110,6 +151,26 @@ func (c *Config) HasSccache() bool {
return c.IsUsingRunsOn() && c.IsUsingLinux() && c.Sccache != ""
}

// HasStickyDiskCache reports whether sticky disk caching was requested. The
// Linux check happens inside the stickydisk package so non-Linux runners get
// an explicit warning instead of a silent skip.
func (c *Config) HasStickyDiskCache() bool {
return c.IsUsingRunsOn() && (len(c.Cache) > 0 || len(c.CachePaths) > 0)
}

// splitList splits a newline and/or comma separated input into trimmed,
// non-empty entries.
func splitList(input string) []string {
var entries []string
for _, entry := range strings.FieldsFunc(input, func(r rune) bool { return r == '\n' || r == ',' }) {
entry = strings.TrimSpace(entry)
if entry != "" {
entries = append(entries, entry)
}
}
return entries
}

func (c *Config) IsUsingRunsOn() bool {
return os.Getenv("RUNS_ON_RUNNER_NAME") != ""
}
Expand Down
81 changes: 81 additions & 0 deletions internal/gitproxy/fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package gitproxy

import (
"io"
"net/http"
)

// hopHeaders are connection-level headers that must not be forwarded.
var hopHeaders = []string{
"Connection",
"Proxy-Connection",
"Keep-Alive",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}

// forwardUpstream transparently replays the request against the real host
// (https://{host}{rest}), streaming the response back. Used for everything
// the mirror cannot or should not serve: LFS endpoints, receive-pack,
// requests for objects missing from the mirror, or mirror failures. The
// client's own Authorization header travels with the request, so upstream
// enforces the exact same permissions as a direct call.
func (s *Server) forwardUpstream(w http.ResponseWriter, r *http.Request, t target, body io.Reader, reason string) {
url := s.upstreamBase(t.host) + t.rest
if r.URL.RawQuery != "" {
url += "?" + r.URL.RawQuery
}

req, err := http.NewRequestWithContext(r.Context(), r.Method, url, body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
req.Header = r.Header.Clone()
for _, h := range hopHeaders {
req.Header.Del(h)
}
req.Host = t.host

// RoundTrip (not http.Client) so redirects pass through to the git/LFS
// client untouched, exactly as if it had talked to upstream directly.
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
s.log.Error("upstream forward failed", "url", url, "reason", reason, "err", err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()

s.log.Info("forwarded upstream", "url", url, "reason", reason, "status", resp.StatusCode)
header := w.Header()
for k, vv := range resp.Header {
for _, v := range vv {
header.Add(k, v)
}
}
for _, h := range hopHeaders {
header.Del(h)
}
header.Set(statusHeader, "upstream-"+reason)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(&flushWriter{w}, resp.Body); err != nil {
s.log.Warn("upstream response copy interrupted", "url", url, "err", err)
}
}

// flushWriter flushes after each write so pack data streams to the git
// client instead of buffering the whole response.
type flushWriter struct {
w http.ResponseWriter
}

func (fw *flushWriter) Write(p []byte) (int, error) {
n, err := fw.w.Write(p)
if f, ok := fw.w.(http.Flusher); ok {
f.Flush()
}
return n, err
}
Loading
Loading