diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go new file mode 100644 index 0000000000..bc29817a2f --- /dev/null +++ b/pkg/workflow/compiler_activation_job.go @@ -0,0 +1,392 @@ +package workflow + +import ( + "errors" + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/sliceutil" + "github.com/github/gh-aw/pkg/stringutil" +) + +var compilerActivationJobLog = logger.New("workflow:compiler_activation_job") + +// 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) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) + + // Generate agentic run info immediately after setup so aw_info.json is ready as early as possible. + // This ensures it is available for prompt generation and can be uploaded together with prompt.txt. + engine, err := c.getAgenticEngine(data.AI) + if err != nil { + return nil, fmt.Errorf("failed to get agentic engine: %w", err) + } + compilerActivationJobLog.Print("Generating aw_info step in activation job (first step after setup)") + 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 }}" + + // 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)") + } + + // 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...) + + // Add timestamp check for lock file vs source file using GitHub API + // No checkout step needed - uses GitHub API to check commit times + steps = append(steps, " - name: Check workflow file timestamps\n") + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.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", GetActionPin("actions/github-script"))) + if len(data.Bots) > 0 { + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_ALLOWED_BOTS: %s\n", 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 + // Note: The reaction was already added in the pre-activation job for immediate feedback + if data.StatusComment != nil && *data.StatusComment { + reactionCondition := BuildReactionCondition() + + 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", reactionCondition.Render())) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + + // 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") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("add_workflow_run_comment.cjs")) + + // Add comment outputs (no reaction_id since reaction was added in pre-activation) + 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 }}" + } + + // 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", lockCondition.Render())) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + 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") + } + } + + // 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 _, exists := outputs["comment_repo"]; !exists { + outputs["comment_repo"] = `""` + } + + // 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) + } + } + + // 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 !sliceutil.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 = combinedExpr.Render() + } 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 = combinedExpr.Render() + } else { + activationCondition = activatedExpr.Render() + } + } 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) + compilerActivationJobLog.Print("Generating prompt in activation job") + c.generatePromptInActivationJob(&steps, data, preActivationJobCreated, customJobsBeforeActivation) + + // Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download + compilerActivationJobLog.Print("Adding activation artifact upload step") + 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, " name: activation\n") + 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, " retention-days: 1\n") + + // Set permissions - activation job always needs contents:read for GitHub API access + // Also add reaction permissions if reaction is configured and not "none" + // 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 + } + + if data.AIReaction != "" && data.AIReaction != "none" { + permsMap[PermissionDiscussions] = PermissionWrite + permsMap[PermissionIssues] = PermissionWrite + permsMap[PermissionPullRequests] = PermissionWrite + } + + // Add issues:write permission if lock-for-agent is enabled (even without reaction) + if data.LockForAgent { + permsMap[PermissionIssues] = PermissionWrite + } + + 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 + } + + job := &Job{ + Name: string(constants.ActivationJobName), + If: activationCondition, + HasWorkflowRunSafetyChecks: workflowRunRepoSafety != "", // Mark job as having workflow_run safety checks + RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Permissions: permissions, + Environment: environment, + Steps: steps, + Outputs: outputs, + Needs: activationNeeds, // Depend on pre-activation job if it exists + } + + return job, nil +} + +// generatePromptInActivationJob generates the prompt creation steps and adds them to the activation job +// This creates the prompt.txt file that will be uploaded as an artifact and downloaded by the agent job +// beforeActivationJobs is the list of custom job names that run before (i.e., are dependencies of) activation. +// Passing nil or an empty slice means no custom jobs run before activation; expressions referencing any +// custom job will be filtered out of the substitution step to avoid actionlint errors. +func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *WorkflowData, preActivationJobCreated bool, beforeActivationJobs []string) { + compilerActivationJobLog.Print("Generating prompt steps in activation job") + + // Use a string builder to collect the YAML + var yaml strings.Builder + + // Call the existing generatePrompt method to get all the prompt steps + c.generatePrompt(&yaml, data, preActivationJobCreated, beforeActivationJobs) + + // Append the generated YAML content as a single string to steps + yamlContent := yaml.String() + *steps = append(*steps, yamlContent) + + compilerActivationJobLog.Print("Prompt generation steps added to activation job") +} + +// generateCheckoutGitHubFolderForActivation generates the checkout step for .github and .agents folders +// specifically for the activation job. Unlike generateCheckoutGitHubFolder, this method doesn't skip +// the checkout when the agent job will have a full repository checkout, because the activation job +// runs before the agent job and needs independent access to workflow files for runtime imports during +// prompt generation. +func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) []string { + // Check if action-tag is specified - if so, skip checkout + if data != nil && data.Features != nil { + if actionTagVal, exists := data.Features["action-tag"]; exists { + if actionTagStr, ok := actionTagVal.(string); ok && actionTagStr != "" { + // action-tag is set, no checkout needed + compilerActivationJobLog.Print("Skipping .github checkout in activation: action-tag specified") + return nil + } + } + } + + // Note: We don't check data.Permissions for contents read access here because + // the activation job ALWAYS gets contents:read added to its permissions (see buildActivationJob + // around line 720). The workflow's original permissions may not include contents:read, + // but the activation job will always have it for GitHub API access and runtime imports. + // The agent job uses only the user-specified permissions (no automatic contents:read augmentation). + + // For activation job, always add sparse checkout of .github and .agents folders + // This is needed for runtime imports during prompt generation + // sparse-checkout-cone-mode: true ensures subdirectories under .github/ are recursively included + compilerActivationJobLog.Print("Adding .github and .agents sparse checkout in activation job") + return []string{ + " - name: Checkout .github and .agents folders\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), + " with:\n", + " sparse-checkout: |\n", + " .github\n", + " .agents\n", + " sparse-checkout-cone-mode: true\n", + " fetch-depth: 1\n", + " persist-credentials: false\n", + } +} diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go deleted file mode 100644 index 8d7c48097f..0000000000 --- a/pkg/workflow/compiler_activation_jobs.go +++ /dev/null @@ -1,1052 +0,0 @@ -package workflow - -import ( - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - "strconv" - "strings" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/stringutil" -) - -var compilerActivationJobsLog = logger.New("workflow:compiler_activation_jobs") - -// buildPreActivationJob creates a unified pre-activation job that combines membership checks and stop-time validation. -// This job exposes a single "activated" output that indicates whether the workflow should proceed. -func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionCheck bool) (*Job, error) { - compilerActivationJobsLog.Printf("Building pre-activation job: needsPermissionCheck=%v, hasStopTime=%v", needsPermissionCheck, data.StopTime != "") - var steps []string - var permissions string - - // Extract custom steps and outputs from jobs.pre-activation if present - customSteps, customOutputs, err := c.extractPreActivationCustomFields(data.Jobs) - if err != nil { - return nil, fmt.Errorf("failed to extract pre-activation custom fields: %w", err) - } - - // 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 - // This requires contents: read permission - steps = append(steps, c.generateCheckoutActionsFolder(data)...) - needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0 - - // Pre-activation job doesn't need project support (no safe outputs processed here) - steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) - - // Determine permissions for pre-activation job - var perms *Permissions - if needsContentsRead { - perms = NewPermissionsContentsRead() - } - - // Add reaction permissions if reaction is configured (reactions added in pre-activation for immediate feedback) - if data.AIReaction != "" && data.AIReaction != "none" { - if perms == nil { - perms = NewPermissions() - } - // Add write permissions for reactions - perms.Set(PermissionIssues, PermissionWrite) - perms.Set(PermissionPullRequests, PermissionWrite) - perms.Set(PermissionDiscussions, PermissionWrite) - } - - // Add actions: read permission if rate limiting is configured (needed to query workflow runs) - if data.RateLimit != nil { - if perms == nil { - perms = NewPermissions() - } - perms.Set(PermissionActions, PermissionRead) - } - - // Set permissions if any were configured - if perms != nil { - permissions = perms.RenderToYAML() - } - - // Add reaction step immediately after setup for instant user feedback - // This happens BEFORE any checks, so users see progress immediately - if data.AIReaction != "" && data.AIReaction != "none" { - reactionCondition := BuildReactionCondition() - - 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", reactionCondition.Render())) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - - // 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") - // Explicitly use the GitHub Actions token (GITHUB_TOKEN) for reactions - // This ensures proper authentication for adding reactions - steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("add_reaction.cjs")) - } - - // Add team member check if permission checks are needed - if needsPermissionCheck { - steps = c.generateMembershipCheck(data, steps) - } - - // Add rate limit check if configured - if data.RateLimit != nil { - steps = c.generateRateLimitCheck(data, steps) - } - - // Add stop-time check if configured - if data.StopTime != "" { - // Extract workflow name for the stop-time check - workflowName := data.Name - - steps = append(steps, " - name: Check stop-time limit\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckStopTimeStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - // Strip ANSI escape codes from stop-time value - cleanStopTime := stringutil.StripANSI(data.StopTime) - steps = append(steps, fmt.Sprintf(" GH_AW_STOP_TIME: %s\n", cleanStopTime)) - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_stop_time.cjs")) - } - - // Add skip-if-match check if configured - if data.SkipIfMatch != nil { - // Extract workflow name for the skip-if-match check - workflowName := data.Name - - steps = append(steps, " - name: Check skip-if-match query\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfMatchStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfMatch.Query)) - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MAX_MATCHES: \"%d\"\n", data.SkipIfMatch.Max)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_match.cjs")) - } - - // Add skip-if-no-match check if configured - if data.SkipIfNoMatch != nil { - // Extract workflow name for the skip-if-no-match check - workflowName := data.Name - - steps = append(steps, " - name: Check skip-if-no-match query\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfNoMatchStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfNoMatch.Query)) - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MIN_MATCHES: \"%d\"\n", data.SkipIfNoMatch.Min)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_no_match.cjs")) - } - - // Add skip-roles check if configured - if len(data.SkipRoles) > 0 { - // Extract workflow name for the skip-roles check - workflowName := data.Name - - steps = append(steps, " - name: Check skip-roles\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipRolesStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_ROLES: %s\n", strings.Join(data.SkipRoles, ","))) - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) - steps = append(steps, " with:\n") - steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_skip_roles.cjs")) - } - - // Add skip-bots check if configured - if len(data.SkipBots) > 0 { - // Extract workflow name for the skip-bots check - workflowName := data.Name - - steps = append(steps, " - name: Check skip-bots\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipBotsStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BOTS: %s\n", strings.Join(data.SkipBots, ","))) - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_skip_bots.cjs")) - } - - // Add command position check if this is a command workflow - if len(data.Command) > 0 { - steps = append(steps, " - name: Check command position\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckCommandPositionStepID)) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - // Pass commands as JSON array - commandsJSON, _ := json.Marshal(data.Command) - steps = append(steps, fmt.Sprintf(" GH_AW_COMMANDS: %q\n", string(commandsJSON))) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_command_position.cjs")) - } - - // Append custom steps from jobs.pre-activation if present - if len(customSteps) > 0 { - compilerActivationJobsLog.Printf("Adding %d custom steps to pre-activation job", len(customSteps)) - steps = append(steps, customSteps...) - } - - // Generate the activated output expression using expression builders - var activatedNode ConditionNode - - // Build condition nodes for each check - var conditions []ConditionNode - - if needsPermissionCheck { - // Add membership check condition - membershipCheck := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckMembershipStepID, constants.IsTeamMemberOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, membershipCheck) - } - - if data.StopTime != "" { - // Add stop-time check condition - stopTimeCheck := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckStopTimeStepID, constants.StopTimeOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, stopTimeCheck) - } - - if data.SkipIfMatch != nil { - // Add skip-if-match check condition - skipCheckOk := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfMatchStepID, constants.SkipCheckOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, skipCheckOk) - } - - if data.SkipIfNoMatch != nil { - // Add skip-if-no-match check condition - skipNoMatchCheckOk := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfNoMatchStepID, constants.SkipNoMatchCheckOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, skipNoMatchCheckOk) - } - - if len(data.SkipRoles) > 0 { - // Add skip-roles check condition - skipRolesCheckOk := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipRolesStepID, constants.SkipRolesOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, skipRolesCheckOk) - } - - if len(data.SkipBots) > 0 { - // Add skip-bots check condition - skipBotsCheckOk := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipBotsStepID, constants.SkipBotsOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, skipBotsCheckOk) - } - - if data.RateLimit != nil { - // Add rate limit check condition - rateLimitCheck := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckRateLimitStepID, constants.RateLimitOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, rateLimitCheck) - } - - if len(data.Command) > 0 { - // Add command position check condition - commandPositionCheck := BuildComparison( - BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckCommandPositionStepID, constants.CommandPositionOkOutput)), - "==", - BuildStringLiteral("true"), - ) - conditions = append(conditions, commandPositionCheck) - } - - // Build the final expression - if len(conditions) == 0 { - // This should never happen - it means pre-activation job was created without any checks - // If we reach this point, it's a developer error in the compiler logic - return nil, errors.New("developer error: pre-activation job created without permission check or stop-time configuration") - } else if len(conditions) == 1 { - // Single condition - activatedNode = conditions[0] - } else { - // Multiple conditions - combine with AND - activatedNode = conditions[0] - for i := 1; i < len(conditions); i++ { - activatedNode = BuildAnd(activatedNode, conditions[i]) - } - } - - // Render the expression with ${{ }} wrapper - activatedExpression := fmt.Sprintf("${{ %s }}", activatedNode.Render()) - - outputs := map[string]string{ - "activated": activatedExpression, - } - - // Always declare matched_command output so actionlint can resolve the type. - // For command workflows, reference the check_command_position step output. - // For non-command workflows, emit an empty string so the output key is defined. - if len(data.Command) > 0 { - outputs[constants.MatchedCommandOutput] = fmt.Sprintf("${{ steps.%s.outputs.%s }}", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput) - } else { - outputs[constants.MatchedCommandOutput] = "''" - } - - // Merge custom outputs from jobs.pre-activation if present - if len(customOutputs) > 0 { - compilerActivationJobsLog.Printf("Adding %d custom outputs to pre-activation job", len(customOutputs)) - maps.Copy(outputs, customOutputs) - } - - // Pre-activation job uses the user's original if condition (data.If) - // The workflow_run safety check is NOT applied here - it's only on the activation job - // Don't include conditions that reference custom job outputs (those belong on the agent job) - var jobIfCondition string - if !c.referencesCustomJobOutputs(data.If, data.Jobs) { - jobIfCondition = data.If - } - - job := &Job{ - Name: string(constants.PreActivationJobName), - If: jobIfCondition, - RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), - Permissions: permissions, - Steps: steps, - Outputs: outputs, - } - - return job, nil -} - -// extractPreActivationCustomFields extracts custom steps and outputs from jobs.pre-activation field in frontmatter. -// It validates that only steps and outputs fields are present, and errors on any other fields. -// If both jobs.pre-activation and jobs.pre_activation are defined, imports from both. -// Returns (customSteps, customOutputs, error). -func (c *Compiler) extractPreActivationCustomFields(jobs map[string]any) ([]string, map[string]string, error) { - if jobs == nil { - return nil, nil, nil - } - - var customSteps []string - var customOutputs map[string]string - - // Check both jobs.pre-activation and jobs.pre_activation (users might define both by mistake) - // Import from both if both are defined - jobVariants := []string{"pre-activation", string(constants.PreActivationJobName)} - - for _, jobName := range jobVariants { - preActivationJob, exists := jobs[jobName] - if !exists { - continue - } - - // jobs.pre-activation must be a map - configMap, ok := preActivationJob.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("jobs.%s must be an object, got %T", jobName, preActivationJob) - } - - // Validate that only steps and outputs fields are present - allowedFields := map[string]bool{ - "steps": true, - "outputs": true, - } - - for field := range configMap { - if !allowedFields[field] { - return nil, nil, fmt.Errorf("jobs.%s: unsupported field '%s' - only 'steps' and 'outputs' are allowed", jobName, field) - } - } - - // Extract steps - if stepsValue, hasSteps := configMap["steps"]; hasSteps { - stepsList, ok := stepsValue.([]any) - if !ok { - return nil, nil, fmt.Errorf("jobs.%s.steps must be an array, got %T", jobName, stepsValue) - } - - for i, step := range stepsList { - stepMap, ok := step.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("jobs.%s.steps[%d] must be an object, got %T", jobName, i, step) - } - - // Convert step to YAML - stepYAML, err := c.convertStepToYAML(stepMap) - if err != nil { - return nil, nil, fmt.Errorf("failed to convert jobs.%s.steps[%d] to YAML: %w", jobName, i, err) - } - customSteps = append(customSteps, stepYAML) - } - compilerActivationJobsLog.Printf("Extracted %d custom steps from jobs.%s", len(stepsList), jobName) - } - - // Extract outputs - if outputsValue, hasOutputs := configMap["outputs"]; hasOutputs { - outputsMap, ok := outputsValue.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("jobs.%s.outputs must be an object, got %T", jobName, outputsValue) - } - - if customOutputs == nil { - customOutputs = make(map[string]string) - } - for key, val := range outputsMap { - valStr, ok := val.(string) - if !ok { - return nil, nil, fmt.Errorf("jobs.%s.outputs.%s must be a string, got %T", jobName, key, val) - } - // If the same output key is defined in both variants, the second one wins (pre_activation) - customOutputs[key] = valStr - } - compilerActivationJobsLog.Printf("Extracted %d custom outputs from jobs.%s", len(outputsMap), jobName) - } - } - - return customSteps, customOutputs, nil -} - -// 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) - steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) - - // Generate agentic run info immediately after setup so aw_info.json is ready as early as possible. - // This ensures it is available for prompt generation and can be uploaded together with prompt.txt. - engine, err := c.getAgenticEngine(data.AI) - if err != nil { - return nil, fmt.Errorf("failed to get agentic engine: %w", err) - } - compilerActivationJobsLog.Print("Generating aw_info step in activation job (first step after setup)") - 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 }}" - - // 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 }}" - compilerActivationJobsLog.Printf("Added validate-secret step to activation job") - } else { - compilerActivationJobsLog.Printf("Skipped validate-secret step (engine does not require secret validation)") - } - - // 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...) - - // Add timestamp check for lock file vs source file using GitHub API - // No checkout step needed - uses GitHub API to check commit times - steps = append(steps, " - name: Check workflow file timestamps\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename)) - steps = append(steps, " with:\n") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.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", GetActionPin("actions/github-script"))) - if len(data.Bots) > 0 { - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" GH_AW_ALLOWED_BOTS: %s\n", 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 - // Note: The reaction was already added in the pre-activation job for immediate feedback - if data.StatusComment != nil && *data.StatusComment { - reactionCondition := BuildReactionCondition() - - 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", reactionCondition.Render())) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - - // 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") - steps = append(steps, " script: |\n") - steps = append(steps, generateGitHubScriptWithRequire("add_workflow_run_comment.cjs")) - - // Add comment outputs (no reaction_id since reaction was added in pre-activation) - 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 }}" - } - - // 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", lockCondition.Render())) - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) - 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" { - compilerActivationJobsLog.Print("Adding lock notification to reaction message") - } - } - - // 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 _, exists := outputs["comment_repo"]; !exists { - outputs["comment_repo"] = `""` - } - - // 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) - } - } - - // 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 !sliceutil.Contains(customJobsBeforeActivation, jobName) { - customJobsBeforeActivation = append(customJobsBeforeActivation, jobName) - compilerActivationJobsLog.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 = combinedExpr.Render() - } 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 = combinedExpr.Render() - } else { - activationCondition = activatedExpr.Render() - } - } 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) - compilerActivationJobsLog.Print("Generating prompt in activation job") - c.generatePromptInActivationJob(&steps, data, preActivationJobCreated, customJobsBeforeActivation) - - // Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download - compilerActivationJobsLog.Print("Adding activation artifact upload step") - 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, " name: activation\n") - 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, " retention-days: 1\n") - - // Set permissions - activation job always needs contents:read for GitHub API access - // Also add reaction permissions if reaction is configured and not "none" - // 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 - } - - if data.AIReaction != "" && data.AIReaction != "none" { - permsMap[PermissionDiscussions] = PermissionWrite - permsMap[PermissionIssues] = PermissionWrite - permsMap[PermissionPullRequests] = PermissionWrite - } - - // Add issues:write permission if lock-for-agent is enabled (even without reaction) - if data.LockForAgent { - permsMap[PermissionIssues] = PermissionWrite - } - - 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 - } - - job := &Job{ - Name: string(constants.ActivationJobName), - If: activationCondition, - HasWorkflowRunSafetyChecks: workflowRunRepoSafety != "", // Mark job as having workflow_run safety checks - RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), - Permissions: permissions, - Environment: environment, - Steps: steps, - Outputs: outputs, - Needs: activationNeeds, // Depend on pre-activation job if it exists - } - - return job, nil -} - -// buildMainJob creates the main agent job that runs the AI agent with the configured engine and tools. -// This job depends on the activation job if it exists, and handles the main workflow logic. -func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (*Job, error) { - log.Printf("Building main job for workflow: %s", data.Name) - var steps []string - - // Add setup action steps at the beginning of the job - setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" || c.actionMode.IsScript() { - // For dev mode (local action path), checkout the actions folder first - steps = append(steps, c.generateCheckoutActionsFolder(data)...) - - // Main job doesn't need project support (no safe outputs processed here) - steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) - } - - // Checkout .github folder is now done in activation job (before prompt generation) - // This ensures the activation job has access to .github and .agents folders for runtime imports - - // Find custom jobs that depend on pre_activation - these are handled by the activation job - customJobsBeforeActivation := c.getCustomJobsDependingOnPreActivation(data.Jobs) - - var jobCondition = data.If - if activationJobCreated { - // If the if condition references custom jobs that run before activation, - // the activation job handles the condition, so clear it here - if c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { - jobCondition = "" // Activation job handles this condition - } else if !c.referencesCustomJobOutputs(data.If, data.Jobs) { - jobCondition = "" // Main job depends on activation job, so no need for inline condition - } - // Note: If data.If references custom jobs that DON'T depend on pre_activation, - // we keep the condition on the agent job - } - - // Note: workflow_run repository safety check is applied exclusively to activation job - - // Permission checks are now handled by the separate check_membership job - // No role checks needed in the main job - - // Build step content using the generateMainJobSteps helper method - // but capture it into a string instead of writing directly - var stepBuilder strings.Builder - if err := c.generateMainJobSteps(&stepBuilder, data); err != nil { - return nil, fmt.Errorf("failed to generate main job steps: %w", err) - } - - // Split the steps content into individual step entries - stepsContent := stepBuilder.String() - if stepsContent != "" { - steps = append(steps, stepsContent) - } - - var depends []string - if activationJobCreated { - depends = []string{string(constants.ActivationJobName)} // Depend on the activation job only if it exists - } - - // Add custom jobs as dependencies only if they don't depend on pre_activation or agent - // Custom jobs that depend on pre_activation are now dependencies of activation, - // so the agent job gets them transitively through activation - // Custom jobs that depend on agent should run AFTER the agent job, not before it - if data.Jobs != nil { - for _, jobName := range slices.Sorted(maps.Keys(data.Jobs)) { - // Skip jobs.pre-activation (or pre_activation) as it's handled specially - if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" { - continue - } - - // Only add as direct dependency if it doesn't depend on pre_activation or agent - // (jobs that depend on pre_activation are handled through activation) - // (jobs that depend on agent are post-execution jobs like failure handlers) - if configMap, ok := data.Jobs[jobName].(map[string]any); ok { - if !jobDependsOnPreActivation(configMap) && !jobDependsOnAgent(configMap) { - depends = append(depends, jobName) - } - } - } - } - - // IMPORTANT: Even though jobs that depend on pre_activation are transitively accessible - // through the activation job, if the workflow content directly references their outputs - // (e.g., ${{ needs.search_issues.outputs.* }}), we MUST add them as direct dependencies. - // This is required for GitHub Actions expression evaluation and actionlint validation. - // Also check custom steps from the frontmatter, which are also added to the agent job. - var contentBuilder strings.Builder - contentBuilder.WriteString(data.MarkdownContent) - if data.CustomSteps != "" { - contentBuilder.WriteByte('\n') - contentBuilder.WriteString(data.CustomSteps) - } - referencedJobs := c.getReferencedCustomJobs(contentBuilder.String(), data.Jobs) - for _, jobName := range referencedJobs { - // Skip jobs.pre-activation (or pre_activation) as it's handled specially - if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" { - continue - } - - // Check if this job is already in depends - alreadyDepends := slices.Contains(depends, jobName) - // Add it if not already present - if !alreadyDepends { - depends = append(depends, jobName) - compilerActivationJobsLog.Printf("Added direct dependency on custom job '%s' because it's referenced in workflow content", jobName) - } - } - - // Build outputs for all engines (GH_AW_SAFE_OUTPUTS functionality) - // Build job outputs - // Always include model output for reuse in other jobs - now sourced from activation job - outputs := map[string]string{ - "model": "${{ needs.activation.outputs.model }}", - } - - // Note: secret_verification_result is now an output of the activation job (not the agent job). - // The validate-secret step runs in the activation job, before context variable validation. - - // Add safe-output specific outputs if the workflow uses the safe-outputs feature - if data.SafeOutputs != nil { - outputs["output"] = "${{ steps.collect_output.outputs.output }}" - outputs["output_types"] = "${{ steps.collect_output.outputs.output_types }}" - outputs["has_patch"] = "${{ steps.collect_output.outputs.has_patch }}" - } - - // Add inline detection outputs if threat detection is enabled - if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil { - outputs["detection_success"] = "${{ steps.detection_conclusion.outputs.success }}" - outputs["detection_conclusion"] = "${{ steps.detection_conclusion.outputs.conclusion }}" - compilerActivationJobsLog.Print("Added detection_success and detection_conclusion outputs to agent job") - } - - // Add checkout_pr_success output to track PR checkout status only if the checkout-pr step will be generated - // This is used by the conclusion job to skip failure handling when checkout fails - // (e.g., when PR is merged and branch is deleted) - // The checkout-pr step is only generated when the workflow has contents read permission - if ShouldGeneratePRCheckoutStep(data) { - outputs["checkout_pr_success"] = "${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}" - compilerActivationJobsLog.Print("Added checkout_pr_success output (workflow has contents read access)") - } else { - compilerActivationJobsLog.Print("Skipped checkout_pr_success output (workflow lacks contents read access)") - } - - // Build job-level environment variables for safe outputs - var env map[string]string - if data.SafeOutputs != nil { - env = make(map[string]string) - - // Set GH_AW_SAFE_OUTPUTS to path in /opt (read-only mount for agent container) - // The MCP server writes agent outputs to this file during execution - // This file is in /opt to prevent the agent container from having write access - env["GH_AW_SAFE_OUTPUTS"] = "/opt/gh-aw/safeoutputs/outputs.jsonl" - - // Set GH_AW_MCP_LOG_DIR for safe outputs MCP server logging - // Store in mcp-logs directory so it's included in mcp-logs artifact - env["GH_AW_MCP_LOG_DIR"] = "/tmp/gh-aw/mcp-logs/safeoutputs" - - // Set config and tools paths (readonly files in /opt/gh-aw) - env["GH_AW_SAFE_OUTPUTS_CONFIG_PATH"] = "/opt/gh-aw/safeoutputs/config.json" - env["GH_AW_SAFE_OUTPUTS_TOOLS_PATH"] = "/opt/gh-aw/safeoutputs/tools.json" - - // Add asset-related environment variables - // These must always be set (even to empty) because awmg v0.0.12+ validates ${VAR} references - if data.SafeOutputs.UploadAssets != nil { - env["GH_AW_ASSETS_BRANCH"] = fmt.Sprintf("%q", data.SafeOutputs.UploadAssets.BranchName) - env["GH_AW_ASSETS_MAX_SIZE_KB"] = strconv.Itoa(data.SafeOutputs.UploadAssets.MaxSizeKB) - env["GH_AW_ASSETS_ALLOWED_EXTS"] = fmt.Sprintf("%q", strings.Join(data.SafeOutputs.UploadAssets.AllowedExts, ",")) - } else { - // Set empty defaults when upload-assets is not configured - env["GH_AW_ASSETS_BRANCH"] = `""` - env["GH_AW_ASSETS_MAX_SIZE_KB"] = "0" - env["GH_AW_ASSETS_ALLOWED_EXTS"] = `""` - } - - // DEFAULT_BRANCH is used by safeoutputs MCP server - // Use repository default branch from GitHub context - env["DEFAULT_BRANCH"] = "${{ github.event.repository.default_branch }}" - } - - // Set GH_AW_WORKFLOW_ID_SANITIZED for cache-memory keys - // This contains the workflow ID with all hyphens removed and lowercased - // Used in cache keys to avoid spaces and special characters - if data.WorkflowID != "" { - if env == nil { - env = make(map[string]string) - } - sanitizedID := SanitizeWorkflowIDForCacheKey(data.WorkflowID) - env["GH_AW_WORKFLOW_ID_SANITIZED"] = sanitizedID - } - - // Generate agent concurrency configuration - agentConcurrency := GenerateJobConcurrencyConfig(data) - - // Set up permissions for the agent job - // In dev/script mode, automatically add contents: read if the actions folder checkout is needed - // In release mode, use the permissions as specified by the user (no automatic augmentation) - permissions := data.Permissions - needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0 - if needsContentsRead { - if permissions == "" { - perms := NewPermissionsContentsRead() - permissions = perms.RenderToYAML() - } else { - parser := NewPermissionsParser(permissions) - perms := parser.ToPermissions() - if level, exists := perms.Get(PermissionContents); !exists || level == PermissionNone { - perms.Set(PermissionContents, PermissionRead) - permissions = perms.RenderToYAML() - } - } - } - - job := &Job{ - Name: string(constants.AgentJobName), - If: jobCondition, - RunsOn: c.indentYAMLLines(data.RunsOn, " "), - Environment: c.indentYAMLLines(data.Environment, " "), - Container: c.indentYAMLLines(data.Container, " "), - Services: c.indentYAMLLines(data.Services, " "), - Permissions: c.indentYAMLLines(permissions, " "), - Concurrency: c.indentYAMLLines(agentConcurrency, " "), - Env: env, - Steps: steps, - Needs: depends, - Outputs: outputs, - } - - return job, nil -} - -// generatePromptInActivationJob generates the prompt creation steps and adds them to the activation job -// This creates the prompt.txt file that will be uploaded as an artifact and downloaded by the agent job -// beforeActivationJobs is the list of custom job names that run before (i.e., are dependencies of) activation. -// Passing nil or an empty slice means no custom jobs run before activation; expressions referencing any -// custom job will be filtered out of the substitution step to avoid actionlint errors. -func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *WorkflowData, preActivationJobCreated bool, beforeActivationJobs []string) { - compilerActivationJobsLog.Print("Generating prompt steps in activation job") - - // Use a string builder to collect the YAML - var yaml strings.Builder - - // Call the existing generatePrompt method to get all the prompt steps - c.generatePrompt(&yaml, data, preActivationJobCreated, beforeActivationJobs) - - // Append the generated YAML content as a single string to steps - yamlContent := yaml.String() - *steps = append(*steps, yamlContent) - - compilerActivationJobsLog.Print("Prompt generation steps added to activation job") -} - -// generateCheckoutGitHubFolderForActivation generates the checkout step for .github and .agents folders -// specifically for the activation job. Unlike generateCheckoutGitHubFolder, this method doesn't skip -// the checkout when the agent job will have a full repository checkout, because the activation job -// runs before the agent job and needs independent access to workflow files for runtime imports during -// prompt generation. -func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) []string { - // Check if action-tag is specified - if so, skip checkout - if data != nil && data.Features != nil { - if actionTagVal, exists := data.Features["action-tag"]; exists { - if actionTagStr, ok := actionTagVal.(string); ok && actionTagStr != "" { - // action-tag is set, no checkout needed - compilerActivationJobsLog.Print("Skipping .github checkout in activation: action-tag specified") - return nil - } - } - } - - // Note: We don't check data.Permissions for contents read access here because - // the activation job ALWAYS gets contents:read added to its permissions (see buildActivationJob - // around line 720). The workflow's original permissions may not include contents:read, - // but the activation job will always have it for GitHub API access and runtime imports. - // The agent job uses only the user-specified permissions (no automatic contents:read augmentation). - - // For activation job, always add sparse checkout of .github and .agents folders - // This is needed for runtime imports during prompt generation - // sparse-checkout-cone-mode: true ensures subdirectories under .github/ are recursively included - compilerActivationJobsLog.Print("Adding .github and .agents sparse checkout in activation job") - return []string{ - " - name: Checkout .github and .agents folders\n", - fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), - " with:\n", - " sparse-checkout: |\n", - " .github\n", - " .agents\n", - " sparse-checkout-cone-mode: true\n", - " fetch-depth: 1\n", - " persist-credentials: false\n", - } -} diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go new file mode 100644 index 0000000000..b42b6159a5 --- /dev/null +++ b/pkg/workflow/compiler_main_job.go @@ -0,0 +1,243 @@ +package workflow + +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" +) + +var compilerMainJobLog = logger.New("workflow:compiler_main_job") + +// buildMainJob creates the main agent job that runs the AI agent with the configured engine and tools. +// This job depends on the activation job if it exists, and handles the main workflow logic. +func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (*Job, error) { + log.Printf("Building main job for workflow: %s", data.Name) + var steps []string + + // Add setup action steps at the beginning of the job + setupActionRef := c.resolveActionReference("./actions/setup", data) + if setupActionRef != "" || c.actionMode.IsScript() { + // For dev mode (local action path), checkout the actions folder first + steps = append(steps, c.generateCheckoutActionsFolder(data)...) + + // Main job doesn't need project support (no safe outputs processed here) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) + } + + // Checkout .github folder is now done in activation job (before prompt generation) + // This ensures the activation job has access to .github and .agents folders for runtime imports + + // Find custom jobs that depend on pre_activation - these are handled by the activation job + customJobsBeforeActivation := c.getCustomJobsDependingOnPreActivation(data.Jobs) + + var jobCondition = data.If + if activationJobCreated { + // If the if condition references custom jobs that run before activation, + // the activation job handles the condition, so clear it here + if c.referencesCustomJobOutputs(data.If, data.Jobs) && len(customJobsBeforeActivation) > 0 { + jobCondition = "" // Activation job handles this condition + } else if !c.referencesCustomJobOutputs(data.If, data.Jobs) { + jobCondition = "" // Main job depends on activation job, so no need for inline condition + } + // Note: If data.If references custom jobs that DON'T depend on pre_activation, + // we keep the condition on the agent job + } + + // Note: workflow_run repository safety check is applied exclusively to activation job + + // Permission checks are now handled by the separate check_membership job + // No role checks needed in the main job + + // Build step content using the generateMainJobSteps helper method + // but capture it into a string instead of writing directly + var stepBuilder strings.Builder + if err := c.generateMainJobSteps(&stepBuilder, data); err != nil { + return nil, fmt.Errorf("failed to generate main job steps: %w", err) + } + + // Split the steps content into individual step entries + stepsContent := stepBuilder.String() + if stepsContent != "" { + steps = append(steps, stepsContent) + } + + var depends []string + if activationJobCreated { + depends = []string{string(constants.ActivationJobName)} // Depend on the activation job only if it exists + } + + // Add custom jobs as dependencies only if they don't depend on pre_activation or agent + // Custom jobs that depend on pre_activation are now dependencies of activation, + // so the agent job gets them transitively through activation + // Custom jobs that depend on agent should run AFTER the agent job, not before it + if data.Jobs != nil { + for _, jobName := range slices.Sorted(maps.Keys(data.Jobs)) { + // Skip jobs.pre-activation (or pre_activation) as it's handled specially + if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" { + continue + } + + // Only add as direct dependency if it doesn't depend on pre_activation or agent + // (jobs that depend on pre_activation are handled through activation) + // (jobs that depend on agent are post-execution jobs like failure handlers) + if configMap, ok := data.Jobs[jobName].(map[string]any); ok { + if !jobDependsOnPreActivation(configMap) && !jobDependsOnAgent(configMap) { + depends = append(depends, jobName) + } + } + } + } + + // IMPORTANT: Even though jobs that depend on pre_activation are transitively accessible + // through the activation job, if the workflow content directly references their outputs + // (e.g., ${{ needs.search_issues.outputs.* }}), we MUST add them as direct dependencies. + // This is required for GitHub Actions expression evaluation and actionlint validation. + // Also check custom steps from the frontmatter, which are also added to the agent job. + var contentBuilder strings.Builder + contentBuilder.WriteString(data.MarkdownContent) + if data.CustomSteps != "" { + contentBuilder.WriteByte('\n') + contentBuilder.WriteString(data.CustomSteps) + } + referencedJobs := c.getReferencedCustomJobs(contentBuilder.String(), data.Jobs) + for _, jobName := range referencedJobs { + // Skip jobs.pre-activation (or pre_activation) as it's handled specially + if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" { + continue + } + + // Check if this job is already in depends + alreadyDepends := slices.Contains(depends, jobName) + // Add it if not already present + if !alreadyDepends { + depends = append(depends, jobName) + compilerMainJobLog.Printf("Added direct dependency on custom job '%s' because it's referenced in workflow content", jobName) + } + } + + // Build outputs for all engines (GH_AW_SAFE_OUTPUTS functionality) + // Build job outputs + // Always include model output for reuse in other jobs - now sourced from activation job + outputs := map[string]string{ + "model": "${{ needs.activation.outputs.model }}", + } + + // Note: secret_verification_result is now an output of the activation job (not the agent job). + // The validate-secret step runs in the activation job, before context variable validation. + + // Add safe-output specific outputs if the workflow uses the safe-outputs feature + if data.SafeOutputs != nil { + outputs["output"] = "${{ steps.collect_output.outputs.output }}" + outputs["output_types"] = "${{ steps.collect_output.outputs.output_types }}" + outputs["has_patch"] = "${{ steps.collect_output.outputs.has_patch }}" + } + + // Add inline detection outputs if threat detection is enabled + if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil { + outputs["detection_success"] = "${{ steps.detection_conclusion.outputs.success }}" + outputs["detection_conclusion"] = "${{ steps.detection_conclusion.outputs.conclusion }}" + compilerMainJobLog.Print("Added detection_success and detection_conclusion outputs to agent job") + } + + // Add checkout_pr_success output to track PR checkout status only if the checkout-pr step will be generated + // This is used by the conclusion job to skip failure handling when checkout fails + // (e.g., when PR is merged and branch is deleted) + // The checkout-pr step is only generated when the workflow has contents read permission + if ShouldGeneratePRCheckoutStep(data) { + outputs["checkout_pr_success"] = "${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}" + compilerMainJobLog.Print("Added checkout_pr_success output (workflow has contents read access)") + } else { + compilerMainJobLog.Print("Skipped checkout_pr_success output (workflow lacks contents read access)") + } + + // Build job-level environment variables for safe outputs + var env map[string]string + if data.SafeOutputs != nil { + env = make(map[string]string) + + // Set GH_AW_SAFE_OUTPUTS to path in /opt (read-only mount for agent container) + // The MCP server writes agent outputs to this file during execution + // This file is in /opt to prevent the agent container from having write access + env["GH_AW_SAFE_OUTPUTS"] = "/opt/gh-aw/safeoutputs/outputs.jsonl" + + // Set GH_AW_MCP_LOG_DIR for safe outputs MCP server logging + // Store in mcp-logs directory so it's included in mcp-logs artifact + env["GH_AW_MCP_LOG_DIR"] = "/tmp/gh-aw/mcp-logs/safeoutputs" + + // Set config and tools paths (readonly files in /opt/gh-aw) + env["GH_AW_SAFE_OUTPUTS_CONFIG_PATH"] = "/opt/gh-aw/safeoutputs/config.json" + env["GH_AW_SAFE_OUTPUTS_TOOLS_PATH"] = "/opt/gh-aw/safeoutputs/tools.json" + + // Add asset-related environment variables + // These must always be set (even to empty) because awmg v0.0.12+ validates ${VAR} references + if data.SafeOutputs.UploadAssets != nil { + env["GH_AW_ASSETS_BRANCH"] = fmt.Sprintf("%q", data.SafeOutputs.UploadAssets.BranchName) + env["GH_AW_ASSETS_MAX_SIZE_KB"] = strconv.Itoa(data.SafeOutputs.UploadAssets.MaxSizeKB) + env["GH_AW_ASSETS_ALLOWED_EXTS"] = fmt.Sprintf("%q", strings.Join(data.SafeOutputs.UploadAssets.AllowedExts, ",")) + } else { + // Set empty defaults when upload-assets is not configured + env["GH_AW_ASSETS_BRANCH"] = `""` + env["GH_AW_ASSETS_MAX_SIZE_KB"] = "0" + env["GH_AW_ASSETS_ALLOWED_EXTS"] = `""` + } + + // DEFAULT_BRANCH is used by safeoutputs MCP server + // Use repository default branch from GitHub context + env["DEFAULT_BRANCH"] = "${{ github.event.repository.default_branch }}" + } + + // Set GH_AW_WORKFLOW_ID_SANITIZED for cache-memory keys + // This contains the workflow ID with all hyphens removed and lowercased + // Used in cache keys to avoid spaces and special characters + if data.WorkflowID != "" { + if env == nil { + env = make(map[string]string) + } + sanitizedID := SanitizeWorkflowIDForCacheKey(data.WorkflowID) + env["GH_AW_WORKFLOW_ID_SANITIZED"] = sanitizedID + } + + // Generate agent concurrency configuration + agentConcurrency := GenerateJobConcurrencyConfig(data) + + // Set up permissions for the agent job + // In dev/script mode, automatically add contents: read if the actions folder checkout is needed + // In release mode, use the permissions as specified by the user (no automatic augmentation) + permissions := data.Permissions + needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0 + if needsContentsRead { + if permissions == "" { + perms := NewPermissionsContentsRead() + permissions = perms.RenderToYAML() + } else { + parser := NewPermissionsParser(permissions) + perms := parser.ToPermissions() + if level, exists := perms.Get(PermissionContents); !exists || level == PermissionNone { + perms.Set(PermissionContents, PermissionRead) + permissions = perms.RenderToYAML() + } + } + } + + job := &Job{ + Name: string(constants.AgentJobName), + If: jobCondition, + RunsOn: c.indentYAMLLines(data.RunsOn, " "), + Environment: c.indentYAMLLines(data.Environment, " "), + Container: c.indentYAMLLines(data.Container, " "), + Services: c.indentYAMLLines(data.Services, " "), + Permissions: c.indentYAMLLines(permissions, " "), + Concurrency: c.indentYAMLLines(agentConcurrency, " "), + Env: env, + Steps: steps, + Needs: depends, + Outputs: outputs, + } + + return job, nil +} diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go new file mode 100644 index 0000000000..8dc2b6e931 --- /dev/null +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -0,0 +1,442 @@ +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/stringutil" +) + +var compilerActivationJobsLog = logger.New("workflow:compiler_activation_jobs") + +// buildPreActivationJob creates a unified pre-activation job that combines membership checks and stop-time validation. +// This job exposes a single "activated" output that indicates whether the workflow should proceed. +func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionCheck bool) (*Job, error) { + compilerActivationJobsLog.Printf("Building pre-activation job: needsPermissionCheck=%v, hasStopTime=%v", needsPermissionCheck, data.StopTime != "") + var steps []string + var permissions string + + // Extract custom steps and outputs from jobs.pre-activation if present + customSteps, customOutputs, err := c.extractPreActivationCustomFields(data.Jobs) + if err != nil { + return nil, fmt.Errorf("failed to extract pre-activation custom fields: %w", err) + } + + // 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 + // This requires contents: read permission + steps = append(steps, c.generateCheckoutActionsFolder(data)...) + needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0 + + // Pre-activation job doesn't need project support (no safe outputs processed here) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) + + // Determine permissions for pre-activation job + var perms *Permissions + if needsContentsRead { + perms = NewPermissionsContentsRead() + } + + // Add reaction permissions if reaction is configured (reactions added in pre-activation for immediate feedback) + if data.AIReaction != "" && data.AIReaction != "none" { + if perms == nil { + perms = NewPermissions() + } + // Add write permissions for reactions + perms.Set(PermissionIssues, PermissionWrite) + perms.Set(PermissionPullRequests, PermissionWrite) + perms.Set(PermissionDiscussions, PermissionWrite) + } + + // Add actions: read permission if rate limiting is configured (needed to query workflow runs) + if data.RateLimit != nil { + if perms == nil { + perms = NewPermissions() + } + perms.Set(PermissionActions, PermissionRead) + } + + // Set permissions if any were configured + if perms != nil { + permissions = perms.RenderToYAML() + } + + // Add reaction step immediately after setup for instant user feedback + // This happens BEFORE any checks, so users see progress immediately + if data.AIReaction != "" && data.AIReaction != "none" { + reactionCondition := BuildReactionCondition() + + 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", reactionCondition.Render())) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + + // 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") + // Explicitly use the GitHub Actions token (GITHUB_TOKEN) for reactions + // This ensures proper authentication for adding reactions + steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("add_reaction.cjs")) + } + + // Add team member check if permission checks are needed + if needsPermissionCheck { + steps = c.generateMembershipCheck(data, steps) + } + + // Add rate limit check if configured + if data.RateLimit != nil { + steps = c.generateRateLimitCheck(data, steps) + } + + // Add stop-time check if configured + if data.StopTime != "" { + // Extract workflow name for the stop-time check + workflowName := data.Name + + steps = append(steps, " - name: Check stop-time limit\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckStopTimeStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + // Strip ANSI escape codes from stop-time value + cleanStopTime := stringutil.StripANSI(data.StopTime) + steps = append(steps, fmt.Sprintf(" GH_AW_STOP_TIME: %s\n", cleanStopTime)) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_stop_time.cjs")) + } + + // Add skip-if-match check if configured + if data.SkipIfMatch != nil { + // Extract workflow name for the skip-if-match check + workflowName := data.Name + + steps = append(steps, " - name: Check skip-if-match query\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfMatchStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfMatch.Query)) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MAX_MATCHES: \"%d\"\n", data.SkipIfMatch.Max)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_match.cjs")) + } + + // Add skip-if-no-match check if configured + if data.SkipIfNoMatch != nil { + // Extract workflow name for the skip-if-no-match check + workflowName := data.Name + + steps = append(steps, " - name: Check skip-if-no-match query\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipIfNoMatchStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfNoMatch.Query)) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MIN_MATCHES: \"%d\"\n", data.SkipIfNoMatch.Min)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_no_match.cjs")) + } + + // Add skip-roles check if configured + if len(data.SkipRoles) > 0 { + // Extract workflow name for the skip-roles check + workflowName := data.Name + + steps = append(steps, " - name: Check skip-roles\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipRolesStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_ROLES: %s\n", strings.Join(data.SkipRoles, ","))) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, " with:\n") + steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_skip_roles.cjs")) + } + + // Add skip-bots check if configured + if len(data.SkipBots) > 0 { + // Extract workflow name for the skip-bots check + workflowName := data.Name + + steps = append(steps, " - name: Check skip-bots\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckSkipBotsStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_BOTS: %s\n", strings.Join(data.SkipBots, ","))) + steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_skip_bots.cjs")) + } + + // Add command position check if this is a command workflow + if len(data.Command) > 0 { + steps = append(steps, " - name: Check command position\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckCommandPositionStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + // Pass commands as JSON array + commandsJSON, _ := json.Marshal(data.Command) + steps = append(steps, fmt.Sprintf(" GH_AW_COMMANDS: %q\n", string(commandsJSON))) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_command_position.cjs")) + } + + // Append custom steps from jobs.pre-activation if present + if len(customSteps) > 0 { + compilerActivationJobsLog.Printf("Adding %d custom steps to pre-activation job", len(customSteps)) + steps = append(steps, customSteps...) + } + + // Generate the activated output expression using expression builders + var activatedNode ConditionNode + + // Build condition nodes for each check + var conditions []ConditionNode + + if needsPermissionCheck { + // Add membership check condition + membershipCheck := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckMembershipStepID, constants.IsTeamMemberOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, membershipCheck) + } + + if data.StopTime != "" { + // Add stop-time check condition + stopTimeCheck := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckStopTimeStepID, constants.StopTimeOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, stopTimeCheck) + } + + if data.SkipIfMatch != nil { + // Add skip-if-match check condition + skipCheckOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfMatchStepID, constants.SkipCheckOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipCheckOk) + } + + if data.SkipIfNoMatch != nil { + // Add skip-if-no-match check condition + skipNoMatchCheckOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipIfNoMatchStepID, constants.SkipNoMatchCheckOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipNoMatchCheckOk) + } + + if len(data.SkipRoles) > 0 { + // Add skip-roles check condition + skipRolesCheckOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipRolesStepID, constants.SkipRolesOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipRolesCheckOk) + } + + if len(data.SkipBots) > 0 { + // Add skip-bots check condition + skipBotsCheckOk := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckSkipBotsStepID, constants.SkipBotsOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, skipBotsCheckOk) + } + + if data.RateLimit != nil { + // Add rate limit check condition + rateLimitCheck := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckRateLimitStepID, constants.RateLimitOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, rateLimitCheck) + } + + if len(data.Command) > 0 { + // Add command position check condition + commandPositionCheck := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckCommandPositionStepID, constants.CommandPositionOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, commandPositionCheck) + } + + // Build the final expression + if len(conditions) == 0 { + // This should never happen - it means pre-activation job was created without any checks + // If we reach this point, it's a developer error in the compiler logic + return nil, errors.New("developer error: pre-activation job created without permission check or stop-time configuration") + } else if len(conditions) == 1 { + // Single condition + activatedNode = conditions[0] + } else { + // Multiple conditions - combine with AND + activatedNode = conditions[0] + for i := 1; i < len(conditions); i++ { + activatedNode = BuildAnd(activatedNode, conditions[i]) + } + } + + // Render the expression with ${{ }} wrapper + activatedExpression := fmt.Sprintf("${{ %s }}", activatedNode.Render()) + + outputs := map[string]string{ + "activated": activatedExpression, + } + + // Always declare matched_command output so actionlint can resolve the type. + // For command workflows, reference the check_command_position step output. + // For non-command workflows, emit an empty string so the output key is defined. + if len(data.Command) > 0 { + outputs[constants.MatchedCommandOutput] = fmt.Sprintf("${{ steps.%s.outputs.%s }}", constants.CheckCommandPositionStepID, constants.MatchedCommandOutput) + } else { + outputs[constants.MatchedCommandOutput] = "''" + } + + // Merge custom outputs from jobs.pre-activation if present + if len(customOutputs) > 0 { + compilerActivationJobsLog.Printf("Adding %d custom outputs to pre-activation job", len(customOutputs)) + maps.Copy(outputs, customOutputs) + } + + // Pre-activation job uses the user's original if condition (data.If) + // The workflow_run safety check is NOT applied here - it's only on the activation job + // Don't include conditions that reference custom job outputs (those belong on the agent job) + var jobIfCondition string + if !c.referencesCustomJobOutputs(data.If, data.Jobs) { + jobIfCondition = data.If + } + + job := &Job{ + Name: string(constants.PreActivationJobName), + If: jobIfCondition, + RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Permissions: permissions, + Steps: steps, + Outputs: outputs, + } + + return job, nil +} + +// extractPreActivationCustomFields extracts custom steps and outputs from jobs.pre-activation field in frontmatter. +// It validates that only steps and outputs fields are present, and errors on any other fields. +// If both jobs.pre-activation and jobs.pre_activation are defined, imports from both. +// Returns (customSteps, customOutputs, error). +func (c *Compiler) extractPreActivationCustomFields(jobs map[string]any) ([]string, map[string]string, error) { + if jobs == nil { + return nil, nil, nil + } + + var customSteps []string + var customOutputs map[string]string + + // Check both jobs.pre-activation and jobs.pre_activation (users might define both by mistake) + // Import from both if both are defined + jobVariants := []string{"pre-activation", string(constants.PreActivationJobName)} + + for _, jobName := range jobVariants { + preActivationJob, exists := jobs[jobName] + if !exists { + continue + } + + // jobs.pre-activation must be a map + configMap, ok := preActivationJob.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("jobs.%s must be an object, got %T", jobName, preActivationJob) + } + + // Validate that only steps and outputs fields are present + allowedFields := map[string]bool{ + "steps": true, + "outputs": true, + } + + for field := range configMap { + if !allowedFields[field] { + return nil, nil, fmt.Errorf("jobs.%s: unsupported field '%s' - only 'steps' and 'outputs' are allowed", jobName, field) + } + } + + // Extract steps + if stepsValue, hasSteps := configMap["steps"]; hasSteps { + stepsList, ok := stepsValue.([]any) + if !ok { + return nil, nil, fmt.Errorf("jobs.%s.steps must be an array, got %T", jobName, stepsValue) + } + + for i, step := range stepsList { + stepMap, ok := step.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("jobs.%s.steps[%d] must be an object, got %T", jobName, i, step) + } + + // Convert step to YAML + stepYAML, err := c.convertStepToYAML(stepMap) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert jobs.%s.steps[%d] to YAML: %w", jobName, i, err) + } + customSteps = append(customSteps, stepYAML) + } + compilerActivationJobsLog.Printf("Extracted %d custom steps from jobs.%s", len(stepsList), jobName) + } + + // Extract outputs + if outputsValue, hasOutputs := configMap["outputs"]; hasOutputs { + outputsMap, ok := outputsValue.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("jobs.%s.outputs must be an object, got %T", jobName, outputsValue) + } + + if customOutputs == nil { + customOutputs = make(map[string]string) + } + for key, val := range outputsMap { + valStr, ok := val.(string) + if !ok { + return nil, nil, fmt.Errorf("jobs.%s.outputs.%s must be a string, got %T", jobName, key, val) + } + // If the same output key is defined in both variants, the second one wins (pre_activation) + customOutputs[key] = valStr + } + compilerActivationJobsLog.Printf("Extracted %d custom outputs from jobs.%s", len(outputsMap), jobName) + } + } + + return customSteps, customOutputs, nil +}