Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7ab2f5c
Add sticky disk cache support via new cache/path inputs
crohr Jul 3, 2026
83d08fd
Document cache/path inputs
crohr Jul 3, 2026
b43f07b
Add buildkit cache mode (dedicated buildkitd on sticky disk)
crohr Jul 4, 2026
e14ca55
Add disk-pressure handling for sticky disk caches
crohr Jul 4, 2026
a1900ef
Add Windows support to sticky disk cache modes
crohr Jul 4, 2026
18872e7
Rebuild action binaries
crohr Jul 4, 2026
782b1f3
Add git cache mode (fast checkouts via sticky-disk mirrors)
crohr Jul 8, 2026
4505b1c
Rebuild action binaries
crohr Jul 8, 2026
51a0a92
Update snap label documentation
crohr Jul 10, 2026
0f9527f
Delegate sticky BuildKit to setup-buildx (#45)
crohr Jul 24, 2026
0246065
Merge remote-tracking branch 'origin/main' into auto/review-pr-43
crohr Jul 24, 2026
9285517
Address sticky cache review feedback
crohr Jul 24, 2026
8353fcb
dist: rebuild binaries
crohr Jul 24, 2026
c9c58fe
Address remaining sticky cache reviews
crohr Jul 24, 2026
c09594e
dist: rebuild binaries
crohr Jul 24, 2026
9739fe9
ci: use bootstrap RunsOn environment
crohr Jul 24, 2026
b40a240
Harden sticky cache lifecycle
crohr Jul 24, 2026
655b799
dist: rebuild binaries
crohr Jul 24, 2026
cfac959
ci: test runs-on cache v5
crohr Jul 24, 2026
e4747e7
test: wait for sticky snapshots to settle
crohr Jul 24, 2026
687e271
fix: clean up failed sticky cache setup
crohr Jul 24, 2026
fd8bda5
fix: harden interrupted sticky caches
crohr Jul 24, 2026
6d53342
fix: close sticky cache lifecycle gaps
crohr Jul 24, 2026
6accd3e
fix: close final sticky cache review gaps
crohr Jul 24, 2026
ab459ae
fix: preserve fallback sticky cache state
crohr Jul 24, 2026
7363fae
fix: ignore undeclared legacy inputs
crohr Jul 24, 2026
438b50e
fix: recover interrupted sticky cache state
crohr Jul 24, 2026
9f9fb83
ci: bound missing sticky disk wait
crohr Jul 24, 2026
bdd8fc7
fix: validate restored sticky cache state
crohr Jul 24, 2026
0d3160a
fix: recover interrupted BuildKit volume setup
crohr Jul 25, 2026
cd12007
fix: harden sticky cache recovery paths
crohr Jul 25, 2026
5931d81
fix: secure sticky cache reuse edge cases
crohr Jul 25, 2026
a26376f
fix: close sticky cache authorization gaps
crohr Jul 25, 2026
ed4191e
fix: close remaining sticky cache edge cases
crohr Jul 25, 2026
d7fcf9c
fix: track cross-step sticky cache state
crohr Jul 25, 2026
bfebbeb
fix: restore sticky cache host state
crohr Jul 25, 2026
9a322ad
fix: make sticky cleanup failures safe
crohr Jul 25, 2026
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
114 changes: 114 additions & 0 deletions .github/workflows/test-sticky-buildkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Test / Sticky BuildKit

on:
push:
branches:
- main
- v*
- feature/*
- fix/*
- auto/*
workflow_dispatch:

jobs:
cold:
if: github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- id: pinned
version: v0.34.1
- id: latest
version: latest
runs-on: runs-on=${{ github.run_id }}/cpu=2/family=m7/image=ubuntu24-full-x64/sticky=buildkit-${{ github.run_id }}-${{ matrix.id }}:20gb
steps:
- uses: actions/checkout@v6
- id: runs-on
uses: ./
with:
sticky_cache: |
buildkit
custom,path=.runs-on-custom-cache/${{ matrix.id }}
- name: Populate custom cache
run: echo "${{ matrix.id }}" > .runs-on-custom-cache/${{ matrix.id }}/marker
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
name: ${{ steps.runs-on.outputs.buildkit-builder }}
version: ${{ matrix.version }}
driver: docker-container
driver-opts: |
image=moby/buildkit:v0.31.1
buildkitd-config-inline: ${{ steps.runs-on.outputs.buildkit-inline-config }}
cleanup: false
- name: Populate BuildKit cache
shell: bash
run: |
mkdir -p /tmp/runs-on-buildkit-test
printf 'FROM busybox:1.36\nRUN dd if=/dev/zero of=/layer.bin bs=1M count=16\n' > /tmp/runs-on-buildkit-test/Dockerfile
docker buildx build --builder "${{ steps.runs-on.outputs.buildkit-builder }}" --progress=plain --load --tag runs-on-buildkit-test:cold /tmp/runs-on-buildkit-test

wait-for-snapshot:
needs: cold
runs-on: ubuntu-slim
steps:
- name: Wait for the cold snapshot to become selectable
run: sleep 45

warm:
needs: wait-for-snapshot
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- id: pinned
version: v0.34.1
- id: latest
version: latest
runs-on: runs-on=${{ github.run_id }}/cpu=2/family=m7/image=ubuntu24-full-x64/sticky=buildkit-${{ github.run_id }}-${{ matrix.id }}:20gb
steps:
- uses: actions/checkout@v6
- id: runs-on
uses: ./
with:
sticky_cache: |
buildkit
custom,path=.runs-on-custom-cache/${{ matrix.id }}
- name: Verify restored state
env:
CACHE_HIT: ${{ steps.runs-on.outputs.cache-hit }}
run: |
test "$CACHE_HIT" = true
test "$(cat .runs-on-custom-cache/${{ matrix.id }}/marker)" = "${{ matrix.id }}"
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
name: ${{ steps.runs-on.outputs.buildkit-builder }}
version: ${{ matrix.version }}
driver: docker-container
driver-opts: |
image=moby/buildkit:v0.31.1
buildkitd-config-inline: ${{ steps.runs-on.outputs.buildkit-inline-config }}
cleanup: false
- name: Verify cached build
shell: bash
run: |
mkdir -p /tmp/runs-on-buildkit-test
printf 'FROM busybox:1.36\nRUN dd if=/dev/zero of=/layer.bin bs=1M count=16\n' > /tmp/runs-on-buildkit-test/Dockerfile
docker buildx build --builder "${{ steps.runs-on.outputs.buildkit-builder }}" --progress=plain --load --tag runs-on-buildkit-test:warm /tmp/runs-on-buildkit-test 2>&1 | tee /tmp/runs-on-buildkit.log
grep -q 'CACHED' /tmp/runs-on-buildkit.log

missing-sticky-disk:
runs-on: runs-on=${{ github.run_id }}/env=bootstrap/cpu=2/family=m7/image=ubuntu24-full-x64
steps:
- uses: actions/checkout@v6
- id: sticky_cache
continue-on-error: true
uses: ./
with:
sticky_cache: buildkit
- name: Verify missing sticky disk failed
env:
OUTCOME: ${{ steps.sticky_cache.outcome }}
run: test "$OUTCOME" = failure
141 changes: 141 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,147 @@ echo "SCCACHE_S3_KEY_PREFIX=cache/sccache" >> $GITHUB_ENV
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
```

### `sticky_cache`

Available for Linux and Windows runners on jobs with a sticky-disk label. Use `sticky=<size>` for the default snapshot lineage or `sticky=<name>:<size>` for a named lineage; the optional name must come first. Volume settings follow the size, for example `sticky=go-cache:20gb:gp3:750mbs:6000iops`. The `apt`, `buildkit`, and `git` cache modes are Linux only.

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/sticky=20gb
steps:
- uses: actions/checkout@v4
- uses: runs-on/action@v2
with:
sticky_cache: |
go
node
```

Each non-empty line is one cache record. A record starts with a mode and may
include comma-separated `key=value` options. Use one line per mode; the old
comma-separated mode list is not supported.

```yaml
with:
sticky_cache: |
go
node
buildkit
custom,path=vendor/custom-cache,path=~/.cache/my-tool
```

The action fails if the sticky disk is absent or does not become ready before
`sticky_wait_timeout`. The `custom` mode requires one or more `path=` options;
repeat the record or option to persist several paths. Relative paths resolve
from `GITHUB_WORKSPACE`, `~/` resolves from the runner home, and absolute paths
are preserved. Literal commas in paths are unsupported.

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 (the official setup-buildx builder stores its state on the sticky disk) |
| `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` |
| `custom` | | One or more paths supplied with `path=` |

#### `buildkit` mode (Docker layer cache)

The `buildkit` mode prepares the state volume used by Docker's official `setup-buildx-action`, backed by the sticky disk. RunsOn does not download or start its own BuildKit daemon. The actions must run in this order, with the fixed builder topology and cleanup settings shown below:

```yaml
jobs:
build:
runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/sticky=docker:20gb
steps:
- uses: actions/checkout@v4
- id: runs-on
uses: runs-on/action@v2
with:
sticky_cache: buildkit
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
name: ${{ steps.runs-on.outputs.buildkit-builder }}
version: v0.34.1
driver: docker-container
driver-opts: |
image=moby/buildkit:v0.31.1
buildkitd-config-inline: ${{ steps.runs-on.outputs.buildkit-inline-config }}
cleanup: false
- uses: docker/build-push-action@v7
with:
builder: ${{ steps.runs-on.outputs.buildkit-builder }}
context: .
load: true
```

Sticky BuildKit caching supports one `docker-container` node named by the `buildkit-builder` output. The RunsOn post step verifies that setup-buildx mounted the expected sticky volume, then stops and removes the builder before the disk is snapshotted. A missing setup step, reversed action order, different builder name, appended node, or setup-buildx cleanup causes a clear failure instead of silently using ephemeral cache storage.

The action always emits `buildkit-builder` and `buildkit-inline-config`, even without a sticky disk. When the RunsOn `ecr-pull-through` extra transparently mirrors Docker Hub, the inline config contains the matching BuildKit registry mirror; otherwise it is empty. This also supports a regular non-sticky builder:

```yaml
- id: runs-on
uses: runs-on/action@v2
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
name: ${{ steps.runs-on.outputs.buildkit-builder }}
buildkitd-config-inline: ${{ steps.runs-on.outputs.buildkit-inline-config }}
```

Use `docker buildx build` (or `docker/build-push-action`) with the emitted builder; add `--load` when you need the built image in the local Docker daemon. `docker pull` and plain `docker build` do not use this cache.

#### `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/sticky=20gb
steps:
- uses: runs-on/action@v2
with:
sticky_cache: git
- uses: actions/checkout@v4
```

To combine it with workspace-relative `custom,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 `custom,path=...` records to persist arbitrary additional paths. Relative paths are resolved against the workspace, so run this action **after** `actions/checkout` when caching workspace-relative paths (e.g. `custom,path=vendor/bundle`).

**Disk pressure:** the buildkit cache uses BuildKit's default garbage collection. 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 `sticky=` 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:

* `sticky_wait_timeout` - how long to wait for the sticky disk to be ready, as a Go duration (default `5m`)

The action sets a `cache-hit` output: `true` when every requested path was restored from a previous snapshot. It also sets `buildkit-builder` to the stable builder name and `buildkit-inline-config` to the ECR mirror TOML described above.

## Development

Make your source code changes in a commit, then rebuild and commit the generated binaries and JS files:
Expand Down
22 changes: 21 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,24 @@ 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: ''
sticky_cache:
description: 'Newline-separated sticky-disk cache records. Each line is a mode followed by comma-separated key=value options, for example buildkit or custom,path=vendor/cache. Supported modes: go, node, yarn, pnpm, ruby, rust, python, uv, poetry, apt, buildkit, git, gradle, maven, playwright, custom. Requires a sticky=<size> or sticky=<name>:<size> label; the action fails if the disk is absent or not ready before sticky_wait_timeout. Linux and Windows (apt, buildkit and git modes are Linux only). Run after actions/checkout for workspace-relative custom paths; git must run before checkout; buildkit must run before docker/setup-buildx-action.'
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 }}
sticky_wait_timeout:
description: 'How long to wait for the sticky disk to be ready (Go duration, e.g. "5m")'
required: false
default: '5m'

outputs:
cache-hit:
description: 'Set to "true" when every requested cache path was restored from a previous snapshot, "false" otherwise'
buildkit-builder:
description: 'Stable Buildx builder name to pass to docker/setup-buildx-action and docker/build-push-action'
buildkit-inline-config:
description: 'BuildKit TOML for the RunsOn ECR Docker Hub pull-through mirror, or an empty string when unavailable'
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=
43 changes: 43 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package config

import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"

"github.com/sethvargo/go-githubactions"
)
Expand All @@ -17,6 +19,8 @@ type Config struct {
NetworkInterface string
DiskDevice string
Sccache string
StickyCache []string
StickyWaitTimeout time.Duration
ZctionsResultsURL string
ZctionsCacheURL string
ActionsResultsURL string
Expand Down Expand Up @@ -63,6 +67,36 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) {

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

var legacyInputs []string
for _, name := range []string{"cache", "path", "wait_timeout", "fail_on_missing"} {
if action.GetInput(name) != "" {
legacyInputs = append(legacyInputs, name)
}
}
if len(legacyInputs) > 0 {
return nil, fmt.Errorf("legacy input(s) %s are no longer supported; use sticky_cache and sticky_wait_timeout (sticky cache requests now always fail when the disk is unavailable)", strings.Join(legacyInputs, ", "))
}

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

cfg.StickyWaitTimeout = 5 * time.Minute
waitTimeoutStr := action.GetInput("sticky_wait_timeout")
if waitTimeoutStr != "" {
if timeout, err := time.ParseDuration(waitTimeoutStr); err == nil {
cfg.StickyWaitTimeout = timeout
} else {
action.Warningf("Error parsing 'sticky_wait_timeout' input '%s': %v. Using default 5m.", waitTimeoutStr, 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 +108,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 'sticky_cache': %v", cfg.StickyCache)
action.Infof("Input 'sticky_wait_timeout': %s", cfg.StickyWaitTimeout)

if cfg.ZctionsResultsURL != "" {
action.Infof("ZCTIONS_RESULTS_URL is set: %s", cfg.ZctionsResultsURL)
Expand Down Expand Up @@ -110,6 +146,13 @@ 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.StickyCache) > 0
}

func (c *Config) IsUsingRunsOn() bool {
return os.Getenv("RUNS_ON_RUNNER_NAME") != ""
}
Expand Down
Loading
Loading