Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions actions/setup/js/check_command_position.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@actions/github-script" />

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
Expand Down Expand Up @@ -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)}`);
Expand Down
20 changes: 13 additions & 7 deletions actions/setup/js/check_membership.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@actions/github-script" />

const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot } = require("./check_permissions_utils.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");

async function main() {
const { eventName } = context;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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`);
Expand All @@ -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.`);
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/check_membership.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
};

Expand Down
6 changes: 5 additions & 1 deletion actions/setup/js/check_skip_bots.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

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
Expand Down Expand Up @@ -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.`);
Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/check_skip_bots.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/check_skip_if_check_failing.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/check_skip_if_check_failing.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/check_skip_if_match.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/check_skip_if_no_match.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/check_skip_if_no_match.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ describe("check_skip_if_no_match", () => {
warnings: [],
errors: [],
outputs: {},
summary: {
addRaw: () => mockCore.summary,
write: async () => {},
},
};

mockCore.info = msg => {
Expand Down
5 changes: 4 additions & 1 deletion actions/setup/js/check_skip_roles.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@actions/github-script" />

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
Expand Down Expand Up @@ -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.`);
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/check_skip_roles.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
};

Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/check_stop_time.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@actions/github-script" />

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;
Expand Down Expand Up @@ -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;
}

Expand Down
38 changes: 38 additions & 0 deletions actions/setup/js/pre_activation_summary.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @ts-check
/// <reference types="@actions/github-script" />

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") {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block intends to log “unexpected errors”, but it only warns when the thrown value has a code property and that code is not ENOENT. If renderTemplateFromFile throws an Error without code, the error is silently swallowed and you fall back without any warning. Consider warning for all errors except ENOENT (including when err.code is missing) so template rendering problems don’t get hidden.

Suggested change
if (err && typeof err === "object" && "code" in err && err.code !== "ENOENT") {
const isEnoent = !!(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
if (!isEnoent) {

Copilot uses AI. Check for mistakes.
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 };
85 changes: 85 additions & 0 deletions actions/setup/js/pre_activation_summary.test.cjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
});
9 changes: 9 additions & 0 deletions actions/setup/md/pre_activation_skip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## ⏭️ Workflow Activation Skipped

> {reason}

**Remediation:** {remediation}

---

_See the `pre_activation` job log for full details._
2 changes: 2 additions & 0 deletions pkg/workflow/compiler_pre_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +442 to 446
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added Go doc comment references generateReportSkipStep, but there is no such function in this file (and the comment is placed immediately before extractPreActivationCustomFields). This is misleading in Godoc and can confuse future readers. Either add/restore the referenced function, or move/rename this comment to match the actual function it documents.

Suggested change
// 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.
// extractPreActivationCustomFields extracts custom steps and outputs from the
// 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, it imports
// from both.

Copilot uses AI. Check for mistakes.
Expand Down
Loading