Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
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/snap=buildkit-${{ github.run_id }}:20gb
steps:
- uses: actions/checkout@v6
- id: runs-on
uses: ./
with:
sticky_cache: |
buildkit,fail-on-missing=true
custom,path=.runs-on-custom-cache/${{ matrix.id }},fail-on-missing=true
- 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/snap=buildkit-${{ github.run_id }}:20gb
steps:
- uses: actions/checkout@v6
- id: runs-on
uses: ./
with:
sticky_cache: |
buildkit,fail-on-missing=true
custom,path=.runs-on-custom-cache/${{ matrix.id }},fail-on-missing=true
- 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-required-cache:
runs-on: runs-on=${{ github.run_id }}/cpu=2/family=m7/image=ubuntu24-full-x64
steps:
- uses: actions/checkout@v6
- id: required_cache
continue-on-error: true
uses: ./
with:
sticky_cache: buildkit,fail-on-missing=true
- name: Verify missing required cache failed
env:
OUTCOME: ${{ steps.required_cache.outcome }}
run: test "$OUTCOME" = failure
85 changes: 74 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ echo "SCCACHE_S3_KEY_PREFIX=cache/sccache" >> $GITHUB_ENV
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
```

### `cache` / `path`
### `sticky_cache`

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

Expand All @@ -340,11 +340,31 @@ jobs:
- uses: actions/checkout@v4
- uses: runs-on/action@v2
with:
cache: |
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,fail-on-missing=true
custom,path=vendor/custom-cache,path=~/.cache/my-tool
```

Every mode accepts `fail-on-missing=true|false` (default `false`). When true,
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 |
Expand All @@ -359,13 +379,57 @@ Supported cache modes and the directories they persist:
| `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) |
| `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/snap=docker:20gb
steps:
- uses: actions/checkout@v4
- id: runs-on
uses: runs-on/action@v2
with:
sticky_cache: buildkit,fail-on-missing=true
- 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 }}
```

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.
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)

Expand All @@ -380,28 +444,27 @@ jobs:
steps:
- uses: runs-on/action@v2
with:
cache: git
sticky_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.
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 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`).
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 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.
**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 `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`)
* `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.
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

Expand Down
18 changes: 7 additions & 11 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,23 @@ inputs:
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: ''
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> or snap=<name>:<size> label on the job; the optional name must come first, before the size and any volume settings. 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).'
sticky_cache:
description: 'Newline-separated sticky-disk cache records. Each line is a mode followed by comma-separated key=value options, for example buildkit,fail-on-missing=true 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 snap=<size> or snap=<name>:<size> label. 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 }}
path:
description: 'Newline separated list of arbitrary paths to persist on the sticky disk. Requires a snap=<size> or snap=<name>:<size> label on the job; the optional name must come first, before the size and any volume settings. Linux and Windows.'
required: false
default: ''
wait_timeout:
sticky_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'
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'
55 changes: 13 additions & 42 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ type Config struct {
NetworkInterface string
DiskDevice string
Sccache string
Cache []string
CachePaths []string
CacheWaitTimeout time.Duration
CacheFailOnMissing bool
StickyCache []string
StickyWaitTimeout time.Duration
ZctionsResultsURL string
ZctionsCacheURL string
ActionsResultsURL string
Expand Down Expand Up @@ -68,37 +66,23 @@ 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") {
stickyCacheInput := action.GetInput("sticky_cache")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy cache inputs wired

For workflows still using the existing v2 cache:/path: inputs, this now leaves cfg.StickyCache empty, so HasStickyDiskCache returns false and the action succeeds without setting up any sticky mounts. Since the README/examples still target runs-on/action@v2, users upgrading within v2 will silently lose all sticky caching; either map the legacy inputs to the new record format or fail loudly.

Useful? React with 👍 / 👎.

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

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

Expand All @@ -113,8 +97,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)
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 @@ -155,20 +139,7 @@ func (c *Config) HasSccache() bool {
// 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
return c.IsUsingRunsOn() && len(c.StickyCache) > 0
}

func (c *Config) IsUsingRunsOn() bool {
Expand Down
Loading
Loading