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.