Skip to content
Merged
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
1 change: 1 addition & 0 deletions pkg/cli/compile_file_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines 223 to 226
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

pruneStaleActionCacheEntries is invoked here for the watch-mode incremental recompilation path, but the initial full compilation path (compileAllWorkflowFiles) still calls saveActionCache without pruning (see pkg/cli/compile_file_operations.go around the saveActionCache call). This means watch mode’s initial compile (compile_watch.go) and the upgrade command can still leave stale gh-aw-actions entries in actions-lock.json until a subsequent file-change recompilation happens. Consider pruning in compileAllWorkflowFiles as well (or centralizing pruning in the shared save helper) so all compilation flows consistently clean up stale entries.

Copilot uses AI. Check for mistakes.
if verbose {
Expand Down
26 changes: 26 additions & 0 deletions pkg/cli/compile_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions pkg/workflow/action_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
107 changes: 107 additions & 0 deletions pkg/workflow/action_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading