diff --git a/.github/workflows/test-sccache.yml b/.github/workflows/test-sccache.yml index 1195f16..777c741 100644 --- a/.github/workflows/test-sccache.yml +++ b/.github/workflows/test-sccache.yml @@ -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 @@ -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" + } diff --git a/README.md b/README.md index 57d73ca..9b01f18 100644 --- a/README.md +++ b/README.md @@ -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//-/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, `/`) 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=` for the default snapshot lineage or `sticky=:` 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. diff --git a/action.yml b/action.yml index a37126e..d934f1d 100644 --- a/action.yml +++ b/action.yml @@ -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//-/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= or sticky=: 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 diff --git a/internal/config/config.go b/internal/config/config.go index 548e5ed..22b1822 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ type Config struct { NetworkInterface string DiskDevice string Sccache string + SccachePrefix string StickyCache []string StickyWaitTimeout time.Duration ZctionsResultsURL string @@ -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 != "" { @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d299ed..55cf68a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") diff --git a/internal/sccache/sccache.go b/internal/sccache/sccache.go index e149b5b..c804895 100644 --- a/internal/sccache/sccache.go +++ b/internal/sccache/sccache.go @@ -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 @@ -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) @@ -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 +} diff --git a/internal/sccache/sccache_test.go b/internal/sccache/sccache_test.go new file mode 100644 index 0000000..bfe431a --- /dev/null +++ b/internal/sccache/sccache_test.go @@ -0,0 +1,177 @@ +package sccache + +import ( + "bytes" + "runtime" + "strings" + "testing" + + "github.com/sethvargo/go-githubactions" +) + +func TestDefaultKeyPrefixScopesRepositoryAndPlatform(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "123456789") + t.Setenv("GITHUB_REPOSITORY", "runs-on/action") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/123456789/linux-x64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +func TestDefaultKeyPrefixFallsBackToRepositorySlug(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", "Runs-On/Action") + t.Setenv("RUNNER_OS", "Windows") + t.Setenv("RUNNER_ARCH", "X64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/runs-on/action/windows-x64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +// The owner and the name of a repository may both contain the separator, so a +// flattened slug would hand foo-bar/baz and foo/bar-baz the same cache. +func TestDefaultKeyPrefixKeepsRepositoriesApartInTheSlugFallback(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + t.Setenv("GITHUB_REPOSITORY", "foo-bar/baz") + first := DefaultKeyPrefix() + + t.Setenv("GITHUB_REPOSITORY", "foo/bar-baz") + second := DefaultKeyPrefix() + + if first == second { + t.Fatalf("foo-bar/baz and foo/bar-baz share the prefix %q", first) + } +} + +func TestDefaultKeyPrefixStaysWellFormedWithoutRepositoryIdentity(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", "") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "ARM64") + + if got, want := DefaultKeyPrefix(), "cache/sccache/unknown/linux-arm64/v1"; got != want { + t.Fatalf("DefaultKeyPrefix() = %q, want %q", got, want) + } +} + +// path.Join resolves "." and "..", so a component that survived sanitization +// could walk the generated key out of the cache/sccache namespace. +func TestDefaultKeyPrefixCannotEscapeTheNamespace(t *testing.T) { + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + + for _, repository := range []string{"..", ".", "../..", "owner/.."} { + t.Run(repository, func(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "") + t.Setenv("GITHUB_REPOSITORY", repository) + + if got := DefaultKeyPrefix(); !strings.HasPrefix(got, KeyPrefixRoot+"/") { + t.Fatalf("DefaultKeyPrefix() = %q, want a key under %q", got, KeyPrefixRoot) + } + }) + } +} + +// The Actions environment and the Go runtime spell the same platform +// differently, so an environment missing RUNNER_ARCH must not land on a second +// prefix for the machine that has it. +func TestDefaultKeyPrefixSpellsThePlatformConsistently(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "42") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + withEnv := DefaultKeyPrefix() + + t.Setenv("RUNNER_OS", "") + t.Setenv("RUNNER_ARCH", "") + if got := DefaultKeyPrefix(); got != withEnv && runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + t.Fatalf("DefaultKeyPrefix() = %q without platform env, want %q", got, withEnv) + } +} + +func TestResolveKeyPrefixPrefersExplicitInput(t *testing.T) { + action := githubactions.New() + + for _, tc := range []struct { + name string + input string + want string + }{ + {name: "legacy stack-wide prefix", input: "cache/sccache", want: "cache/sccache"}, + {name: "surrounding whitespace", input: " team/sccache ", want: "team/sccache"}, + {name: "surrounding slashes", input: "/team/sccache/", want: "team/sccache"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := ResolveKeyPrefix(action, tc.input); got != tc.want { + t.Fatalf("ResolveKeyPrefix(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// A prefix outside cache/ is accepted but flagged, because the runner instance +// profile has no S3 access to it and sccache would fail with access denied. +// An explicit prefix is exported through GITHUB_ENV and used as an S3 key, so +// values that cannot survive either are refused rather than passed through. +func TestResolveKeyPrefixRefusesUnusableValues(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "42") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + want := "cache/sccache/42/linux-x64/v1" + + for name, input := range map[string]string{ + "env file delimiter": "cache/x\n_GitHubActionsFileCommandDelimeter_\nAWS_REGION<<_GitHubActionsFileCommandDelimeter_\nattacker", + "carriage return": "cache/x\rcache/y", + "parent component": "cache/../other", + "dot component": "cache/./other", + "oversized": "cache/" + strings.Repeat("x", maxKeyPrefixBytes), + } { + t.Run(name, func(t *testing.T) { + if got := ResolveKeyPrefix(githubactions.New(), input); got != want { + t.Fatalf("ResolveKeyPrefix(%q) = %q, want the default %q", input, got, want) + } + }) + } +} + +func TestResolveKeyPrefixWarnsOutsideTheCacheNamespace(t *testing.T) { + for _, tc := range []struct { + input string + wantWarn bool + }{ + {input: "builds/shared-toolchain", wantWarn: true}, + {input: "cacheable/sccache", wantWarn: true}, + {input: "cache", wantWarn: false}, + {input: "cache/shared-toolchain", wantWarn: false}, + } { + t.Run(tc.input, func(t *testing.T) { + var out bytes.Buffer + action := githubactions.New(githubactions.WithWriter(&out)) + + if got := ResolveKeyPrefix(action, tc.input); got != tc.input { + t.Fatalf("ResolveKeyPrefix(%q) = %q, want it unchanged", tc.input, got) + } + if warned := strings.Contains(out.String(), "::warning::"); warned != tc.wantWarn { + t.Fatalf("warning emitted = %t, want %t (output %q)", warned, tc.wantWarn, out.String()) + } + }) + } +} + +func TestResolveKeyPrefixNeverTargetsTheBucketRoot(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY_ID", "123456789") + t.Setenv("RUNNER_OS", "Linux") + t.Setenv("RUNNER_ARCH", "X64") + action := githubactions.New() + + for _, input := range []string{"", " ", "/", "///", " / / ", "// //", "\t/\t"} { + if got, want := ResolveKeyPrefix(action, input), "cache/sccache/123456789/linux-x64/v1"; got != want { + t.Fatalf("ResolveKeyPrefix(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/main-linux-amd64 b/main-linux-amd64 index fc3641b..610932f 100755 Binary files a/main-linux-amd64 and b/main-linux-amd64 differ diff --git a/main-linux-arm64 b/main-linux-arm64 index dac5569..7abb5e2 100755 Binary files a/main-linux-arm64 and b/main-linux-arm64 differ diff --git a/main-windows-amd64.exe b/main-windows-amd64.exe index 5057e64..8a760c4 100755 Binary files a/main-windows-amd64.exe and b/main-windows-amd64.exe differ diff --git a/main.go b/main.go index a4ca69a..3faf661 100644 --- a/main.go +++ b/main.go @@ -77,7 +77,7 @@ func handleMainExecution(action *githubactions.Action, ctx context.Context) { // Configure sccache if requested if cfg.HasSccache() { - if err := sccache.ConfigureSccache(action, cfg.Sccache); err != nil { + if err := sccache.ConfigureSccache(action, cfg.Sccache, cfg.SccachePrefix); err != nil { action.Errorf("Failed to configure sccache: %v", err) } }