diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 0a684211ed0..e12149a652f 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -23,16 +23,6 @@ }, "action_description": "Add labels to an issue or a pull request." }, - "actions/ai-inference@v2.1.1": { - "repo": "actions/ai-inference", - "version": "v2.1.1", - "sha": "a7805884c80886efc241e94a5351df715968a0ad" - }, - "actions/attest-build-provenance@v4.1.0": { - "repo": "actions/attest-build-provenance", - "version": "v4.1.0", - "sha": "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" - }, "actions/cache/restore@v5.0.5": { "repo": "actions/cache/restore", "version": "v5.0.5", @@ -68,21 +58,11 @@ "version": "v9.0.0", "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" }, - "actions/setup-dotnet@v5.3.0": { - "repo": "actions/setup-dotnet", - "version": "v5.3.0", - "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" - }, "actions/setup-go@v6.4.0": { "repo": "actions/setup-go", "version": "v6.4.0", "sha": "4a3601121dd01d1626a1e23e37211e3254c1c06c" }, - "actions/setup-java@v5.2.0": { - "repo": "actions/setup-java", - "version": "v5.2.0", - "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" - }, "actions/setup-node@v6.4.0": { "repo": "actions/setup-node", "version": "v6.4.0", @@ -103,21 +83,6 @@ "version": "v0.24.0", "sha": "e22c389904149dbc22b58101806040fa8d37a610" }, - "astral-sh/setup-uv@v8.2.0": { - "repo": "astral-sh/setup-uv", - "version": "v8.2.0", - "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" - }, - "cli/gh-extension-precompile@v2.1.0": { - "repo": "cli/gh-extension-precompile", - "version": "v2.1.0", - "sha": "9e2237c30f869ad3bcaed6a4be2cd43564dd421b" - }, - "denoland/setup-deno@v2.0.4": { - "repo": "denoland/setup-deno", - "version": "v2.0.4", - "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" - }, "docker/build-push-action@v7.2.0": { "repo": "docker/build-push-action", "version": "v7.2.0", @@ -140,41 +105,61 @@ "version": "v4.1.0", "sha": "d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5" }, - "erlef/setup-beam@v1.24.0": { - "repo": "erlef/setup-beam", - "version": "v1.24.0", - "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" - }, "github/codeql-action/upload-sarif@v4.36.2": { "repo": "github/codeql-action/upload-sarif", "version": "v4.36.2", "sha": "8aad20d150bbac5944a9f9d289da16a4b0d87c1e" }, - "github/gh-aw-actions/setup@v0.79.8": { - "repo": "github/gh-aw-actions/setup", - "version": "v0.79.8", - "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + "github/stale-repos@v9.0.14": { + "repo": "github/stale-repos", + "version": "v9.0.14", + "sha": "378f317724a25737b846bd9895bacf5726dd72ff" + }, + "safedep/pmg@v1": { + "repo": "safedep/pmg", + "version": "v1", + "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" + }, + "super-linter/super-linter@v8.6.0": { + "repo": "super-linter/super-linter", + "version": "v8.6.0", + "sha": "9e863354e3ff62e0727d37183162c4a88873df41" + }, + "actions/setup-dotnet@v5.3.0": { + "repo": "actions/setup-dotnet", + "version": "v5.3.0", + "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" + }, + "actions/setup-java@v5.2.0": { + "repo": "actions/setup-java", + "version": "v5.2.0", + "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" + }, + "astral-sh/setup-uv@v8.2.0": { + "repo": "astral-sh/setup-uv", + "version": "v8.2.0", + "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" + }, + "denoland/setup-deno@v2.0.4": { + "repo": "denoland/setup-deno", + "version": "v2.0.4", + "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" + }, + "erlef/setup-beam@v1.24.0": { + "repo": "erlef/setup-beam", + "version": "v1.24.0", + "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" }, "github/gh-aw/actions/setup-cli@v0.79.8": { "repo": "github/gh-aw/actions/setup-cli", "version": "v0.79.8", "sha": "8b02ab336d100a5746e9f53b8bc2b22878278a6f" }, - "github/stale-repos@v9.0.14": { - "repo": "github/stale-repos", - "version": "v9.0.14", - "sha": "378f317724a25737b846bd9895bacf5726dd72ff" - }, "haskell-actions/setup@v2.11.0": { "repo": "haskell-actions/setup", "version": "v2.11.0", "sha": "cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553" }, - "microsoft/apm-action@v1.9.1": { - "repo": "microsoft/apm-action", - "version": "v1.9.1", - "sha": "e5650fb81c4b5965090a17bd1ed1956071e95d17" - }, "oven-sh/setup-bun@v2.2.0": { "repo": "oven-sh/setup-bun", "version": "v2.2.0", @@ -184,16 +169,6 @@ "repo": "ruby/setup-ruby", "version": "v1.313.0", "sha": "89f90524b88a01fe6e0b732220432cc6142926af" - }, - "safedep/pmg@v1": { - "repo": "safedep/pmg", - "version": "v1", - "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" - }, - "super-linter/super-linter@v8.6.0": { - "repo": "super-linter/super-linter", - "version": "v8.6.0", - "sha": "9e863354e3ff62e0727d37183162c4a88873df41" } }, "containers": { diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh index 1c565c9e893..c7a5ed2ffed 100755 --- a/actions/setup-cli/install.sh +++ b/actions/setup-cli/install.sh @@ -1,7 +1,7 @@ #!/bin/bash set +o histexpand -# Script sync note: install-gh-aw.sh is canonical. actions/setup-cli/install.sh is copied from install-gh-aw.sh. +# Kept in sync with actions/setup-cli/install.sh — edit this file, then copy to that path. # Script to download and install gh-aw binary for the current OS and architecture # Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin) diff --git a/pkg/actionpins/data/action_pins.json b/pkg/actionpins/data/action_pins.json index 0a684211ed0..e12149a652f 100644 --- a/pkg/actionpins/data/action_pins.json +++ b/pkg/actionpins/data/action_pins.json @@ -23,16 +23,6 @@ }, "action_description": "Add labels to an issue or a pull request." }, - "actions/ai-inference@v2.1.1": { - "repo": "actions/ai-inference", - "version": "v2.1.1", - "sha": "a7805884c80886efc241e94a5351df715968a0ad" - }, - "actions/attest-build-provenance@v4.1.0": { - "repo": "actions/attest-build-provenance", - "version": "v4.1.0", - "sha": "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" - }, "actions/cache/restore@v5.0.5": { "repo": "actions/cache/restore", "version": "v5.0.5", @@ -68,21 +58,11 @@ "version": "v9.0.0", "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" }, - "actions/setup-dotnet@v5.3.0": { - "repo": "actions/setup-dotnet", - "version": "v5.3.0", - "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" - }, "actions/setup-go@v6.4.0": { "repo": "actions/setup-go", "version": "v6.4.0", "sha": "4a3601121dd01d1626a1e23e37211e3254c1c06c" }, - "actions/setup-java@v5.2.0": { - "repo": "actions/setup-java", - "version": "v5.2.0", - "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" - }, "actions/setup-node@v6.4.0": { "repo": "actions/setup-node", "version": "v6.4.0", @@ -103,21 +83,6 @@ "version": "v0.24.0", "sha": "e22c389904149dbc22b58101806040fa8d37a610" }, - "astral-sh/setup-uv@v8.2.0": { - "repo": "astral-sh/setup-uv", - "version": "v8.2.0", - "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" - }, - "cli/gh-extension-precompile@v2.1.0": { - "repo": "cli/gh-extension-precompile", - "version": "v2.1.0", - "sha": "9e2237c30f869ad3bcaed6a4be2cd43564dd421b" - }, - "denoland/setup-deno@v2.0.4": { - "repo": "denoland/setup-deno", - "version": "v2.0.4", - "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" - }, "docker/build-push-action@v7.2.0": { "repo": "docker/build-push-action", "version": "v7.2.0", @@ -140,41 +105,61 @@ "version": "v4.1.0", "sha": "d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5" }, - "erlef/setup-beam@v1.24.0": { - "repo": "erlef/setup-beam", - "version": "v1.24.0", - "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" - }, "github/codeql-action/upload-sarif@v4.36.2": { "repo": "github/codeql-action/upload-sarif", "version": "v4.36.2", "sha": "8aad20d150bbac5944a9f9d289da16a4b0d87c1e" }, - "github/gh-aw-actions/setup@v0.79.8": { - "repo": "github/gh-aw-actions/setup", - "version": "v0.79.8", - "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + "github/stale-repos@v9.0.14": { + "repo": "github/stale-repos", + "version": "v9.0.14", + "sha": "378f317724a25737b846bd9895bacf5726dd72ff" + }, + "safedep/pmg@v1": { + "repo": "safedep/pmg", + "version": "v1", + "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" + }, + "super-linter/super-linter@v8.6.0": { + "repo": "super-linter/super-linter", + "version": "v8.6.0", + "sha": "9e863354e3ff62e0727d37183162c4a88873df41" + }, + "actions/setup-dotnet@v5.3.0": { + "repo": "actions/setup-dotnet", + "version": "v5.3.0", + "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" + }, + "actions/setup-java@v5.2.0": { + "repo": "actions/setup-java", + "version": "v5.2.0", + "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" + }, + "astral-sh/setup-uv@v8.2.0": { + "repo": "astral-sh/setup-uv", + "version": "v8.2.0", + "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" + }, + "denoland/setup-deno@v2.0.4": { + "repo": "denoland/setup-deno", + "version": "v2.0.4", + "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" + }, + "erlef/setup-beam@v1.24.0": { + "repo": "erlef/setup-beam", + "version": "v1.24.0", + "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" }, "github/gh-aw/actions/setup-cli@v0.79.8": { "repo": "github/gh-aw/actions/setup-cli", "version": "v0.79.8", "sha": "8b02ab336d100a5746e9f53b8bc2b22878278a6f" }, - "github/stale-repos@v9.0.14": { - "repo": "github/stale-repos", - "version": "v9.0.14", - "sha": "378f317724a25737b846bd9895bacf5726dd72ff" - }, "haskell-actions/setup@v2.11.0": { "repo": "haskell-actions/setup", "version": "v2.11.0", "sha": "cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553" }, - "microsoft/apm-action@v1.9.1": { - "repo": "microsoft/apm-action", - "version": "v1.9.1", - "sha": "e5650fb81c4b5965090a17bd1ed1956071e95d17" - }, "oven-sh/setup-bun@v2.2.0": { "repo": "oven-sh/setup-bun", "version": "v2.2.0", @@ -184,16 +169,6 @@ "repo": "ruby/setup-ruby", "version": "v1.313.0", "sha": "89f90524b88a01fe6e0b732220432cc6142926af" - }, - "safedep/pmg@v1": { - "repo": "safedep/pmg", - "version": "v1", - "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" - }, - "super-linter/super-linter@v8.6.0": { - "repo": "super-linter/super-linter", - "version": "v8.6.0", - "sha": "9e863354e3ff62e0727d37183162c4a88873df41" } }, "containers": { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 95ec6befb7d..66c5c723a89 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -433,7 +433,7 @@ func compileAllFilesInDirectory( } // Post-processing - if err := runPostProcessingForDirectory(ctx, compiler, workflowDataList, config, workflowsDir, gitRoot, successCount); err != nil { + if err := runPostProcessingForDirectory(ctx, compiler, workflowDataList, config, workflowsDir, gitRoot, successCount, errorCount); err != nil { return workflowDataList, err } @@ -551,6 +551,7 @@ func runPostProcessingForDirectory( workflowsDir string, gitRoot string, successCount int, + errorCount int, ) error { // Get action cache actionCache := compiler.GetSharedActionCache() @@ -598,6 +599,11 @@ func runPostProcessingForDirectory( // Prune stale gh-aw-actions entries before saving pruneStaleActionCacheEntries(compiler, actionCache) + // Prune orphaned entries — entries for action versions no longer referenced + // by any workflow in the directory (e.g. old pins left after a version bump). + // Safe to call only after a full-directory compilation with zero compile errors. + pruneOrphanedActionCacheEntries(compiler, actionCache, errorCount) + // Save action cache (errors are logged but non-fatal) _ = saveActionCache(actionCache, config.Verbose) diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index 38b6a249ded..ff96df5d6c9 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -298,3 +298,31 @@ func pruneStaleActionCacheEntries(compiler *workflow.Compiler, actionCache *work actionCache.PruneStaleGHAWEntries(version, compiler.EffectiveActionsRepo()) } + +// pruneOrphanedActionCacheEntries removes entries from the action cache that were +// not referenced during the current compilation run. This garbage-collects entries +// for action versions no longer used by any workflow in the target directory (e.g. +// old version pins left behind after bumping a `uses:` tag). +// +// This is only safe to call after a full-directory compilation — compiling a +// subset of files would incorrectly prune entries still referenced by other +// (uncompiled) workflows — and only when there were zero compile errors. +func pruneOrphanedActionCacheEntries(compiler *workflow.Compiler, actionCache *workflow.ActionCache, errorCount int) { + if actionCache == nil { + return + } + if errorCount > 0 { + return + } + + resolver := compiler.GetSharedActionResolver() + if resolver == nil { + return + } + + usedKeys := resolver.GetUsedCacheKeys() + pruned := actionCache.PruneOrphanedEntries(usedKeys) + if pruned > 0 { + compilePostProcessingLog.Printf("Pruned %d orphaned entries from actions-lock.json", pruned) + } +} diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go index 800edf44f98..bc847616a78 100644 --- a/pkg/workflow/action_cache.go +++ b/pkg/workflow/action_cache.go @@ -96,6 +96,59 @@ func (c *ActionCache) DeleteContainerPin(image string) { } } +// PruneOrphanedEntries removes action cache entries whose keys are not present +// in referencedKeys. It returns the number of entries that were removed. +// This is used to keep actions-lock.json a faithful reflection of what the +// compiled workflows actually reference — entries for old action versions that +// are no longer used by any workflow are removed. +func (c *ActionCache) PruneOrphanedEntries(referencedKeys map[string]bool) int { + if len(referencedKeys) == 0 { + return 0 + } + + // Compiler-generated actions that should never be pruned. + // These are embedded in Go code rather than markdown workflows and include: + // - Core workflow actions (cache, checkout, github-script) + // - Runtime setup actions (from runtime_definitions.go) + // - Security scanning actions (CodeQL) + compilerGeneratedRepos := []string{ + "actions/cache/", + "actions/checkout", + "actions/github-script", + "github/codeql-action/upload-sarif", + } + + // Add all runtime-managed actions from runtime_definitions.go + for _, runtime := range knownRuntimes { + if runtime.ActionRepo != "" { + compilerGeneratedRepos = append(compilerGeneratedRepos, runtime.ActionRepo) + } + } + + isCompilerGenerated := func(cacheKey string) bool { + for _, repo := range compilerGeneratedRepos { + if strings.HasPrefix(cacheKey, repo) { + return true + } + } + return false + } + + pruned := 0 + for key := range c.Entries { + if !referencedKeys[key] && !isCompilerGenerated(key) { + delete(c.Entries, key) + c.dirty = true + pruned++ + actionCacheLog.Printf("Pruned orphaned action cache entry: %s", key) + } + } + if pruned > 0 { + actionCacheLog.Printf("Pruned %d orphaned action cache entries, %d entries remaining", pruned, len(c.Entries)) + } + return pruned +} + // PruneStaleContainerPins removes container pin entries whose keys are not present // in knownImages. It returns the number of entries that were removed. // This is used to keep actions-lock.json consistent with the set of images @@ -353,6 +406,30 @@ func (c *ActionCache) FindEntryBySHA(repo, sha string) (ActionCacheEntry, bool) return ActionCacheEntry{}, false } +// FindAnyEntryForRepo finds any cache entry for the given repo, +// preferring the newest version (by sorting keys and taking first match). +// Returns the cache key, entry, and true if found, or empty values and false if not found. +// This is used when the compiler needs to reference an action but doesn't know the version. +func (c *ActionCache) FindAnyEntryForRepo(repo string) (string, ActionCacheEntry, bool) { + prefix := repo + "@" + var matchedKeys []string + for key := range c.Entries { + if strings.HasPrefix(key, prefix) { + matchedKeys = append(matchedKeys, key) + } + } + if len(matchedKeys) == 0 { + actionCacheLog.Printf("No cache entries found for repo: %s", repo) + return "", ActionCacheEntry{}, false + } + // Sort keys and take the first one (lexicographically, which tends to favor newer versions) + sort.Strings(matchedKeys) + firstKey := matchedKeys[len(matchedKeys)-1] // Take the last one for descending order (v9 > v1) + entry := c.Entries[firstKey] + actionCacheLog.Printf("Found cache entry for %s: %s", repo, firstKey) + return firstKey, entry, true +} + // Set stores a new cache entry, preserving any already-cached inputs when the SHA // is unchanged. If the SHA changes (e.g. a moving tag points to a new commit), // cached inputs are cleared to stay consistent with the newly-pinned commit. diff --git a/pkg/workflow/action_cache_container_pin_test.go b/pkg/workflow/action_cache_container_pin_test.go index dbac363966c..5327e80796b 100644 --- a/pkg/workflow/action_cache_container_pin_test.go +++ b/pkg/workflow/action_cache_container_pin_test.go @@ -115,6 +115,7 @@ func TestContainerPinMarshalSortedOutput(t *testing.T) { cache := NewActionCache(tmpDir) cache.Set("actions/checkout", "v5", "sha1") cache.SetContainerPin("z-image:latest", "sha256:zzz", "z-image:latest@sha256:zzz") + cache.SetContainerPin("m-image:v2", "sha256:mmm", "m-image:v2@sha256:mmm") cache.SetContainerPin("a-image:latest", "sha256:aaa", "a-image:latest@sha256:aaa") require.NoError(t, cache.Save()) @@ -122,10 +123,29 @@ func TestContainerPinMarshalSortedOutput(t *testing.T) { content, err := os.ReadFile(filepath.Join(tmpDir, ".github", "aw", CacheFileName)) require.NoError(t, err) - // Both container images should appear in the JSON. contentStr := string(content) - assert.Contains(t, contentStr, `"a-image:latest"`, "a-image pin in output") - assert.Contains(t, contentStr, `"z-image:latest"`, "z-image pin in output") + + // Verify that entries appear in alphabetical order by checking their positions + containers := []string{ + "a-image:latest", + "m-image:v2", + "z-image:latest", + } + + lastPos := -1 + for _, container := range containers { + pos := indexOf(contentStr, `"`+container+`"`) + if pos == -1 { + t.Errorf("Container %s not found in cache file", container) + continue + } + if pos < lastPos { + t.Errorf("Container %s appears before previous container (not sorted)", container) + } + lastPos = pos + } + + // Verify containers section is present assert.Contains(t, contentStr, `"containers"`, "containers section present") // Reload and verify round-trip. @@ -134,6 +154,9 @@ func TestContainerPinMarshalSortedOutput(t *testing.T) { pin, ok := cache2.GetContainerPin("a-image:latest") require.True(t, ok) assert.Equal(t, "sha256:aaa", pin.Digest) + pin, ok = cache2.GetContainerPin("m-image:v2") + require.True(t, ok) + assert.Equal(t, "sha256:mmm", pin.Digest) pin, ok = cache2.GetContainerPin("z-image:latest") require.True(t, ok) assert.Equal(t, "sha256:zzz", pin.Digest) diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go index 9014f2aa58f..e2fac9e3a72 100644 --- a/pkg/workflow/action_cache_test.go +++ b/pkg/workflow/action_cache_test.go @@ -881,3 +881,166 @@ func TestActionCacheReleasedAtClearedOnSHAChange(t *testing.T) { t.Error("ReleasedAt should be cleared when SHA changes") } } + +// TestPruneOrphanedEntries verifies that PruneOrphanedEntries removes entries +// whose keys are absent from the referenced set, while preserving referenced ones. +func TestPruneOrphanedEntries(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + // Simulate a version bump: v1.7.2 was old, v1.9.1 is now used. + cache.Set("microsoft/apm-action", "v1.7.2", "sha_old") + cache.Set("microsoft/apm-action", "v1.9.1", "sha_new") + cache.Set("actions/checkout", "v4", "sha_checkout") + + if len(cache.Entries) != 3 { + t.Fatalf("Expected 3 entries before pruning, got %d", len(cache.Entries)) + } + + // Only v1.9.1 and checkout are referenced by compiled workflows. + referenced := map[string]bool{ + "microsoft/apm-action@v1.9.1": true, + "actions/checkout@v4": true, + } + pruned := cache.PruneOrphanedEntries(referenced) + + if pruned != 1 { + t.Errorf("Expected 1 pruned entry, got %d", pruned) + } + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries after pruning, got %d", len(cache.Entries)) + } + if _, exists := cache.Entries["microsoft/apm-action@v1.7.2"]; exists { + t.Error("Expected orphaned entry microsoft/apm-action@v1.7.2 to be removed") + } + if _, exists := cache.Entries["microsoft/apm-action@v1.9.1"]; !exists { + t.Error("Expected referenced entry microsoft/apm-action@v1.9.1 to remain") + } + if _, exists := cache.Entries["actions/checkout@v4"]; !exists { + t.Error("Expected referenced entry actions/checkout@v4 to remain") + } +} + +// TestPruneOrphanedEntries_EmptyReferenced verifies that passing an empty +// referenced set is a no-op (safe guard: an empty set most likely means no +// compilation happened, not that all entries are orphaned). +func TestPruneOrphanedEntries_EmptyReferenced(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + cache.Set("actions/checkout", "v4", "sha1") + cache.Set("actions/setup-node", "v4", "sha2") + + pruned := cache.PruneOrphanedEntries(map[string]bool{}) + + if pruned != 0 { + t.Errorf("Expected 0 pruned entries (empty referenced set is a no-op), got %d", pruned) + } + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (unchanged), got %d", len(cache.Entries)) + } +} + +// TestPruneOrphanedEntries_NoneOrphaned verifies that no entries are removed +// when all cached entries are referenced. +func TestPruneOrphanedEntries_NoneOrphaned(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + cache.Set("actions/checkout", "v4", "sha1") + cache.Set("actions/setup-node", "v4", "sha2") + + referenced := map[string]bool{ + "actions/checkout@v4": true, + "actions/setup-node@v4": true, + } + pruned := cache.PruneOrphanedEntries(referenced) + + if pruned != 0 { + t.Errorf("Expected 0 pruned entries, got %d", pruned) + } + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (unchanged), got %d", len(cache.Entries)) + } +} + +// TestPruneOrphanedEntries_AllOrphaned verifies that all entries are removed +// when none are referenced (but referenced set is non-empty). +func TestPruneOrphanedEntries_AllOrphaned(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + // Use actions that are NOT runtime-managed or compiler-generated + cache.Set("microsoft/apm-action", "v1.7.2", "sha1") + cache.Set("cli/gh-extension-precompile", "v2.1.0", "sha2") + + // Referenced set is non-empty but contains neither of the cached entries. + referenced := map[string]bool{ + "some/other-action@v1": true, + } + pruned := cache.PruneOrphanedEntries(referenced) + + // Both entries should be pruned (neither is compiler-generated or runtime-managed) + if pruned != 2 { + t.Errorf("Expected 2 pruned entries, got %d", pruned) + } + if len(cache.Entries) != 0 { + t.Errorf("Expected 0 entries after pruning all orphans, got %d", len(cache.Entries)) + } +} + +// TestPruneOrphanedEntries_PreservesCompilerGenerated verifies that compiler-generated +// actions are never pruned, even when not in the referenced set. +func TestPruneOrphanedEntries_PreservesCompilerGenerated(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + // Add compiler-generated actions, runtime-managed actions, and a regular action + cache.Set("actions/cache/save", "v4", "sha_cache_save") + cache.Set("actions/checkout", "v4", "sha_checkout") + cache.Set("github/codeql-action/upload-sarif", "v4", "sha_codeql") + cache.Set("actions/setup-node", "v6", "sha_node") // runtime-managed + cache.Set("actions/setup-python", "v5", "sha_python") // runtime-managed + cache.Set("ruby/setup-ruby", "v1", "sha_ruby") // runtime-managed + cache.Set("microsoft/apm-action", "v1.7.2", "sha_old") + + if len(cache.Entries) != 7 { + t.Fatalf("Expected 7 entries before pruning, got %d", len(cache.Entries)) + } + + // Only reference microsoft/apm-action, but not the compiler-generated or runtime-managed ones + referenced := map[string]bool{ + "microsoft/apm-action@v1.7.2": true, + } + pruned := cache.PruneOrphanedEntries(referenced) + + // Should not prune compiler-generated or runtime-managed actions + if pruned != 0 { + t.Errorf("Expected 0 pruned entries (compiler-generated and runtime-managed actions preserved), got %d", pruned) + } + if len(cache.Entries) != 7 { + t.Errorf("Expected 7 entries after pruning (all preserved), got %d", len(cache.Entries)) + } + + // Verify compiler-generated actions are preserved + if _, exists := cache.Entries["actions/cache/save@v4"]; !exists { + t.Error("Expected compiler-generated actions/cache/save@v4 to be preserved") + } + if _, exists := cache.Entries["actions/checkout@v4"]; !exists { + t.Error("Expected compiler-generated actions/checkout@v4 to be preserved") + } + if _, exists := cache.Entries["github/codeql-action/upload-sarif@v4"]; !exists { + t.Error("Expected compiler-generated github/codeql-action/upload-sarif@v4 to be preserved") + } + + // Verify runtime-managed actions are preserved + if _, exists := cache.Entries["actions/setup-node@v6"]; !exists { + t.Error("Expected runtime-managed actions/setup-node@v6 to be preserved") + } + if _, exists := cache.Entries["actions/setup-python@v5"]; !exists { + t.Error("Expected runtime-managed actions/setup-python@v5 to be preserved") + } + if _, exists := cache.Entries["ruby/setup-ruby@v1"]; !exists { + t.Error("Expected runtime-managed ruby/setup-ruby@v1 to be preserved") + } +} diff --git a/pkg/workflow/action_pins.go b/pkg/workflow/action_pins.go index 3c1b5c2afa5..d1ff2ab83b7 100644 --- a/pkg/workflow/action_pins.go +++ b/pkg/workflow/action_pins.go @@ -85,6 +85,10 @@ func getActionPin(repo string) string { // // This is the preferred call site for code running inside a Compiler method, since it // automatically honours the per-compilation GHES compat flag without any global state. +// +// If the compiler has an action cache and resolver, this method will check the cache for +// any existing entry and mark it as "used" for orphan pruning. This ensures compiler-generated +// action references (e.g., actions/cache/save in notify steps) are tracked. func (c *Compiler) getActionPin(repo string) string { if c.ghesArtifactCompat { if pin, ok := ghesArtifactCompatPins[repo]; ok { @@ -92,6 +96,22 @@ func (c *Compiler) getActionPin(repo string) string { return actionpins.FormatPinnedActionReference(repo, pin.sha, pin.version) } } + + // Check the cache for any existing entry for this repo (regardless of version). + // Compiler-generated actions don't specify versions, so we use any cached entry we have. + cache := c.GetSharedActionCache() + resolver := c.GetSharedActionResolver() + if cache != nil { + if cacheKey, entry, found := cache.FindAnyEntryForRepo(repo); found { + // Mark this cache key as used so it won't be pruned as orphaned + if resolver != nil { + resolver.MarkCacheKeyAsUsed(cacheKey) + } + return actionpins.FormatPinnedActionReference(repo, entry.SHA, entry.Version) + } + } + + // Fall back to embedded pins if no cache entry exists return getActionPin(repo) } diff --git a/pkg/workflow/action_resolver.go b/pkg/workflow/action_resolver.go index f586dda6011..15c9ae02820 100644 --- a/pkg/workflow/action_resolver.go +++ b/pkg/workflow/action_resolver.go @@ -3,6 +3,7 @@ package workflow import ( "context" "fmt" + "maps" "strings" "time" @@ -18,6 +19,7 @@ var resolverLog = logger.New("workflow:action_resolver") type ActionResolver struct { cache *ActionCache failedResolutions map[string]bool // tracks failed resolution attempts in current run (key: "repo@version") + usedCacheKeys map[string]bool // tracks cache keys that were hit or newly set during this run } // NewActionResolver creates a new action resolver @@ -25,6 +27,64 @@ func NewActionResolver(cache *ActionCache) *ActionResolver { return &ActionResolver{ cache: cache, failedResolutions: make(map[string]bool), + usedCacheKeys: make(map[string]bool), + } +} + +// GetUsedCacheKeys returns the set of cache keys (in "repo@version" format) that +// were successfully resolved from the cache or written to the cache during this run. +// These represent the action pins actually referenced by the compiled workflows. +func (r *ActionResolver) GetUsedCacheKeys() map[string]bool { + keys := make(map[string]bool, len(r.usedCacheKeys)) + maps.Copy(keys, r.usedCacheKeys) + return keys +} + +// MarkCacheKeyAsUsed explicitly marks a cache key as used during this compilation run. +// This is useful for compiler-generated actions that aren't resolved through ResolveSHA. +func (r *ActionResolver) MarkCacheKeyAsUsed(cacheKey string) { + r.usedCacheKeys[cacheKey] = true + resolverLog.Printf("Marked cache key as used: %s", cacheKey) +} + +// MarkCompilerGeneratedActionsAsUsed scans the cache for any entries matching +// compiler-generated action repos and marks them as used. This ensures that actions +// embedded in generated YAML (e.g., actions/cache, actions/checkout) aren't pruned +// as orphaned entries even if they weren't resolved through ResolveSHA. +// +// This is called after workflow compilation to handle actions that are hardcoded +// in code generators (cache.go, checkout_step_generator.go, etc.) rather than +// resolved from markdown workflows. +func (r *ActionResolver) MarkCompilerGeneratedActionsAsUsed() { + if r.cache == nil { + return + } + + // List of action repos that are commonly generated by the compiler + compilerGeneratedRepos := []string{ + "actions/cache", + "actions/cache/restore", + "actions/cache/save", + "actions/checkout", + "actions/github-script", + "actions/upload-artifact", + "actions/download-artifact", + "actions/create-github-app-token", + "github/codeql-action/upload-sarif", + } + + marked := 0 + for _, repo := range compilerGeneratedRepos { + if cacheKey, _, found := r.cache.FindAnyEntryForRepo(repo); found { + if !r.usedCacheKeys[cacheKey] { + r.usedCacheKeys[cacheKey] = true + marked++ + resolverLog.Printf("Marked compiler-generated action as used: %s", cacheKey) + } + } + } + if marked > 0 { + resolverLog.Printf("Marked %d compiler-generated action(s) as used", marked) } } @@ -36,6 +96,7 @@ func (r *ActionResolver) ResolveSHA(ctx context.Context, repo, version string) ( // Create a cache key for tracking failed resolutions and cache lookups. // Computed once here and reused below to avoid duplicate allocation. cacheKey := formatActionCacheKey(repo, version) + r.usedCacheKeys[cacheKey] = true // Check if we've already failed to resolve this action in this run if r.failedResolutions[cacheKey] { diff --git a/pkg/workflow/action_resolver_test.go b/pkg/workflow/action_resolver_test.go index e193b87cf9d..8f59f79d558 100644 --- a/pkg/workflow/action_resolver_test.go +++ b/pkg/workflow/action_resolver_test.go @@ -90,6 +90,9 @@ func TestActionResolverFailedResolutionCache(t *testing.T) { if !resolver.failedResolutions[cacheKey] { t.Errorf("Expected failed resolution to be tracked for %s", cacheKey) } + if !resolver.GetUsedCacheKeys()[cacheKey] { + t.Errorf("Expected used cache keys to track attempted resolution for %s", cacheKey) + } // Second attempt should be skipped and return error immediately _, err2 := resolver.ResolveSHA(context.Background(), repo, version) @@ -102,6 +105,9 @@ func TestActionResolverFailedResolutionCache(t *testing.T) { if !strings.Contains(err2.Error(), expectedErrMsg) { t.Errorf("Expected error message to contain %q, got: %v", expectedErrMsg, err2) } + if !resolver.GetUsedCacheKeys()[cacheKey] { + t.Errorf("Expected used cache keys to retain attempted resolution key %s", cacheKey) + } } // Note: Testing the actual GitHub API resolution requires network access @@ -202,3 +208,51 @@ func TestParseTagRefTSV(t *testing.T) { }) } } + +// TestActionResolverUsedCacheKeysOnCacheHit verifies that GetUsedCacheKeys tracks +// cache hits — i.e. keys that were already in the cache and returned by ResolveSHA. +func TestActionResolverUsedCacheKeysOnCacheHit(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + resolver := NewActionResolver(cache) + + // Pre-populate the cache with two entries. + cache.Set("owner/action-a", "v1", "sha_a") + cache.Set("owner/action-b", "v2", "sha_b") + + // Resolve only action-a — it should appear in UsedCacheKeys. + sha, err := resolver.ResolveSHA(context.Background(), "owner/action-a", "v1") + if err != nil { + t.Fatalf("Expected no error for cached entry, got: %v", err) + } + + if sha != "sha_a" { + t.Errorf("Expected sha_a, got %q", sha) + } + + usedKeys := resolver.GetUsedCacheKeys() + if !usedKeys["owner/action-a@v1"] { + t.Error("Expected owner/action-a@v1 to be in used cache keys after a cache hit") + } + if usedKeys["owner/action-b@v2"] { + t.Error("Expected owner/action-b@v2 to be absent from used cache keys (never resolved)") + } +} + +func TestActionResolverGetUsedCacheKeysReturnsCopy(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + resolver := NewActionResolver(cache) + cache.Set("owner/action-a", "v1", "sha_a") + + if _, err := resolver.ResolveSHA(context.Background(), "owner/action-a", "v1"); err != nil { + t.Fatalf("Expected no error resolving cache hit: %v", err) + } + + usedKeys := resolver.GetUsedCacheKeys() + delete(usedKeys, "owner/action-a@v1") + + if !resolver.GetUsedCacheKeys()["owner/action-a@v1"] { + t.Error("Expected resolver used cache keys to be immutable via returned map") + } +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 3f6b8014fb5..48c1359eae0 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -530,6 +530,13 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath } } + // Mark compiler-generated actions as used to prevent pruning. + // This handles actions that are hardcoded in code generators (cache.go, + // checkout_step_generator.go, etc.) rather than resolved from markdown workflows. + if resolver := c.GetSharedActionResolver(); resolver != nil { + resolver.MarkCompilerGeneratedActionsAsUsed() + } + // Write output if err := c.writeWorkflowOutput(lockFile, yamlContent, markdownPath); err != nil { return err diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 88be670510f..39189343685 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -428,6 +428,14 @@ func (c *Compiler) GetSharedActionCache() *ActionCache { return cache } +// GetSharedActionResolver returns the shared action resolver used by this compiler instance. +// The resolver is lazily initialized on first access and shared across all workflows. +// It tracks which cache keys were used during compilation, enabling orphaned-entry pruning. +func (c *Compiler) GetSharedActionResolver() *ActionResolver { + _, resolver := c.getSharedActionResolver() + return resolver +} + // SkipIfMatchConfig holds the configuration for skip-if-match conditions type SkipIfMatchConfig struct { Query string // GitHub search query to check before running workflow diff --git a/pkg/workflow/create_code_scanning_alert.go b/pkg/workflow/create_code_scanning_alert.go index 44b4fbff0f0..09586f87fa2 100644 --- a/pkg/workflow/create_code_scanning_alert.go +++ b/pkg/workflow/create_code_scanning_alert.go @@ -143,7 +143,7 @@ func (c *Compiler) buildCodeScanningUploadJob(data *WorkflowData) (*Job, error) // Step: Upload SARIF file to GitHub Code Scanning. steps = append(steps, " - name: Upload SARIF to GitHub Code Scanning\n") steps = append(steps, fmt.Sprintf(" id: %s\n", constants.UploadCodeScanningJobName)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("github/codeql-action/upload-sarif"))) + steps = append(steps, fmt.Sprintf(" uses: %s\n", c.getActionPin("github/codeql-action/upload-sarif"))) steps = append(steps, " with:\n") // NOTE: github/codeql-action/upload-sarif uses 'token' as the input name, not 'github-token' // Pass restoreToken as the fallback so GitHub App-minted tokens flow through consistently. diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 0a684211ed0..e12149a652f 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -23,16 +23,6 @@ }, "action_description": "Add labels to an issue or a pull request." }, - "actions/ai-inference@v2.1.1": { - "repo": "actions/ai-inference", - "version": "v2.1.1", - "sha": "a7805884c80886efc241e94a5351df715968a0ad" - }, - "actions/attest-build-provenance@v4.1.0": { - "repo": "actions/attest-build-provenance", - "version": "v4.1.0", - "sha": "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" - }, "actions/cache/restore@v5.0.5": { "repo": "actions/cache/restore", "version": "v5.0.5", @@ -68,21 +58,11 @@ "version": "v9.0.0", "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" }, - "actions/setup-dotnet@v5.3.0": { - "repo": "actions/setup-dotnet", - "version": "v5.3.0", - "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" - }, "actions/setup-go@v6.4.0": { "repo": "actions/setup-go", "version": "v6.4.0", "sha": "4a3601121dd01d1626a1e23e37211e3254c1c06c" }, - "actions/setup-java@v5.2.0": { - "repo": "actions/setup-java", - "version": "v5.2.0", - "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" - }, "actions/setup-node@v6.4.0": { "repo": "actions/setup-node", "version": "v6.4.0", @@ -103,21 +83,6 @@ "version": "v0.24.0", "sha": "e22c389904149dbc22b58101806040fa8d37a610" }, - "astral-sh/setup-uv@v8.2.0": { - "repo": "astral-sh/setup-uv", - "version": "v8.2.0", - "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" - }, - "cli/gh-extension-precompile@v2.1.0": { - "repo": "cli/gh-extension-precompile", - "version": "v2.1.0", - "sha": "9e2237c30f869ad3bcaed6a4be2cd43564dd421b" - }, - "denoland/setup-deno@v2.0.4": { - "repo": "denoland/setup-deno", - "version": "v2.0.4", - "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" - }, "docker/build-push-action@v7.2.0": { "repo": "docker/build-push-action", "version": "v7.2.0", @@ -140,41 +105,61 @@ "version": "v4.1.0", "sha": "d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5" }, - "erlef/setup-beam@v1.24.0": { - "repo": "erlef/setup-beam", - "version": "v1.24.0", - "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" - }, "github/codeql-action/upload-sarif@v4.36.2": { "repo": "github/codeql-action/upload-sarif", "version": "v4.36.2", "sha": "8aad20d150bbac5944a9f9d289da16a4b0d87c1e" }, - "github/gh-aw-actions/setup@v0.79.8": { - "repo": "github/gh-aw-actions/setup", - "version": "v0.79.8", - "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + "github/stale-repos@v9.0.14": { + "repo": "github/stale-repos", + "version": "v9.0.14", + "sha": "378f317724a25737b846bd9895bacf5726dd72ff" + }, + "safedep/pmg@v1": { + "repo": "safedep/pmg", + "version": "v1", + "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" + }, + "super-linter/super-linter@v8.6.0": { + "repo": "super-linter/super-linter", + "version": "v8.6.0", + "sha": "9e863354e3ff62e0727d37183162c4a88873df41" + }, + "actions/setup-dotnet@v5.3.0": { + "repo": "actions/setup-dotnet", + "version": "v5.3.0", + "sha": "9a946fdbd5fb07b82b2f5a4466058b876ab72bb2" + }, + "actions/setup-java@v5.2.0": { + "repo": "actions/setup-java", + "version": "v5.2.0", + "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" + }, + "astral-sh/setup-uv@v8.2.0": { + "repo": "astral-sh/setup-uv", + "version": "v8.2.0", + "sha": "fac544c07dec837d0ccb6301d7b5580bf5edae39" + }, + "denoland/setup-deno@v2.0.4": { + "repo": "denoland/setup-deno", + "version": "v2.0.4", + "sha": "667a34cdef165d8d2b2e98dde39547c9daac7282" + }, + "erlef/setup-beam@v1.24.0": { + "repo": "erlef/setup-beam", + "version": "v1.24.0", + "sha": "fc68ffb90438ef2936bbb3251622353b3dcb2f93" }, "github/gh-aw/actions/setup-cli@v0.79.8": { "repo": "github/gh-aw/actions/setup-cli", "version": "v0.79.8", "sha": "8b02ab336d100a5746e9f53b8bc2b22878278a6f" }, - "github/stale-repos@v9.0.14": { - "repo": "github/stale-repos", - "version": "v9.0.14", - "sha": "378f317724a25737b846bd9895bacf5726dd72ff" - }, "haskell-actions/setup@v2.11.0": { "repo": "haskell-actions/setup", "version": "v2.11.0", "sha": "cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553" }, - "microsoft/apm-action@v1.9.1": { - "repo": "microsoft/apm-action", - "version": "v1.9.1", - "sha": "e5650fb81c4b5965090a17bd1ed1956071e95d17" - }, "oven-sh/setup-bun@v2.2.0": { "repo": "oven-sh/setup-bun", "version": "v2.2.0", @@ -184,16 +169,6 @@ "repo": "ruby/setup-ruby", "version": "v1.313.0", "sha": "89f90524b88a01fe6e0b732220432cc6142926af" - }, - "safedep/pmg@v1": { - "repo": "safedep/pmg", - "version": "v1", - "sha": "46cc70db535107183c9e752bb55d1d5c5f1a9290" - }, - "super-linter/super-linter@v8.6.0": { - "repo": "super-linter/super-linter", - "version": "v8.6.0", - "sha": "9e863354e3ff62e0727d37183162c4a88873df41" } }, "containers": {