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 .changeset/patch-add-safe-output-body-footer.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { generateFooterWithMessages, getBodyFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs");
const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
Expand All @@ -30,6 +30,7 @@ const { MAX_LABELS, MAX_ASSIGNEES } = require("./constants.cjs");
const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs");
const { parseDeduplicateByTitle, normalizeTitleForDedup, findDuplicateByTitle } = require("./issue_title_dedup.cjs");
const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs");
const MAX_GITHUB_BODY_LENGTH = 65536;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ISSUE_FIELD_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const RECENTLY_CLOSED_DEDUP_DAYS = 30;
Expand Down Expand Up @@ -1079,6 +1080,11 @@ async function main(config = {}) {
bodyLines.push(``, footer);
}

const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] body_footer is appended to bodyLines directly, bypassing the sanitizeContent call applied to processedBody a few lines above (line 919). Since body-footer is workflow-author-controlled (not agent output), this is lower risk than the main body, but it's inconsistent with the codebase's existing security posture where all body content flows through sanitization before hitting bodyLines.

💡 Consideration

If body-footer templates ever interpolate values that could contain untrusted data (e.g. a future placeholder pulling from PR/issue titles), this unsanitized path would become an injection vector. Worth a one-line comment documenting the assumption that body-footer is always static/workflow-author-controlled and never agent-influenced.

@copilot please address this.

if (bodyFooter) {
bodyLines.push(``, bodyFooter.trimEnd());
}

// Add standalone workflow-id marker for searchability (consistent with comments)
// Always add XML markers even when footer is disabled
if (workflowId) {
Expand Down Expand Up @@ -1188,6 +1194,9 @@ async function main(config = {}) {
}

try {
if (body.length > MAX_GITHUB_BODY_LENGTH) {
throw new Error(`${ERR_VALIDATION}: Issue body exceeds GitHub's maximum length of ${MAX_GITHUB_BODY_LENGTH} characters`);
}
const { data: issue } = await withRetry(
() =>
githubClient.rest.issues.create({
Expand Down
30 changes: 30 additions & 0 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ describe("create_issue", () => {
);
});

it("should append the configured body footer when the generated footer is disabled", async () => {
const handler = await main({
footer: false,
body_footer: "Required footer from {workflow_name}: {run_url}",
});
await handler({
title: "Test Issue",
body: "Test body content",
});

const createCall = mockGithub.rest.issues.create.mock.calls[0][0];
expect(createCall.body).toContain("Test body content\n\nRequired footer from Test Workflow: https://github.com/test-owner/test-repo/actions/runs/12345");
expect(createCall.body).not.toContain("> Generated by");
expect(createCall.body.indexOf("Required footer from Test Workflow")).toBeLessThan(createCall.body.indexOf("<!-- gh-aw-workflow-id:"));
});

it("should place the configured body footer after the generated footer", async () => {
const handler = await main({
body_footer: "Required final content",
});
await handler({
title: "Test Issue",
body: "Test body content",
});

const body = mockGithub.rest.issues.create.mock.calls[0][0].body;
expect(body.indexOf("> Generated by")).toBeLessThan(body.indexOf("Required final content"));
expect(body.indexOf("Required final content")).toBeLessThan(body.indexOf("<!-- gh-aw-workflow-id:"));
});

it("should use body as title when title is missing", async () => {
const handler = await main({});
const result = await handler({
Expand Down
47 changes: 37 additions & 10 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKey
const { parseBoolTemplatable, parseIntTemplatable } = require("./templatable.cjs");
const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs");
const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs");
const { getBodyFooterMessage } = require("./messages_footer.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
Expand Down Expand Up @@ -64,6 +65,8 @@ const {
} = require("./create_pull_request_helpers.cjs");
const { isStackedEnabled, parseStackMetadata, hasCircularStackDependency, buildStackMetadataLines, stackedDisabledError, circularStackError, verifyStackBaseBranchExists, createStackTracker } = require("./stacked_pull_requests.cjs");

const MAX_GITHUB_BODY_LENGTH = 65536;

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/
Expand Down Expand Up @@ -449,6 +452,10 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile
* @returns {Promise<{data: any, issueRepoParts: {owner: string, repo: string}}>}
*/
async function createFallbackIssue(githubClient, repoParts, title, body, labels, assignees) {
if (body.length > MAX_GITHUB_BODY_LENGTH) {
throw new Error(`Fallback issue body exceeds GitHub's maximum length of ${MAX_GITHUB_BODY_LENGTH} characters`);
}

const payload = {
owner: repoParts.owner,
repo: repoParts.repo,
Expand Down Expand Up @@ -1673,6 +1680,13 @@ async function main(config = {}) {
footerParts.push(footer);
}

const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl });
if (bodyFooter) {
const renderedBodyFooter = bodyFooter.trimEnd();
bodyLines.push(``, renderedBodyFooter);
footerParts.push(renderedBodyFooter);
}

// Add standalone workflow-id marker for searchability (consistent with comments)
// Always add XML markers even when footer is disabled
if (workflowId) {
Expand Down Expand Up @@ -1701,6 +1715,9 @@ async function main(config = {}) {
const issueSafeBody = neutralizeClosingKeywordsForIssueBody(body);
// Footer section (footer + workflow-id marker) used when ordering protected-files notices
const footerContent = footerParts.join("\n\n");
const issueSafeFooterContent = neutralizeClosingKeywordsForIssueBody(footerContent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds a second footer code path for protected-file fallback issues, but the tests never exercise it, so a regression here will ship silently.

💡 Add a focused fallback-path test

create_pull_request.cjs now threads body_footer through issueSafeFooterContent and the manifest-protection fallback body builder, which is exactly the branch most likely to drift because it has its own body assembly and closing-keyword neutralization. Please add a test that forces the protected-files fallback issue path and asserts the configured body_footer is present after sanitization, otherwise this feature is only covered for the happy-path PR body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] New issueSafeFooterContent neutralizes closing keywords in the footer (which now includes the configured body-footer) before it reaches the protected-file fallback issue, but no test exercises this path with a body_footer containing a closing keyword (e.g. Fixes #1).

💡 Suggested test

Add a case to the manifest-protection fallback tests that configures body_footer: "Fixes #1" and asserts the resulting fallback issue body contains a neutralized keyword (e.g. `Fixes` #1 or similar) rather than an active closing reference. Without this, a regression that skips neutralization for the new footer content would silently auto-close unrelated issues.

@copilot please address this.

const hiddenFallbackMetadata = [callerWorkflowId && generateWorkflowCallIdMarker(callerWorkflowId), closeOlderKey && generateCloseKeyMarker(closeOlderKey)].filter(Boolean).join("\n");
const issueSafeFallbackFooter = [issueSafeFooterContent, hiddenFallbackMetadata].filter(Boolean).join("\n");

// Build labels array - merge config labels with message labels
let labels = [...envLabels];
Expand Down Expand Up @@ -1903,7 +1920,7 @@ async function main(config = {}) {
.replace(/\s+/g, " ")
.trim();
const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage);
const fallbackBody = `${issueSafeBody}
const fallbackBody = `${issueSafeMainBodyContent}

---

Expand All @@ -1929,7 +1946,9 @@ git push ${shellQuote(pushRemoteUrl || "origin")} ${shellQuote(branchName)}
gh pr create --title ${shellQuote(title)} --base ${shellQuote(baseBranch)} --head ${shellQuote(getPullRequestHeadRef(branchName))} --repo ${shellQuote(`${repoParts.owner}/${repoParts.repo}`)}
\`\`\`

</details>`;
</details>

${issueSafeFallbackFooter}`;

try {
const { data: issue, issueRepoParts } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
Expand Down Expand Up @@ -2272,7 +2291,7 @@ gh pr create --title ${shellQuote(title)} --base ${shellQuote(baseBranch)} --hea
.replace(/\s+/g, " ")
.trim();
const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage);
const fallbackBody = `${issueSafeBody}
const fallbackBody = `${issueSafeMainBodyContent}

---

Expand All @@ -2299,7 +2318,9 @@ gh pr create --title ${shellQuote(title)} --base ${shellQuote(baseBranch)} --hea
\`\`\`

</details>
${patchPreview}`;
${patchPreview}

${issueSafeFallbackFooter}`;

try {
const { data: issue, issueRepoParts } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
Expand Down Expand Up @@ -2462,7 +2483,7 @@ ${patchPreview}`;
const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md");
fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, {
main_body: issueSafeMainBodyContent,
footer: footerContent,
footer: issueSafeFooterContent,
files: fileList,
apply_instructions: applyInstructions,
branch_name: branchName,
Expand All @@ -2473,7 +2494,7 @@ ${patchPreview}`;
} else {
// Normal case — push succeeded, provide compare URL.
const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, undefined, getPullRequestHeadRef(branchName));
fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, issueSafeFooterContent, fileList, createPrUrl);
}

try {
Expand All @@ -2484,7 +2505,7 @@ ${patchPreview}`;
if (!manifestProtectionPushFailedError) {
try {
const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number, getPullRequestHeadRef(branchName));
const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, issueSafeFooterContent, fileList, createPrUrl);

await withRetry(
() =>
Expand Down Expand Up @@ -2524,6 +2545,9 @@ ${patchPreview}`;

// Try to create the pull request, with fallback to issue creation
try {
if (body.length > MAX_GITHUB_BODY_LENGTH) {
throw new Error(`Pull request body exceeds GitHub's maximum length of ${MAX_GITHUB_BODY_LENGTH} characters`);
}
const { data: pullRequest } = await createOrUpdatePullRequest({
githubClient,
repoParts,
Expand Down Expand Up @@ -2795,7 +2819,8 @@ ${patchPreview}`;

const fallbackTemplatePath = getPromptPath("pr_permission_denied_fallback.md");
const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
body: issueSafeBody,
body: issueSafeMainBodyContent,
footer: issueSafeFallbackFooter,
branch_name: branchName,
create_pr_url: createPrUrl,
faq_url: FAQ_CREATE_PR_PERMISSIONS_URL,
Expand Down Expand Up @@ -2858,7 +2883,7 @@ ${patchPreview}`;
patchPreview = generatePatchPreview(patchContent);
}

const fallbackBody = `${issueSafeBody}
const fallbackBody = `${issueSafeMainBodyContent}

---

Expand All @@ -2872,7 +2897,9 @@ To create the pull request manually:
\`\`\`sh
gh pr create --title "${title}" --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo}
\`\`\`
${patchPreview}`;
${patchPreview}

${issueSafeFallbackFooter}`;

try {
const { data: issue, issueRepoParts } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
Expand Down
16 changes: 16 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,22 @@ describe("create_pull_request - auto-close-issue configuration", () => {
expect(createCall?.body).not.toContain("Closes #");
expect(createCall?.body).not.toContain("Resolves #");
});

it("should append the configured body footer when the generated footer is disabled", async () => {
const { main } = require("./create_pull_request.cjs");
const handler = await main({
allow_empty: true,
footer: false,
body_footer: "Required pull request footer",
});

await handler({ title: "Test PR", body: "Test body" }, {});

const createCall = global.github.rest.pulls.create.mock.calls[0]?.[0];
expect(createCall?.body).toContain("Test body\n\n- Fixes #42\n\nRequired pull request footer");
expect(createCall?.body).not.toContain("> Generated by");
expect(createCall?.body.indexOf("Required pull request footer")).toBeLessThan(createCall?.body.indexOf("<!-- gh-aw-workflow-id:"));
});
});

describe("create_pull_request - max limit enforcement", () => {
Expand Down
12 changes: 11 additions & 1 deletion actions/setup/js/messages.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@
const { getMessages, renderTemplate, renderTemplateFromFile } = require("./messages_core.cjs");

// Re-export footer messages
const { getDetectionCautionAlert, getFooterMessage, getFooterInstallMessage, getFooterAgentFailureIssueMessage, getFooterAgentFailureCommentMessage, generateFooterWithMessages, generateXMLMarker } = require("./messages_footer.cjs");
const {
getDetectionCautionAlert,
getBodyFooterMessage,
getFooterMessage,
getFooterInstallMessage,
getFooterAgentFailureIssueMessage,
getFooterAgentFailureCommentMessage,
generateFooterWithMessages,
generateXMLMarker,
} = require("./messages_footer.cjs");

// Re-export staged mode messages
const { getStagedTitle, getStagedDescription } = require("./messages_staged.cjs");
Expand All @@ -43,6 +52,7 @@ module.exports = {
renderTemplate,
renderTemplateFromFile,
getDetectionCautionAlert,
getBodyFooterMessage,
getFooterMessage,
getFooterInstallMessage,
getFooterAgentFailureIssueMessage,
Expand Down
11 changes: 11 additions & 0 deletions actions/setup/js/messages.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ describe("messages.cjs", () => {
expect(result).toBe("> Custom: [Custom Workflow](https://example.com/run/456)");
});

it("should render a handler body footer template", async () => {
const { getBodyFooterMessage } = await import("./messages.cjs");

const result = getBodyFooterMessage("Generated by [{workflow_name}]({run_url})", {
workflowName: "Custom Workflow",
runUrl: "https://example.com/run/456",
});

expect(result).toBe("Generated by [Custom Workflow](https://example.com/run/456)");
});

it("should NOT append triggering number suffix when custom footer is configured", async () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
footer: "> Custom: [{workflow_name}]({run_url})",
Expand Down
14 changes: 14 additions & 0 deletions actions/setup/js/messages_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,19 @@ function getFooterMessage(ctx) {
return renderedDefaultFooter + getRunAgainHints(renderedDefaultFooter);
}

/**
* Render a deterministic footer configured for a generated issue or pull request body.
* @param {string|undefined} template - Body footer template
* @param {{workflowName: string, runUrl: string}} ctx - Template context
* @returns {string} Rendered body footer, or an empty string when not configured
*/
function getBodyFooterMessage(template, ctx) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

actions/setup/js/messages_footer.cjs:302: yagni: new getBodyFooterMessage helper for one renderTemplate call. Inline it at the two call sites and drop the wrapper.

if (!template) {
return "";
}
return renderTemplate(template, toSnakeCase(ctx));
}

/**
* @param {string|undefined} commandsJSON
* @returns {string|undefined}
Expand Down Expand Up @@ -772,6 +785,7 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl

module.exports = {
getDetectionCautionAlert,
getBodyFooterMessage,
getFooterMessage,
getFooterInstallMessage,
getFooterWorkflowRecompileMessage,
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/types/safe-outputs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface SafeOutputConfig {
*/
interface CreateIssueConfig extends SafeOutputConfig {
"title-prefix"?: string;
"body-footer"?: string;
"deduplicate-by-title"?: boolean | number;
labels?: string[];
"target-repo"?: string;
Expand Down Expand Up @@ -94,6 +95,7 @@ interface AddCommentConfig extends SafeOutputConfig {
*/
interface CreatePullRequestConfig extends SafeOutputConfig {
"title-prefix"?: string;
"body-footer"?: string;
labels?: string[];
reviewers?: string | string[];
"team-reviewers"?: string | string[];
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/md/pr_permission_denied_fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
> **[Click here to create the pull request]({create_pr_url})**

To fix the permissions issue, go to **Settings** → **Actions** → **General** and enable **Allow GitHub Actions to create and approve pull requests**. See also: [gh-aw FAQ]({faq_url}){patch_preview}

{footer}
18 changes: 18 additions & 0 deletions docs/src/content/docs/reference/footers.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ safe-outputs:

Individual handler settings always take precedence over the global setting.

## Deterministic Body Footers

Use `body-footer` on `create-issue` or `create-pull-request` to append workflow-defined content after the agent-generated body:

```yaml wrap
safe-outputs:
create-issue:
body-footer: |
---
Generated by [{workflow_name}]({run_url})
create-pull-request:
body-footer: |
---
Review the generated changes before merging.
```

The body footer is appended even when `footer: false`. It is the final visible content in the body; hidden workflow metadata remains after it. The template supports `{workflow_name}` and `{run_url}` placeholders.

## PR Review Footer Control

For PR reviews (`submit-pull-request-review`), the `footer` field supports conditional control over when the footer is added to the review body:
Expand Down
Loading