diff --git a/pkg/cli/compile_file_operations.go b/pkg/cli/compile_file_operations.go index 8eb20b8047..5b0cdc82f9 100644 --- a/pkg/cli/compile_file_operations.go +++ b/pkg/cli/compile_file_operations.go @@ -221,6 +221,7 @@ func compileModifiedFilesWithDependencies(compiler *workflow.Compiler, depGraph successCount := stats.Total - stats.Errors if actionCache != nil { + pruneStaleActionCacheEntries(compiler, actionCache) if err := actionCache.Save(); err != nil { compileHelpersLog.Printf("Failed to save action cache: %v", err) if verbose { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 9121b88c6a..561d8d1a60 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -475,6 +475,9 @@ func runPostProcessing( // to check for expires fields, so we skip it when compiling specific files to avoid // unnecessary parsing and warnings from unrelated workflows + // Prune stale gh-aw-actions entries before saving + pruneStaleActionCacheEntries(compiler, actionCache) + // Save action cache (errors are logged but non-fatal) _ = saveActionCache(actionCache, config.Verbose) @@ -517,12 +520,35 @@ func runPostProcessingForDirectory( } } + // Prune stale gh-aw-actions entries before saving + pruneStaleActionCacheEntries(compiler, actionCache) + // Save action cache (errors are logged but non-fatal) _ = saveActionCache(actionCache, config.Verbose) return nil } +// pruneStaleActionCacheEntries removes stale gh-aw-actions entries from the +// action cache whose version does not match the compiler's current version. +// This prevents actions-lock.json from accumulating entries for old compiler +// releases that are no longer referenced by any compiled workflow. +func pruneStaleActionCacheEntries(compiler *workflow.Compiler, actionCache *workflow.ActionCache) { + if actionCache == nil { + return + } + + // Determine the effective version: actionTag takes precedence when explicitly + // set (e.g., via --action-tag for testing against a specific release), otherwise + // fall back to the compiler's built-in version from the binary. + version := compiler.GetActionTag() + if version == "" { + version = compiler.GetVersion() + } + + actionCache.PruneStaleGHAWEntries(version, compiler.EffectiveActionsRepo()) +} + // outputResults outputs compilation results in the requested format func outputResults( stats *CompilationStats, diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go index 48ed232e49..67827ee862 100644 --- a/pkg/workflow/action_cache.go +++ b/pkg/workflow/action_cache.go @@ -437,6 +437,51 @@ func (c *ActionCache) deduplicateEntries() { } } +// PruneStaleGHAWEntries removes entries from the cache for the gh-aw-actions +// repository whose version does not match the current compiler version. +// +// When the compiler is updated (e.g., from v0.67.1 to v0.67.3), previously +// compiled workflows referenced setup@v0.67.1 but the new compiler pins to +// setup@v0.67.3. Without pruning, both entries survive in actions-lock.json, +// leaving a stale entry that is never referenced by any compiled lock file. +// +// Only prunes when the current version is a release version (starts with "v"). +// Dev builds, empty versions, and other non-release versions are skipped to +// avoid accidentally removing valid entries during development. +// +// Parameters: +// - currentVersion: the compiler version that is currently in use (e.g., "v0.67.3") +// - actionsRepoPrefix: the org/repo prefix for gh-aw-actions (e.g., "github/gh-aw-actions") +func (c *ActionCache) PruneStaleGHAWEntries(currentVersion string, actionsRepoPrefix string) { + if currentVersion == "" || actionsRepoPrefix == "" { + return + } + // Only prune for clean release versions (e.g., "v0.67.3"), not dev/dirty builds + if !strings.HasPrefix(currentVersion, "v") || strings.Contains(currentVersion, "-") { + return + } + + var toDelete []string + for key, entry := range c.Entries { + if !strings.HasPrefix(entry.Repo, actionsRepoPrefix+"/") { + continue + } + if entry.Version != currentVersion { + actionCacheLog.Printf("Pruning stale gh-aw-actions entry: %s (version %s != current %s)", key, entry.Version, currentVersion) + toDelete = append(toDelete, key) + } + } + + for _, key := range toDelete { + delete(c.Entries, key) + } + + if len(toDelete) > 0 { + c.dirty = true + actionCacheLog.Printf("Pruned %d stale gh-aw-actions entries, %d entries remaining", len(toDelete), len(c.Entries)) + } +} + // isMorePreciseVersion returns true if v1 is more precise than v2 // For example: "v4.3.0" is more precise than "v4" func isMorePreciseVersion(v1, v2 string) bool { diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go index f5bf5acb58..b9c5e86476 100644 --- a/pkg/workflow/action_cache_test.go +++ b/pkg/workflow/action_cache_test.go @@ -612,3 +612,110 @@ func TestActionCacheInputs(t *testing.T) { t.Error("Expected created entry to have the given inputs") } } + +// TestPruneStaleGHAWEntries tests that stale gh-aw-actions entries are pruned +func TestPruneStaleGHAWEntries(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + // Set up a scenario that mirrors the bug: + // - An old setup action entry from a previous compiler version + // - A current setup action entry from the current compiler version + // - A non-gh-aw-actions entry that should be preserved + cache.Set("github/gh-aw-actions/setup", "v0.67.1", "sha_old") + cache.Set("github/gh-aw-actions/setup", "v0.67.3", "sha_new") + cache.Set("actions/checkout", "v5", "sha_checkout") + + if len(cache.Entries) != 3 { + t.Fatalf("Expected 3 entries before pruning, got %d", len(cache.Entries)) + } + + // Prune stale entries for version v0.67.3 + cache.PruneStaleGHAWEntries("v0.67.3", "github/gh-aw-actions") + + // Should have 2 entries: current setup + checkout + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries after pruning, got %d", len(cache.Entries)) + } + + // The old setup entry should be gone + if _, exists := cache.Entries["github/gh-aw-actions/setup@v0.67.1"]; exists { + t.Error("Expected stale setup@v0.67.1 to be pruned") + } + + // The current setup entry should remain + if _, exists := cache.Entries["github/gh-aw-actions/setup@v0.67.3"]; !exists { + t.Error("Expected current setup@v0.67.3 to remain") + } + + // Non-gh-aw-actions entries should remain + if _, exists := cache.Entries["actions/checkout@v5"]; !exists { + t.Error("Expected actions/checkout@v5 to remain") + } +} + +// TestPruneStaleGHAWEntriesMultipleActions tests pruning with multiple gh-aw-actions +func TestPruneStaleGHAWEntriesMultipleActions(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + // Multiple gh-aw-actions at the old version, plus one at the current version + cache.Set("github/gh-aw-actions/setup", "v0.67.1", "sha1") + cache.Set("github/gh-aw-actions/setup", "v0.67.3", "sha2") + cache.Set("github/gh-aw-actions/create-issue", "v0.67.1", "sha3") + cache.Set("github/gh-aw-actions/create-issue", "v0.67.3", "sha4") + cache.Set("actions/checkout", "v5", "sha5") + + cache.PruneStaleGHAWEntries("v0.67.3", "github/gh-aw-actions") + + // Should keep only the v0.67.3 entries + checkout + if len(cache.Entries) != 3 { + t.Errorf("Expected 3 entries after pruning, got %d", len(cache.Entries)) + } + + if _, exists := cache.Entries["github/gh-aw-actions/setup@v0.67.1"]; exists { + t.Error("Expected stale setup@v0.67.1 to be pruned") + } + if _, exists := cache.Entries["github/gh-aw-actions/create-issue@v0.67.1"]; exists { + t.Error("Expected stale create-issue@v0.67.1 to be pruned") + } +} + +// TestPruneStaleGHAWEntriesNoOp tests that pruning is a no-op for non-release or empty versions +func TestPruneStaleGHAWEntriesNoOp(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + cache.Set("github/gh-aw-actions/setup", "v0.67.1", "sha1") + cache.Set("actions/checkout", "v5", "sha2") + + // Should be a no-op for "dev" version (not a release) + cache.PruneStaleGHAWEntries("dev", "github/gh-aw-actions") + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (no pruning for dev), got %d", len(cache.Entries)) + } + + // Should be a no-op for empty version + cache.PruneStaleGHAWEntries("", "github/gh-aw-actions") + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (no pruning for empty version), got %d", len(cache.Entries)) + } + + // Should be a no-op for empty prefix + cache.PruneStaleGHAWEntries("v0.67.3", "") + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (no pruning for empty prefix), got %d", len(cache.Entries)) + } + + // Should be a no-op for dirty dev builds (e.g., "abc123-dirty") + cache.PruneStaleGHAWEntries("abc123-dirty", "github/gh-aw-actions") + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (no pruning for dirty build), got %d", len(cache.Entries)) + } + + // Should be a no-op for dirty release builds (e.g., "v0.67.3-dirty") + cache.PruneStaleGHAWEntries("v0.67.3-dirty", "github/gh-aw-actions") + if len(cache.Entries) != 2 { + t.Errorf("Expected 2 entries (no pruning for dirty release build), got %d", len(cache.Entries)) + } +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 0245de556d..2ed42e82c3 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -247,6 +247,12 @@ func (c *Compiler) effectiveActionsRepo() string { return GitHubActionsOrgRepo } +// EffectiveActionsRepo returns the actions repository used for action mode references. +// Returns the override if set, otherwise returns the default GitHubActionsOrgRepo. +func (c *Compiler) EffectiveActionsRepo() string { + return c.effectiveActionsRepo() +} + // GetVersion returns the version string used by the compiler func (c *Compiler) GetVersion() string { return c.version