diff --git a/docs/adr/26879-activation-job-builder-context-phase-based-construction.md b/docs/adr/26879-activation-job-builder-context-phase-based-construction.md new file mode 100644 index 00000000000..1f30038ccc0 --- /dev/null +++ b/docs/adr/26879-activation-job-builder-context-phase-based-construction.md @@ -0,0 +1,108 @@ +# ADR-26879: Activation Job Builder Context for Phase-Based Construction + +**Date**: 2026-04-17 +**Status**: Draft +**Deciders**: pelikhan, Copilot + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +`pkg/workflow/compiler_activation_job.go` grew to 1084 lines with a single `buildActivationJob` +function of 624 lines, triggering Architecture Guardian violations for both file-size and +function-size thresholds. The function accumulated 15+ local variables (steps, outputs, reaction +flags, label-command state, app-token flags) that were threaded through dozens of inline logic +blocks. A straightforward extraction into private helpers would require passing that growing set +of parameters between each helper, trading one form of complexity for another. + +### Decision + +We will introduce a dedicated `activationJobBuildContext` struct to carry all mutable construction +state across focused builder phases. The orchestrator (`buildActivationJob`) becomes a thin +sequencer that calls phase methods such as `addActivationFeedbackAndValidationSteps` and +`addActivationRepositoryAndOutputSteps`; each phase reads from and mutates the shared context. +Builder logic is co-located in a new file, `compiler_activation_job_builder.go`, while the +orchestrator remains in `compiler_activation_job.go`. This keeps parameter lists minimal and +makes each construction phase independently readable. + +### Alternatives Considered + +#### Alternative 1: Parameter Threading + +Extract helper functions that receive individual parameters for each piece of state they need. +This avoids a new struct type and keeps each helper's dependencies explicit in its signature. +It was rejected because the activation job manages 15+ distinct state fields; threading them +through every helper would produce large, fragile parameter lists and obscure which helpers +modify which state. + +#### Alternative 2: Immutable Fluent Builder + +Adopt a fluent builder pattern where each phase returns a new builder value (immutable-style), +offering clearer data-flow audit trails. It was not chosen because activation-job construction +is always sequential and single-call; the overhead of immutable value copying adds complexity +without a practical benefit in this synchronous, single-goroutine context. + +#### Alternative 3: Leave the Monolithic Function Intact + +Keep `buildActivationJob` as a single 624-line function and suppress or accept the Architecture +Guardian violations. This was rejected because the violations represent genuine readability and +maintainability problems, not false positives, and deferring the split would make future +extensions progressively harder. + +### Consequences + +#### Positive +- `buildActivationJob` shrinks from 624 lines to ~40 lines, making the construction sequence immediately legible. +- `compiler_activation_job.go` is reduced from 1084 to 497 lines, resolving the file-size violation. +- Each builder phase is independently readable and has a well-defined mutation contract documented via Go doc comments. +- Error propagation is centralised at the orchestration boundary with a consistent `fmt.Errorf("...: %w", err)` convention. + +#### Negative +- `activationJobBuildContext` is a mutable, order-dependent struct; phases must be invoked in the correct sequence or the context can be left in an inconsistent state. +- The new builder file (`compiler_activation_job_builder.go`) is 473 lines and may itself approach size limits if activation logic continues to grow. +- Unit-testing individual phases requires constructing a populated `activationJobBuildContext`, increasing test-setup boilerplate slightly. + +#### Neutral +- Both files remain in the same `workflow` package; build constraints and imports are unchanged. +- `activationJobBuildContext` is unexported, so this is a purely internal refactor with no public API surface change. + +--- + +## 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). + +### Build Context Lifecycle + +1. Implementations **MUST** create a new `activationJobBuildContext` via `newActivationJobBuildContext` before invoking any builder phase method. +2. Implementations **MUST NOT** share a single `activationJobBuildContext` instance across multiple concurrent `buildActivationJob` invocations. +3. The `activationJobBuildContext` struct **MUST** remain unexported and **MUST NOT** appear in any public API surface. +4. Implementations **MUST NOT** read fields of `activationJobBuildContext` before they are populated by the appropriate initialisation or phase method. + +### Phase Sequencing + +1. Builder phase methods **MUST** be invoked in the order defined in `buildActivationJob`: feedback/validation → repository/outputs → command/label outputs → needs/condition → prompt generation → artifact upload. +2. Phase methods that can return an error **MUST** have their errors propagated; errors **MUST NOT** be silently ignored. +3. New builder phases **SHOULD** be added as dedicated `*Compiler` methods rather than inlined into `buildActivationJob`. +4. Each phase method **SHOULD** include a Go doc comment describing which `activationJobBuildContext` fields it reads and which it mutates. + +### File Organisation + +1. The orchestrator function `buildActivationJob` **MUST** remain in `compiler_activation_job.go`. +2. The `activationJobBuildContext` type definition and all phase methods **MUST** reside in `compiler_activation_job_builder.go`. +3. Both files **MUST** remain in the `workflow` package. +4. Either file **SHOULD NOT** exceed 600 lines; if a file approaches this limit, phase logic **SHOULD** be extracted into additional focused files within the same package. + +### 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/24573630987) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 751bb94356e..bf4f9d9c343 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -1,15 +1,11 @@ package workflow import ( - "encoding/json" - "errors" "fmt" - "slices" "strings" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/stringutil" "github.com/goccy/go-yaml" ) @@ -29,627 +25,44 @@ var activationMetadataTriggerFields = map[string]struct{}{ // buildActivationJob creates the activation job that handles timestamp checking, reactions, and locking. // This job depends on the pre-activation job if it exists, and runs before the main agent job. func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreated bool, workflowRunRepoSafety string, lockFilename string) (*Job, error) { - outputs := map[string]string{} - var steps []string - - // Team member check is now handled by the separate check_membership job - // No inline role checks needed in the task job anymore - - // Add setup step to copy activation scripts (required - no inline fallback) - setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef == "" { - return nil, errors.New("setup action reference is required but could not be resolved") - } - - // For dev mode (local action path), checkout the actions folder first - steps = append(steps, c.generateCheckoutActionsFolder(data)...) - - // Activation job doesn't need project support (no safe outputs processed here) - // When a pre-activation job exists, reuse its trace ID so all three jobs (pre_activation, - // activation, agent) share a single OTLP trace. When no pre-activation job exists, the - // empty string instructs the setup action to generate a new root trace ID. - activationSetupTraceID := "" - if preActivationJobCreated { - activationSetupTraceID = fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.PreActivationJobName) - } - steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false, activationSetupTraceID)...) - // Expose the trace ID for cross-job span correlation so downstream jobs can reuse it - outputs["setup-trace-id"] = "${{ steps.setup.outputs.trace-id }}" - - // Mask OTLP telemetry headers immediately after setup so authentication tokens cannot - // leak into runner debug logs for any subsequent step in the activation job. - if isOTLPHeadersPresent(data) { - steps = append(steps, generateOTLPHeadersMaskStep()) - } - - // When a workflow_call trigger is present, resolve the platform (host) repository using - // the job.workflow_* context fields. job.workflow_repository identifies the - // platform repo and job.workflow_sha pins the checkout to the exact executing revision, - // correctly handling all relay patterns including cross-repo and cross-org scenarios. - if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { - compilerActivationJobLog.Print("Adding resolve-host-repo step for workflow_call trigger") - steps = append(steps, c.generateResolveHostRepoStep(data)) - } - - // In workflow_call context, compute a unique artifact prefix from a hash of the - // workflow inputs. This prefix is applied to all artifact names so that multiple - // callers of the same reusable workflow can run concurrently in the same workflow - // run without artifact name collisions. - if hasWorkflowCallTrigger(data.On) { - compilerActivationJobLog.Print("Adding artifact prefix computation step for workflow_call trigger") - steps = append(steps, generateArtifactPrefixStep()...) - outputs[constants.ArtifactPrefixOutputName] = "${{ steps.artifact-prefix.outputs.prefix }}" - } - - // Generate agentic run info immediately after setup so aw_info.json is ready as early as - // possible. This step runs before the reaction so that its data is captured even if the - // reaction step fails. - engine, err := c.getAgenticEngine(data.AI) + ctx, err := c.newActivationJobBuildContext(data, preActivationJobCreated, workflowRunRepoSafety, lockFilename) if err != nil { - return nil, fmt.Errorf("failed to get agentic engine: %w", err) - } - compilerActivationJobLog.Print("Generating aw_info step in activation job") - var awInfoYaml strings.Builder - c.generateCreateAwInfo(&awInfoYaml, data, engine) - steps = append(steps, awInfoYaml.String()) - // Expose the model output from the activation job so downstream jobs can reference it - outputs["model"] = "${{ steps.generate_aw_info.outputs.model }}" - // Track whether the lockdown check failed so the conclusion job can surface - // the configuration error in the failure issue even when the agent never ran. - outputs["lockdown_check_failed"] = "${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}" - // Track whether the frontmatter hash check failed (stale lock file detected) so the - // conclusion job can surface a specialised failure issue with remediation guidance. - // The output is only present when stale-check is enabled (the default). - if !data.StaleCheckDisabled { - outputs["stale_lock_file_failed"] = "${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}" - } - - // Expose the resolved platform (host) repository and ref so agent and safe_outputs jobs - // can use needs.activation.outputs.target_repo / target_ref for any checkout that must - // target the platform repo and branch rather than github.repository (the caller's repo in - // cross-repo workflow_call scenarios, especially when pinned to a non-default branch). - if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { - outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}" - outputs["target_repo_name"] = "${{ steps.resolve-host-repo.outputs.target_repo_name }}" - outputs["target_ref"] = "${{ steps.resolve-host-repo.outputs.target_ref }}" - } - - // Compute reaction/comment/label flags early so the app token and reaction steps can be - // inserted right after generate_aw_info for fast user feedback. - hasReaction := data.AIReaction != "" && data.AIReaction != "none" - reactionIncludesIssues := shouldIncludeIssueReactions(data) - reactionIncludesPullRequests := shouldIncludePullRequestReactions(data) - reactionIncludesDiscussions := shouldIncludeDiscussionReactions(data) - hasStatusComment := data.StatusComment != nil && *data.StatusComment - statusCommentIncludesIssues := shouldIncludeIssueStatusComments(data) - statusCommentIncludesPullRequests := shouldIncludePullRequestStatusComments(data) - statusCommentIncludesDiscussions := shouldIncludeDiscussionStatusComments(data) - hasLabelCommand := len(data.LabelCommand) > 0 - // shouldRemoveLabel is true when label-command is active AND remove_label is not disabled - shouldRemoveLabel := hasLabelCommand && data.LabelCommandRemoveLabel - // Compute filtered label events once and reuse below (permissions + app token scopes) - filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents) - - // needsAppTokenForRepoAccess is true when the GitHub App token is needed for reading - // the callee's repository contents — specifically for the .github checkout step and the - // lock-file hash check step in cross-org workflow_call scenarios. - needsAppTokenForRepoAccess := data.ActivationGitHubApp != nil && !data.StaleCheckDisabled - - // Mint a single activation app token upfront if a GitHub App is configured and any - // step in the activation job will need it (reaction, status-comment, label removal, - // or repository access for checkout/hash-check). - // This avoids minting multiple tokens. - if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || shouldRemoveLabel || needsAppTokenForRepoAccess) { - // Build the combined permissions needed for all activation steps. - // For label removal we only add the scopes required by the enabled events. - appPerms := NewPermissions() - addActivationInteractionPermissions( - appPerms, - data.On, - hasReaction, - reactionIncludesIssues, - reactionIncludesPullRequests, - reactionIncludesDiscussions, - hasStatusComment, - statusCommentIncludesIssues, - statusCommentIncludesPullRequests, - statusCommentIncludesDiscussions, - ) - if shouldRemoveLabel { - if slices.Contains(filteredLabelEvents, "issues") || slices.Contains(filteredLabelEvents, "pull_request") { - appPerms.Set(PermissionIssues, PermissionWrite) - } - if slices.Contains(filteredLabelEvents, "discussion") { - appPerms.Set(PermissionDiscussions, PermissionWrite) - } - } - if needsAppTokenForRepoAccess { - // contents:read is needed for the .github checkout and the lock-file hash check. - appPerms.Set(PermissionContents, PermissionRead) - } - steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...) - // Track whether the token minting succeeded so the conclusion job can surface - // GitHub App authentication errors in the failure issue. - outputs["activation_app_token_minting_failed"] = "${{ steps.activation-app-token.outcome == 'failure' }}" - } - - // Add reaction step right after generate_aw_info so it is shown to the user as fast as - // possible. generate_aw_info runs first so its data is captured even if the reaction fails. - // This runs in the activation job so it can use any configured github-token or github-app. - if hasReaction { - reactionCondition := BuildReactionConditionForTargets( - reactionIncludesIssues, - reactionIncludesPullRequests, - reactionIncludesDiscussions, - ) - - steps = append(steps, fmt.Sprintf(" - name: Add %s reaction for immediate feedback\n", data.AIReaction)) - steps = append(steps, " id: react\n") - steps = append(steps, fmt.Sprintf(" if: %s\n", RenderCondition(reactionCondition))) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - - // Add environment variables - steps = append(steps, " env:\n") - // Quote the reaction value to prevent YAML interpreting +1/-1 as integers - steps = append(steps, fmt.Sprintf(" GH_AW_REACTION: %q\n", data.AIReaction)) - - steps = append(steps, " with:\n") - // Use configured github-token or app-minted token; fall back to GITHUB_TOKEN - steps = append(steps, fmt.Sprintf(" github-token: %s\n", c.resolveActivationToken(data))) - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("add_reaction.cjs")) - } - - // Add secret validation step before context variable validation. - // This validates that the required engine secrets are available before any other checks. - secretValidationStep := engine.GetSecretValidationStep(data) - if len(secretValidationStep) > 0 { - for _, line := range secretValidationStep { - steps = append(steps, line+"\n") - } - outputs["secret_verification_result"] = "${{ steps.validate-secret.outputs.verification_result }}" - compilerActivationJobLog.Printf("Added validate-secret step to activation job") - } else { - compilerActivationJobLog.Printf("Skipped validate-secret step (engine does not require secret validation)") - } - - // Add cross-repo setup guidance when workflow_call is a trigger. - // This step only runs when secret validation fails in a cross-repo context, - // providing actionable guidance to the caller team about configuring secrets. - // Use steps.resolve-host-repo.outputs.target_repo != github.repository instead of - // github.event_name == 'workflow_call': the latter never fires for event-driven relays - // (issue_comment/push → workflow_call) where the event_name is the originating event. - if hasWorkflowCallTrigger(data.On) { - compilerActivationJobLog.Print("Adding cross-repo setup guidance step for workflow_call trigger") - steps = append(steps, " - name: Print cross-repo setup guidance\n") - steps = append(steps, " if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository\n") - steps = append(steps, " run: |\n") - steps = append(steps, " echo \"::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets.\"\n") - steps = append(steps, " echo \"::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow.\"\n") - steps = append(steps, " echo \"::error::See: https://github.github.com/gh-aw/patterns/central-repo-ops/#cross-repo-setup\"\n") - } - - // Checkout .github and .agents folders for accessing workflow configurations and runtime imports - // This is needed for prompt generation which may reference runtime imports from .github folder - // Always add this checkout in activation job since it needs access to workflow files for runtime imports - checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data) - steps = append(steps, checkoutSteps...) - - // Save agent config folders from the sparse checkout into /tmp/gh-aw/base/ so they can be - // included in the activation artifact and later restored in the agent job after the PR checkout. - // This prevents fork PRs from overwriting trusted skill/instruction files with malicious content. - // The folder and file lists are derived from the engine registry so no manual sync is needed. - if len(checkoutSteps) > 0 { - compilerActivationJobLog.Print("Adding step to save agent config folders for base branch restoration") - registry := GetGlobalEngineRegistry() - steps = append(steps, generateSaveBaseGitHubFoldersStep( - registry.GetAllAgentManifestFolders(), - registry.GetAllAgentManifestFiles(), - )...) - } - - // Add frontmatter hash check to detect stale lock files using GitHub API. - // Compares the hash embedded in the lock file against the source .md file to detect stale lock files. - // No checkout step needed - uses GitHub API to fetch file contents. - // Skipped when on.stale-check: false is set in the frontmatter. - if !data.StaleCheckDisabled { - steps = append(steps, " - name: Check workflow lock file\n") - steps = append(steps, " id: check-lock-file\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename)) - // Inject the GitHub Actions context workflow_ref expression as GH_AW_CONTEXT_WORKFLOW_REF - // for check_workflow_timestamp_api.cjs. Note: despite what was previously documented, - // ${{ github.workflow_ref }} resolves to the CALLER's workflow ref in reusable workflow - // contexts, not the callee's. The referenced_workflows API lookup in the script is the - // primary mechanism for resolving the callee's repo; GH_AW_CONTEXT_WORKFLOW_REF serves - // as a fallback when the API is unavailable or finds no matching entry. - steps = append(steps, " GH_AW_CONTEXT_WORKFLOW_REF: \"${{ github.workflow_ref }}\"\n") - steps = append(steps, " with:\n") - // Use configured github-token or app-minted token if set; omit to use default GITHUB_TOKEN. - // This is required for cross-org workflow_call where the default GITHUB_TOKEN cannot - // access the callee's repository contents via API. - hashToken := c.resolveActivationToken(data) - if hashToken != "${{ secrets.GITHUB_TOKEN }}" { - steps = append(steps, fmt.Sprintf(" github-token: %s\n", hashToken)) - } - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs")) - } - - // Add compile-agentic version update check, unless disabled via check-for-updates: false. - // The check downloads .github/aw/releases.json from the gh-aw repository and verifies that the - // compiled version is not blocked and meets the minimum supported version requirement. - // If the download fails, the check is skipped (soft failure). - if !data.UpdateCheckDisabled && IsReleasedVersion(c.version) { - steps = append(steps, " - name: Check compile-agentic version\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_COMPILED_VERSION: \"%s\"\n", c.version)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_version_updates.cjs")) - } - - // Generate sanitized text/title/body outputs if needed - // This step computes sanitized versions of the triggering content (issue/PR/comment text, title, body) - // and makes them available as step outputs. - // - // IMPORTANT: These outputs are referenced as steps.sanitized.outputs.{text|title|body} in workflow markdown. - // Users should use ${{ steps.sanitized.outputs.text }} directly in their workflows. - // The outputs are also exposed as needs.activation.outputs.* for downstream jobs. - if data.NeedsTextOutput { - steps = append(steps, " - name: Compute current body text\n") - steps = append(steps, " id: sanitized\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - if len(data.Bots) > 0 { - steps = append(steps, " env:\n") - steps = append(steps, formatYAMLEnv(" ", "GH_AW_ALLOWED_BOTS", strings.Join(data.Bots, ","))) - } - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("compute_text.cjs")) - - // Set up outputs - includes text, title, and body - // These are exposed as needs.activation.outputs.* for downstream jobs - // and as steps.sanitized.outputs.* within the activation job (where prompts are rendered) - outputs["text"] = "${{ steps.sanitized.outputs.text }}" - outputs["title"] = "${{ steps.sanitized.outputs.title }}" - outputs["body"] = "${{ steps.sanitized.outputs.body }}" - } - - // Add comment with workflow run link if status comments are explicitly enabled - if data.StatusComment != nil && *data.StatusComment { - statusCommentCondition := BuildStatusCommentCondition( - statusCommentIncludesIssues, - statusCommentIncludesPullRequests, - statusCommentIncludesDiscussions, - ) - - steps = append(steps, " - name: Add comment with workflow run link\n") - steps = append(steps, " id: add-comment\n") - steps = append(steps, fmt.Sprintf(" if: %s\n", RenderCondition(statusCommentCondition))) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - - // Add environment variables - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", data.Name)) - - // Add tracker-id if present - if data.TrackerID != "" { - steps = append(steps, fmt.Sprintf(" GH_AW_TRACKER_ID: %q\n", data.TrackerID)) - } - - // Add lock-for-agent status if enabled - if data.LockForAgent { - steps = append(steps, " GH_AW_LOCK_FOR_AGENT: \"true\"\n") - } - - // Pass custom messages config if present (for custom run-started messages) - if data.SafeOutputs != nil && data.SafeOutputs.Messages != nil { - messagesJSON, err := serializeMessagesConfig(data.SafeOutputs.Messages) - if err != nil { - return nil, fmt.Errorf("failed to serialize messages config for activation job: %w", err) - } - if messagesJSON != "" { - steps = append(steps, fmt.Sprintf(" GH_AW_SAFE_OUTPUT_MESSAGES: %q\n", messagesJSON)) - } - } - - steps = append(steps, " with:\n") - // Use configured github-token or app-minted token if set; omit to use default GITHUB_TOKEN - commentToken := c.resolveActivationToken(data) - if commentToken != "${{ secrets.GITHUB_TOKEN }}" { - steps = append(steps, fmt.Sprintf(" github-token: %s\n", commentToken)) - } - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("add_workflow_run_comment.cjs")) - - // Add comment outputs - outputs["comment_id"] = "${{ steps.add-comment.outputs.comment-id }}" - outputs["comment_url"] = "${{ steps.add-comment.outputs.comment-url }}" - outputs["comment_repo"] = "${{ steps.add-comment.outputs.comment-repo }}" + return nil, fmt.Errorf("failed to create activation job build context: %w", err) } - // Add lock step if lock-for-agent is enabled - if data.LockForAgent { - // Build condition: only lock if event type is 'issues' or 'issue_comment' - // lock-for-agent can be configured under on.issues or on.issue_comment - // For issue_comment events, context.issue.number automatically resolves to the parent issue - lockCondition := BuildOr( - BuildEventTypeEquals("issues"), - BuildEventTypeEquals("issue_comment"), - ) - - steps = append(steps, " - name: Lock issue for agent workflow\n") - steps = append(steps, " id: lock-issue\n") - steps = append(steps, fmt.Sprintf(" if: %s\n", RenderCondition(lockCondition))) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("lock-issue.cjs")) - - // Add output for tracking if issue was locked - outputs["issue_locked"] = "${{ steps.lock-issue.outputs.locked }}" - - // Add lock message to reaction comment if reaction is enabled - if data.AIReaction != "" && data.AIReaction != "none" { - compilerActivationJobLog.Print("Adding lock notification to reaction message") - } + if err := c.addActivationFeedbackAndValidationSteps(ctx); err != nil { + return nil, err } - - // Always declare comment_id and comment_repo outputs to avoid actionlint errors - // These will be empty if no reaction is configured, and the scripts handle empty values gracefully - // Use plain empty strings (quoted) to avoid triggering security scanners like zizmor - if _, exists := outputs["comment_id"]; !exists { - outputs["comment_id"] = `""` + if err := c.addActivationRepositoryAndOutputSteps(ctx); err != nil { + return nil, err } - if _, exists := outputs["comment_repo"]; !exists { - outputs["comment_repo"] = `""` + if err := c.addActivationCommandAndLabelOutputs(ctx); err != nil { + return nil, err } - // Add slash_command output if this is a command workflow - // This output contains the matched command name from check_command_position step - if len(data.Command) > 0 { - if preActivationJobCreated { - // Reference the matched_command output from pre_activation job - outputs["slash_command"] = fmt.Sprintf("${{ needs.%s.outputs.%s }}", string(constants.PreActivationJobName), constants.MatchedCommandOutput) - } else { - // Fallback to steps reference if pre_activation doesn't exist (shouldn't happen for command workflows) - outputs["slash_command"] = fmt.Sprintf("${{ steps.%s.outputs.%s }}", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput) - } - } - - // Add label removal step and label_command output for label-command workflows. - // When a label-command trigger fires, the triggering label is immediately removed - // so that the same label can be applied again to trigger the workflow in the future. - // This step is skipped when remove_label is set to false. - if shouldRemoveLabel { - // The removal step only makes sense for actual "labeled" events; for - // workflow_dispatch we skip it silently via the env-based label check. - steps = append(steps, " - name: Remove trigger label\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.RemoveTriggerLabelStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - steps = append(steps, " env:\n") - // Pass label names as a JSON array so the script can validate the label - labelNamesJSON, err := json.Marshal(data.LabelCommand) - if err != nil { - return nil, fmt.Errorf("failed to marshal label-command names: %w", err) - } - steps = append(steps, formatYAMLEnv(" ", "GH_AW_LABEL_NAMES", string(labelNamesJSON))) - steps = append(steps, " with:\n") - // Use GitHub App or custom token if configured (avoids needing elevated GITHUB_TOKEN permissions) - labelToken := c.resolveActivationToken(data) - if labelToken != "${{ secrets.GITHUB_TOKEN }}" { - steps = append(steps, fmt.Sprintf(" github-token: %s\n", labelToken)) - } - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) - - // Expose the matched label name as a job output for downstream jobs to consume - outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.RemoveTriggerLabelStepID) - } else if hasLabelCommand { - // When remove_label is disabled, emit a github-script step that runs get_trigger_label.cjs - // (via generateGitHubScriptWithRequire) to safely resolve the triggering command name for - // both label_command and slash_command events and emit a unified `command_name` output - // (plus a `label_name` alias). - steps = append(steps, " - name: Get trigger label name\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.GetTriggerLabelStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) - // Pass the pre-computed matched slash-command (if any) so the script can provide a - // unified command_name for workflows that have both label_command and slash_command. - if len(data.Command) > 0 { - steps = append(steps, " env:\n") - if preActivationJobCreated { - steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ needs.%s.outputs.%s }}\n", string(constants.PreActivationJobName), constants.MatchedCommandOutput)) - } else { - steps = append(steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ steps.%s.outputs.%s }}\n", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput)) - } - } - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("get_trigger_label.cjs")) - outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.GetTriggerLabelStepID) - outputs["command_name"] = fmt.Sprintf("${{ steps.%s.outputs.command_name }}", constants.GetTriggerLabelStepID) - } - - // If no steps have been added, add a placeholder step to make the job valid - // This can happen when the activation job is created only for an if condition - if len(steps) == 0 { - steps = append(steps, " - run: echo \"Activation success\"\n") - } - - // Build the conditional expression that validates activation status and other conditions - var activationNeeds []string - var activationCondition string - - // Find custom jobs that depend on pre_activation - these run before activation - customJobsBeforeActivation := c.getCustomJobsDependingOnPreActivation(data.Jobs) - - // Find custom jobs whose outputs are referenced in the markdown body but have no explicit needs. - // These jobs must run before activation so their outputs are available when the activation job - // builds the prompt. Without this, activation would reference their outputs while they haven't - // run yet, causing actionlint errors and incorrect prompt substitutions. - promptReferencedJobs := c.getCustomJobsReferencedInPromptWithNoActivationDep(data) - for _, jobName := range promptReferencedJobs { - if !slices.Contains(customJobsBeforeActivation, jobName) { - customJobsBeforeActivation = append(customJobsBeforeActivation, jobName) - compilerActivationJobLog.Printf("Added '%s' to activation dependencies: referenced in markdown body and has no explicit needs", jobName) - } - } - - if preActivationJobCreated { - // Activation job depends on pre-activation job and checks the "activated" output - activationNeeds = []string{string(constants.PreActivationJobName)} - - // Also depend on custom jobs that run after pre_activation but before activation - activationNeeds = append(activationNeeds, customJobsBeforeActivation...) - - activatedExpr := BuildEquals( - BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.%s", string(constants.PreActivationJobName), constants.ActivatedOutput)), - BuildStringLiteral("true"), - ) - - // If there are custom jobs before activation and the if condition references them, - // include that condition in the activation job's if clause - if data.If != "" && c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { - // Include the custom job output condition in the activation job - unwrappedIf := stripExpressionWrapper(data.If) - ifExpr := &ExpressionNode{Expression: unwrappedIf} - combinedExpr := BuildAnd(activatedExpr, ifExpr) - activationCondition = RenderCondition(combinedExpr) - } else if data.If != "" && !c.referencesCustomJobOutputs(data.If, data.Jobs) { - // Include user's if condition that doesn't reference custom jobs - unwrappedIf := stripExpressionWrapper(data.If) - ifExpr := &ExpressionNode{Expression: unwrappedIf} - combinedExpr := BuildAnd(activatedExpr, ifExpr) - activationCondition = RenderCondition(combinedExpr) - } else { - activationCondition = RenderCondition(activatedExpr) - } - } else { - // No pre-activation check needed - // Add custom jobs that would run before activation as dependencies - activationNeeds = append(activationNeeds, customJobsBeforeActivation...) - - if data.If != "" && c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { - // Include the custom job output condition - activationCondition = data.If - } else if !c.referencesCustomJobOutputs(data.If, data.Jobs) { - activationCondition = data.If - } - } - - // Apply workflow_run repository safety check exclusively to activation job - // This check is combined with any existing activation condition - if workflowRunRepoSafety != "" { - activationCondition = c.combineJobIfConditions(activationCondition, workflowRunRepoSafety) - } - - // Generate prompt in the activation job (before agent job runs) + c.configureActivationNeedsAndCondition(ctx) compilerActivationJobLog.Print("Generating prompt in activation job") - c.generatePromptInActivationJob(&steps, data, preActivationJobCreated, customJobsBeforeActivation) - - // APM packaging is handled by the separate "apm" job that depends on activation. - // That job packs the bundle and uploads it as an artifact; the agent job then - // depends on the apm job to download and restore it. - - // Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download. - // In workflow_call context the artifact is prefixed to avoid name clashes when multiple callers - // invoke the same reusable workflow within the same parent workflow run. - compilerActivationJobLog.Print("Adding activation artifact upload step") - activationArtifactName := artifactPrefixExprForActivationJob(data) + constants.ActivationArtifactName - steps = append(steps, " - name: Upload activation artifact\n") - steps = append(steps, " if: success()\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/upload-artifact"))) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" name: %s\n", activationArtifactName)) - steps = append(steps, " path: |\n") - steps = append(steps, " /tmp/gh-aw/aw_info.json\n") - steps = append(steps, " /tmp/gh-aw/aw-prompts/prompt.txt\n") - steps = append(steps, " /tmp/gh-aw/"+constants.GithubRateLimitsFilename+"\n") - steps = append(steps, " /tmp/gh-aw/base\n") - steps = append(steps, " if-no-files-found: ignore\n") - steps = append(steps, " retention-days: 1\n") - - // Set permissions - activation job always needs contents:read for GitHub API access - // Also add reaction/comment permissions if reaction or status-comment is configured - // Also add issues:write permission if lock-for-agent is enabled (for locking issues) - permsMap := map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionRead, // Always needed for GitHub API access to check file commits - } - - // Add actions:read permission when the hash check API step is emitted. - // check_workflow_timestamp_api.cjs calls github.rest.actions.getWorkflowRun() which - // requires the actions:read scope. GitHub Actions enforces explicit permissions when - // any permissions block is present, so we must add it explicitly. - if !data.StaleCheckDisabled { - permsMap[PermissionActions] = PermissionRead - } - - addActivationInteractionPermissionsMap( - permsMap, - data.On, - hasReaction, - reactionIncludesIssues, - reactionIncludesPullRequests, - reactionIncludesDiscussions, - hasStatusComment, - statusCommentIncludesIssues, - statusCommentIncludesPullRequests, - statusCommentIncludesDiscussions, - ) - - // Add issues:write permission if lock-for-agent is enabled (even without reaction) - if data.LockForAgent { - permsMap[PermissionIssues] = PermissionWrite - } - - // Add write permissions for label removal when label_command is configured and remove_label is enabled. - // Only grant the scopes required by the enabled events: - // - issues/pull_request events need issues:write (PR labels use the issues REST API) - // - discussion events need discussions:write - // When a github-app token is configured, the GITHUB_TOKEN permissions are irrelevant - // for the label removal step (it uses the app token instead), so we skip them. - // When remove_label is false, no label removal occurs so these permissions are not needed. - if shouldRemoveLabel && data.ActivationGitHubApp == nil { - if slices.Contains(filteredLabelEvents, "issues") || slices.Contains(filteredLabelEvents, "pull_request") { - permsMap[PermissionIssues] = PermissionWrite - } - if slices.Contains(filteredLabelEvents, "discussion") { - permsMap[PermissionDiscussions] = PermissionWrite - } + c.generatePromptInActivationJob(&ctx.steps, data, preActivationJobCreated, ctx.customJobsBeforeActivation) + c.addActivationArtifactUploadStep(ctx) + if len(ctx.steps) == 0 { + ctx.steps = append(ctx.steps, " - run: echo \"Activation success\"\n") } - perms := NewPermissionsFromMap(permsMap) - permissions := perms.RenderToYAML() - - // Set environment if manual-approval is configured - var environment string - if data.ManualApproval != "" { - // Strip ANSI escape codes from manual-approval environment name - cleanManualApproval := stringutil.StripANSI(data.ManualApproval) - environment = "environment: " + cleanManualApproval - } - - // In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode). if c.actionMode.IsScript() { - steps = append(steps, c.generateScriptModeCleanupStep()) + ctx.steps = append(ctx.steps, c.generateScriptModeCleanupStep()) } - job := &Job{ + return &Job{ Name: string(constants.ActivationJobName), - If: activationCondition, - HasWorkflowRunSafetyChecks: workflowRunRepoSafety != "", // Mark job as having workflow_run safety checks + If: ctx.activationCondition, + HasWorkflowRunSafetyChecks: workflowRunRepoSafety != "", RunsOn: c.formatFrameworkJobRunsOn(data), - Permissions: permissions, - Environment: environment, - Steps: steps, - Outputs: outputs, - Needs: activationNeeds, // Depend on pre-activation job if it exists - } - - return job, nil + Permissions: c.buildActivationPermissions(ctx), + Environment: c.buildActivationEnvironment(ctx), + Steps: ctx.steps, + Outputs: ctx.outputs, + Needs: ctx.activationNeeds, + }, nil } func addActivationInteractionPermissions( diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go new file mode 100644 index 00000000000..91c33c1dd3c --- /dev/null +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -0,0 +1,473 @@ +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/stringutil" +) + +// activationJobBuildContext carries mutable state while composing the activation job. +// It is created once by newActivationJobBuildContext, then incrementally mutated by +// helper methods in buildActivationJob, and discarded after the final Job is assembled. +type activationJobBuildContext struct { + data *WorkflowData + preActivationJob bool + workflowRunRepoSafety string + lockFilename string + steps []string + outputs map[string]string + engine CodingAgentEngine + hasReaction bool + reactionIssues bool + reactionPullRequests bool + reactionDiscussions bool + hasStatusComment bool + statusCommentIssues bool + statusCommentPRs bool + statusCommentDiscussions bool + hasLabelCommand bool + shouldRemoveLabel bool + filteredLabelEvents []string + needsAppTokenForAccess bool + + customJobsBeforeActivation []string + activationNeeds []string + activationCondition string +} + +// newActivationJobBuildContext initializes activation-job state with setup, aw_info, and base outputs. +func (c *Compiler) newActivationJobBuildContext( + data *WorkflowData, + preActivationJobCreated bool, + workflowRunRepoSafety string, + lockFilename string, +) (*activationJobBuildContext, error) { + setupActionRef := c.resolveActionReference("./actions/setup", data) + if setupActionRef == "" { + return nil, errors.New("failed to resolve setup action reference; ensure ./actions/setup exists and is accessible") + } + + ctx := &activationJobBuildContext{ + data: data, + preActivationJob: preActivationJobCreated, + workflowRunRepoSafety: workflowRunRepoSafety, + lockFilename: lockFilename, + outputs: map[string]string{}, + hasReaction: data.AIReaction != "" && data.AIReaction != "none", + reactionIssues: shouldIncludeIssueReactions(data), + reactionPullRequests: shouldIncludePullRequestReactions(data), + reactionDiscussions: shouldIncludeDiscussionReactions(data), + hasStatusComment: data.StatusComment != nil && *data.StatusComment, + statusCommentIssues: shouldIncludeIssueStatusComments(data), + statusCommentPRs: shouldIncludePullRequestStatusComments(data), + statusCommentDiscussions: shouldIncludeDiscussionStatusComments(data), + hasLabelCommand: len(data.LabelCommand) > 0, + filteredLabelEvents: FilterLabelCommandEvents(data.LabelCommandEvents), + needsAppTokenForAccess: data.ActivationGitHubApp != nil && !data.StaleCheckDisabled, + } + ctx.shouldRemoveLabel = ctx.hasLabelCommand && data.LabelCommandRemoveLabel + + ctx.steps = append(ctx.steps, c.generateCheckoutActionsFolder(data)...) + activationSetupTraceID := "" + if preActivationJobCreated { + activationSetupTraceID = fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.PreActivationJobName) + } + ctx.steps = append(ctx.steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false, activationSetupTraceID)...) + ctx.outputs["setup-trace-id"] = "${{ steps.setup.outputs.trace-id }}" + + if isOTLPHeadersPresent(data) { + ctx.steps = append(ctx.steps, generateOTLPHeadersMaskStep()) + } + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + compilerActivationJobLog.Print("Adding resolve-host-repo step for workflow_call trigger") + ctx.steps = append(ctx.steps, c.generateResolveHostRepoStep(data)) + } + if hasWorkflowCallTrigger(data.On) { + compilerActivationJobLog.Print("Adding artifact prefix computation step for workflow_call trigger") + ctx.steps = append(ctx.steps, generateArtifactPrefixStep()...) + ctx.outputs[constants.ArtifactPrefixOutputName] = "${{ steps.artifact-prefix.outputs.prefix }}" + } + + engine, err := c.getAgenticEngine(data.AI) + if err != nil { + return nil, fmt.Errorf("failed to get agentic engine: %w", err) + } + ctx.engine = engine + compilerActivationJobLog.Print("Generating aw_info step in activation job") + var awInfoYAML strings.Builder + c.generateCreateAwInfo(&awInfoYAML, data, engine) + ctx.steps = append(ctx.steps, awInfoYAML.String()) + ctx.outputs["model"] = "${{ steps.generate_aw_info.outputs.model }}" + ctx.outputs["lockdown_check_failed"] = "${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}" + if !data.StaleCheckDisabled { + ctx.outputs["stale_lock_file_failed"] = "${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}" + } + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + ctx.outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}" + ctx.outputs["target_repo_name"] = "${{ steps.resolve-host-repo.outputs.target_repo_name }}" + ctx.outputs["target_ref"] = "${{ steps.resolve-host-repo.outputs.target_ref }}" + } + + return ctx, nil +} + +// addActivationFeedbackAndValidationSteps appends token minting, reactions, secret validation, and guidance. +func (c *Compiler) addActivationFeedbackAndValidationSteps(ctx *activationJobBuildContext) error { + data := ctx.data + if data.ActivationGitHubApp != nil && (ctx.hasReaction || ctx.hasStatusComment || ctx.shouldRemoveLabel || ctx.needsAppTokenForAccess) { + appPerms := NewPermissions() + addActivationInteractionPermissions( + appPerms, + data.On, + ctx.hasReaction, + ctx.reactionIssues, + ctx.reactionPullRequests, + ctx.reactionDiscussions, + ctx.hasStatusComment, + ctx.statusCommentIssues, + ctx.statusCommentPRs, + ctx.statusCommentDiscussions, + ) + if ctx.shouldRemoveLabel { + if slices.Contains(ctx.filteredLabelEvents, "issues") || slices.Contains(ctx.filteredLabelEvents, "pull_request") { + appPerms.Set(PermissionIssues, PermissionWrite) + } + if slices.Contains(ctx.filteredLabelEvents, "discussion") { + appPerms.Set(PermissionDiscussions, PermissionWrite) + } + } + if ctx.needsAppTokenForAccess { + appPerms.Set(PermissionContents, PermissionRead) + } + ctx.steps = append(ctx.steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...) + ctx.outputs["activation_app_token_minting_failed"] = "${{ steps.activation-app-token.outcome == 'failure' }}" + } + + if ctx.hasReaction { + reactionCondition := BuildReactionConditionForTargets( + ctx.reactionIssues, + ctx.reactionPullRequests, + ctx.reactionDiscussions, + ) + ctx.steps = append(ctx.steps, fmt.Sprintf(" - name: Add %s reaction for immediate feedback\n", data.AIReaction)) + ctx.steps = append(ctx.steps, " id: react\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" if: %s\n", RenderCondition(reactionCondition))) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_REACTION: %q\n", data.AIReaction)) + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" github-token: %s\n", c.resolveActivationToken(data))) + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("add_reaction.cjs")) + } + + secretValidationStep := ctx.engine.GetSecretValidationStep(data) + if len(secretValidationStep) > 0 { + for _, line := range secretValidationStep { + ctx.steps = append(ctx.steps, line+"\n") + } + ctx.outputs["secret_verification_result"] = "${{ steps.validate-secret.outputs.verification_result }}" + compilerActivationJobLog.Printf("Added validate-secret step to activation job") + } else { + compilerActivationJobLog.Printf("Skipped validate-secret step (engine does not require secret validation)") + } + + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + compilerActivationJobLog.Print("Adding cross-repo setup guidance step for workflow_call trigger") + ctx.steps = append(ctx.steps, " - name: Print cross-repo setup guidance\n") + ctx.steps = append(ctx.steps, " if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository\n") + ctx.steps = append(ctx.steps, " run: |\n") + ctx.steps = append(ctx.steps, " echo \"::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets.\"\n") + ctx.steps = append(ctx.steps, " echo \"::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow.\"\n") + ctx.steps = append(ctx.steps, " echo \"::error::See: https://github.github.com/gh-aw/patterns/central-repo-ops/#cross-repo-setup\"\n") + } + + return nil +} + +// addActivationRepositoryAndOutputSteps appends checkout, validation, sanitization, comment, and lock steps. +func (c *Compiler) addActivationRepositoryAndOutputSteps(ctx *activationJobBuildContext) error { + data := ctx.data + + checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data) + ctx.steps = append(ctx.steps, checkoutSteps...) + if len(checkoutSteps) > 0 { + compilerActivationJobLog.Print("Adding step to save agent config folders for base branch restoration") + registry := GetGlobalEngineRegistry() + ctx.steps = append(ctx.steps, generateSaveBaseGitHubFoldersStep( + registry.GetAllAgentManifestFolders(), + registry.GetAllAgentManifestFiles(), + )...) + } + + if !data.StaleCheckDisabled { + ctx.steps = append(ctx.steps, " - name: Check workflow lock file\n") + ctx.steps = append(ctx.steps, " id: check-lock-file\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", ctx.lockFilename)) + ctx.steps = append(ctx.steps, " GH_AW_CONTEXT_WORKFLOW_REF: \"${{ github.workflow_ref }}\"\n") + ctx.steps = append(ctx.steps, " with:\n") + hashToken := c.resolveActivationToken(data) + if hashToken != "${{ secrets.GITHUB_TOKEN }}" { + ctx.steps = append(ctx.steps, fmt.Sprintf(" github-token: %s\n", hashToken)) + } + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs")) + } + + if !data.UpdateCheckDisabled && IsReleasedVersion(c.version) { + ctx.steps = append(ctx.steps, " - name: Check compile-agentic version\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_COMPILED_VERSION: \"%s\"\n", c.version)) + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("check_version_updates.cjs")) + } + + if data.NeedsTextOutput { + ctx.steps = append(ctx.steps, " - name: Compute current body text\n") + ctx.steps = append(ctx.steps, " id: sanitized\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + if len(data.Bots) > 0 { + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_ALLOWED_BOTS", strings.Join(data.Bots, ","))) + } + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("compute_text.cjs")) + ctx.outputs["text"] = "${{ steps.sanitized.outputs.text }}" + ctx.outputs["title"] = "${{ steps.sanitized.outputs.title }}" + ctx.outputs["body"] = "${{ steps.sanitized.outputs.body }}" + } + + if data.StatusComment != nil && *data.StatusComment { + statusCommentCondition := BuildStatusCommentCondition( + ctx.statusCommentIssues, + ctx.statusCommentPRs, + ctx.statusCommentDiscussions, + ) + ctx.steps = append(ctx.steps, " - name: Add comment with workflow run link\n") + ctx.steps = append(ctx.steps, " id: add-comment\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" if: %s\n", RenderCondition(statusCommentCondition))) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", data.Name)) + if data.TrackerID != "" { + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_TRACKER_ID: %q\n", data.TrackerID)) + } + if data.LockForAgent { + ctx.steps = append(ctx.steps, " GH_AW_LOCK_FOR_AGENT: \"true\"\n") + } + if data.SafeOutputs != nil && data.SafeOutputs.Messages != nil { + messagesJSON, err := serializeMessagesConfig(data.SafeOutputs.Messages) + if err != nil { + return fmt.Errorf("failed to serialize messages config for activation job: %w", err) + } + if messagesJSON != "" { + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_SAFE_OUTPUT_MESSAGES: %q\n", messagesJSON)) + } + } + ctx.steps = append(ctx.steps, " with:\n") + commentToken := c.resolveActivationToken(data) + if commentToken != "${{ secrets.GITHUB_TOKEN }}" { + ctx.steps = append(ctx.steps, fmt.Sprintf(" github-token: %s\n", commentToken)) + } + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("add_workflow_run_comment.cjs")) + ctx.outputs["comment_id"] = "${{ steps.add-comment.outputs.comment-id }}" + ctx.outputs["comment_url"] = "${{ steps.add-comment.outputs.comment-url }}" + ctx.outputs["comment_repo"] = "${{ steps.add-comment.outputs.comment-repo }}" + } + + if data.LockForAgent { + lockCondition := BuildOr( + BuildEventTypeEquals("issues"), + BuildEventTypeEquals("issue_comment"), + ) + ctx.steps = append(ctx.steps, " - name: Lock issue for agent workflow\n") + ctx.steps = append(ctx.steps, " id: lock-issue\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" if: %s\n", RenderCondition(lockCondition))) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("lock-issue.cjs")) + ctx.outputs["issue_locked"] = "${{ steps.lock-issue.outputs.locked }}" + if data.AIReaction != "" && data.AIReaction != "none" { + compilerActivationJobLog.Print("Adding lock notification to reaction message") + } + } + + if _, exists := ctx.outputs["comment_id"]; !exists { + ctx.outputs["comment_id"] = `""` + } + if _, exists := ctx.outputs["comment_repo"]; !exists { + ctx.outputs["comment_repo"] = `""` + } + + return nil +} + +// addActivationCommandAndLabelOutputs appends slash-command and label-command output steps. +func (c *Compiler) addActivationCommandAndLabelOutputs(ctx *activationJobBuildContext) error { + data := ctx.data + + if len(data.Command) > 0 { + if ctx.preActivationJob { + ctx.outputs["slash_command"] = fmt.Sprintf("${{ needs.%s.outputs.%s }}", string(constants.PreActivationJobName), constants.MatchedCommandOutput) + } else { + ctx.outputs["slash_command"] = fmt.Sprintf("${{ steps.%s.outputs.%s }}", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput) + } + } + + if ctx.shouldRemoveLabel { + ctx.steps = append(ctx.steps, " - name: Remove trigger label\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" id: %s\n", constants.RemoveTriggerLabelStepID)) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + ctx.steps = append(ctx.steps, " env:\n") + labelNamesJSON, err := json.Marshal(data.LabelCommand) + if err != nil { + return fmt.Errorf("failed to marshal label-command names: %w", err) + } + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_LABEL_NAMES", string(labelNamesJSON))) + ctx.steps = append(ctx.steps, " with:\n") + labelToken := c.resolveActivationToken(data) + if labelToken != "${{ secrets.GITHUB_TOKEN }}" { + ctx.steps = append(ctx.steps, fmt.Sprintf(" github-token: %s\n", labelToken)) + } + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) + ctx.outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.RemoveTriggerLabelStepID) + } else if ctx.hasLabelCommand { + ctx.steps = append(ctx.steps, " - name: Get trigger label name\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" id: %s\n", constants.GetTriggerLabelStepID)) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data))) + if len(data.Command) > 0 { + ctx.steps = append(ctx.steps, " env:\n") + if ctx.preActivationJob { + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ needs.%s.outputs.%s }}\n", string(constants.PreActivationJobName), constants.MatchedCommandOutput)) + } else { + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_MATCHED_COMMAND: ${{ steps.%s.outputs.%s }}\n", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput)) + } + } + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("get_trigger_label.cjs")) + ctx.outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.GetTriggerLabelStepID) + ctx.outputs["command_name"] = fmt.Sprintf("${{ steps.%s.outputs.command_name }}", constants.GetTriggerLabelStepID) + } + + return nil +} + +// configureActivationNeedsAndCondition computes and sets activation dependencies and final job condition. +// This helper mutates the context but only derives values from workflow data and has no error paths. +func (c *Compiler) configureActivationNeedsAndCondition(ctx *activationJobBuildContext) { + data := ctx.data + customJobsBeforeActivation := c.getCustomJobsDependingOnPreActivation(data.Jobs) + promptReferencedJobs := c.getCustomJobsReferencedInPromptWithNoActivationDep(data) + for _, jobName := range promptReferencedJobs { + if !slices.Contains(customJobsBeforeActivation, jobName) { + customJobsBeforeActivation = append(customJobsBeforeActivation, jobName) + compilerActivationJobLog.Printf("Added '%s' to activation dependencies: referenced in markdown body and has no explicit needs", jobName) + } + } + ctx.customJobsBeforeActivation = customJobsBeforeActivation + + if ctx.preActivationJob { + ctx.activationNeeds = []string{string(constants.PreActivationJobName)} + ctx.activationNeeds = append(ctx.activationNeeds, customJobsBeforeActivation...) + activatedExpr := BuildEquals( + BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.%s", string(constants.PreActivationJobName), constants.ActivatedOutput)), + BuildStringLiteral("true"), + ) + if data.If != "" && c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { + unwrappedIf := stripExpressionWrapper(data.If) + ifExpr := &ExpressionNode{Expression: unwrappedIf} + ctx.activationCondition = RenderCondition(BuildAnd(activatedExpr, ifExpr)) + } else if data.If != "" && !c.referencesCustomJobOutputs(data.If, data.Jobs) { + unwrappedIf := stripExpressionWrapper(data.If) + ifExpr := &ExpressionNode{Expression: unwrappedIf} + ctx.activationCondition = RenderCondition(BuildAnd(activatedExpr, ifExpr)) + } else { + ctx.activationCondition = RenderCondition(activatedExpr) + } + } else { + ctx.activationNeeds = append(ctx.activationNeeds, customJobsBeforeActivation...) + if data.If != "" && c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { + ctx.activationCondition = data.If + } else if !c.referencesCustomJobOutputs(data.If, data.Jobs) { + ctx.activationCondition = data.If + } + } + + if ctx.workflowRunRepoSafety != "" { + ctx.activationCondition = c.combineJobIfConditions(ctx.activationCondition, ctx.workflowRunRepoSafety) + } +} + +// addActivationArtifactUploadStep appends the activation artifact upload step for downstream jobs. +func (c *Compiler) addActivationArtifactUploadStep(ctx *activationJobBuildContext) { + compilerActivationJobLog.Print("Adding activation artifact upload step") + activationArtifactName := artifactPrefixExprForActivationJob(ctx.data) + constants.ActivationArtifactName + ctx.steps = append(ctx.steps, " - name: Upload activation artifact\n") + ctx.steps = append(ctx.steps, " if: success()\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/upload-artifact"))) + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" name: %s\n", activationArtifactName)) + ctx.steps = append(ctx.steps, " path: |\n") + ctx.steps = append(ctx.steps, " /tmp/gh-aw/aw_info.json\n") + ctx.steps = append(ctx.steps, " /tmp/gh-aw/aw-prompts/prompt.txt\n") + ctx.steps = append(ctx.steps, " /tmp/gh-aw/"+constants.GithubRateLimitsFilename+"\n") + ctx.steps = append(ctx.steps, " /tmp/gh-aw/base\n") + ctx.steps = append(ctx.steps, " if-no-files-found: ignore\n") + ctx.steps = append(ctx.steps, " retention-days: 1\n") +} + +// buildActivationPermissions builds activation job permissions from workflow features and selected interactions. +func (c *Compiler) buildActivationPermissions(ctx *activationJobBuildContext) string { + permsMap := map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionRead, + } + if !ctx.data.StaleCheckDisabled { + permsMap[PermissionActions] = PermissionRead + } + addActivationInteractionPermissionsMap( + permsMap, + ctx.data.On, + ctx.hasReaction, + ctx.reactionIssues, + ctx.reactionPullRequests, + ctx.reactionDiscussions, + ctx.hasStatusComment, + ctx.statusCommentIssues, + ctx.statusCommentPRs, + ctx.statusCommentDiscussions, + ) + if ctx.data.LockForAgent { + permsMap[PermissionIssues] = PermissionWrite + } + if ctx.shouldRemoveLabel && ctx.data.ActivationGitHubApp == nil { + if slices.Contains(ctx.filteredLabelEvents, "issues") || slices.Contains(ctx.filteredLabelEvents, "pull_request") { + permsMap[PermissionIssues] = PermissionWrite + } + if slices.Contains(ctx.filteredLabelEvents, "discussion") { + permsMap[PermissionDiscussions] = PermissionWrite + } + } + return NewPermissionsFromMap(permsMap).RenderToYAML() +} + +// buildActivationEnvironment returns manual-approval environment YAML, with ANSI removed. +func (c *Compiler) buildActivationEnvironment(ctx *activationJobBuildContext) string { + if ctx.data.ManualApproval == "" { + return "" + } + return "environment: " + stringutil.StripANSI(ctx.data.ManualApproval) +} diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index d15c43b423e..df6ba38dd9a 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -297,6 +297,72 @@ func TestCheckoutDoesNotUseEventNameExpression(t *testing.T) { "checkout must not use github.action_repository") } +// TestActivationCrossRepoGuidanceStepRequiresResolveHostRepo verifies that the +// cross-repo setup guidance step is only emitted when resolve-host-repo exists. +func TestActivationCrossRepoGuidanceStepRequiresResolveHostRepo(t *testing.T) { + tests := []struct { + name string + inlinedImports bool + expectGuidanceStep bool + expectResolveHostID bool + }{ + { + name: "workflow_call without inlined imports includes guidance and resolve step", + inlinedImports: false, + expectGuidanceStep: true, + expectResolveHostID: true, + }, + { + name: "workflow_call with inlined imports excludes guidance and resolve step", + inlinedImports: true, + expectGuidanceStep: false, + expectResolveHostID: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + workflowData := &WorkflowData{ + Name: "Test Workflow", + Command: []string{"echo", "test"}, + MarkdownContent: "# Test\n\nContent", + On: `"on": + workflow_call:`, + InlinedImports: tt.inlinedImports, + } + + job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml") + require.NoError(t, err, "buildActivationJob should succeed") + require.NotNil(t, job, "activation job should be created") + + stepsStr := strings.Join(job.Steps, "\n") + + if tt.expectGuidanceStep { + assert.Contains(t, stepsStr, "Print cross-repo setup guidance", + "guidance step should be emitted when resolve-host-repo is available") + assert.Contains(t, stepsStr, "steps.resolve-host-repo.outputs.target_repo != github.repository", + "guidance step should reference resolve-host-repo output when emitted") + } else { + assert.NotContains(t, stepsStr, "Print cross-repo setup guidance", + "guidance step must not be emitted when resolve-host-repo is not generated") + assert.NotContains(t, stepsStr, "steps.resolve-host-repo.outputs.target_repo != github.repository", + "activation steps must not reference resolve-host-repo outputs when step is absent") + } + + if tt.expectResolveHostID { + assert.Contains(t, stepsStr, "id: resolve-host-repo", + "resolve-host-repo step should be present") + } else { + assert.NotContains(t, stepsStr, "id: resolve-host-repo", + "resolve-host-repo step should be absent for inlined imports") + } + }) + } +} + // TestActivationJobTargetRepoOutput verifies that the activation job exposes target_repo as an // output when a workflow_call trigger is present (without inlined imports), so that agent and // safe_outputs jobs can reference needs.activation.outputs.target_repo.