Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
39 changes: 39 additions & 0 deletions .github/workflows/test-sccache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ jobs:
echo "SCCACHE_S3_KEY_PREFIX: $SCCACHE_S3_KEY_PREFIX"
echo "RUSTC_WRAPPER: $RUSTC_WRAPPER"

expected="cache/sccache/${{ github.repository_id }}/linux-x64/v1"
if [ "$SCCACHE_S3_KEY_PREFIX" != "$expected" ]; then
echo "::error::Expected the default key prefix to be $expected"
exit 1
fi

# Runs last: it overwrites the prefix exported by the invocation above.
- uses: ./
with:
sccache: s3
sccache_prefix: /cache/sccache/shared-namespace/

- name: Verify the explicit prefix wins
run: |
echo "SCCACHE_S3_KEY_PREFIX: $SCCACHE_S3_KEY_PREFIX"
if [ "$SCCACHE_S3_KEY_PREFIX" != "cache/sccache/shared-namespace" ]; then
echo "::error::Expected the explicit sccache_prefix input to be used verbatim"
exit 1
fi

test-sccache-windows:
runs-on: runs-on=${{ github.run_id }}/env=bootstrap/cpu=2/family=m7/image=windows25-full-x64/extras=s3-cache

Expand Down Expand Up @@ -208,3 +228,22 @@ jobs:
Write-Host "SCCACHE_REGION: $env:SCCACHE_REGION"
Write-Host "SCCACHE_S3_KEY_PREFIX: $env:SCCACHE_S3_KEY_PREFIX"
Write-Host "RUSTC_WRAPPER: $env:RUSTC_WRAPPER"

$expected = "cache/sccache/${{ github.repository_id }}/windows-x64/v1"
if ($env:SCCACHE_S3_KEY_PREFIX -ne $expected) {
throw "Expected the default key prefix to be $expected"
}

# Runs last: it overwrites the prefix exported by the invocation above.
- uses: ./
with:
sccache: s3
sccache_prefix: /cache/sccache/shared-namespace/

- name: Verify the explicit prefix wins
shell: pwsh
run: |
Write-Host "SCCACHE_S3_KEY_PREFIX: $env:SCCACHE_S3_KEY_PREFIX"
if ($env:SCCACHE_S3_KEY_PREFIX -ne "cache/sccache/shared-namespace") {
throw "Expected the explicit sccache_prefix input to be used verbatim"
}
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,44 @@ What this does under the hood is the equivalent of:
echo "SCCACHE_GHA_ENABLED=false" >> $GITHUB_ENV
echo "SCCACHE_BUCKET=${{ env.RUNS_ON_S3_BUCKET_CACHE}}" >> $GITHUB_ENV
echo "SCCACHE_REGION=${{ env.RUNS_ON_AWS_REGION}}" >> $GITHUB_ENV
echo "SCCACHE_S3_KEY_PREFIX=cache/sccache" >> $GITHUB_ENV
echo "SCCACHE_S3_KEY_PREFIX=cache/sccache/${{ github.repository_id }}/linux-x64/v1" >> $GITHUB_ENV
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
```

### `sccache_prefix`

Sets the S3 key prefix used by the `sccache: s3` backend.

By default the action scopes compiler cache objects per repository and per runner platform:

```text
cache/sccache/<repository id>/<runner os>-<runner arch>/v1
```

The repository id is used rather than the `owner/name` slug so that renaming or transferring a repository does not invalidate its cache; the action falls back to the slug (as two key components, `<owner>/<name>`) when `GITHUB_REPOSITORY_ID` is not exposed. The trailing `v1` is a layout version, so a future change to the key layout can be rolled out without reusing existing objects.

This is operational isolation, not a security boundary: repositories sharing a RunsOn stack still share the bucket and the runner IAM role. What it buys you is per-repository cache ownership, growth and cost attribution, targeted invalidation, and freedom to change one repository's cache layout without touching the others.

Set the input explicitly to leave that scoping behind, for instance when several repositories compile the same sources and should reuse each other's cache. The value replaces the whole default prefix, so give every participating repository the same one:

```yaml
jobs:
build:
runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache
steps:
- uses: runs-on/action@v2
with:
sccache: s3
sccache_prefix: /cache/sccache/shared-namespace/
- uses: mozilla-actions/sccache-action@v0.0.9
```

Leading and trailing slashes are trimmed, so that example resolves to `cache/sccache/shared-namespace`. None of the default's components are kept and nothing is appended, so add platform or version components yourself if you want to be able to expire them separately. A value that carries no key components (empty, whitespace, or only slashes) falls back to the default rather than writing to the bucket root, which is shared with the other RunsOn caches. So do values that cannot be used as a key: control characters, `.` or `..` components, and anything long enough to crowd out the cache key inside S3's 1024 byte limit.

Previously every repository on a stack shared the flat `cache/sccache` prefix. Moving to the scoped default therefore starts one cold cache per repository and platform; the previous behaviour is available with `sccache_prefix: cache/sccache`. Objects written under the old prefix are left to the stack's cache lifecycle rule, which expires everything under `cache/` after `S3CacheExpirationInDays` (10 by default).

Keep custom prefixes under `cache/` as well: the runner instance profile is only granted S3 access to that namespace, so a prefix outside it fails with access denied.

### `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.
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ 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: ''
sccache_prefix:
description: 'S3 key prefix for the sccache S3 backend. Defaults to a repository-scoped prefix, "cache/sccache/<repository id>/<runner os>-<runner arch>/v1". Set an explicit value to opt into a shared namespace, for example "cache/sccache" to keep the previous stack-wide prefix.'
required: false
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. Custom paths must be directories. Supported modes: go, node, yarn, pnpm, ruby, rust, python, uv, poetry, apt, buildkit, git, git-full, 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, git and git-full modes are Linux only). Run after actions/checkout for workspace-relative caches; git and git-full must run before checkout and cannot share an invocation with workspace-relative caches; buildkit must run before docker/setup-buildx-action.'
required: false
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Config struct {
NetworkInterface string
DiskDevice string
Sccache string
SccachePrefix string
StickyCache []string
StickyWaitTimeout time.Duration
ZctionsResultsURL string
Expand Down Expand Up @@ -66,6 +67,7 @@ func NewConfigFromInputs(action *githubactions.Action) (*Config, error) {
}

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

stickyCacheInput := action.GetInput("sticky_cache")
if stickyCacheInput != "" {
Expand Down Expand Up @@ -101,6 +103,7 @@ 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 'sccache_prefix': %s", cfg.SccachePrefix)
action.Infof("Input 'sticky_cache': %v", cfg.StickyCache)
action.Infof("Input 'sticky_wait_timeout': %s", cfg.StickyWaitTimeout)

Expand Down
22 changes: 22 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ func TestHasSccacheOnRunsOn(t *testing.T) {
}
}

func TestSccachePrefixInput(t *testing.T) {
t.Setenv("INPUT_SCCACHE", "s3")
t.Setenv("INPUT_SCCACHE_PREFIX", "cache/sccache")

cfg, err := NewConfigFromInputs(githubactions.New())
if err != nil {
t.Fatal(err)
}
if got, want := cfg.SccachePrefix, "cache/sccache"; got != want {
t.Fatalf("SccachePrefix = %q, want %q", got, want)
}

t.Setenv("INPUT_SCCACHE_PREFIX", "")
cfg, err = NewConfigFromInputs(githubactions.New())
if err != nil {
t.Fatal(err)
}
if cfg.SccachePrefix != "" {
t.Fatalf("SccachePrefix = %q, want the default prefix to be resolved later", cfg.SccachePrefix)
}
}

func TestStickyCacheInputs(t *testing.T) {
t.Setenv("INPUT_STICKY_CACHE", " go \n\n buildkit \n custom,path=vendor/cache ")
t.Setenv("INPUT_STICKY_WAIT_TIMEOUT", "90s")
Expand Down
157 changes: 155 additions & 2 deletions internal/sccache/sccache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,42 @@ package sccache

import (
"os"
"path"
"regexp"
"runtime"
"strings"

"github.com/sethvargo/go-githubactions"
)

const (
// cacheNamespace is the only part of the RunsOn cache bucket the runner
// instance profile is granted S3 access to, so every prefix has to live
// under it (see cloudformation/template.yaml in runs-on/runs-on).
cacheNamespace = "cache"
// KeyPrefixRoot is the namespace RunsOn owns for compiler caches inside the
// shared S3 cache bucket. Generated prefixes are scoped below it so that
// repositories keep independent cache ownership, cost attribution, and
// invalidation, even though they may share the bucket and the runner IAM role.
KeyPrefixRoot = cacheNamespace + "/sccache"
// keyPrefixSchema versions the generated layout so a future change to it can
// be rolled out without reusing objects written under the previous scheme.
keyPrefixSchema = "v1"
// maxKeyPrefixBytes leaves most of S3's 1024 byte key limit to the cache key
// sccache appends to the prefix.
maxKeyPrefixBytes = 512
// unknownScope keeps generated prefixes well formed when the environment
// does not expose the identity a scope is derived from.
unknownScope = "unknown"
)

// unsafeScopeChars matches everything that is not kept verbatim in a scope
// component, so a surprising repository or platform value cannot reshape the key.
var unsafeScopeChars = regexp.MustCompile(`[^a-z0-9._-]+`)

// ConfigureSccache configures sccache with the appropriate backend.
// Currently only supports "s3" backend for RunsOn S3 cache bucket.
func ConfigureSccache(action *githubactions.Action, backend string) error {
func ConfigureSccache(action *githubactions.Action, backend string, keyPrefix string) error {
if backend != "s3" {
action.Warningf("Unsupported sccache backend: %s. Only 's3' is currently supported.", backend)
return nil
Expand All @@ -28,18 +57,21 @@ func ConfigureSccache(action *githubactions.Action, backend string) error {
return nil
}

prefix := ResolveKeyPrefix(action, keyPrefix)

// Set sccache environment variables
envVars := map[string]string{
"SCCACHE_GHA_ENABLED": "false",
"SCCACHE_BUCKET": bucket,
"SCCACHE_REGION": region,
"SCCACHE_S3_KEY_PREFIX": "cache/sccache",
"SCCACHE_S3_KEY_PREFIX": prefix,
"RUSTC_WRAPPER": "sccache",
}

action.Infof("Configuring sccache with S3 backend...")
action.Infof("Using bucket: %s", bucket)
action.Infof("Using region: %s", region)
action.Infof("Using key prefix: %s", prefix)

for key, value := range envVars {
action.SetEnv(key, value)
Expand All @@ -49,3 +81,124 @@ func ConfigureSccache(action *githubactions.Action, backend string) error {
action.Infof("sccache S3 backend configured successfully!")
return nil
}

// ResolveKeyPrefix returns the S3 key prefix to use, preferring an explicit
// input over the repository-scoped default. An input that carries no key
// components is reported and ignored rather than silently writing to the bucket
// root, which is shared with the other RunsOn caches.
func ResolveKeyPrefix(action *githubactions.Action, input string) string {
// Slashes and spaces are trimmed together: trimming them in two passes lets a
// value like " / / " survive as a single space and become the key prefix.
trimmed := strings.Trim(input, " \t\n\r/")
if trimmed != "" && !usableKeyPrefix(action, trimmed) {
return DefaultKeyPrefix()
}
if trimmed != "" {
// The runner instance profile is only granted S3 access on cache/*, so a
// prefix outside that namespace fails with access denied at compile time
// rather than here. Warn instead of rejecting: a customized stack may
// grant more than the default one does.
if trimmed != cacheNamespace && !strings.HasPrefix(trimmed, cacheNamespace+"/") {
action.Warningf("'sccache_prefix' %q is outside the cache/ namespace. RunsOn only grants the runner instance profile S3 access to cache/*, so sccache will likely fail with access denied.", trimmed)
}
return trimmed
}
if strings.TrimSpace(input) != "" {
action.Warningf("Ignoring 'sccache_prefix' input %q because it contains no key components. Using the default prefix instead.", input)
}
return DefaultKeyPrefix()
}

// usableKeyPrefix reports whether an explicit prefix is safe to export and to
// use as an S3 key prefix, warning about the reason when it is not.
func usableKeyPrefix(action *githubactions.Action, prefix string) bool {
// The prefix is exported through GITHUB_ENV, whose multiline records use a
// fixed delimiter that the value is interpolated into unescaped, so a value
// carrying newlines could close its own record and define unrelated
// variables for later steps. Control characters have no place in an S3 key
// either.
if strings.ContainsFunc(prefix, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
action.Warningf("Ignoring 'sccache_prefix': the value contains control characters, which cannot be exported safely. Using the default prefix instead.")
return false
}
// path.Join is not used on an explicit value, so "." and ".." would survive
// into the key itself rather than being resolved.
for _, component := range strings.Split(prefix, "/") {
if component == "." || component == ".." {
action.Warningf("Ignoring 'sccache_prefix' %q: %q is not usable as a key component. Using the default prefix instead.", prefix, component)
return false
}
}
// S3 caps a key at 1024 bytes and sccache appends its own hash to the
// prefix, so leave the object key room to exist.
if len(prefix) > maxKeyPrefixBytes {
action.Warningf("Ignoring 'sccache_prefix': the value is %d bytes, leaving too little of S3's 1024 byte key limit for the cache key itself. Using the default prefix instead.", len(prefix))
return false
}
return true
}

// DefaultKeyPrefix builds the repository-scoped default prefix, for example
// "cache/sccache/123456789/linux-x64/v1". Repository identity comes first so a
// bucket lifecycle rule or a targeted invalidation can address a single
// repository, then the platform, then the layout version.
func DefaultKeyPrefix() string {
return path.Join(KeyPrefixRoot, repositoryScope(), platformScope(), keyPrefixSchema)
}

// repositoryScope prefers the numeric repository id because it survives renames
// and transfers, and falls back to the owner/name slug when it is unavailable.
// The slug keeps owner and name as separate key components: flattening them into
// one would map distinct repositories onto the same prefix, since both halves may
// contain the separator (foo-bar/baz and foo/bar-baz would collide).
func repositoryScope() string {
if id := sanitizeScope(os.Getenv("GITHUB_REPOSITORY_ID")); id != unknownScope {
return id
}
owner, name, ok := strings.Cut(os.Getenv("GITHUB_REPOSITORY"), "/")
if !ok {
return sanitizeScope(owner)
}
return path.Join(sanitizeScope(owner), sanitizeScope(name))
}

// platformScope derives the runner platform from the Actions environment, and
// from the running binary when the action is exercised outside a runner. The
// fallback is translated into RUNNER_OS/RUNNER_ARCH spelling so that the same
// machine does not end up with two prefixes depending on which values are set.
func platformScope() string {
osName := os.Getenv("RUNNER_OS")
if strings.TrimSpace(osName) == "" {
osName = runnerSpelling(runtime.GOOS)
}
arch := os.Getenv("RUNNER_ARCH")
if strings.TrimSpace(arch) == "" {
arch = runnerSpelling(runtime.GOARCH)
}
return sanitizeScope(osName) + "-" + sanitizeScope(arch)
}

// runnerSpelling maps Go's platform names onto the ones the runner exports.
func runnerSpelling(value string) string {
switch value {
case "darwin":
return "macos"
case "amd64":
return "x64"
case "386":
return "x86"
default:
return value
}
}

func sanitizeScope(value string) string {
cleaned := unsafeScopeChars.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-")
cleaned = strings.Trim(cleaned, "-")
// "." and ".." are resolved by path.Join and would walk the key out of the
// cache/sccache namespace, so they never become a component of their own.
if cleaned == "" || cleaned == "." || cleaned == ".." {
return unknownScope
}
return cleaned
}
Loading