diff --git a/actions/setup/js/check_command_position.cjs b/actions/setup/js/check_command_position.cjs index 5c8b6ef04a1..349ccf1f114 100644 --- a/actions/setup/js/check_command_position.cjs +++ b/actions/setup/js/check_command_position.cjs @@ -2,6 +2,7 @@ /// const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); /** * Check if command is the first word in the triggering text @@ -100,6 +101,10 @@ async function main() { core.warning(`⚠️ None of the commands [${expectedCommands}] matched the first word (found: '${firstWord}'). Workflow will be skipped.`); core.setOutput("command_position_ok", "false"); core.setOutput("matched_command", ""); + await writeDenialSummary( + `The trigger comment did not start with a required command. Expected one of: ${expectedCommands}. Found: \`${firstWord}\`.`, + "Make sure the trigger comment starts with the required command defined in `on.command:` in the workflow frontmatter." + ); } } catch (error) { core.setFailed(`${ERR_API}: ${getErrorMessage(error)}`); diff --git a/actions/setup/js/check_membership.cjs b/actions/setup/js/check_membership.cjs index 85427cf7082..712f3606c04 100644 --- a/actions/setup/js/check_membership.cjs +++ b/actions/setup/js/check_membership.cjs @@ -2,6 +2,7 @@ /// const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot } = require("./check_permissions_utils.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); async function main() { const { eventName } = context; @@ -46,6 +47,7 @@ async function main() { core.setOutput("is_team_member", "false"); core.setOutput("result", "config_error"); core.setOutput("error_message", "Configuration error: Required permissions not specified"); + await writeDenialSummary("Configuration error: Required permissions not specified.", "Contact the repository administrator to fix the workflow frontmatter configuration."); return; } @@ -76,11 +78,13 @@ async function main() { core.setOutput("user_permission", "bot"); return; } else if (botStatus.isBot && !botStatus.isActive) { + const errorMessage = `Access denied: Bot '${actor}' is not active/installed on this repository`; core.warning(`Bot '${actor}' is in the allowed list but not active/installed on ${owner}/${repo}`); core.setOutput("is_team_member", "false"); core.setOutput("result", "bot_not_active"); core.setOutput("user_permission", result.permission ?? "bot"); - core.setOutput("error_message", `Access denied: Bot '${actor}' is not active/installed on this repository`); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, "The bot is in the allowed list but is not installed or active on this repository. Install the GitHub App and try again."); return; } else { core.info(`Actor '${actor}' is in allowed bots list but bot status check failed`); @@ -90,18 +94,20 @@ async function main() { // Not authorized by role or bot if (result.error) { + const errorMessage = `Repository permission check failed: ${result.error}`; core.setOutput("is_team_member", "false"); core.setOutput("result", "api_error"); - core.setOutput("error_message", `Repository permission check failed: ${result.error}`); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, "The permission check failed with a GitHub API error. Check the `pre_activation` job log for details."); } else { + const errorMessage = + `Access denied: User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}. ` + + `To allow this user to run the workflow, add their role to the frontmatter. Example: roles: [${requiredPermissions.join(", ")}, ${result.permission}]`; core.setOutput("is_team_member", "false"); core.setOutput("result", "insufficient_permissions"); core.setOutput("user_permission", result.permission); - core.setOutput( - "error_message", - `Access denied: User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}. ` + - `To allow this user to run the workflow, add their role to the frontmatter. Example: roles: [${requiredPermissions.join(", ")}, ${result.permission}]` - ); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, `To allow a bot or GitHub App actor, add it to \`on.bots:\` in the workflow frontmatter. ` + `To change the required roles for human actors, update \`on.roles:\` in the workflow frontmatter.`); } } } diff --git a/actions/setup/js/check_membership.test.cjs b/actions/setup/js/check_membership.test.cjs index adb04b06098..3432f24162a 100644 --- a/actions/setup/js/check_membership.test.cjs +++ b/actions/setup/js/check_membership.test.cjs @@ -86,6 +86,13 @@ describe("check_membership.cjs", () => { utilsFunction(mockCore, mockGithub, mockContext, process, mockModule, moduleExports, mockRequire); return mockModule.exports; } + if (modulePath === "./pre_activation_summary.cjs") { + return { + writeDenialSummary: async (reason, remediation) => { + await mockCore.summary.addRaw(`${reason}\n${remediation}`).write(); + }, + }; + } throw new Error(`Module not found: ${modulePath}`); }; diff --git a/actions/setup/js/check_skip_bots.cjs b/actions/setup/js/check_skip_bots.cjs index 3dc528beb5c..78c53294c59 100644 --- a/actions/setup/js/check_skip_bots.cjs +++ b/actions/setup/js/check_skip_bots.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); + /** * Check if the workflow should be skipped based on bot/user identity * Reads skip-bots from GH_AW_SKIP_BOTS environment variable @@ -48,10 +50,12 @@ async function main() { if (isSkipped) { // User is in skip-bots, skip the workflow + const errorMessage = `Workflow skipped: User '${actor}' is in skip-bots: [${skipBots.join(", ")}]`; core.info(`❌ User '${actor}' is in skip-bots [${skipBots.join(", ")}]. Workflow will be skipped.`); core.setOutput("skip_bots_ok", "false"); core.setOutput("result", "skipped"); - core.setOutput("error_message", `Workflow skipped: User '${actor}' is in skip-bots: [${skipBots.join(", ")}]`); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, "Update `on.skip-bots:` in the workflow frontmatter to change which bots are excluded."); } else { // User is NOT in skip-bots, allow workflow to proceed core.info(`✅ User '${actor}' is NOT in skip-bots [${skipBots.join(", ")}]. Workflow will proceed.`); diff --git a/actions/setup/js/check_skip_bots.test.cjs b/actions/setup/js/check_skip_bots.test.cjs index 765e07279f1..151392f5bd3 100644 --- a/actions/setup/js/check_skip_bots.test.cjs +++ b/actions/setup/js/check_skip_bots.test.cjs @@ -12,6 +12,10 @@ describe("check_skip_bots.cjs", () => { error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, }; mockContext = { diff --git a/actions/setup/js/check_skip_if_check_failing.cjs b/actions/setup/js/check_skip_if_check_failing.cjs index fff1ebd93b7..97d0ff5475c 100644 --- a/actions/setup/js/check_skip_if_check_failing.cjs +++ b/actions/setup/js/check_skip_if_check_failing.cjs @@ -4,6 +4,7 @@ const { getErrorMessage, isRateLimitError } = require("./error_helpers.cjs"); const { ERR_API } = require("./error_codes.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); /** * Determines the ref to check for CI status. @@ -206,6 +207,7 @@ async function main() { const names = failingChecks.map(r => (r.status === "completed" ? `${r.name} (${r.conclusion})` : `${r.name} (${r.status})`)).join(", "); core.warning(`⚠️ Failing CI checks detected on "${ref}": ${names}. Workflow execution will be prevented by activation job.`); core.setOutput("skip_if_check_failing_ok", "false"); + await writeDenialSummary(`Failing CI checks detected on \`${ref}\`: ${names}.`, "Fix the failing check(s) referenced in `on.skip-if-check-failing:`, or update the frontmatter configuration."); return; } diff --git a/actions/setup/js/check_skip_if_check_failing.test.cjs b/actions/setup/js/check_skip_if_check_failing.test.cjs index a1e8e303115..e60ca5fe0e7 100644 --- a/actions/setup/js/check_skip_if_check_failing.test.cjs +++ b/actions/setup/js/check_skip_if_check_failing.test.cjs @@ -12,6 +12,10 @@ describe("check_skip_if_check_failing.cjs", () => { error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, }; mockGithub = { diff --git a/actions/setup/js/check_skip_if_match.cjs b/actions/setup/js/check_skip_if_match.cjs index 074605cfca7..fc379c91e7a 100644 --- a/actions/setup/js/check_skip_if_match.cjs +++ b/actions/setup/js/check_skip_if_match.cjs @@ -4,6 +4,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); async function main() { const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MAX_MATCHES: maxMatchesStr = "1", GH_AW_SKIP_SCOPE: skipScope } = process.env; @@ -42,6 +43,7 @@ async function main() { if (totalCount >= maxMatches) { core.warning(`🔍 Skip condition matched (${totalCount} items found, threshold: ${maxMatches}). Workflow execution will be prevented by activation job.`); core.setOutput("skip_check_ok", "false"); + await writeDenialSummary(`Skip-if-match query matched: ${totalCount} item(s) found (threshold: ${maxMatches}).`, "Update `on.skip-if-match:` in the workflow frontmatter if this skip was unexpected."); return; } diff --git a/actions/setup/js/check_skip_if_no_match.cjs b/actions/setup/js/check_skip_if_no_match.cjs index 8d089068322..b3edfef7803 100644 --- a/actions/setup/js/check_skip_if_no_match.cjs +++ b/actions/setup/js/check_skip_if_no_match.cjs @@ -4,6 +4,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); async function main() { const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MIN_MATCHES: minMatchesStr = "1", GH_AW_SKIP_SCOPE: skipScope } = process.env; @@ -42,6 +43,7 @@ async function main() { if (totalCount < minMatches) { core.warning(`🔍 Skip condition matched (${totalCount} items found, minimum required: ${minMatches}). Workflow execution will be prevented by activation job.`); core.setOutput("skip_no_match_check_ok", "false"); + await writeDenialSummary(`Skip-if-no-match query returned too few results: ${totalCount} item(s) found (minimum required: ${minMatches}).`, "Update `on.skip-if-no-match:` in the workflow frontmatter if this skip was unexpected."); return; } diff --git a/actions/setup/js/check_skip_if_no_match.test.cjs b/actions/setup/js/check_skip_if_no_match.test.cjs index 8d89d7bfbe6..5642a48f17a 100644 --- a/actions/setup/js/check_skip_if_no_match.test.cjs +++ b/actions/setup/js/check_skip_if_no_match.test.cjs @@ -19,6 +19,10 @@ describe("check_skip_if_no_match", () => { warnings: [], errors: [], outputs: {}, + summary: { + addRaw: () => mockCore.summary, + write: async () => {}, + }, }; mockCore.info = msg => { diff --git a/actions/setup/js/check_skip_roles.cjs b/actions/setup/js/check_skip_roles.cjs index d62c8b2377a..78256087b27 100644 --- a/actions/setup/js/check_skip_roles.cjs +++ b/actions/setup/js/check_skip_roles.cjs @@ -2,6 +2,7 @@ /// const { checkRepositoryPermission } = require("./check_permissions_utils.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); /** * Check if the workflow should be skipped based on user's role @@ -44,11 +45,13 @@ async function main() { if (result.authorized) { // User has one of the skip-roles, skip the workflow + const errorMessage = `Workflow skipped: User '${actor}' has role '${result.permission}' which is in skip-roles: [${skipRoles.join(", ")}]`; core.info(`❌ User '${actor}' has role '${result.permission}' which is in skip-roles [${skipRoles.join(", ")}]. Workflow will be skipped.`); core.setOutput("skip_roles_ok", "false"); core.setOutput("result", "skipped"); core.setOutput("user_permission", result.permission); - core.setOutput("error_message", `Workflow skipped: User '${actor}' has role '${result.permission}' which is in skip-roles: [${skipRoles.join(", ")}]`); + core.setOutput("error_message", errorMessage); + await writeDenialSummary(errorMessage, "Update `on.skip-roles:` in the workflow frontmatter to change which roles are excluded."); } else { // User does NOT have any of the skip-roles, allow workflow to proceed core.info(`✅ User '${actor}' has role '${result.permission}' which is NOT in skip-roles [${skipRoles.join(", ")}]. Workflow will proceed.`); diff --git a/actions/setup/js/check_skip_roles.test.cjs b/actions/setup/js/check_skip_roles.test.cjs index d7911e67239..4453890f499 100644 --- a/actions/setup/js/check_skip_roles.test.cjs +++ b/actions/setup/js/check_skip_roles.test.cjs @@ -84,6 +84,13 @@ describe("check_skip_roles.cjs", () => { utilsFunction(mockCore, mockGithub, mockContext, process, mockModule, moduleExports, mockRequire); return mockModule.exports; } + if (modulePath === "./pre_activation_summary.cjs") { + return { + writeDenialSummary: async (reason, remediation) => { + await mockCore.summary.addRaw(`${reason}\n${remediation}`).write(); + }, + }; + } throw new Error(`Module not found: ${modulePath}`); }; diff --git a/actions/setup/js/check_stop_time.cjs b/actions/setup/js/check_stop_time.cjs index 6addb4c2162..fc3ca7304ba 100644 --- a/actions/setup/js/check_stop_time.cjs +++ b/actions/setup/js/check_stop_time.cjs @@ -2,6 +2,7 @@ /// const { ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); async function main() { const stopTime = process.env.GH_AW_STOP_TIME; const workflowName = process.env.GH_AW_WORKFLOW_NAME; @@ -33,6 +34,7 @@ async function main() { if (currentTime >= stopTimeDate) { core.warning(`⏰ Stop time reached. Workflow execution will be prevented by activation job.`); core.setOutput("stop_time_ok", "false"); + await writeDenialSummary(`Workflow '${workflowName}' has passed its configured stop-time (${stopTimeDate.toISOString()}).`, "Update or remove `on.stop-after:` in the workflow frontmatter to extend the active window."); return; } diff --git a/actions/setup/js/pre_activation_summary.cjs b/actions/setup/js/pre_activation_summary.cjs new file mode 100644 index 00000000000..951940b2b56 --- /dev/null +++ b/actions/setup/js/pre_activation_summary.cjs @@ -0,0 +1,38 @@ +// @ts-check +/// + +const path = require("path"); +const { renderTemplateFromFile } = require("./messages_core.cjs"); + +/** + * Writes a pre-activation skip denial summary to the GitHub Actions job summary. + * Uses the pre_activation_skip.md template from the prompts directory when available, + * falling back to a hardcoded format when the template cannot be loaded (e.g. in tests). + * + * @param {string} reason - The denial reason message + * @param {string} remediation - Remediation hint for the operator + */ +async function writeDenialSummary(reason, remediation) { + let content; + + const runnerTemp = process.env.RUNNER_TEMP; + if (runnerTemp) { + const templatePath = path.join(runnerTemp, "gh-aw", "prompts", "pre_activation_skip.md"); + try { + content = renderTemplateFromFile(templatePath, { reason, remediation }); + } catch (err) { + // Log unexpected errors but still fall through to the hardcoded fallback + if (err && typeof err === "object" && "code" in err && err.code !== "ENOENT") { + core.warning(`pre_activation_summary: could not read template ${templatePath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + + if (!content) { + content = `## ⏭️ Workflow Activation Skipped\n\n> ${reason}\n\n**Remediation:** ${remediation}\n\n---\n_See the \`pre_activation\` job log for full details._`; + } + + await core.summary.addRaw(content).write(); +} + +module.exports = { writeDenialSummary }; diff --git a/actions/setup/js/pre_activation_summary.test.cjs b/actions/setup/js/pre_activation_summary.test.cjs new file mode 100644 index 00000000000..f751fc329a9 --- /dev/null +++ b/actions/setup/js/pre_activation_summary.test.cjs @@ -0,0 +1,85 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +describe("pre_activation_summary.cjs", () => { + let mockCore; + let originalRunnerTemp; + + beforeEach(() => { + mockCore = { + info: vi.fn(), + warning: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, + }; + global.core = mockCore; + originalRunnerTemp = process.env.RUNNER_TEMP; + vi.resetModules(); + }); + + afterEach(() => { + if (originalRunnerTemp !== undefined) { + process.env.RUNNER_TEMP = originalRunnerTemp; + } else { + delete process.env.RUNNER_TEMP; + } + delete global.core; + vi.clearAllMocks(); + }); + + describe("writeDenialSummary", () => { + it("uses the markdown template when template file exists", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); + const promptsDir = path.join(tmpDir, "gh-aw", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(promptsDir, "pre_activation_skip.md"), "## Skipped\n\n> {reason}\n\n**Fix:** {remediation}\n", "utf8"); + + process.env.RUNNER_TEMP = tmpDir; + + try { + const { writeDenialSummary } = await import("./pre_activation_summary.cjs"); + await writeDenialSummary("Denied: insufficient perms", "Update frontmatter roles"); + + expect(mockCore.summary.addRaw).toHaveBeenCalledWith("## Skipped\n\n> Denied: insufficient perms\n\n**Fix:** Update frontmatter roles\n"); + expect(mockCore.summary.write).toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("falls back to hardcoded format when RUNNER_TEMP is not set", async () => { + delete process.env.RUNNER_TEMP; + + const { writeDenialSummary } = await import("./pre_activation_summary.cjs"); + await writeDenialSummary("Bot not authorized", "Add bot to on.bots:"); + + const rawCall = mockCore.summary.addRaw.mock.calls[0][0]; + expect(rawCall).toContain("Bot not authorized"); + expect(rawCall).toContain("Add bot to on.bots:"); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("falls back to hardcoded format when template file does not exist", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-test-")); + process.env.RUNNER_TEMP = tmpDir; + // No template file created + + try { + const { writeDenialSummary } = await import("./pre_activation_summary.cjs"); + await writeDenialSummary("Stop time exceeded", "Update on.stop-after:"); + + const rawCall = mockCore.summary.addRaw.mock.calls[0][0]; + expect(rawCall).toContain("Stop time exceeded"); + expect(rawCall).toContain("Update on.stop-after:"); + expect(mockCore.summary.write).toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/actions/setup/md/pre_activation_skip.md b/actions/setup/md/pre_activation_skip.md new file mode 100644 index 00000000000..7a7e4a1c863 --- /dev/null +++ b/actions/setup/md/pre_activation_skip.md @@ -0,0 +1,9 @@ +## ⏭️ Workflow Activation Skipped + +> {reason} + +**Remediation:** {remediation} + +--- + +_See the `pre_activation` job log for full details._ diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 80d1cf8fce9..51069ef63bb 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -439,6 +439,8 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec return job, nil } +// generateReportSkipStep generates the "Report skip reason" step for the pre-activation job. +// The step runs with if: always() and writes skip reasons to the GitHub Actions job summary // 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.