diff --git a/pkg/cli/compile_maintenance_test.go b/pkg/cli/compile_maintenance_test.go index fd4dda2dfb9..6f870e841f8 100644 --- a/pkg/cli/compile_maintenance_test.go +++ b/pkg/cli/compile_maintenance_test.go @@ -179,6 +179,266 @@ Test workflow that creates issues without expiration. } } +func TestCompileSpecificFiles_PreservesDisabledImplicitActionFailureExpiry(t *testing.T) { + tempDir := testutil.TempDir(t, "test-*") + workflowsDir := filepath.Join(tempDir, ".github/workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + t.Chdir(tempDir) + + initCmd := exec.Command("git", "init") + initCmd.Dir = tempDir + if err := initCmd.Run(); err != nil { + t.Fatalf("Failed to initialize git repo: %v", err) + } + + workflowContent := `--- +name: "Test Workflow No Expires" +on: + workflow_dispatch: +engine: copilot +--- + +Test workflow without expiration. +` + workflowPath := filepath.Join(workflowsDir, "test-no-expires.md") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + config := CompileConfig{} + if _, err := CompileWorkflows(context.Background(), config); err != nil { + t.Fatalf("Full CompileWorkflows failed: %v", err) + } + + lockPath := filepath.Join(workflowsDir, "test-no-expires.lock.yml") + fullCompileContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read full compile output: %v", err) + } + if !strings.Contains(string(fullCompileContent), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"`) { + t.Fatal("Full compile should disable the implicit action failure expiry") + } + + config.MarkdownFiles = []string{"test-no-expires"} + if _, err := CompileWorkflows(context.Background(), config); err != nil { + t.Fatalf("Targeted CompileWorkflows failed: %v", err) + } + + targetedCompileContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read targeted compile output: %v", err) + } + if string(targetedCompileContent) != string(fullCompileContent) { + t.Fatal("Targeted compile should produce the same lock file as a full compile") + } +} + +// TestCompileSpecificFiles_PreservesExplicitActionFailureExpiry verifies that a +// targeted compile does not zero out an explicitly configured +// maintenance.action_failure_issue_expires value, even when no +// agentics-maintenance.yml exists yet (the first-compile case). +func TestCompileSpecificFiles_PreservesExplicitActionFailureExpiry(t *testing.T) { + tempDir := testutil.TempDir(t, "test-*") + workflowsDir := filepath.Join(tempDir, ".github/workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + t.Chdir(tempDir) + + initCmd := exec.Command("git", "init") + initCmd.Dir = tempDir + if err := initCmd.Run(); err != nil { + t.Fatalf("Failed to initialize git repo: %v", err) + } + + awConfig := `{ + "maintenance": { + "action_failure_issue_expires": 48 + } +} +` + if err := os.WriteFile(filepath.Join(workflowsDir, "aw.json"), []byte(awConfig), 0644); err != nil { + t.Fatalf("Failed to write aw.json: %v", err) + } + + workflowContent := `--- +name: "Test Workflow No Expires" +on: + workflow_dispatch: +engine: copilot +--- + +Test workflow without expiration. +` + workflowPath := filepath.Join(workflowsDir, "test-no-expires.md") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Targeted compile, with no agentics-maintenance.yml present yet. + config := CompileConfig{MarkdownFiles: []string{"test-no-expires"}} + if _, err := CompileWorkflows(context.Background(), config); err != nil { + t.Fatalf("Targeted CompileWorkflows failed: %v", err) + } + + lockPath := filepath.Join(workflowsDir, "test-no-expires.lock.yml") + content, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read targeted compile output: %v", err) + } + if !strings.Contains(string(content), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "48"`) { + t.Fatal("Targeted compile must preserve the explicit 48-hour action failure expiry, not reset it to 0") + } +} + +// TestCompileSpecificFiles_PatchesDirectPathOutsideDefaultWorkflowDir verifies +// that a targeted compile of a direct file path outside the default workflow +// directory patches the lock file actually emitted next to that input, and +// does not touch an unrelated lock file of the same workflow ID in the +// default workflow directory. +func TestCompileSpecificFiles_PatchesDirectPathOutsideDefaultWorkflowDir(t *testing.T) { + tempDir := testutil.TempDir(t, "test-*") + workflowsDir := filepath.Join(tempDir, ".github/workflows") + customDir := filepath.Join(tempDir, "custom") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + if err := os.MkdirAll(customDir, 0755); err != nil { + t.Fatalf("Failed to create custom directory: %v", err) + } + + t.Chdir(tempDir) + + initCmd := exec.Command("git", "init") + initCmd.Dir = tempDir + if err := initCmd.Run(); err != nil { + t.Fatalf("Failed to initialize git repo: %v", err) + } + + workflowContent := `--- +name: "Test Workflow No Expires" +on: + workflow_dispatch: +engine: copilot +--- + +Test workflow without expiration. +` + // Same workflow ID (derived from base file name) in both the default + // workflow directory and an unrelated custom directory. + defaultPath := filepath.Join(workflowsDir, "foo.md") + if err := os.WriteFile(defaultPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write default workflow file: %v", err) + } + customPath := filepath.Join(customDir, "foo.md") + if err := os.WriteFile(customPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write custom workflow file: %v", err) + } + + // Seed the default lock file with an explicit non-zero marker to make sure + // it is left untouched by the targeted compile of the unrelated custom path. + defaultLockPath := filepath.Join(workflowsDir, "foo.lock.yml") + sentinelContent := "GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: \"168\"\n" + if err := os.WriteFile(defaultLockPath, []byte(sentinelContent), 0644); err != nil { + t.Fatalf("Failed to seed default lock file: %v", err) + } + + // Targeted compile of the direct path outside the default workflow directory. + config := CompileConfig{MarkdownFiles: []string{customPath}} + if _, err := CompileWorkflows(context.Background(), config); err != nil { + t.Fatalf("Targeted CompileWorkflows failed: %v", err) + } + + customLockPath := filepath.Join(customDir, "foo.lock.yml") + customLockContent, err := os.ReadFile(customLockPath) + if err != nil { + t.Fatalf("Failed to read custom compile output: %v", err) + } + if !strings.Contains(string(customLockContent), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"`) { + t.Fatal("Compiling a direct path outside the default workflow directory should still reconcile its own lock file") + } + + // The unrelated default-directory lock file (sharing the same workflow ID) + // must remain untouched by the targeted compile of the custom path. + defaultLockContent, err := os.ReadFile(defaultLockPath) + if err != nil { + t.Fatalf("Failed to read default lock file: %v", err) + } + if string(defaultLockContent) != sentinelContent { + t.Fatal("Targeted compile of a direct path must not rewrite an unrelated lock file with the same workflow ID") + } +} + +// TestCompileSpecificFiles_DisablesExpiryWhenCloseExpiredJobDisabled verifies +// that the implicit action-failure expiry marker is disabled when +// agentics-maintenance.yml exists but its close-expired-entities job has been +// disabled via maintenance.disabled_jobs, since nothing would consume the marker. +func TestCompileSpecificFiles_DisablesExpiryWhenCloseExpiredJobDisabled(t *testing.T) { + tempDir := testutil.TempDir(t, "test-*") + workflowsDir := filepath.Join(tempDir, ".github/workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + t.Chdir(tempDir) + + initCmd := exec.Command("git", "init") + initCmd.Dir = tempDir + if err := initCmd.Run(); err != nil { + t.Fatalf("Failed to initialize git repo: %v", err) + } + + awConfig := `{ + "maintenance": { + "disabled_jobs": ["close-expired-entities"] + } +} +` + if err := os.WriteFile(filepath.Join(workflowsDir, "aw.json"), []byte(awConfig), 0644); err != nil { + t.Fatalf("Failed to write aw.json: %v", err) + } + + // A pre-existing maintenance workflow file, present but (per aw.json above) + // not actually running the close-expired-entities job. + maintenancePath := filepath.Join(workflowsDir, "agentics-maintenance.yml") + maintenanceContent := "name: agentics-maintenance\non:\n schedule:\n - cron: '37 0 * * *'\n" + if err := os.WriteFile(maintenancePath, []byte(maintenanceContent), 0644); err != nil { + t.Fatalf("Failed to create maintenance workflow: %v", err) + } + + workflowContent := `--- +name: "Test Workflow No Expires" +on: + workflow_dispatch: +engine: copilot +--- + +Test workflow without expiration. +` + workflowPath := filepath.Join(workflowsDir, "test-no-expires.md") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + config := CompileConfig{MarkdownFiles: []string{"test-no-expires"}} + if _, err := CompileWorkflows(context.Background(), config); err != nil { + t.Fatalf("Targeted CompileWorkflows failed: %v", err) + } + + lockPath := filepath.Join(workflowsDir, "test-no-expires.lock.yml") + content, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read targeted compile output: %v", err) + } + if !strings.Contains(string(content), `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "0"`) { + t.Fatal("Implicit expiry marker should be disabled when the close-expired-entities job is disabled, even if agentics-maintenance.yml exists") + } +} + func TestCompileWithCustomDir_SkipsMaintenanceWorkflow(t *testing.T) { // Create temporary directory structure tempDir := testutil.TempDir(t, "test-*") diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 8c7d7043abe..ffbd148f15f 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -78,6 +78,7 @@ func compileSpecificFiles( //nolint:largefunc // Orchestrates the full targeted var lockFilesForYamllint []string // lock files for yamllint YAML linter var lockFilesForShellcheck []string // lock files for shellcheck run step linting var shellcheckResources []workflow.ShellScriptResource + var compiledLockFiles []string // every lock file actually emitted, regardless of which lint tools are enabled // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -155,6 +156,7 @@ func compileSpecificFiles( //nolint:largefunc // Orchestrates the full targeted // Collect lock files for batch security tools if !config.NoEmit && fileResult.lockFile != "" { if _, err := os.Stat(fileResult.lockFile); err == nil { + compiledLockFiles = append(compiledLockFiles, fileResult.lockFile) if config.Actionlint { lockFilesForActionlint = append(lockFilesForActionlint, fileResult.lockFile) } @@ -324,7 +326,7 @@ func compileSpecificFiles( //nolint:largefunc // Orchestrates the full targeted displaySafeUpdateWarnings(compiler, config.JSONOutput) // Post-processing - if err := runPostProcessing(compiler, workflowDataList, config, compiledCount); err != nil { + if err := runPostProcessing(compiler, workflowDataList, compiledLockFiles, config, compiledCount); err != nil { return workflowDataList, err } @@ -828,6 +830,7 @@ func runPurgeOperations(workflowsDir string, data *purgeTrackingData, verbose bo func runPostProcessing( compiler *workflow.Compiler, workflowDataList []*workflow.WorkflowData, + compiledLockFiles []string, config CompileConfig, successCount int, ) error { @@ -855,12 +858,23 @@ func runPostProcessing( } } - // Generate maintenance workflow if needed - // Only generate when compiling all workflows (not specific files) - // Skip when using custom --dir option or when compiling specific files - // Note: Maintenance workflow generation requires parsing all workflows in the directory - // to check for expires fields, so we skip it when compiling specific files to avoid - // unnecessary parsing and warnings from unrelated workflows + // Reconcile the implicit action-failure expiry marker so specific-file + // compiles agree with what a full directory compile would produce. + // Maintenance workflow generation itself is skipped for specific-file + // compiles because it requires parsing every workflow in the directory to + // check for expires fields; only reconcile when using the default + // workflow directory (custom --dir compiles and --no-emit compiles are + // left untouched). + if !config.NoEmit && config.WorkflowDir == "" && len(compiledLockFiles) > 0 { + if gitRoot, err := gitutil.FindGitRoot(); err == nil { + absWorkflowDir := getAbsoluteWorkflowDir(getWorkflowsDir(), gitRoot) + repoConfig, err := workflow.LoadRepoConfig(gitRoot) + if err != nil { + repoConfig = nil + } + workflow.DisableDefaultActionFailureExpiryMarkersIfUnenforced(compiledLockFiles, absWorkflowDir, repoConfig) + } + } // Prune stale gh-aw-actions entries before saving pruneStaleActionCacheEntries(compiler, actionCache) diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index df2d315803d..91e7d7efb89 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -518,11 +518,25 @@ func scanWorkflowsForExpires(workflowDataList []*WorkflowData, repoConfig *RepoC // are never affected by this function, because an explicit configuration // always makes scanWorkflowsForExpires report hasExpires=true. func disableDefaultActionFailureExpiryMarkers(workflowDataList []*WorkflowData, workflowDir string) { + var lockFiles []string for _, workflowData := range workflowDataList { if workflowData == nil || workflowData.WorkflowID == "" { continue } - lockFile := filepath.Join(workflowDir, workflowData.WorkflowID+".lock.yml") + lockFiles = append(lockFiles, filepath.Join(workflowDir, workflowData.WorkflowID+".lock.yml")) + } + patchActionFailureExpiryMarkersInFiles(lockFiles) +} + +// patchActionFailureExpiryMarkersInFiles disables the implicit action-failure +// expiry marker (see actionFailureIssueExpiryLineRegex) in each of the given +// lock files, in place. Missing files (e.g. from --no-emit compiles) are +// skipped without error. +func patchActionFailureExpiryMarkersInFiles(lockFiles []string) { + for _, lockFile := range lockFiles { + if lockFile == "" { + continue + } content, err := os.ReadFile(lockFile) if err != nil { // Lock file may not exist (e.g. --no-emit compiles); nothing to patch. diff --git a/pkg/workflow/maintenance_workflow_expiry.go b/pkg/workflow/maintenance_workflow_expiry.go new file mode 100644 index 00000000000..624b6f263bd --- /dev/null +++ b/pkg/workflow/maintenance_workflow_expiry.go @@ -0,0 +1,43 @@ +package workflow + +import ( + "os" + "path/filepath" +) + +// DisableDefaultActionFailureExpiryMarkersIfUnenforced disables implicit +// action-failure expiry markers in lockFiles when no maintenance workflow can +// be relied on to consume them. lockFiles must be the actual emitted lock +// file paths for the compiled workflows (not reconstructed from workflowDir), +// so that targets outside the default workflow directory are patched +// correctly. repoConfig may be nil. +// +// An explicit maintenance.action_failure_issue_expires in aw.json always +// opts a full directory compile into maintenance workflow generation (see +// IsActionFailureIssueExpiresExplicit), so this function leaves markers +// untouched whenever that is set, regardless of maintenance workflow state. +func DisableDefaultActionFailureExpiryMarkersIfUnenforced(lockFiles []string, workflowDir string, repoConfig *RepoConfig) { + if repoConfig.IsActionFailureIssueExpiresExplicit() { + return + } + if isActionFailureExpiryEnforced(workflowDir, repoConfig) { + return + } + patchActionFailureExpiryMarkersInFiles(lockFiles) +} + +// isActionFailureExpiryEnforced reports whether a maintenance workflow exists +// and is configured to run the close-expired-entities job that consumes the +// action-failure expiry marker. repoConfig may be nil. +func isActionFailureExpiryEnforced(workflowDir string, repoConfig *RepoConfig) bool { + if repoConfig != nil && repoConfig.MaintenanceDisabled { + return false + } + if _, err := os.Stat(filepath.Join(workflowDir, "agentics-maintenance.yml")); os.IsNotExist(err) { + return false + } + if repoConfig != nil && repoConfig.Maintenance != nil && repoConfig.Maintenance.IsJobDisabled("close-expired-entities") { + return false + } + return true +}