From 324cf27b7591db7f7f31477a8900b8150c66d06f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 05:22:20 +0000 Subject: [PATCH 1/5] Initial plan From d9b42892148cf6138b83524d653c6ccbdade3b00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 05:33:05 +0000 Subject: [PATCH 2/5] chore: initial plan for context propagation in action SHA resolution Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c4820d8f-3c5c-401a-b1fc-d5fe0662fc5f Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/actionpins/data/action_pins.json | 15 +++++++++++++++ pkg/workflow/data/action_pins.json | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pkg/actionpins/data/action_pins.json b/pkg/actionpins/data/action_pins.json index 8a76c8303d5..1c1d3e4cd89 100644 --- a/pkg/actionpins/data/action_pins.json +++ b/pkg/actionpins/data/action_pins.json @@ -38,6 +38,11 @@ "version": "v5.0.5", "sha": "27d5ce7f107fe9357f9df03efb73ab90386fccae" }, + "actions/cache/save@v4": { + "repo": "actions/cache/save", + "version": "v4", + "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" + }, "actions/cache/save@v5.0.5": { "repo": "actions/cache/save", "version": "v5.0.5", @@ -48,6 +53,11 @@ "version": "v5.0.5", "sha": "27d5ce7f107fe9357f9df03efb73ab90386fccae" }, + "actions/checkout@v4": { + "repo": "actions/checkout", + "version": "v4", + "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" + }, "actions/checkout@v6.0.2": { "repo": "actions/checkout", "version": "v6.0.2", @@ -88,6 +98,11 @@ "version": "v6.4.0", "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e" }, + "actions/setup-python@v5": { + "repo": "actions/setup-python", + "version": "v5", + "sha": "a26af69be951a213d495a4c3e4e4022e16d87065" + }, "actions/setup-python@v6.2.0": { "repo": "actions/setup-python", "version": "v6.2.0", diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 8a76c8303d5..1c1d3e4cd89 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -38,6 +38,11 @@ "version": "v5.0.5", "sha": "27d5ce7f107fe9357f9df03efb73ab90386fccae" }, + "actions/cache/save@v4": { + "repo": "actions/cache/save", + "version": "v4", + "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" + }, "actions/cache/save@v5.0.5": { "repo": "actions/cache/save", "version": "v5.0.5", @@ -48,6 +53,11 @@ "version": "v5.0.5", "sha": "27d5ce7f107fe9357f9df03efb73ab90386fccae" }, + "actions/checkout@v4": { + "repo": "actions/checkout", + "version": "v4", + "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" + }, "actions/checkout@v6.0.2": { "repo": "actions/checkout", "version": "v6.0.2", @@ -88,6 +98,11 @@ "version": "v6.4.0", "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e" }, + "actions/setup-python@v5": { + "repo": "actions/setup-python", + "version": "v5", + "sha": "a26af69be951a213d495a4c3e4e4022e16d87065" + }, "actions/setup-python@v6.2.0": { "repo": "actions/setup-python", "version": "v6.2.0", From 2559577dbb1f1c7d6f1e5bd75300a7554ab31024 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 05:56:42 +0000 Subject: [PATCH 3/5] fix: propagate context in action SHA resolution to enable timeout/cancellation - Thread ctx through add command call chain: addWorkflows/addWorkflowsWithTracking/addWorkflowWithTracking/addWorkflowsWithPR now accept ctx fetchAllRemoteDependencies now uses propagated ctx instead of context.Background() - Add ctx to SHA checker functions: CheckActionSHAUpdates and ValidateActionSHAsInLockFile accept ctx Both ValidateActionSHAsInLockFile calls in compile_validation.go propagate ctx - Thread ctx through compile pipeline: CompileWorkflowWithValidation and CompileWorkflowDataWithValidation accept ctx compileWorkflowFile, compileSpecificFiles, compileAllFilesInDirectory accept ctx runPostProcessing, runPostProcessingForDirectory, generateMaintenanceWorkflowWrapper accept ctx - Add ctx to maintenance workflow path: resolveActionRef, FetchDefaultBranch, GenerateMaintenanceWorkflow accept ctx buildMaintenanceWorkflowYAML, generateInstallCLISteps accept ctx generateAllSideRepoMaintenanceWorkflows, generateSideRepoMaintenanceWorkflow accept ctx InitRepository and ensureMaintenanceWorkflow accept ctx - Add ctx to action reference path: ResolveSetupActionReference and resolveSetupActionRef accept ctx resolver.ResolveSHA calls now use propagated ctx Compiler struct gains ctx field with SetContext method and context() accessor - Add context propagation to compilation wrappers: compileWorkflow, compileWorkflowWithRefresh, compileWorkflowWithTracking accept ctx - Add test for context cancellation in CheckActionSHAUpdates Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c4820d8f-3c5c-401a-b1fc-d5fe0662fc5f Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/add_command.go | 28 +++---- pkg/cli/add_command_test.go | 5 +- pkg/cli/add_gitattributes_test.go | 7 +- pkg/cli/add_interactive_git.go | 2 +- pkg/cli/add_workflow_compilation.go | 23 +++--- pkg/cli/add_workflow_pr.go | 5 +- pkg/cli/compile_file_operations.go | 3 +- pkg/cli/compile_guard_policy_test.go | 17 +++-- pkg/cli/compile_orchestrator.go | 4 +- pkg/cli/compile_pipeline.go | 15 ++-- pkg/cli/compile_post_processing.go | 4 +- pkg/cli/compile_security_benchmark_test.go | 13 ++-- pkg/cli/compile_update_discussion_test.go | 3 +- pkg/cli/compile_validation.go | 15 +++- pkg/cli/compile_workflow_processor.go | 4 +- pkg/cli/enable.go | 3 +- pkg/cli/error_formatting_test.go | 3 +- pkg/cli/file_tracker_test.go | 5 +- pkg/cli/init.go | 9 ++- pkg/cli/init_command.go | 2 +- pkg/cli/init_command_test.go | 31 ++++---- pkg/cli/init_mcp_test.go | 7 +- pkg/cli/init_test.go | 11 +-- pkg/cli/interfaces_test.go | 5 +- pkg/cli/update_actions.go | 2 +- pkg/cli/update_command_test.go | 4 +- pkg/cli/update_workflows.go | 2 +- pkg/workflow/action_reference.go | 17 +++-- pkg/workflow/action_reference_test.go | 7 +- pkg/workflow/action_sha_checker.go | 8 +- .../action_sha_checker_integration_test.go | 9 ++- pkg/workflow/action_sha_checker_test.go | 36 ++++++++- pkg/workflow/action_sha_validation_test.go | 3 +- pkg/workflow/compiler_custom_actions_test.go | 11 +-- pkg/workflow/compiler_types.go | 18 +++++ pkg/workflow/maintenance_workflow.go | 24 +++--- pkg/workflow/maintenance_workflow_test.go | 73 ++++++++++--------- pkg/workflow/maintenance_workflow_yaml.go | 14 ++-- pkg/workflow/side_repo_maintenance.go | 13 ++-- 39 files changed, 280 insertions(+), 185 deletions(-) diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 85adfc43f1d..d9cc75f9c36 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -184,13 +184,13 @@ func AddWorkflows(ctx context.Context, workflows []string, opts AddOptions) (*Ad return nil, err } - return AddResolvedWorkflows(workflows, resolved, opts) + return AddResolvedWorkflows(ctx, workflows, resolved, opts) } // AddResolvedWorkflows adds workflows using pre-resolved workflow data. // This allows callers to resolve workflows early (e.g., to show descriptions) and then add them later. // The opts.Quiet parameter suppresses detailed output (useful for interactive mode where output is already shown). -func AddResolvedWorkflows(workflowStrings []string, resolved *ResolvedWorkflows, opts AddOptions) (*AddWorkflowsResult, error) { +func AddResolvedWorkflows(ctx context.Context, workflowStrings []string, resolved *ResolvedWorkflows, opts AddOptions) (*AddWorkflowsResult, error) { addLog.Printf("Adding workflows: count=%d, engineOverride=%s, createPR=%v, noGitattributes=%v, opts.WorkflowDir=%s, noStopAfter=%v, stopAfter=%s", len(workflowStrings), opts.EngineOverride, opts.CreatePR, opts.NoGitattributes, opts.WorkflowDir, opts.NoStopAfter, opts.StopAfter) result := &AddWorkflowsResult{} @@ -222,7 +222,7 @@ func AddResolvedWorkflows(workflowStrings []string, resolved *ResolvedWorkflows, // Handle PR creation workflow if opts.CreatePR { addLog.Print("Creating workflow with PR") - prNumber, prURL, err := addWorkflowsWithPR(resolved.Workflows, opts) + prNumber, prURL, err := addWorkflowsWithPR(ctx, resolved.Workflows, opts) if err != nil { return nil, err } @@ -233,11 +233,11 @@ func AddResolvedWorkflows(workflowStrings []string, resolved *ResolvedWorkflows, // Handle normal workflow addition - pass resolved workflows with content addLog.Print("Adding workflows normally without PR") - return result, addWorkflows(resolved.Workflows, opts) + return result, addWorkflows(ctx, resolved.Workflows, opts) } // addWorkflows handles workflow addition using pre-fetched content -func addWorkflows(workflows []*ResolvedWorkflow, opts AddOptions) error { +func addWorkflows(ctx context.Context, workflows []*ResolvedWorkflow, opts AddOptions) error { addLog.Printf("Adding %d workflow(s) to repository", len(workflows)) // Create file tracker for all operations tracker, err := NewFileTracker() @@ -248,11 +248,11 @@ func addWorkflows(workflows []*ResolvedWorkflow, opts AddOptions) error { } tracker = nil } - return addWorkflowsWithTracking(workflows, tracker, opts) + return addWorkflowsWithTracking(ctx, workflows, tracker, opts) } -// addWorkflows handles workflow addition using pre-fetched content -func addWorkflowsWithTracking(workflows []*ResolvedWorkflow, tracker *FileTracker, opts AddOptions) error { +// addWorkflowsWithTracking handles workflow addition using pre-fetched content +func addWorkflowsWithTracking(ctx context.Context, workflows []*ResolvedWorkflow, tracker *FileTracker, opts AddOptions) error { addLog.Printf("Adding %d workflow(s) with tracking: force=%v, disableSecurityScanner=%v", len(workflows), opts.Force, opts.DisableSecurityScanner) // Ensure .gitattributes is configured unless flag is set if !opts.NoGitattributes { @@ -278,7 +278,7 @@ func addWorkflowsWithTracking(workflows []*ResolvedWorkflow, tracker *FileTracke fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Adding workflow %d/%d: %s", i+1, len(workflows), resolved.Spec.WorkflowName))) } - if err := addWorkflowWithTracking(resolved, tracker, opts); err != nil { + if err := addWorkflowWithTracking(ctx, resolved, tracker, opts); err != nil { return fmt.Errorf("failed to add workflow '%s': %w", resolved.Spec.String(), err) } } @@ -291,7 +291,7 @@ func addWorkflowsWithTracking(workflows []*ResolvedWorkflow, tracker *FileTracke } // addWorkflowWithTracking adds a workflow using pre-fetched content with file tracking -func addWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, opts AddOptions) error { +func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tracker *FileTracker, opts AddOptions) error { workflowSpec := resolved.Spec sourceContent := resolved.Content sourceInfo := resolved.SourceInfo @@ -363,7 +363,7 @@ func addWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, o // For remote workflows, fetch and save all dependencies (includes, imports, dispatch workflows, resources) if !isLocalWorkflowPath(workflowSpec.WorkflowPath) { - if err := fetchAllRemoteDependencies(context.Background(), string(sourceContent), workflowSpec, githubWorkflowsDir, opts.Verbose, opts.Force, tracker); err != nil { + if err := fetchAllRemoteDependencies(ctx, string(sourceContent), workflowSpec, githubWorkflowsDir, opts.Verbose, opts.Force, tracker); err != nil { return err } } else if sourceInfo != nil && sourceInfo.IsLocal { @@ -536,15 +536,15 @@ func addWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, o // Compile any dispatch-workflow .md dependencies that were just fetched and lack a // .lock.yml. The dispatch-workflow validator requires every .md dispatch target to be // compiled before the main workflow can be validated. - compileDispatchWorkflowDependencies(destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker) + compileDispatchWorkflowDependencies(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker) // Compile the workflow if tracker != nil { - if err := compileWorkflowWithTracking(destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker); err != nil { + if err := compileWorkflowWithTracking(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorChain(err)) } } else { - if err := compileWorkflow(destFile, opts.Verbose, opts.Quiet, opts.EngineOverride); err != nil { + if err := compileWorkflow(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorChain(err)) } } diff --git a/pkg/cli/add_command_test.go b/pkg/cli/add_command_test.go index 0cef08b9204..61b1e720f04 100644 --- a/pkg/cli/add_command_test.go +++ b/pkg/cli/add_command_test.go @@ -151,6 +151,7 @@ func TestAddResolvedWorkflows(t *testing.T) { opts := AddOptions{} _, err := AddResolvedWorkflows( + context.Background(), []string{"test/repo/test-workflow"}, resolved, opts, @@ -461,7 +462,7 @@ func TestAddWorkflowWithTracking_SourceFieldVariants(t *testing.T) { } opts := AddOptions{DisableSecurityScanner: true} - err := addWorkflowWithTracking(resolved, nil, opts) + err := addWorkflowWithTracking(context.Background(), resolved, nil, opts) require.NoError(t, err, "addWorkflowWithTracking should succeed") written, err := os.ReadFile(filepath.Join(workflowsDir, tt.spec.WorkflowName+".md")) @@ -515,7 +516,7 @@ func TestAddWorkflowWithTracking_UsesActualFetchedPath(t *testing.T) { opts := AddOptions{ DisableSecurityScanner: true, } - err := addWorkflowWithTracking(resolved, nil, opts) + err := addWorkflowWithTracking(context.Background(), resolved, nil, opts) require.NoError(t, err, "addWorkflowWithTracking should succeed") // Read the written file diff --git a/pkg/cli/add_gitattributes_test.go b/pkg/cli/add_gitattributes_test.go index c4c80c7f0ad..a502e01e4d8 100644 --- a/pkg/cli/add_gitattributes_test.go +++ b/pkg/cli/add_gitattributes_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "os/exec" "path/filepath" @@ -89,7 +90,7 @@ This is a test workflow.` // Call addWorkflows with noGitattributes=false opts := AddOptions{} - err := addWorkflows([]*ResolvedWorkflow{resolved}, opts) + err := addWorkflows(context.Background(), []*ResolvedWorkflow{resolved}, opts) if err != nil { // Log any error but don't fail - we're testing gitattributes behavior t.Logf("Note: workflow addition returned: %v", err) @@ -119,7 +120,7 @@ This is a test workflow.` opts := AddOptions{NoGitattributes: true} // Call addWorkflows with noGitattributes=true - err := addWorkflows([]*ResolvedWorkflow{resolved}, opts) + err := addWorkflows(context.Background(), []*ResolvedWorkflow{resolved}, opts) if err != nil { // Log any error but don't fail - we're testing gitattributes behavior t.Logf("Note: workflow addition returned: %v", err) @@ -142,7 +143,7 @@ This is a test workflow.` opts := AddOptions{NoGitattributes: true} // Call addWorkflows with noGitattributes=true - err := addWorkflows([]*ResolvedWorkflow{resolved}, opts) + err := addWorkflows(context.Background(), []*ResolvedWorkflow{resolved}, opts) if err != nil { // Log any error but don't fail - we're testing gitattributes behavior t.Logf("Note: workflow addition returned: %v", err) diff --git a/pkg/cli/add_interactive_git.go b/pkg/cli/add_interactive_git.go index 3ab419d1b81..2983ec4d8cc 100644 --- a/pkg/cli/add_interactive_git.go +++ b/pkg/cli/add_interactive_git.go @@ -39,7 +39,7 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co StopAfter: c.StopAfter, DisableSecurityScanner: false, } - result, err := AddResolvedWorkflows(c.WorkflowSpecs, c.resolvedWorkflows, opts) + result, err := AddResolvedWorkflows(ctx, c.WorkflowSpecs, c.resolvedWorkflows, opts) if err != nil { return fmt.Errorf("failed to add workflow: %w", err) } diff --git a/pkg/cli/add_workflow_compilation.go b/pkg/cli/add_workflow_compilation.go index 13573c733bb..c31d28c7619 100644 --- a/pkg/cli/add_workflow_compilation.go +++ b/pkg/cli/add_workflow_compilation.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "os" "path/filepath" @@ -16,13 +17,13 @@ var addWorkflowCompilationLog = logger.New("cli:add_workflow_compilation") // compileWorkflow compiles a workflow file without refreshing stop time. // This is a convenience wrapper around compileWorkflowWithRefresh. -func compileWorkflow(filePath string, verbose bool, quiet bool, engineOverride string) error { - return compileWorkflowWithRefresh(filePath, verbose, quiet, engineOverride, false) +func compileWorkflow(ctx context.Context, filePath string, verbose bool, quiet bool, engineOverride string) error { + return compileWorkflowWithRefresh(ctx, filePath, verbose, quiet, engineOverride, false) } // compileWorkflowWithRefresh compiles a workflow file with optional stop time refresh. // This function handles the compilation process and ensures .gitattributes is updated. -func compileWorkflowWithRefresh(filePath string, verbose bool, quiet bool, engineOverride string, refreshStopTime bool) error { +func compileWorkflowWithRefresh(ctx context.Context, filePath string, verbose bool, quiet bool, engineOverride string, refreshStopTime bool) error { addWorkflowCompilationLog.Printf("Compiling workflow: file=%s, refresh_stop_time=%v, engine=%s", filePath, refreshStopTime, engineOverride) // Create compiler with auto-detected version and action mode @@ -33,7 +34,7 @@ func compileWorkflowWithRefresh(filePath string, verbose bool, quiet bool, engin compiler.SetRefreshStopTime(refreshStopTime) compiler.SetQuiet(quiet) - if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { + if err := CompileWorkflowWithValidation(ctx, compiler, filePath, verbose, false, false, false, false, false); err != nil { addWorkflowCompilationLog.Printf("Compilation failed: %v", err) return err } @@ -55,13 +56,13 @@ func compileWorkflowWithRefresh(filePath string, verbose bool, quiet bool, engin // compileWorkflowWithTracking compiles a workflow and tracks generated files. // This is a convenience wrapper around compileWorkflowWithTrackingAndRefresh. -func compileWorkflowWithTracking(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker) error { - return compileWorkflowWithTrackingAndRefresh(filePath, verbose, quiet, engineOverride, tracker, false) +func compileWorkflowWithTracking(ctx context.Context, filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker) error { + return compileWorkflowWithTrackingAndRefresh(ctx, filePath, verbose, quiet, engineOverride, tracker, false) } // compileWorkflowWithTrackingAndRefresh compiles a workflow, tracks generated files, and optionally refreshes stop time. // This function ensures that the file tracker records all files created or modified during compilation. -func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker, refreshStopTime bool) error { +func compileWorkflowWithTrackingAndRefresh(ctx context.Context, filePath string, verbose bool, quiet bool, engineOverride string, tracker *FileTracker, refreshStopTime bool) error { addWorkflowCompilationLog.Printf("Compiling workflow with tracking: file=%s, refresh_stop_time=%v", filePath, refreshStopTime) // Generate the expected lock file path @@ -102,7 +103,7 @@ func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet compiler.SetFileTracker(tracker) compiler.SetRefreshStopTime(refreshStopTime) compiler.SetQuiet(quiet) - if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil { + if err := CompileWorkflowWithValidation(ctx, compiler, filePath, verbose, false, false, false, false, false); err != nil { return err } @@ -128,7 +129,7 @@ func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet // workflowFile that are present locally but lack a corresponding .lock.yml. This must be // called before compiling the main workflow, because the dispatch-workflow validator // requires every referenced .md workflow to have an up-to-date .lock.yml. -func compileDispatchWorkflowDependencies(workflowFile string, verbose, quiet bool, engineOverride string, tracker *FileTracker) { +func compileDispatchWorkflowDependencies(ctx context.Context, workflowFile string, verbose, quiet bool, engineOverride string, tracker *FileTracker) { // Parse the merged safe-outputs to get the canonical list of dispatch-workflow names. compiler := workflow.NewCompiler() data, err := compiler.ParseWorkflowFile(workflowFile) @@ -157,9 +158,9 @@ func compileDispatchWorkflowDependencies(workflowFile string, verbose, quiet boo var compileErr error if tracker != nil { - compileErr = compileWorkflowWithTracking(mdPath, verbose, quiet, engineOverride, tracker) + compileErr = compileWorkflowWithTracking(ctx, mdPath, verbose, quiet, engineOverride, tracker) } else { - compileErr = compileWorkflow(mdPath, verbose, quiet, engineOverride) + compileErr = compileWorkflow(ctx, mdPath, verbose, quiet, engineOverride) } if compileErr != nil { // Best-effort: log and continue so the main workflow can still give a clear error. diff --git a/pkg/cli/add_workflow_pr.go b/pkg/cli/add_workflow_pr.go index 54df1f6adda..3e974a10a5a 100644 --- a/pkg/cli/add_workflow_pr.go +++ b/pkg/cli/add_workflow_pr.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "math/rand" "os" @@ -45,7 +46,7 @@ func sanitizeBranchName(name string) string { } // addWorkflowsWithPR handles workflow addition with PR creation using pre-resolved workflows. -func addWorkflowsWithPR(workflows []*ResolvedWorkflow, opts AddOptions) (int, string, error) { +func addWorkflowsWithPR(ctx context.Context, workflows []*ResolvedWorkflow, opts AddOptions) (int, string, error) { addWorkflowPRLog.Printf("Adding %d workflow(s) with PR creation (resolved)", len(workflows)) // Get current branch for restoration later @@ -86,7 +87,7 @@ func addWorkflowsWithPR(workflows []*ResolvedWorkflow, opts AddOptions) (int, st addWorkflowPRLog.Print("Adding workflows to repository") prOpts := opts prOpts.DisableSecurityScanner = false - if err := addWorkflowsWithTracking(workflows, tracker, prOpts); err != nil { + if err := addWorkflowsWithTracking(ctx, workflows, tracker, prOpts); err != nil { addWorkflowPRLog.Printf("Failed to add workflows: %v", err) // Rollback on error if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose { diff --git a/pkg/cli/compile_file_operations.go b/pkg/cli/compile_file_operations.go index d8eec5f0101..5f5cfcfd573 100644 --- a/pkg/cli/compile_file_operations.go +++ b/pkg/cli/compile_file_operations.go @@ -35,6 +35,7 @@ package cli import ( + "context" "fmt" "os" "path/filepath" @@ -68,7 +69,7 @@ func compileSingleFile(compiler *workflow.Compiler, file string, stats *Compilat fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Compiling: "+file)) } - if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false, false); err != nil { + if err := CompileWorkflowWithValidation(context.Background(), compiler, file, verbose, false, false, false, false, false); err != nil { // Always show compilation errors on new line // Note: Don't wrap in FormatErrorMessage as the error is already formatted by console.FormatError fmt.Fprintln(os.Stderr, err.Error()) diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index 56883205a8b..2641d0b0200 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "path/filepath" "testing" @@ -133,7 +134,7 @@ This workflow specifies repos without min-integrity. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) if tt.expectError { require.Error(t, err, "Expected compilation to fail") @@ -173,7 +174,7 @@ This workflow uses min-integrity without specifying repos. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") // Read the compiled lock file and verify it contains the correct guard-policies JSON block. @@ -233,7 +234,7 @@ This workflow uses blocked-users and approval-labels. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-guard-policy-blocked.lock.yml") @@ -283,7 +284,7 @@ This workflow passes blocked-users and approval-labels as expressions. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-guard-policy-expr.lock.yml") @@ -331,7 +332,7 @@ This workflow passes blocked-users as a comma-separated string. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-guard-policy-csv.lock.yml") @@ -379,7 +380,7 @@ This workflow uses trusted-users alongside blocked-users. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-guard-policy-trusted.lock.yml") @@ -424,7 +425,7 @@ This workflow passes trusted-users as a GitHub Actions expression. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-guard-policy-trusted-expr.lock.yml") @@ -465,7 +466,7 @@ This workflow sets trusted-users without min-integrity (should fail). require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.Error(t, err, "Expected compilation to fail without min-integrity") assert.Contains(t, err.Error(), "min-integrity", "Error should mention min-integrity requirement") } diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 99fe2c27c85..4fed2b7b43f 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -83,9 +83,9 @@ func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.Wo // Compile specific files or all files in directory if len(config.MarkdownFiles) > 0 { // Compile specific workflow files - return compileSpecificFiles(compiler, config, stats, &validationResults) + return compileSpecificFiles(ctx, compiler, config, stats, &validationResults) } // Compile all workflow files in directory - return compileAllFilesInDirectory(compiler, config, workflowDir, stats, &validationResults) + return compileAllFilesInDirectory(ctx, compiler, config, workflowDir, stats, &validationResults) } diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 20d4f1ce0a1..d90171c14c3 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -22,6 +22,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -39,6 +40,7 @@ var compileOrchestrationLog = logger.New("cli:compile_pipeline") // compileSpecificFiles compiles a specific list of workflow files func compileSpecificFiles( + ctx context.Context, compiler *workflow.Compiler, config CompileConfig, stats *CompilationStats, @@ -96,7 +98,7 @@ func compileSpecificFiles( // Compile regular workflow file (disable per-file security tools) fileResult := compileWorkflowFile( - compiler, resolvedFile, config.Verbose, config.JSONOutput, + ctx, compiler, resolvedFile, config.Verbose, config.JSONOutput, config.NoEmit, false, false, false, // Disable per-file security tools config.Strict, shouldValidate, ) @@ -183,7 +185,7 @@ func compileSpecificFiles( displaySafeUpdateWarnings(compiler, config.JSONOutput) // Post-processing - if err := runPostProcessing(compiler, workflowDataList, config, compiledCount); err != nil { + if err := runPostProcessing(ctx, compiler, workflowDataList, config, compiledCount); err != nil { return workflowDataList, err } @@ -204,6 +206,7 @@ func compileSpecificFiles( // compileAllFilesInDirectory compiles all workflow files in a directory func compileAllFilesInDirectory( + ctx context.Context, compiler *workflow.Compiler, config CompileConfig, workflowDir string, @@ -273,7 +276,7 @@ func compileAllFilesInDirectory( // Compile regular workflow file (disable per-file security tools) fileResult := compileWorkflowFile( - compiler, file, config.Verbose, config.JSONOutput, + ctx, compiler, file, config.Verbose, config.JSONOutput, config.NoEmit, false, false, false, // Disable per-file security tools config.Strict, shouldValidate, ) @@ -365,7 +368,7 @@ func compileAllFilesInDirectory( } // Post-processing - if err := runPostProcessingForDirectory(compiler, workflowDataList, config, workflowsDir, gitRoot, successCount); err != nil { + if err := runPostProcessingForDirectory(ctx, compiler, workflowDataList, config, workflowsDir, gitRoot, successCount); err != nil { return workflowDataList, err } @@ -429,6 +432,7 @@ func runPurgeOperations(workflowsDir string, data *purgeTrackingData, verbose bo // runPostProcessing runs post-processing for specific files compilation func runPostProcessing( + ctx context.Context, compiler *workflow.Compiler, workflowDataList []*workflow.WorkflowData, config CompileConfig, @@ -471,6 +475,7 @@ func runPostProcessing( // runPostProcessingForDirectory runs post-processing for directory compilation func runPostProcessingForDirectory( + ctx context.Context, compiler *workflow.Compiler, workflowDataList []*workflow.WorkflowData, config CompileConfig, @@ -498,7 +503,7 @@ func runPostProcessingForDirectory( // Skip maintenance workflow generation when using custom --dir option if !config.NoEmit && config.WorkflowDir == "" { absWorkflowDir := getAbsoluteWorkflowDir(workflowsDir, gitRoot) - if err := generateMaintenanceWorkflowWrapper(compiler, workflowDataList, absWorkflowDir, gitRoot, config.Verbose, config.Strict); err != nil { + if err := generateMaintenanceWorkflowWrapper(ctx, compiler, workflowDataList, absWorkflowDir, gitRoot, config.Verbose, config.Strict); err != nil { if config.Strict { return err } diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index 4746c45fd0b..68d5706edd2 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -35,6 +35,7 @@ package cli import ( + "context" "fmt" "os" "path/filepath" @@ -70,6 +71,7 @@ func generateDependabotManifestsWrapper( // generateMaintenanceWorkflowWrapper generates maintenance workflow if any workflow uses expires field func generateMaintenanceWorkflowWrapper( + ctx context.Context, compiler *workflow.Compiler, workflowDataList []*workflow.WorkflowData, workflowsDir string, @@ -89,7 +91,7 @@ func generateMaintenanceWorkflowWrapper( repoConfig = nil } - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { + if err := workflow.GenerateMaintenanceWorkflow(ctx, workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { if strict { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/cli/compile_security_benchmark_test.go b/pkg/cli/compile_security_benchmark_test.go index ac6f364ed32..0cec73b0d4c 100644 --- a/pkg/cli/compile_security_benchmark_test.go +++ b/pkg/cli/compile_security_benchmark_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "path/filepath" "testing" @@ -56,7 +57,7 @@ PR Number: ${{ github.event.pull_request.number }} b.ReportAllocs() for b.Loop() { // Compile with actionlint enabled (per-file mode for benchmarking) - _ = CompileWorkflowWithValidation(compiler, testFile, false, false, false, true, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, false, false, true, false, false) } } @@ -105,7 +106,7 @@ Issue: ${{ needs.activation.outputs.text }} b.ReportAllocs() for b.Loop() { // Compile with zizmor enabled - _ = CompileWorkflowWithValidation(compiler, testFile, false, true, false, false, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, true, false, false, false, false) } } @@ -150,7 +151,7 @@ Repository: ${{ github.repository }} b.ReportAllocs() for b.Loop() { // Compile with poutine enabled - _ = CompileWorkflowWithValidation(compiler, testFile, false, false, true, false, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, false, true, false, false, false) } } @@ -220,7 +221,7 @@ PR Details: b.ReportAllocs() for b.Loop() { // Compile with all security tools enabled (zizmor, poutine, actionlint) - _ = CompileWorkflowWithValidation(compiler, testFile, false, true, true, true, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, true, true, true, false, false) } } @@ -269,7 +270,7 @@ PR Number: ${{ github.event.pull_request.number }} b.ReportAllocs() for b.Loop() { // Compile without any security tools - _ = CompileWorkflowWithValidation(compiler, testFile, false, false, false, false, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, false, false, false, false, false) } } @@ -422,6 +423,6 @@ Triggered by: ${{ github.actor }} b.ReportAllocs() for b.Loop() { // Compile with all security tools enabled - _ = CompileWorkflowWithValidation(compiler, testFile, false, true, true, true, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, testFile, false, true, true, true, false, false) } } diff --git a/pkg/cli/compile_update_discussion_test.go b/pkg/cli/compile_update_discussion_test.go index dcabbd278df..b048fa04f0e 100644 --- a/pkg/cli/compile_update_discussion_test.go +++ b/pkg/cli/compile_update_discussion_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "path/filepath" "testing" @@ -52,7 +53,7 @@ the agent can modify when using update-discussion. require.NoError(t, err, "Failed to write workflow file") compiler := workflow.NewCompiler() - err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + err = CompileWorkflowWithValidation(context.Background(), compiler, workflowPath, false, false, false, false, false, false) require.NoError(t, err, "Expected compilation to succeed") lockFilePath := filepath.Join(tmpDir, "test-update-discussion-field-enforcement.lock.yml") diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index 4148001188c..9e9872f7723 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -16,7 +17,7 @@ import ( var compileValidationLog = logger.New("cli:compile_validation") // CompileWorkflowWithValidation compiles a workflow with always-on YAML validation for CLI usage -func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error { +func CompileWorkflowWithValidation(ctx context.Context, compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error { compileValidationLog.Printf("Compiling workflow with validation: file=%s, strict=%v, validateSHAs=%v", filePath, strict, validateActionSHAs) // Set workflow identifier for schedule scattering (use repository-relative path for stability) @@ -35,6 +36,9 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, compileValidationLog.Printf("Repository slug for file set: %s", fileRepoSlug) } + // Propagate context to compiler so action SHA resolution can respect cancellation/timeout + compiler.SetContext(ctx) + // Compile the workflow first if err := compiler.CompileWorkflow(filePath); err != nil { compileValidationLog.Printf("Workflow compilation failed: %v", err) @@ -67,7 +71,7 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, compileValidationLog.Print("Validating action SHAs in lock file") // Use the compiler's shared action cache to benefit from cached resolutions actionCache := compiler.GetSharedActionCache() - if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil { + if err := workflow.ValidateActionSHAsInLockFile(ctx, lockFile, actionCache, verbose); err != nil { // Action SHA validation warnings are non-fatal compileValidationLog.Printf("Action SHA validation completed with warnings: %v", err) } @@ -100,9 +104,12 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, // CompileWorkflowDataWithValidation compiles from already-parsed WorkflowData with validation // This avoids re-parsing when the workflow data has already been parsed -func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error { +func CompileWorkflowDataWithValidation(ctx context.Context, compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error { compileValidationLog.Printf("Compiling from parsed WorkflowData: file=%s", filePath) + // Propagate context to compiler so action SHA resolution can respect cancellation/timeout + compiler.SetContext(ctx) + // Compile the workflow using already-parsed data if err := compiler.CompileWorkflowData(workflowData, filePath); err != nil { compileValidationLog.Printf("WorkflowData compilation failed: %v", err) @@ -135,7 +142,7 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData compileValidationLog.Print("Validating action SHAs in lock file") // Use the compiler's shared action cache to benefit from cached resolutions actionCache := compiler.GetSharedActionCache() - if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil { + if err := workflow.ValidateActionSHAsInLockFile(ctx, lockFile, actionCache, verbose); err != nil { // Action SHA validation warnings are non-fatal compileValidationLog.Printf("Action SHA validation completed with warnings: %v", err) } diff --git a/pkg/cli/compile_workflow_processor.go b/pkg/cli/compile_workflow_processor.go index a425d9329ec..38bd38c73d0 100644 --- a/pkg/cli/compile_workflow_processor.go +++ b/pkg/cli/compile_workflow_processor.go @@ -22,6 +22,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -46,6 +47,7 @@ type compileWorkflowFileResult struct { // compileWorkflowFile compiles a single workflow file (not a campaign spec) // Returns the workflow data, lock file path, validation result, and success status func compileWorkflowFile( + ctx context.Context, compiler *workflow.Compiler, resolvedFile string, verbose bool, @@ -127,7 +129,7 @@ func compileWorkflowFile( // Compile the workflow // Disable per-file actionlint run (false instead of actionlint && !noEmit) - we'll batch them - if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { + if err := CompileWorkflowDataWithValidation(ctx, compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, false, strict, validate && !noEmit); err != nil { // Don't print error here - it will be displayed in the compilation summary // The error is stored in ValidationResult for JSON output and summary display result.validationResult.Valid = false diff --git a/pkg/cli/enable.go b/pkg/cli/enable.go index aed89e2ef57..756d83af441 100644 --- a/pkg/cli/enable.go +++ b/pkg/cli/enable.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -119,7 +120,7 @@ func toggleWorkflowsByNames(workflowNames []string, enable bool, repoOverride st // If enabling and lock file doesn't exist locally, try to compile it if enable { if _, err := os.Stat(lockFile); os.IsNotExist(err) { - if err := compileWorkflow(file, false, false, ""); err != nil { + if err := compileWorkflow(context.Background(), file, false, false, ""); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to compile workflow %s to create lock file: %v", name, err))) // If we can't compile and there's no GitHub entry, skip because we can't address it if !exists { diff --git a/pkg/cli/error_formatting_test.go b/pkg/cli/error_formatting_test.go index 7b2c6019fff..fa5b2e5266f 100644 --- a/pkg/cli/error_formatting_test.go +++ b/pkg/cli/error_formatting_test.go @@ -4,6 +4,7 @@ package cli import ( "bytes" + "context" "errors" "fmt" "io" @@ -38,7 +39,7 @@ This is not valid frontmatter // Create compiler and attempt to compile compiler := workflow.NewCompiler() - _ = CompileWorkflowWithValidation(compiler, invalidWorkflow, false, false, false, false, false, false) + _ = CompileWorkflowWithValidation(context.Background(), compiler, invalidWorkflow, false, false, false, false, false, false) // Restore stderr and read captured output w.Close() diff --git a/pkg/cli/file_tracker_test.go b/pkg/cli/file_tracker_test.go index 0eb7431802f..905251c7af3 100644 --- a/pkg/cli/file_tracker_test.go +++ b/pkg/cli/file_tracker_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "os/exec" "path/filepath" @@ -307,7 +308,7 @@ This uses reaction. } // Compile the workflow with tracking - if err := compileWorkflowWithTracking(workflowFileWithReaction, false, false, "", tracker); err != nil { + if err := compileWorkflowWithTracking(context.Background(), workflowFileWithReaction, false, false, "", tracker); err != nil { t.Fatalf("Failed to compile workflow: %v", err) } @@ -352,7 +353,7 @@ This does NOT use ai-reaction. // (Note: Since reaction is now inline, this removal step is no longer needed) // Compile the workflow with tracking - if err := compileWorkflowWithTracking(workflowFileWithoutReaction, false, false, "", tracker2); err != nil { + if err := compileWorkflowWithTracking(context.Background(), workflowFileWithoutReaction, false, false, "", tracker2); err != nil { t.Fatalf("Failed to compile workflow: %v", err) } diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 1b6b23f9989..fd707a8cfbf 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -26,7 +27,7 @@ type InitOptions struct { } // InitRepository initializes the repository for agentic workflows -func InitRepository(opts InitOptions) error { +func InitRepository(ctx context.Context, opts InitOptions) error { initLog.Print("Starting repository initialization for agentic workflows") // Show welcome banner for interactive mode @@ -143,7 +144,7 @@ func InitRepository(opts InitOptions) error { // Generate/update maintenance workflow if any workflows use expires field initLog.Print("Checking for workflows with expires field to generate maintenance workflow") - if err := ensureMaintenanceWorkflow(opts.Verbose); err != nil { + if err := ensureMaintenanceWorkflow(ctx, opts.Verbose); err != nil { initLog.Printf("Failed to generate maintenance workflow: %v", err) // Don't fail init if maintenance workflow generation has issues fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to generate maintenance workflow: %v", err))) @@ -183,7 +184,7 @@ func InitRepository(opts InitOptions) error { // ensureMaintenanceWorkflow checks existing workflows for expires field and generates/updates // the maintenance workflow file if any workflows use it -func ensureMaintenanceWorkflow(verbose bool) error { +func ensureMaintenanceWorkflow(ctx context.Context, verbose bool) error { initLog.Print("Checking for workflows with expires field") // Find git root @@ -238,7 +239,7 @@ func ensureMaintenanceWorkflow(verbose bool) error { repoConfig = nil } - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { + if err := workflow.GenerateMaintenanceWorkflow(ctx, workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/cli/init_command.go b/pkg/cli/init_command.go index fa13da01708..999923ff5a5 100644 --- a/pkg/cli/init_command.go +++ b/pkg/cli/init_command.go @@ -102,7 +102,7 @@ Examples: CreatePR: createPR, RootCmd: cmd.Root(), } - if err := InitRepository(opts); err != nil { + if err := InitRepository(cmd.Context(), opts); err != nil { initCommandLog.Printf("Init command failed: %v", err) return err } diff --git a/pkg/cli/init_command_test.go b/pkg/cli/init_command_test.go index 564fe7e11c2..2fc8da8a1db 100644 --- a/pkg/cli/init_command_test.go +++ b/pkg/cli/init_command_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "os/exec" "path/filepath" @@ -167,7 +168,7 @@ func TestInitRepositoryBasic(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test basic init with MCP enabled by default (mcp=true, noMcp=false behavior) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) failed: %v", err) } @@ -226,7 +227,7 @@ func TestInitRepositoryWithMCP(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with MCP explicitly enabled (same as default) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with MCP failed: %v", err) } @@ -269,7 +270,7 @@ func TestInitRepositoryWithNoMCP(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with --no-mcp flag (mcp=false) - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with --no-mcp failed: %v", err) } @@ -317,7 +318,7 @@ func TestInitRepositoryWithMCPBackwardCompatibility(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with deprecated --mcp flag for backward compatibility (mcp=true) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with deprecated --mcp flag failed: %v", err) } @@ -360,7 +361,7 @@ func TestInitRepositoryVerbose(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test verbose mode with MCP enabled by default (should not error, just produce more output) - err = InitRepository(InitOptions{Verbose: true, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: true, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) in verbose mode failed: %v", err) } @@ -387,7 +388,7 @@ func TestInitRepositoryNotInGitRepo(t *testing.T) { } // Don't initialize git repo - should fail for some operations - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) // The function should handle this gracefully or return an error // Based on the implementation, ensureGitAttributes requires git @@ -421,13 +422,13 @@ func TestInitRepositoryIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init twice with MCP enabled by default - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("First InitRepository(, false, false, false, nil) failed: %v", err) } // Second run should be idempotent - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("Second InitRepository(, false, false, false, nil) failed: %v", err) } @@ -472,12 +473,12 @@ func TestInitRepositoryWithMCPIdempotent(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP twice - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("First InitRepository(, false, false, false, nil) with MCP failed: %v", err) } - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("Second InitRepository(, false, false, false, nil) with MCP failed: %v", err) } @@ -519,7 +520,7 @@ func TestInitRepositoryCreatesDirectories(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Run init with MCP - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) failed: %v", err) } @@ -577,7 +578,7 @@ func TestInitRepositoryErrorHandling(t *testing.T) { } // Test init without git repo (with MCP enabled by default) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) // Should handle error gracefully or return error // The actual behavior depends on implementation @@ -620,7 +621,7 @@ func TestInitRepositoryWithExistingFiles(t *testing.T) { } // Run init with MCP enabled by default - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) failed: %v", err) } @@ -670,7 +671,7 @@ func TestInitRepositoryWithCodespace(t *testing.T) { // Test init with --codespaces flag (with MCP enabled by default and additional repos) additionalRepos := []string{"org/repo1", "owner/repo2"} - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: additionalRepos, CodespaceEnabled: true, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: additionalRepos, CodespaceEnabled: true, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with codespaces failed: %v", err) } @@ -735,7 +736,7 @@ func TestInitCommandWithCodespacesNoArgs(t *testing.T) { exec.Command("git", "config", "user.email", "test@example.com").Run() // Test init with --codespaces flag (no additional repos, MCP enabled by default) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: true, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: true, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with codespaces (no args) failed: %v", err) } diff --git a/pkg/cli/init_mcp_test.go b/pkg/cli/init_mcp_test.go index bc01b6d4c45..b503613ce4e 100644 --- a/pkg/cli/init_mcp_test.go +++ b/pkg/cli/init_mcp_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "encoding/json" "os" "os/exec" @@ -43,7 +44,7 @@ func TestInitRepository_WithMCP(t *testing.T) { } // Call the function with MCP flag (no campaign agent) - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with MCP returned error: %v", err) } @@ -136,13 +137,13 @@ func TestInitRepository_MCP_Idempotent(t *testing.T) { } // Call the function first time with MCP - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with MCP returned error on first call: %v", err) } // Call the function second time with MCP - err = InitRepository(InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: true, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) with MCP returned error on second call: %v", err) } diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index 8ae53260c72..057c993b046 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "os" "os/exec" "path/filepath" @@ -56,7 +57,7 @@ func TestInitRepository(t *testing.T) { } // Call the function (no MCP or campaign) - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) // Check error expectation if tt.wantError { @@ -114,13 +115,13 @@ func TestInitRepository_Idempotent(t *testing.T) { } // Call the function first time - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) returned error on first call: %v", err) } // Call the function second time - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) returned error on second call: %v", err) } @@ -158,7 +159,7 @@ func TestInitRepository_Verbose(t *testing.T) { } // Call the function with verbose=true (should not error) - err = InitRepository(InitOptions{Verbose: true, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: true, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) if err != nil { t.Fatalf("InitRepository(, false, false, false, nil) returned error with verbose=true: %v", err) } @@ -263,7 +264,7 @@ This is a test workflow. } // Call ensureMaintenanceWorkflow - err = ensureMaintenanceWorkflow(false) + err = ensureMaintenanceWorkflow(context.Background(), false) if err != nil { t.Logf("ensureMaintenanceWorkflow returned error (may be expected): %v", err) } diff --git a/pkg/cli/interfaces_test.go b/pkg/cli/interfaces_test.go index bf470375406..14418085407 100644 --- a/pkg/cli/interfaces_test.go +++ b/pkg/cli/interfaces_test.go @@ -4,6 +4,7 @@ package cli import ( "bytes" + "context" "os" "os/exec" "testing" @@ -67,7 +68,7 @@ func TestInitRepository_WithNilRootCmd(t *testing.T) { require.NoError(t, err, "Failed to init git repo") // InitRepository with nil rootCmd and completions disabled should succeed - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: nil}) require.NoError(t, err, "InitRepository with nil rootCmd should succeed when completions are disabled") } @@ -96,7 +97,7 @@ func TestInitRepository_WithRootCmd(t *testing.T) { } // InitRepository with real rootCmd should succeed - err = InitRepository(InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: rootCmd}) + err = InitRepository(context.Background(), InitOptions{Verbose: false, MCP: false, CodespaceRepos: []string{}, CodespaceEnabled: false, Completions: false, CreatePR: false, RootCmd: rootCmd}) require.NoError(t, err, "InitRepository with rootCmd should succeed") } diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 040d27b571b..1c57ddc4965 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -634,7 +634,7 @@ func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverr // Recompile the updated workflow (unless --no-compile is set) if !noCompile { - if err := compileWorkflowWithRefresh(path, verbose, false, engineOverride, false); err != nil { + if err := compileWorkflowWithRefresh(ctx, path, verbose, false, engineOverride, false); err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to recompile %s: %v", path, err))) } diff --git a/pkg/cli/update_command_test.go b/pkg/cli/update_command_test.go index c5256f272c9..f672e262aae 100644 --- a/pkg/cli/update_command_test.go +++ b/pkg/cli/update_command_test.go @@ -683,7 +683,7 @@ This is a test workflow. // Test with refreshStopTime=false (should preserve existing stop time if lock exists) t.Run("compileWorkflowWithRefresh false", func(t *testing.T) { - err := compileWorkflowWithRefresh(workflowFile, false, false, "", false) + err := compileWorkflowWithRefresh(context.Background(), workflowFile, false, false, "", false) if err != nil { t.Logf("Compilation failed (expected in test environment): %v", err) // In a test environment without full setup, compilation may fail, @@ -693,7 +693,7 @@ This is a test workflow. // Test with refreshStopTime=true (should regenerate stop time) t.Run("compileWorkflowWithRefresh true", func(t *testing.T) { - err := compileWorkflowWithRefresh(workflowFile, false, false, "", true) + err := compileWorkflowWithRefresh(context.Background(), workflowFile, false, false, "", true) if err != nil { t.Logf("Compilation failed (expected in test environment): %v", err) // In a test environment without full setup, compilation may fail, diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index 94d3cb25ce1..dacdf303744 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -597,7 +597,7 @@ func updateWorkflow(ctx context.Context, wf *workflowWithSource, allowMajor, for // Compile the updated workflow with refreshStopTime enabled (unless --no-compile is set) if !noCompile { updateLog.Printf("Compiling updated workflow: %s", wf.Name) - if err := compileWorkflowWithRefresh(wf.Path, verbose, false, engineOverride, true); err != nil { + if err := compileWorkflowWithRefresh(ctx, wf.Path, verbose, false, engineOverride, true); err != nil { updateLog.Printf("Compilation failed for workflow %s: %v", wf.Name, err) return fmt.Errorf("failed to compile updated workflow: %w", err) } diff --git a/pkg/workflow/action_reference.go b/pkg/workflow/action_reference.go index f7788ccd309..82cff63990a 100644 --- a/pkg/workflow/action_reference.go +++ b/pkg/workflow/action_reference.go @@ -23,6 +23,7 @@ const ( // workflow generators (like maintenance workflow) that don't have access to WorkflowData. // // Parameters: +// - ctx: Context for cancellation and timeout support // - actionMode: The action mode (dev, release, or action) // - version: The version string to use for release/action mode // - actionTag: Optional override tag/SHA (takes precedence over version when in release mode) @@ -35,14 +36,14 @@ const ( // - For action mode with resolver: "github/gh-aw-actions/setup@ # " (SHA-pinned) // - For action mode without resolver: "github/gh-aw-actions/setup@" (tag-based, SHA resolved later) // - Falls back to local path if version is invalid in release/action mode -func ResolveSetupActionReference(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string { - return resolveSetupActionRef(actionMode, version, actionTag, resolver, "") +func ResolveSetupActionReference(ctx context.Context, actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string { + return resolveSetupActionRef(ctx, actionMode, version, actionTag, resolver, "") } // resolveSetupActionRef is the internal implementation of ResolveSetupActionReference // that accepts an optional actionsOrgRepo override. When actionsOrgRepo is empty, // GitHubActionsOrgRepo is used. -func resolveSetupActionRef(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver, actionsOrgRepo string) string { +func resolveSetupActionRef(ctx context.Context, actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver, actionsOrgRepo string) string { if actionsOrgRepo == "" { actionsOrgRepo = GitHubActionsOrgRepo } @@ -75,7 +76,7 @@ func resolveSetupActionRef(actionMode ActionMode, version string, actionTag stri // If a resolver is available, try to resolve the SHA if resolver != nil { - sha, err := resolver.ResolveSHA(context.Background(), actionRepo, tag) + sha, err := resolver.ResolveSHA(ctx, actionRepo, tag) if err == nil && sha != "" { pinnedRef := formatActionReference(actionRepo, sha, tag) actionRefLog.Printf("Action mode: resolved %s to SHA-pinned reference: %s", remoteRef, pinnedRef) @@ -113,7 +114,7 @@ func resolveSetupActionRef(actionMode ActionMode, version string, actionTag stri // If a resolver is available, try to resolve the SHA if resolver != nil { - sha, err := resolver.ResolveSHA(context.Background(), actionRepo, tag) + sha, err := resolver.ResolveSHA(ctx, actionRepo, tag) if err == nil && sha != "" { pinnedRef := formatActionReference(actionRepo, sha, tag) actionRefLog.Printf("Release mode: resolved %s to SHA-pinned reference: %s", remoteRef, pinnedRef) @@ -164,13 +165,13 @@ func (c *Compiler) resolveActionReference(localActionPath string, data *Workflow resolver = data.ActionResolver } if c.actionTag != "" { - return resolveSetupActionRef(c.actionMode, c.version, c.actionTag, resolver, c.effectiveActionsRepo()) + return resolveSetupActionRef(c.context(), c.actionMode, c.version, c.actionTag, resolver, c.effectiveActionsRepo()) } if !hasActionTag { - return resolveSetupActionRef(c.actionMode, c.version, "", resolver, c.effectiveActionsRepo()) + return resolveSetupActionRef(c.context(), c.actionMode, c.version, "", resolver, c.effectiveActionsRepo()) } // hasActionTag is true and no compiler actionTag: use action mode with the frontmatter tag - return resolveSetupActionRef(ActionModeAction, c.version, frontmatterActionTag, resolver, c.effectiveActionsRepo()) + return resolveSetupActionRef(c.context(), ActionModeAction, c.version, frontmatterActionTag, resolver, c.effectiveActionsRepo()) } // Action mode - use external gh-aw-actions repository diff --git a/pkg/workflow/action_reference_test.go b/pkg/workflow/action_reference_test.go index ef16016259e..ea44eb945f1 100644 --- a/pkg/workflow/action_reference_test.go +++ b/pkg/workflow/action_reference_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -338,7 +339,7 @@ func TestResolveSetupActionReference(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Pass nil for data to test backward compatibility with standalone usage - ref := ResolveSetupActionReference(tt.actionMode, tt.version, tt.actionTag, nil) + ref := ResolveSetupActionReference(context.Background(), tt.actionMode, tt.version, tt.actionTag, nil) assert.Equal(t, tt.expectedRef, ref, tt.description) }) } @@ -352,14 +353,14 @@ func TestResolveSetupActionReferenceWithData(t *testing.T) { // The resolver will fail to resolve github/gh-aw/actions/setup@v1.0.0 // since it's not a real tag, but it should fall back gracefully - ref := ResolveSetupActionReference(ActionModeRelease, "v1.0.0", "", resolver) + ref := ResolveSetupActionReference(context.Background(), ActionModeRelease, "v1.0.0", "", resolver) // Without a valid pin or successful resolution, should return tag-based reference assert.Equal(t, "github/gh-aw/actions/setup@v1.0.0", ref, "should return tag-based reference when SHA resolution fails") }) t.Run("release mode with nil resolver returns tag-based reference", func(t *testing.T) { - ref := ResolveSetupActionReference(ActionModeRelease, "v1.0.0", "", nil) + ref := ResolveSetupActionReference(context.Background(), ActionModeRelease, "v1.0.0", "", nil) assert.Equal(t, "github/gh-aw/actions/setup@v1.0.0", ref, "should return tag-based reference when no resolver provided") }) } diff --git a/pkg/workflow/action_sha_checker.go b/pkg/workflow/action_sha_checker.go index 2c7dacde838..1ae1753d4f2 100644 --- a/pkg/workflow/action_sha_checker.go +++ b/pkg/workflow/action_sha_checker.go @@ -101,7 +101,7 @@ func ExtractActionsFromLockFile(lockFilePath string) ([]ActionUsage, error) { } // CheckActionSHAUpdates checks if actions need updating by comparing with latest SHAs -func CheckActionSHAUpdates(actions []ActionUsage, resolver *ActionResolver) []ActionUpdateCheck { +func CheckActionSHAUpdates(ctx context.Context, actions []ActionUsage, resolver *ActionResolver) []ActionUpdateCheck { actionSHACheckerLog.Printf("Checking %d actions for updates", len(actions)) results := make([]ActionUpdateCheck, 0, len(actions)) @@ -119,7 +119,7 @@ func CheckActionSHAUpdates(actions []ActionUsage, resolver *ActionResolver) []Ac } // Resolve the latest SHA for this version - latestSHA, err := resolver.ResolveSHA(context.Background(), action.Repo, action.Version) + latestSHA, err := resolver.ResolveSHA(ctx, action.Repo, action.Version) if err != nil { actionSHACheckerLog.Printf("Failed to resolve %s@%s: %v", action.Repo, action.Version, err) check.Message = fmt.Sprintf("Unable to check for updates: %v", err) @@ -146,7 +146,7 @@ func CheckActionSHAUpdates(actions []ActionUsage, resolver *ActionResolver) []Ac } // ValidateActionSHAsInLockFile validates action SHAs in a lock file and emits warnings -func ValidateActionSHAsInLockFile(lockFilePath string, cache *ActionCache, verbose bool) error { +func ValidateActionSHAsInLockFile(ctx context.Context, lockFilePath string, cache *ActionCache, verbose bool) error { actionSHACheckerLog.Printf("Validating action SHAs in: %s", lockFilePath) // Extract actions from lock file @@ -167,7 +167,7 @@ func ValidateActionSHAsInLockFile(lockFilePath string, cache *ActionCache, verbo resolver := NewActionResolver(cache) // Check for updates - checks := CheckActionSHAUpdates(actions, resolver) + checks := CheckActionSHAUpdates(ctx, actions, resolver) // Count and report updates updateCount := 0 diff --git a/pkg/workflow/action_sha_checker_integration_test.go b/pkg/workflow/action_sha_checker_integration_test.go index c44c4db4d32..2e1566177d6 100644 --- a/pkg/workflow/action_sha_checker_integration_test.go +++ b/pkg/workflow/action_sha_checker_integration_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "os" "path/filepath" "strings" @@ -53,7 +54,7 @@ jobs: // Test 1: Validation with up-to-date actions (should not error) t.Run("UpToDate", func(t *testing.T) { - err := ValidateActionSHAsInLockFile(lockFile, cache, false) + err := ValidateActionSHAsInLockFile(context.Background(), lockFile, cache, false) if err != nil { t.Errorf("Unexpected error with up-to-date actions: %v", err) } @@ -79,7 +80,7 @@ jobs: // Test 2: Validation with outdated actions (should emit warnings but not error) t.Run("Outdated", func(t *testing.T) { // Note: This will emit warnings to stderr, but should not return an error - err := ValidateActionSHAsInLockFile(outdatedLockFile, cache, false) + err := ValidateActionSHAsInLockFile(context.Background(), outdatedLockFile, cache, false) if err != nil { t.Errorf("Unexpected error with outdated actions: %v", err) } @@ -110,7 +111,7 @@ jobs: cache := NewActionCache(tmpDir) // Validation should handle missing cache gracefully - err := ValidateActionSHAsInLockFile(lockFile, cache, false) + err := ValidateActionSHAsInLockFile(context.Background(), lockFile, cache, false) if err != nil { t.Errorf("Unexpected error with missing cache: %v", err) } @@ -220,7 +221,7 @@ jobs: // Capture stderr output to verify message format // Note: In a real scenario, we'd redirect stderr, but for this test // we just ensure it doesn't error - err := ValidateActionSHAsInLockFile(lockFile, cache, true) + err := ValidateActionSHAsInLockFile(context.Background(), lockFile, cache, true) if err != nil { t.Errorf("Unexpected error: %v", err) } diff --git a/pkg/workflow/action_sha_checker_test.go b/pkg/workflow/action_sha_checker_test.go index 7097551833a..a4b4156f5e2 100644 --- a/pkg/workflow/action_sha_checker_test.go +++ b/pkg/workflow/action_sha_checker_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "os" "path/filepath" "testing" @@ -137,7 +138,7 @@ func TestCheckActionSHAUpdates(t *testing.T) { resolver := NewActionResolver(cache) // Check for updates - checks := CheckActionSHAUpdates(actions, resolver) + checks := CheckActionSHAUpdates(context.Background(), actions, resolver) // Verify results if len(checks) != 2 { @@ -155,6 +156,39 @@ func TestCheckActionSHAUpdates(t *testing.T) { } } +// TestCheckActionSHAUpdates_ContextCancellation verifies that context cancellation is +// respected when checking action SHA updates. When the context is already cancelled, +// the resolver should propagate the cancellation error without hanging. +func TestCheckActionSHAUpdates_ContextCancellation(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + cache := NewActionCache(tmpDir) + + actions := []ActionUsage{ + { + Repo: "actions/checkout", + SHA: "oldsha0000000000000000000000000000000000", + Version: "v4", + }, + } + + // Use an already-cancelled context — the resolver will see ctx.Err() != nil + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + resolver := NewActionResolver(cache) + + // With an already-cancelled context and no cached value, the resolver should + // attempt the resolution and propagate the cancellation. The result will either + // have an empty LatestSHA (because the request was cancelled) or behave like a + // cache miss. The important thing is that it does not hang. + checks := CheckActionSHAUpdates(ctx, actions, resolver) + + // Should still return one result per action even with cancellation + if len(checks) != 1 { + t.Errorf("Expected 1 check result, got %d", len(checks)) + } +} + func TestExtractActionsFromLockFileNoActions(t *testing.T) { // Create a temporary lock file with no actions tmpDir := testutil.TempDir(t, "test-*") diff --git a/pkg/workflow/action_sha_validation_test.go b/pkg/workflow/action_sha_validation_test.go index 222ed4c4958..b72529af664 100644 --- a/pkg/workflow/action_sha_validation_test.go +++ b/pkg/workflow/action_sha_validation_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "os" "path/filepath" "regexp" @@ -228,7 +229,7 @@ jobs: // Run validation - even if no updates are detected, this exercises the code path // In a real scenario with network access, this would detect and save updates - err := ValidateActionSHAsInLockFile(lockFile, cache, false) + err := ValidateActionSHAsInLockFile(context.Background(), lockFile, cache, false) if err != nil { t.Fatalf("Validation failed: %v", err) } diff --git a/pkg/workflow/compiler_custom_actions_test.go b/pkg/workflow/compiler_custom_actions_test.go index 3531e1eb7cb..c89ec9bedc9 100644 --- a/pkg/workflow/compiler_custom_actions_test.go +++ b/pkg/workflow/compiler_custom_actions_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "os" "strings" "testing" @@ -378,7 +379,7 @@ func TestCheckoutActionsFolderDevModeAlwaysEmitsCheckout(t *testing.T) { // TestResolveSetupActionReferenceActionMode tests that action mode resolves to the external gh-aw-actions repo func TestResolveSetupActionReferenceActionMode(t *testing.T) { - ref := ResolveSetupActionReference(ActionModeAction, "v1.2.3", "", nil) + ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.2.3", "", nil) if ref != "github/gh-aw-actions/setup@v1.2.3" { t.Errorf("Action mode should resolve to 'github/gh-aw-actions/setup@v1.2.3', got %q", ref) } @@ -386,7 +387,7 @@ func TestResolveSetupActionReferenceActionMode(t *testing.T) { // TestResolveSetupActionReferenceActionModeWithTag tests action mode with an explicit action tag func TestResolveSetupActionReferenceActionModeWithTag(t *testing.T) { - ref := ResolveSetupActionReference(ActionModeAction, "v1.0.0", "v2.0.0", nil) + ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "v2.0.0", nil) if ref != "github/gh-aw-actions/setup@v2.0.0" { t.Errorf("Action mode with tag should resolve to 'github/gh-aw-actions/setup@v2.0.0', got %q", ref) } @@ -394,7 +395,7 @@ func TestResolveSetupActionReferenceActionModeWithTag(t *testing.T) { // TestResolveSetupActionReferenceActionModeDevVersion tests action mode falls back to local path for dev version func TestResolveSetupActionReferenceActionModeDevVersion(t *testing.T) { - ref := ResolveSetupActionReference(ActionModeAction, "dev", "", nil) + ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "dev", "", nil) if ref != "./actions/setup" { t.Errorf("Action mode with dev version should fall back to './actions/setup', got %q", ref) } @@ -472,7 +473,7 @@ func TestResolveSetupActionReferenceActionModeWithResolver(t *testing.T) { // The resolver will fail to resolve github/gh-aw-actions/setup@v1.0.0 // since it's not a real tag, but it should fall back gracefully to tag-based reference - ref := ResolveSetupActionReference(ActionModeAction, "v1.0.0", "", resolver) + ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", resolver) // Without a valid pin or successful resolution, should return tag-based reference if ref != "github/gh-aw-actions/setup@v1.0.0" { @@ -481,7 +482,7 @@ func TestResolveSetupActionReferenceActionModeWithResolver(t *testing.T) { }) t.Run("action mode with nil resolver returns tag-based reference", func(t *testing.T) { - ref := ResolveSetupActionReference(ActionModeAction, "v1.0.0", "", nil) + ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", nil) if ref != "github/gh-aw-actions/setup@v1.0.0" { t.Errorf("expected 'github/gh-aw-actions/setup@v1.0.0', got %q", ref) } diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 981493d7d00..cc319778af3 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -1,6 +1,7 @@ package workflow import ( + "context" "os" actionpins "github.com/github/gh-aw/pkg/actionpins" @@ -55,6 +56,7 @@ type FileCreationTracker interface { // Compiler handles converting markdown workflows to GitHub Actions YAML type Compiler struct { + ctx context.Context // Context for cancellation and timeout; set at start of each compile operation verbose bool quiet bool // If true, suppress success messages (for interactive mode) engineOverride string @@ -146,6 +148,22 @@ func (c *Compiler) SetSkipValidation(skip bool) { c.skipValidation = skip } +// context returns the compiler's current context, falling back to context.Background() +// when no context has been set for the current compilation operation. +func (c *Compiler) context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + +// SetContext sets the context for the current compilation operation. +// This allows callers to propagate cancellation and timeout support +// through the compiler's internal operations (e.g., action SHA resolution). +func (c *Compiler) SetContext(ctx context.Context) { + c.ctx = ctx +} + // SetRequireDocker configures whether Docker must be available for container image validation. // When true, validation fails with an error if Docker is not installed or the daemon is not running. // When false (default), validation is silently skipped when Docker is unavailable. diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 406988aba45..0218d8dd1a9 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -18,7 +18,7 @@ var maintenanceLog = logger.New("workflow:maintenance_workflow") // In release mode: installs the released CLI via the setup-cli action (gh aw available) // In action mode: installs the released CLI via the gh-aw-actions/setup-cli action (gh aw available) // When resolver is non-nil, attempts to resolve the setup-cli action to a SHA-pinned reference. -func generateInstallCLISteps(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string { +func generateInstallCLISteps(ctx context.Context, actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string { if actionMode == ActionModeDev { return ` - name: Setup Go uses: ` + getActionPin("actions/setup-go") + ` @@ -40,7 +40,7 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st // Action mode: use setup-cli action from external gh-aw-actions repository if actionMode == ActionModeAction { actionRepo := GitHubActionsOrgRepo + "/setup-cli" - ref := resolveActionRef(actionRepo, cliTag, resolver) + ref := resolveActionRef(ctx, actionRepo, cliTag, resolver) return ` - name: Install gh-aw uses: ` + ref + ` with: @@ -51,7 +51,7 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st // Release mode: use setup-cli action (consistent with copilot-setup-steps.yml) actionRepo := GitHubOrgRepo + "/actions/setup-cli" - ref := resolveActionRef(actionRepo, cliTag, resolver) + ref := resolveActionRef(ctx, actionRepo, cliTag, resolver) return ` - name: Install gh-aw uses: ` + ref + ` with: @@ -63,9 +63,9 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st // resolveActionRef attempts to resolve an action repo@tag to a SHA-pinned reference // using the provided resolver. If the resolver is nil or resolution fails, it returns // the tag-based reference (repo@tag). -func resolveActionRef(actionRepo, tag string, resolver ActionSHAResolver) string { +func resolveActionRef(ctx context.Context, actionRepo, tag string, resolver ActionSHAResolver) string { if resolver != nil && tag != "" && tag != "dev" { - sha, err := resolver.ResolveSHA(context.Background(), actionRepo, tag) + sha, err := resolver.ResolveSHA(ctx, actionRepo, tag) if err != nil { maintenanceLog.Printf("Failed to resolve SHA for %s@%s: %v, falling back to tag reference", actionRepo, tag, err) } else if sha != "" { @@ -88,14 +88,14 @@ func getCLICmdPrefix(actionMode ActionMode) string { // FetchDefaultBranch queries the GitHub API to determine the default branch of the // given repository slug (owner/repo). Returns "main" as a fallback when the slug is // empty, not in owner/repo format, or when the API call fails. -func FetchDefaultBranch(slug string) string { +func FetchDefaultBranch(ctx context.Context, slug string) string { const fallback = "main" if slug == "" || strings.Count(slug, "/") != 1 { maintenanceLog.Printf("No valid repository slug, using default branch fallback: %s", fallback) return fallback } maintenanceLog.Printf("Fetching default branch for repository: %s", slug) - output, err := RunGH("Fetching default branch...", "api", "/repos/"+slug, "--jq", ".default_branch") + output, err := RunGHContext(ctx, "Fetching default branch...", "api", "/repos/"+slug, "--jq", ".default_branch") if err != nil { maintenanceLog.Printf("Failed to fetch default branch for %s: %v, falling back to %s", slug, err, fallback) return fallback @@ -115,7 +115,7 @@ func FetchDefaultBranch(slug string) string { // maintenance workflow is deleted and the function returns immediately. // repoSlug is the owner/repo slug used to determine the default branch for the push // trigger; pass an empty string to fall back to "main". -func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool, repoConfig *RepoConfig, repoSlug string) error { +func GenerateMaintenanceWorkflow(ctx context.Context, workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool, repoConfig *RepoConfig, repoSlug string) error { maintenanceLog.Print("Checking if maintenance workflow is needed") // Respect explicit opt-out from aw.json: maintenance: false @@ -159,7 +159,7 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s // Even without expires, side-repo targets still need maintenance workflows // for safe_outputs, create_labels, and validate operations. - return generateAllSideRepoMaintenanceWorkflows(workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, false, 0) + return generateAllSideRepoMaintenanceWorkflows(ctx, workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, false, 0) } maintenanceLog.Printf("Generating maintenance workflow for expired discussions, issues, and pull requests (minimum expires: %d hours)", minExpires) @@ -176,10 +176,10 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s // Fetch the default branch for the push trigger (dev mode only) // Resolved here to avoid passing it through multiple layers; empty slug falls back to "main" - defaultBranch := FetchDefaultBranch(repoSlug) + defaultBranch := FetchDefaultBranch(ctx, repoSlug) // Generate the YAML content for the maintenance workflow - content := buildMaintenanceWorkflowYAML(cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn, defaultBranch, disableLabelTrigger) + content := buildMaintenanceWorkflowYAML(ctx, cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn, defaultBranch, disableLabelTrigger) // Write the maintenance workflow file maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml") @@ -192,7 +192,7 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s maintenanceLog.Print("Maintenance workflow generated successfully") // Generate side-repo maintenance workflows for any SideRepoOps targets detected. - if err := generateAllSideRepoMaintenanceWorkflows(workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { + if err := generateAllSideRepoMaintenanceWorkflows(ctx, workflowDataList, workflowDir, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { return err } diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index dffb3730334..9e931c41539 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -3,6 +3,7 @@ package workflow import ( + "context" "fmt" "os" "path/filepath" @@ -152,7 +153,7 @@ func TestGenerateMaintenanceWorkflow_WithExpires(t *testing.T) { tmpDir := t.TempDir() // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") // Check error expectation if tt.expectError && err == nil { @@ -241,7 +242,7 @@ func TestGenerateMaintenanceWorkflow_DeletesExistingFile(t *testing.T) { } // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -273,7 +274,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -555,7 +556,7 @@ func TestGenerateMaintenanceWorkflow_DisableAgenticWorkflowJob(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{LabelTriggers: &trueVal}, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -702,7 +703,7 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Disabled(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{LabelTriggers: &falseVal}, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -745,7 +746,7 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_Default(t *testing.T) { tmpDir := t.TempDir() // Default: LabelTriggers is nil (omitted) → treated as false (opt-in semantics) → jobs absent - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -786,7 +787,7 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_ExplicitTrue(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{LabelTriggers: &trueVal}, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -846,7 +847,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("dev mode includes push trigger on main for workflow md files", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -870,7 +871,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("dev mode uses custom default branch from buildMaintenanceWorkflowYAML", func(t *testing.T) { // Call buildMaintenanceWorkflowYAML directly to test the branch substitution // without needing a live GitHub API call (FetchDefaultBranch falls back to "main" with no slug) - yaml := buildMaintenanceWorkflowYAML("37 */2 * * *", "Every 2 hours", 1, "ubuntu-slim", ActionModeDev, "v1.0.0", "", nil, nil, "develop", false) + yaml := buildMaintenanceWorkflowYAML(context.Background(), "37 */2 * * *", "Every 2 hours", 1, "ubuntu-slim", ActionModeDev, "v1.0.0", "", nil, nil, "develop", false) if !strings.Contains(yaml, " - develop") { t.Errorf("Push trigger should use the provided default branch 'develop', got:\n%s", yaml[:min(500, len(yaml))]) } @@ -881,7 +882,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("release mode does not include push trigger", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -898,7 +899,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("close-expired-entities and secret-validation exclude push events", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -925,7 +926,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { t.Run("compile-workflows runs on push events (no push exclusion)", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -966,7 +967,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("release mode with action-tag uses remote ref", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1002,7 +1003,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1021,7 +1022,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("dev mode ignores action-tag and uses local path", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1037,7 +1038,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { func TestGenerateInstallCLISteps(t *testing.T) { t.Run("dev mode generates Setup Go and Build gh-aw steps", func(t *testing.T) { - result := generateInstallCLISteps(ActionModeDev, "v1.0.0", "", nil) + result := generateInstallCLISteps(context.Background(), ActionModeDev, "v1.0.0", "", nil) if !strings.Contains(result, "Setup Go") { t.Errorf("Dev mode should include Setup Go step, got:\n%s", result) } @@ -1050,7 +1051,7 @@ func TestGenerateInstallCLISteps(t *testing.T) { }) t.Run("release mode generates setup-cli action step", func(t *testing.T) { - result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil) + result := generateInstallCLISteps(context.Background(), ActionModeRelease, "v1.0.0", "", nil) if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") { t.Errorf("Release mode should use setup-cli action with version, got:\n%s", result) } @@ -1063,7 +1064,7 @@ func TestGenerateInstallCLISteps(t *testing.T) { }) t.Run("release mode uses actionTag over version", func(t *testing.T) { - result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "v2.0.0", nil) + result := generateInstallCLISteps(context.Background(), ActionModeRelease, "v1.0.0", "v2.0.0", nil) if !strings.Contains(result, "setup-cli@v2.0.0") { t.Errorf("Release mode should use actionTag v2.0.0, got:\n%s", result) } @@ -1075,7 +1076,7 @@ func TestGenerateInstallCLISteps(t *testing.T) { cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") resolver := NewActionResolver(cache) - result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", resolver) + result := generateInstallCLISteps(context.Background(), ActionModeRelease, "v1.0.0", "", resolver) expectedRef := "github/gh-aw/actions/setup-cli@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1.0.0" if !strings.Contains(result, expectedRef) { t.Errorf("Release mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result) @@ -1092,7 +1093,7 @@ func TestGenerateInstallCLISteps(t *testing.T) { cache.Set("github/gh-aw-actions/setup-cli", "v1.0.0", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") resolver := NewActionResolver(cache) - result := generateInstallCLISteps(ActionModeAction, "v1.0.0", "", resolver) + result := generateInstallCLISteps(context.Background(), ActionModeAction, "v1.0.0", "", resolver) expectedRef := "github/gh-aw-actions/setup-cli@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v1.0.0" if !strings.Contains(result, expectedRef) { t.Errorf("Action mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result) @@ -1104,7 +1105,7 @@ func TestGenerateInstallCLISteps(t *testing.T) { }) t.Run("release mode without resolver falls back to tag reference", func(t *testing.T) { - result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil) + result := generateInstallCLISteps(context.Background(), ActionModeRelease, "v1.0.0", "", nil) if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") { t.Errorf("Release mode without resolver should fall back to tag reference, got:\n%s", result) } @@ -1134,7 +1135,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode run_operation uses build from source", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1153,7 +1154,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("release mode run_operation uses setup-cli action not gh extension install", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1175,7 +1176,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode compile_workflows uses same codegen as run_operation", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1224,7 +1225,7 @@ func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) { cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd") resolver := NewActionResolver(cache) - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1263,7 +1264,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"my-custom-runner"}}, } - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1286,7 +1287,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"self-hosted", "linux"}}, } - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1308,7 +1309,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { t.Fatalf("Failed to write pre-existing file: %v", err) } cfg := &RepoConfig{MaintenanceDisabled: true} - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1320,7 +1321,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { t.Run("maintenance disabled skips generation even with expires", func(t *testing.T) { tmpDir := t.TempDir() cfg := &RepoConfig{MaintenanceDisabled: true} - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1342,7 +1343,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { } cfg := &RepoConfig{MaintenanceDisabled: true} // The function must succeed (no error), even though a warning is printed. - err := GenerateMaintenanceWorkflow(list, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") + err := GenerateMaintenanceWorkflow(context.Background(), list, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Expected no error when maintenance is disabled with expires, got: %v", err) } @@ -1614,7 +1615,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1668,7 +1669,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1697,7 +1698,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1742,7 +1743,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1771,7 +1772,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1816,7 +1817,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + err := GenerateMaintenanceWorkflow(context.Background(), workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 53f31cb68ab..4fe111de2c1 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -1,6 +1,7 @@ package workflow import ( + "context" "strconv" "strings" @@ -13,6 +14,7 @@ var maintenanceWorkflowYAMLLog = logger.New("workflow:maintenance_workflow_yaml" // agentics-maintenance.yml workflow. It is called by GenerateMaintenanceWorkflow // after the cron schedule and setup parameters have been resolved. func buildMaintenanceWorkflowYAML( + ctx context.Context, cronSchedule, scheduleDesc string, minExpiresDays int, runsOnValue string, @@ -123,7 +125,7 @@ jobs: steps: `) - setupActionRef := ResolveSetupActionReference(actionMode, version, actionTag, resolver) + setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) // Add checkout step only in dev/script mode (for local action paths) if actionMode == ActionModeDev || actionMode == ActionModeScript { @@ -253,7 +255,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Run operation uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: @@ -407,7 +409,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Create missing labels uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: @@ -454,7 +456,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Restore activity report logs cache id: activity_report_logs_cache uses: ` + getActionPin("actions/cache/restore") + ` @@ -607,7 +609,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Validate workflows and file issue on findings uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` @@ -752,7 +754,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Compile workflows run: | ` + getCLICmdPrefix(actionMode) + ` compile --validate --validate-images --verbose diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index 4ef36f0ccaf..273c39ec98c 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -1,6 +1,7 @@ package workflow import ( + "context" _ "embed" "fmt" "os" @@ -84,6 +85,7 @@ func effectiveSideRepoToken(checkout SideRepoTarget) string { // generateAllSideRepoMaintenanceWorkflows detects SideRepoOps targets and // generates a per-target maintenance workflow for each unique static repository. func generateAllSideRepoMaintenanceWorkflows( + ctx context.Context, workflowDataList []*WorkflowData, workflowDir string, version string, @@ -107,7 +109,7 @@ func generateAllSideRepoMaintenanceWorkflows( outPath := filepath.Join(workflowDir, filename) maintenanceLog.Printf("Generating side-repo maintenance workflow: %s → %s", target.Repository, filename) - if err := generateSideRepoMaintenanceWorkflow(target, outPath, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { + if err := generateSideRepoMaintenanceWorkflow(ctx, target, outPath, version, actionMode, actionTag, runsOnValue, resolver, hasExpires, minExpiresDays); err != nil { return fmt.Errorf("failed to generate side-repo maintenance workflow for %s: %w", target.Repository, err) } fmt.Fprintf(os.Stderr, " Generated side-repo maintenance workflow: %s\n", filename) @@ -146,6 +148,7 @@ func generateAllSideRepoMaintenanceWorkflows( // the target repository using the token from the checkout config and sets // GH_AW_TARGET_REPO_SLUG for all cross-repo operations. func generateSideRepoMaintenanceWorkflow( + ctx context.Context, target SideRepoTarget, outPath string, version string, @@ -234,7 +237,7 @@ jobs: ` yaml.WriteString(onSection) - setupActionRef := ResolveSetupActionReference(actionMode, version, actionTag, resolver) + setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) // Add close-expired-entities job only when any workflow uses expires. if hasExpires { @@ -392,7 +395,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Create missing labels in target repository uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: @@ -440,7 +443,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Restore activity report logs cache id: activity_report_logs_cache uses: ` + getActionPin("actions/cache/restore") + ` @@ -552,7 +555,7 @@ jobs: `) - yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) yaml.WriteString(` - name: Validate workflows and file issue on findings uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` env: From 92a3662ecfc8ca890f8757cbdf9705cd263e5bdb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 05:58:36 +0000 Subject: [PATCH 4/5] test: enhance context cancellation test to verify LatestSHA is empty on cancellation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c4820d8f-3c5c-401a-b1fc-d5fe0662fc5f Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/action_sha_checker_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/action_sha_checker_test.go b/pkg/workflow/action_sha_checker_test.go index a4b4156f5e2..4844ddf4e8d 100644 --- a/pkg/workflow/action_sha_checker_test.go +++ b/pkg/workflow/action_sha_checker_test.go @@ -178,15 +178,20 @@ func TestCheckActionSHAUpdates_ContextCancellation(t *testing.T) { resolver := NewActionResolver(cache) // With an already-cancelled context and no cached value, the resolver should - // attempt the resolution and propagate the cancellation. The result will either - // have an empty LatestSHA (because the request was cancelled) or behave like a - // cache miss. The important thing is that it does not hang. + // propagate the cancellation. The result will have an empty LatestSHA because + // the request was cancelled. The important thing is that it does not hang. checks := CheckActionSHAUpdates(ctx, actions, resolver) // Should still return one result per action even with cancellation if len(checks) != 1 { t.Errorf("Expected 1 check result, got %d", len(checks)) } + + // With a cancelled context and no cached SHA, the resolution fails and + // LatestSHA should be empty (no successful resolution occurred). + if checks[0].LatestSHA != "" { + t.Errorf("Expected empty LatestSHA when context is cancelled, got %q", checks[0].LatestSHA) + } } func TestExtractActionsFromLockFileNoActions(t *testing.T) { From b2f00f5f692d6de33c207aee1532b870a3fcfe61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 14:00:46 +0000 Subject: [PATCH 5/5] docs(adr): add draft ADR-30070 for context propagation through SHA resolution pipeline Generated by the Design Decision Gate workflow to document the decision to thread context.Context through all I/O-performing functions in the action SHA resolution and workflow compilation call stacks. Co-Authored-By: Claude Sonnet 4.6 --- ...context-through-sha-resolution-pipeline.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/adr/30070-propagate-context-through-sha-resolution-pipeline.md diff --git a/docs/adr/30070-propagate-context-through-sha-resolution-pipeline.md b/docs/adr/30070-propagate-context-through-sha-resolution-pipeline.md new file mode 100644 index 00000000000..bea3eb6732a --- /dev/null +++ b/docs/adr/30070-propagate-context-through-sha-resolution-pipeline.md @@ -0,0 +1,79 @@ +# ADR-30070: Propagate context.Context Through Action SHA Resolution and Compilation Pipeline + +**Date**: 2026-05-04 +**Status**: Draft +**Deciders**: Unknown (AI-generated draft — review and finalize before accepting) + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +The `gh-aw` CLI resolves GitHub Actions SHA pins by calling GitHub's REST API at multiple points during workflow compilation and the `add` command pipeline. These calls were previously made using hardcoded `context.Background()` contexts, which made every API call immune to cancellation and timeouts. When GitHub's API is slow or unreachable, the tool would hang indefinitely with no way for the caller — including Cobra command handlers, tests, or programmatic users — to interrupt the operation. Go's standard convention for cancellable I/O is to accept a `context.Context` as the first parameter and pass it through to all downstream I/O calls. + +### Decision + +We will thread `context.Context` as the first parameter through every function in the action SHA resolution and workflow compilation call stacks that performs or delegates I/O, replacing all internal uses of `context.Background()` with the caller-supplied context. Where threading context through a method signature is impractical (specifically for `Compiler` internal methods called from many dispatch sites), we store the context on the `Compiler` struct via `SetContext(ctx)` with a `context.Background()` fallback, making the intent visible while preserving backward compatibility for call sites that have no available context. + +### Alternatives Considered + +#### Alternative 1: Keep Hardcoded `context.Background()` (Status Quo) + +Every I/O call would continue to use `context.Background()`, ignoring any cancellation signal from the caller. This is simple and requires no API changes, but it means timeouts and context cancellations from Cobra (e.g., Ctrl-C) have no effect on in-flight GitHub API calls, causing potential indefinite hangs in CI and watch-mode scenarios. + +#### Alternative 2: Package-Level or Global Context Variable + +Store the "current" context in a package-level variable, updated at command entry points. This avoids changing dozens of function signatures but is not idiomatic Go, introduces implicit state shared across goroutines (data-race risk), and makes testing harder since tests cannot inject isolated contexts. This approach was rejected because the signature-threading approach, while verbose, is the standard Go pattern and is safe under concurrent test execution. + +#### Alternative 3: Full Context on `Compiler` Struct (Struct-Only Storage) + +Store context exclusively on the `Compiler` struct and access it through a method everywhere, avoiding parameter changes entirely. This would be more consistent but would hide context flow from function signatures, making it harder for readers to see which functions perform I/O. The hybrid approach chosen — threading context via parameters for most functions and falling back to struct storage only for compiler-internal methods — keeps the call graph readable for the common case. + +### Consequences + +#### Positive +- Callers (Cobra commands, integration tests, background agents) can now cancel or time out all GitHub API calls by cancelling the context they supply. +- `TestCheckActionSHAUpdates_ContextCancellation` demonstrates and enforces the new behavior: a pre-cancelled context causes resolution to skip cleanly rather than hang. +- The code is now conformant with Go's standard context propagation idiom throughout the I/O paths. + +#### Negative +- Every function in the changed call stack has a new leading `context.Context` parameter, which is a breaking API change for any external callers of the affected public functions (`AddResolvedWorkflows`, `CompileWorkflowWithValidation`, `CompileWorkflowDataWithValidation`, `CheckActionSHAUpdates`, `ValidateActionSHAsInLockFile`). +- The `Compiler` struct now carries mutable state (the stored context), which can be surprising in concurrent uses of a single `Compiler` instance. +- Call sites that have no meaningful context (e.g., `enable.go` watch-mode compilation) must explicitly pass `context.Background()`, adding boilerplate without behavioral change. + +#### Neutral +- `FetchDefaultBranch` switches from `RunGH` to `RunGHContext` as a consequence of receiving a real context; callers see no change in behavior when the context is not cancelled. +- The `Compiler.context()` accessor falls back to `context.Background()` if `SetContext` was never called, preserving existing behavior for callers that have not been updated yet. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Context Propagation in I/O Functions + +1. Any function that performs or delegates a GitHub API call, git remote operation, or other network I/O **MUST** accept a `context.Context` as its first parameter. +2. Functions **MUST NOT** replace a caller-supplied context with `context.Background()` before passing it to a downstream I/O call unless the function is explicitly documented as a fire-and-forget background operation. +3. Callers that have no meaningful context available (e.g., watch-mode loops, legacy wrappers) **SHOULD** pass `context.Background()` explicitly and document why they cannot propagate a richer context. +4. New internal helpers that perform only pure computation (no I/O) **MAY** omit the context parameter. + +### Compiler Context Storage + +1. When threading context through every method signature of `Compiler` is impractical, implementations **MUST** call `compiler.SetContext(ctx)` before invoking compilation so that compiler-internal I/O inherits the caller's context. +2. `Compiler.context()` **MUST** return `context.Background()` as a fallback when no context has been set via `SetContext`, ensuring the `Compiler` is safe to use without explicit context injection. +3. `Compiler` instances **SHOULD NOT** be shared across goroutines after `SetContext` has been called, because the stored context field is mutable and not protected by a mutex. + +### Testing + +1. Tests for functions that accept a context **MUST** pass a real (non-nil) context, and **SHOULD** include at least one test case that verifies behavior under a pre-cancelled context. +2. Tests **MUST NOT** pass `nil` as a context argument; they **MUST** pass at minimum `context.Background()`. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25323021162) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*