From 3ef165f867a950506561cba7d0d7912427016474 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:18:49 +0000 Subject: [PATCH 1/9] Initial plan From 2c9bc8fa26776415c355b9f806f08945f7b5b7e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:27:01 +0000 Subject: [PATCH 2/9] Preserve review supersession provenance Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.cjs | 12 ++++++++++++ actions/setup/js/pr_review_buffer.cjs | 7 ++++--- actions/setup/js/pr_review_buffer.test.cjs | 14 ++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index 7c1413d7c64..2804cf63b86 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -172,6 +172,17 @@ function generateWorkflowCallIdMarker(callerWorkflowId) { return ``; } +/** + * Generates a non-rendered Markdown reference definition carrying a workflow-call ID. + * Unlike HTML comments, GitHub preserves this form in pull request review bodies. + * + * @param {string} callerWorkflowId - Calling workflow identifier + * @returns {string} Markdown reference definition for review provenance + */ +function generateWorkflowCallIdReviewMarker(callerWorkflowId) { + return `[gh-aw-workflow-call-id]: # "${encodeURIComponent(callerWorkflowId)}"`; +} + /** * Normalizes a user-supplied close-older-key to identifier style. * Converts to lowercase, replaces runs of non-alphanumeric/dash/underscore characters @@ -294,6 +305,7 @@ module.exports = { generateXMLMarker, generateWorkflowIdMarker, generateWorkflowCallIdMarker, + generateWorkflowCallIdReviewMarker, getWorkflowIdMarkerContent, matchesWorkflowId, isValidWorkflowId, diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 3dfb6975fdb..3883c14499e 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -22,7 +22,7 @@ const { generateFooterWithMessages, getBodyFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); -const { generateWorkflowCallIdMarker, matchesWorkflowId } = require("./generate_footer.cjs"); +const { generateWorkflowCallIdMarker, generateWorkflowCallIdReviewMarker, matchesWorkflowId } = require("./generate_footer.cjs"); const { attachExecutionState, fetchPullRequestReviewState } = require("./safe_output_execution_metadata.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG, isTransientError, sleep } = require("./error_recovery.cjs"); const { ERR_API } = require("./error_codes.cjs"); @@ -368,7 +368,7 @@ function createReviewBuffer() { const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; if (callerWorkflowId) { - body += "\n" + generateWorkflowCallIdMarker(callerWorkflowId); + body += "\n" + generateWorkflowCallIdMarker(callerWorkflowId) + "\n" + generateWorkflowCallIdReviewMarker(callerWorkflowId); } } if (footerContext) { @@ -532,6 +532,7 @@ function createReviewBuffer() { return; } const workflowCallMarker = workflowCallId ? generateWorkflowCallIdMarker(workflowCallId) : ""; + const workflowCallReviewMarker = workflowCallId ? generateWorkflowCallIdReviewMarker(workflowCallId) : ""; try { /** @type {any[]} */ const reviews = []; @@ -564,7 +565,7 @@ function createReviewBuffer() { if (review.state !== "CHANGES_REQUESTED") return false; if (review.user?.type !== "Bot") return false; if (workflowCallMarker) { - return review.body?.includes(workflowCallMarker) || false; + return review.body?.includes(workflowCallMarker) || review.body?.includes(workflowCallReviewMarker) || false; } return matchesWorkflowId(review.body, workflowId); }); diff --git a/actions/setup/js/pr_review_buffer.test.cjs b/actions/setup/js/pr_review_buffer.test.cjs index 47fdc0fb4ed..019e08bc024 100644 --- a/actions/setup/js/pr_review_buffer.test.cjs +++ b/actions/setup/js/pr_review_buffer.test.cjs @@ -800,7 +800,7 @@ describe("pr_review_buffer (factory pattern)", () => { expect(body.indexOf("Generated by")).toBeLessThan(body.indexOf("Policy for")); }); - it("should append workflow-call-id marker to review body when available", async () => { + it("should append durable workflow-call-id provenance to review body when available", async () => { const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/CallerA"; try { @@ -831,6 +831,7 @@ describe("pr_review_buffer (factory pattern)", () => { const callArgs = mockGithub.rest.pulls.createReview.mock.calls[0][0]; expect(callArgs.body).toContain(""); + expect(callArgs.body).toContain('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); } finally { if (previousCallerWorkflowId === undefined) { delete process.env.GH_AW_CALLER_WORKFLOW_ID; @@ -1240,7 +1241,7 @@ describe("pr_review_buffer (factory pattern)", () => { } }); - it("should dismiss older reviews matching workflow-call-id when supersede mode is enabled", async () => { + it("should dismiss only older blocking reviews with durable workflow-call-id provenance", async () => { const previousWorkflowId = process.env.GH_AW_WORKFLOW_ID; const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; process.env.GH_AW_WORKFLOW_ID = "test-workflow"; @@ -1248,6 +1249,10 @@ describe("pr_review_buffer (factory pattern)", () => { try { buffer.setSupersedeOlderReviews(true); buffer.setReviewMetadata("Updated review", "COMMENT"); + buffer.setFooterContext({ + workflowName: "test-workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + }); buffer.setReviewContext({ repo: "owner/repo", repoParts: { owner: "owner", repo: "repo" }, @@ -1263,10 +1268,10 @@ describe("pr_review_buffer (factory pattern)", () => { }); mockGithub.rest.pulls.listReviews.mockResolvedValue({ data: [ - { id: 100, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "\nOld blocking review" }, + { id: 100, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: '[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"\nOld blocking review' }, { id: 101, state: "CHANGES_REQUESTED", user: { login: "human-user", type: "User" }, body: "" }, { id: 102, state: "APPROVED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, - { id: 103, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, + { id: 103, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: '[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"' }, { id: 104, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, ], }); @@ -1275,6 +1280,7 @@ describe("pr_review_buffer (factory pattern)", () => { const result = await buffer.submitReview(); expect(result.success).toBe(true); + expect(mockGithub.rest.pulls.createReview.mock.calls[0][0].body).toContain('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); // submitReview() reads reviews before superseding, during supersede // candidate selection, and again after review creation for after-state. expect(mockGithub.rest.pulls.listReviews).toHaveBeenCalledTimes(3); From 01c17f48870333c0f066758192837fc8c4c02d73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:28:33 +0000 Subject: [PATCH 3/9] Test review provenance encoding Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.test.cjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index 618ee17f1c2..87b58638386 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -37,6 +37,7 @@ describe("generate_footer.cjs", () => { let generateXMLMarker; let generateWorkflowIdMarker; let generateWorkflowCallIdMarker; + let generateWorkflowCallIdReviewMarker; let getWorkflowIdMarkerContent; let normalizeCloseOlderKey; @@ -58,6 +59,7 @@ describe("generate_footer.cjs", () => { generateXMLMarker = module.generateXMLMarker; generateWorkflowIdMarker = module.generateWorkflowIdMarker; generateWorkflowCallIdMarker = module.generateWorkflowCallIdMarker; + generateWorkflowCallIdReviewMarker = module.generateWorkflowCallIdReviewMarker; getWorkflowIdMarkerContent = module.getWorkflowIdMarkerContent; normalizeCloseOlderKey = module.normalizeCloseOlderKey; }); @@ -215,6 +217,14 @@ describe("generate_footer.cjs", () => { expect(result).toBe(""); }); + describe("generateWorkflowCallIdReviewMarker", () => { + it("should generate an encoded non-rendered marker", () => { + const result = generateWorkflowCallIdReviewMarker('owner/repo/Workflow "with" spaces'); + + expect(result).toBe('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FWorkflow%20%22with%22%20spaces"'); + }); + }); + it("should handle caller IDs with slashes (repo/workflow format)", () => { const result = generateWorkflowCallIdMarker("elastic/ai-github-actions/Explore: Live Elasticsearch"); From 5f819d8130f6fe06b4dfa809799f6760944511b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:29:57 +0000 Subject: [PATCH 4/9] Match review provenance exactly Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.cjs | 15 +++++++++++++++ actions/setup/js/generate_footer.test.cjs | 13 +++++++++++++ actions/setup/js/pr_review_buffer.cjs | 5 ++--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index 2804cf63b86..ed4e6a852bc 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -183,6 +183,20 @@ function generateWorkflowCallIdReviewMarker(callerWorkflowId) { return `[gh-aw-workflow-call-id]: # "${encodeURIComponent(callerWorkflowId)}"`; } +/** + * Check whether a review body has an exact workflow-call ID marker line. + * Supports the legacy HTML comment and the durable Markdown reference marker. + * + * @param {string|null|undefined} body - Review body + * @param {string} callerWorkflowId - Calling workflow identifier + * @returns {boolean} Whether the review belongs to the calling workflow + */ +function matchesWorkflowCallId(body, callerWorkflowId) { + if (!body) return false; + const markers = new Set([generateWorkflowCallIdMarker(callerWorkflowId), generateWorkflowCallIdReviewMarker(callerWorkflowId)]); + return body.split(/\r?\n/).some(line => markers.has(line.trim())); +} + /** * Normalizes a user-supplied close-older-key to identifier style. * Converts to lowercase, replaces runs of non-alphanumeric/dash/underscore characters @@ -306,6 +320,7 @@ module.exports = { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateWorkflowCallIdReviewMarker, + matchesWorkflowCallId, getWorkflowIdMarkerContent, matchesWorkflowId, isValidWorkflowId, diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index 87b58638386..afc5f6f915d 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -38,6 +38,7 @@ describe("generate_footer.cjs", () => { let generateWorkflowIdMarker; let generateWorkflowCallIdMarker; let generateWorkflowCallIdReviewMarker; + let matchesWorkflowCallId; let getWorkflowIdMarkerContent; let normalizeCloseOlderKey; @@ -60,6 +61,7 @@ describe("generate_footer.cjs", () => { generateWorkflowIdMarker = module.generateWorkflowIdMarker; generateWorkflowCallIdMarker = module.generateWorkflowCallIdMarker; generateWorkflowCallIdReviewMarker = module.generateWorkflowCallIdReviewMarker; + matchesWorkflowCallId = module.matchesWorkflowCallId; getWorkflowIdMarkerContent = module.getWorkflowIdMarkerContent; normalizeCloseOlderKey = module.normalizeCloseOlderKey; }); @@ -223,6 +225,17 @@ describe("generate_footer.cjs", () => { expect(result).toBe('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FWorkflow%20%22with%22%20spaces"'); }); + + describe("matchesWorkflowCallId", () => { + it("matches exact legacy and durable marker lines only", () => { + const callerWorkflowId = "owner/repo/Caller"; + + expect(matchesWorkflowCallId("Review\n", callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"\nReview', callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('Quoted [gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"', callerWorkflowId)).toBe(false); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"', callerWorkflowId)).toBe(false); + }); + }); }); it("should handle caller IDs with slashes (repo/workflow format)", () => { diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 3883c14499e..d05e6b498e1 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -22,7 +22,7 @@ const { generateFooterWithMessages, getBodyFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); -const { generateWorkflowCallIdMarker, generateWorkflowCallIdReviewMarker, matchesWorkflowId } = require("./generate_footer.cjs"); +const { generateWorkflowCallIdMarker, generateWorkflowCallIdReviewMarker, matchesWorkflowCallId, matchesWorkflowId } = require("./generate_footer.cjs"); const { attachExecutionState, fetchPullRequestReviewState } = require("./safe_output_execution_metadata.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG, isTransientError, sleep } = require("./error_recovery.cjs"); const { ERR_API } = require("./error_codes.cjs"); @@ -532,7 +532,6 @@ function createReviewBuffer() { return; } const workflowCallMarker = workflowCallId ? generateWorkflowCallIdMarker(workflowCallId) : ""; - const workflowCallReviewMarker = workflowCallId ? generateWorkflowCallIdReviewMarker(workflowCallId) : ""; try { /** @type {any[]} */ const reviews = []; @@ -565,7 +564,7 @@ function createReviewBuffer() { if (review.state !== "CHANGES_REQUESTED") return false; if (review.user?.type !== "Bot") return false; if (workflowCallMarker) { - return review.body?.includes(workflowCallMarker) || review.body?.includes(workflowCallReviewMarker) || false; + return matchesWorkflowCallId(review.body, workflowCallId); } return matchesWorkflowId(review.body, workflowId); }); From 81b7e3a19df3f66c176326ac829729623ba1b694 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:31:15 +0000 Subject: [PATCH 5/9] Harden review provenance matching Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.test.cjs | 38 +++++++++++------------ actions/setup/js/pr_review_buffer.cjs | 3 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index afc5f6f915d..865d0f12e78 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -219,25 +219,6 @@ describe("generate_footer.cjs", () => { expect(result).toBe(""); }); - describe("generateWorkflowCallIdReviewMarker", () => { - it("should generate an encoded non-rendered marker", () => { - const result = generateWorkflowCallIdReviewMarker('owner/repo/Workflow "with" spaces'); - - expect(result).toBe('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FWorkflow%20%22with%22%20spaces"'); - }); - - describe("matchesWorkflowCallId", () => { - it("matches exact legacy and durable marker lines only", () => { - const callerWorkflowId = "owner/repo/Caller"; - - expect(matchesWorkflowCallId("Review\n", callerWorkflowId)).toBe(true); - expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"\nReview', callerWorkflowId)).toBe(true); - expect(matchesWorkflowCallId('Quoted [gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"', callerWorkflowId)).toBe(false); - expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"', callerWorkflowId)).toBe(false); - }); - }); - }); - it("should handle caller IDs with slashes (repo/workflow format)", () => { const result = generateWorkflowCallIdMarker("elastic/ai-github-actions/Explore: Live Elasticsearch"); @@ -269,6 +250,25 @@ describe("generate_footer.cjs", () => { }); }); + describe("generateWorkflowCallIdReviewMarker", () => { + it("should generate an encoded non-rendered marker", () => { + const result = generateWorkflowCallIdReviewMarker('owner/repo/Workflow "with" spaces'); + + expect(result).toBe('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FWorkflow%20%22with%22%20spaces"'); + }); + }); + + describe("matchesWorkflowCallId", () => { + it("matches exact legacy and durable marker lines only", () => { + const callerWorkflowId = "owner/repo/Caller"; + + expect(matchesWorkflowCallId("Review\n", callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"\nReview', callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('Quoted [gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"', callerWorkflowId)).toBe(false); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"', callerWorkflowId)).toBe(false); + }); + }); + describe("getWorkflowIdMarkerContent", () => { it("should return marker content without XML wrapper", () => { const result = getWorkflowIdMarkerContent("test-workflow"); diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index d05e6b498e1..497a29c0650 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -531,7 +531,6 @@ function createReviewBuffer() { core.warning("supersede-older-reviews is enabled but neither GH_AW_WORKFLOW_ID nor GH_AW_CALLER_WORKFLOW_ID is set. Skipping stale review dismissal."); return; } - const workflowCallMarker = workflowCallId ? generateWorkflowCallIdMarker(workflowCallId) : ""; try { /** @type {any[]} */ const reviews = []; @@ -563,7 +562,7 @@ function createReviewBuffer() { if (!review || review.id === currentReviewId) return false; if (review.state !== "CHANGES_REQUESTED") return false; if (review.user?.type !== "Bot") return false; - if (workflowCallMarker) { + if (workflowCallId) { return matchesWorkflowCallId(review.body, workflowCallId); } return matchesWorkflowId(review.body, workflowId); From fcf4e690fd0a9ec7fbfd47c38427ad6f2e286cd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:32:35 +0000 Subject: [PATCH 6/9] Validate durable review provenance Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.cjs | 16 +++++++++++++--- actions/setup/js/generate_footer.test.cjs | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index ed4e6a852bc..3b81a32e874 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -192,9 +192,19 @@ function generateWorkflowCallIdReviewMarker(callerWorkflowId) { * @returns {boolean} Whether the review belongs to the calling workflow */ function matchesWorkflowCallId(body, callerWorkflowId) { - if (!body) return false; - const markers = new Set([generateWorkflowCallIdMarker(callerWorkflowId), generateWorkflowCallIdReviewMarker(callerWorkflowId)]); - return body.split(/\r?\n/).some(line => markers.has(line.trim())); + if (!body || !callerWorkflowId) return false; + const legacyMarker = generateWorkflowCallIdMarker(callerWorkflowId); + return body.split(/\r?\n/).some(line => { + const trimmedLine = line.trim(); + if (trimmedLine === legacyMarker) return true; + const durableMatch = trimmedLine.match(/^\[gh-aw-workflow-call-id\]: # "([^"]+)"$/); + if (!durableMatch) return false; + try { + return decodeURIComponent(durableMatch[1]) === callerWorkflowId; + } catch { + return false; + } + }); } /** diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index 865d0f12e78..0b01befbbc4 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -264,8 +264,10 @@ describe("generate_footer.cjs", () => { expect(matchesWorkflowCallId("Review\n", callerWorkflowId)).toBe(true); expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"\nReview', callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2frepo%2fCaller"', callerWorkflowId)).toBe(true); expect(matchesWorkflowCallId('Quoted [gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"', callerWorkflowId)).toBe(false); expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"', callerWorkflowId)).toBe(false); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # ""', "")).toBe(false); }); }); From 0e9a9ec0d2931bdea8a313e195fe896357c6724c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:39:32 +0000 Subject: [PATCH 7/9] Emit review provenance independent of footer mode Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_footer.cjs | 30 +++++++++++++--- actions/setup/js/pr_review_buffer.cjs | 16 ++++++--- actions/setup/js/pr_review_buffer.test.cjs | 42 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index 3b81a32e874..016e7e2b3a8 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -183,6 +183,30 @@ function generateWorkflowCallIdReviewMarker(callerWorkflowId) { return `[gh-aw-workflow-call-id]: # "${encodeURIComponent(callerWorkflowId)}"`; } +/** + * Check whether any trimmed line of a body satisfies a marker predicate. + * + * @param {string|null|undefined} body - Body to scan + * @param {(line: string) => boolean} predicate - Predicate applied to each trimmed line + * @returns {boolean} Whether any line matches + */ +function someMarkerLine(body, predicate) { + if (!body) return false; + return body.split(/\r?\n/).some(line => predicate(line.trim())); +} + +/** + * Check whether a body contains a line that exactly equals the given marker. + * + * @param {string|null|undefined} body - Body to scan + * @param {string} marker - Complete marker line + * @returns {boolean} Whether an exact marker line is present + */ +function matchesExactMarkerLine(body, marker) { + if (!marker) return false; + return someMarkerLine(body, line => line === marker); +} + /** * Check whether a review body has an exact workflow-call ID marker line. * Supports the legacy HTML comment and the durable Markdown reference marker. @@ -193,10 +217,8 @@ function generateWorkflowCallIdReviewMarker(callerWorkflowId) { */ function matchesWorkflowCallId(body, callerWorkflowId) { if (!body || !callerWorkflowId) return false; - const legacyMarker = generateWorkflowCallIdMarker(callerWorkflowId); - return body.split(/\r?\n/).some(line => { - const trimmedLine = line.trim(); - if (trimmedLine === legacyMarker) return true; + if (matchesExactMarkerLine(body, generateWorkflowCallIdMarker(callerWorkflowId))) return true; + return someMarkerLine(body, trimmedLine => { const durableMatch = trimmedLine.match(/^\[gh-aw-workflow-call-id\]: # "([^"]+)"$/); if (!durableMatch) return false; try { diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 497a29c0650..bf2c3f6ef34 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -365,11 +365,6 @@ function createReviewBuffer() { undefined, { skipDetectionCaution: true } ); - - const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; - if (callerWorkflowId) { - body += "\n" + generateWorkflowCallIdMarker(callerWorkflowId) + "\n" + generateWorkflowCallIdReviewMarker(callerWorkflowId); - } } if (footerContext) { const bodyFooter = getBodyFooterMessage(footerContext.bodyFooter, footerContext); @@ -378,6 +373,17 @@ function createReviewBuffer() { } } + // Always embed caller provenance, even when the visible footer is disabled, so + // supersede-older-reviews can identify this review in later runs. The legacy HTML + // marker is kept for consistency with other safe outputs and existing readers; + // the Markdown reference marker is the durable form because GitHub strips HTML + // comments from submitted review bodies. + const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; + if (callerWorkflowId) { + const provenance = generateWorkflowCallIdMarker(callerWorkflowId) + "\n" + generateWorkflowCallIdReviewMarker(callerWorkflowId); + body = body.trim() ? body.trimEnd() + "\n\n" + provenance : provenance; + } + // Build comments array for the API let comments = bufferedComments.map(comment => { /** @type {any} */ diff --git a/actions/setup/js/pr_review_buffer.test.cjs b/actions/setup/js/pr_review_buffer.test.cjs index 019e08bc024..c80e47659c1 100644 --- a/actions/setup/js/pr_review_buffer.test.cjs +++ b/actions/setup/js/pr_review_buffer.test.cjs @@ -841,6 +841,48 @@ describe("pr_review_buffer (factory pattern)", () => { } }); + it("should append workflow-call-id provenance even when footer is disabled", async () => { + const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; + process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/CallerA"; + try { + buffer.addComment({ path: "test.js", line: 1, body: "comment" }); + buffer.setReviewMetadata("", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 42, + pullRequest: { head: { sha: "abc123" } }, + }); + buffer.setFooterContext({ + workflowName: "test-workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + workflowSource: "owner/repo/workflows/test.md@v1", + workflowSourceURL: "https://github.com/owner/repo/blob/main/test.md", + }); + buffer.setFooterMode("none"); + + mockGithub.rest.pulls.createReview.mockResolvedValue({ + data: { + id: 406, + html_url: "https://github.com/owner/repo/pull/42#pullrequestreview-406", + }, + }); + + const result = await buffer.submitReview(); + expect(result.success).toBe(true); + + const callArgs = mockGithub.rest.pulls.createReview.mock.calls[0][0]; + expect(callArgs.body).not.toContain("test-workflow"); + expect(callArgs.body).toBe('\n[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); + } finally { + if (previousCallerWorkflowId === undefined) { + delete process.env.GH_AW_CALLER_WORKFLOW_ID; + } else { + process.env.GH_AW_CALLER_WORKFLOW_ID = previousCallerWorkflowId; + } + } + }); + it("should skip footer when setIncludeFooter('none') is called", async () => { buffer.addComment({ path: "test.js", line: 1, body: "comment" }); buffer.setReviewMetadata("Review body", "COMMENT"); From 802a31f71e81db70f52e78be7e13cf06e7085ec8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:58:06 +0000 Subject: [PATCH 8/9] Standardize generated footer provenance Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/messages.test.cjs | 34 ++++++++++++++------ actions/setup/js/messages_footer.cjs | 36 ++++++++++++---------- docs/src/content/docs/reference/footers.md | 4 +++ 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs index 880c97bbf3c..0bc030f4028 100644 --- a/actions/setup/js/messages.test.cjs +++ b/actions/setup/js/messages.test.cjs @@ -36,6 +36,7 @@ describe("messages.cjs", () => { delete process.env.GH_AW_TRACKER_ID; delete process.env.GITHUB_RUN_ID; delete process.env.GH_AW_WORKFLOW_ID; + delete process.env.GH_AW_CALLER_WORKFLOW_ID; delete process.env.GH_AW_DEPRECATED_COST; delete process.env.GH_AW_AIC; delete process.env.GH_AW_AMBIENT_CONTEXT; @@ -270,7 +271,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Test (Test)"); }); - it("should append history link when historyUrl is provided", async () => { + it("should append provenance on its own line when historyUrl is provided", async () => { const { getFooterMessage } = await import("./messages.cjs"); const result = getFooterMessage({ @@ -279,7 +280,20 @@ describe("messages.cjs", () => { historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", }); - expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + }); + + it("should use the caller workflow ID as the provenance identifier", async () => { + process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/test-workflow"; + const { getFooterMessage } = await import("./messages.cjs"); + + const result = getFooterMessage({ + workflowName: "Test Workflow", + runUrl: "https://github.com/test/repo/actions/runs/123", + historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", + }); + + expect(result).toContain("> Provenance: [owner/repo/test-workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); }); it("should include both triggering number and history link when both are provided", async () => { @@ -292,7 +306,7 @@ describe("messages.cjs", () => { historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", }); - expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) for #42 · [◷](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) for #42\n> Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); }); it("should not append history link when historyUrl is not provided", async () => { @@ -321,7 +335,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)* · [◷](${historyUrl})`); + expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)* · [◷](${historyUrl})\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should render empty string for {history_link} when historyUrl is not provided", async () => { @@ -410,7 +424,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include AI Credits without AI Credits when GH_AW_AIC is set", async () => { @@ -666,7 +680,7 @@ describe("messages.cjs", () => { slashCommand: "deploy", }); - expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})\n> Comment /deploy to run again`); + expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})\n> Comment /deploy to run again`); }); it("should include slash command hint in custom footer templates", async () => { @@ -1214,7 +1228,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)"); }); - it("should include history link without AI Credits in default footer", async () => { + it("should include provenance without AI Credits in default footer", async () => { process.env.GH_AW_DEPRECATED_COST = "5000"; const historyUrl = "https://github.com/search?q=repo:test/repo+is:issue&type=issues"; @@ -1226,7 +1240,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include AIC and ambient context in default footer when available", async () => { @@ -1316,7 +1330,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)"); }); - it("should include history link without AI Credits in default footer", async () => { + it("should include provenance without AI Credits in default footer", async () => { process.env.GH_AW_DEPRECATED_COST = "5000"; const historyUrl = "https://github.com/search?q=repo:test/repo+is:issue&type=issues"; @@ -1328,7 +1342,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include explicit context AIC in the default footer", async () => { diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 8f6314855b8..49f378ee3ea 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -161,7 +161,7 @@ function getAICFromEnv() { * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow * @property {"issue"|"PR"|"discussion"} [triggeringType] - Triggering item type used in the default footer - * @property {string} [historyUrl] - GitHub search URL for items created by this workflow (for the history link) + * @property {string} [historyUrl] - GitHub search URL for items created by this workflow * @property {string} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable * @property {number|string} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) @@ -170,6 +170,18 @@ function getAICFromEnv() { * @property {string} [labelCommand] - Label command name for the run-again hint, when applicable */ +/** + * Append a standardized provenance line when a history search is available. + * @param {string} footer - Rendered footer text + * @param {FooterContext} ctx - Context for footer generation + * @returns {string} Footer text with provenance + */ +function appendFooterProvenance(footer, ctx) { + if (!ctx.historyUrl) return footer; + const identifier = process.env.GH_AW_CALLER_WORKFLOW_ID || process.env.GH_AW_WORKFLOW_ID || ctx.workflowName; + return `${footer}\n> Provenance: [${identifier}](${ctx.historyUrl})`; +} + /** * Get the footer message, using custom template if configured. * @param {FooterContext} ctx - Context for footer generation @@ -265,7 +277,8 @@ function getFooterMessage(ctx) { // Use custom footer template if configured if (messages?.footer) { const renderedCustomFooter = renderTemplate(messages.footer, templateContext); - return renderedCustomFooter + getRunAgainHints(renderedCustomFooter); + const footer = appendFooterProvenance(renderedCustomFooter, ctx); + return footer + getRunAgainHints(footer); } // Default footer template - includes emoji prefix when available @@ -285,12 +298,9 @@ function getFooterMessage(ctx) { if (metricSuffixes.length > 0) { defaultFooter += metricSuffixes.join(""); } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } const renderedDefaultFooter = renderTemplate(defaultFooter, templateContext); - return renderedDefaultFooter + getRunAgainHints(renderedDefaultFooter); + const footer = appendFooterProvenance(renderedDefaultFooter, ctx); + return footer + getRunAgainHints(footer); } /** @@ -496,14 +506,10 @@ function getFooterAgentFailureIssueMessage(ctx) { if (ambientContext) { defaultFooter += ambientContextSuffix; } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } footer = renderTemplate(defaultFooter, templateContext); } - return footer; + return appendFooterProvenance(footer, ctx); } /** @@ -588,14 +594,10 @@ function getFooterAgentFailureCommentMessage(ctx) { if (ambientContext) { defaultFooter += ambientContextSuffix; } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } footer = renderTemplate(defaultFooter, templateContext); } - return footer; + return appendFooterProvenance(footer, ctx); } /** diff --git a/docs/src/content/docs/reference/footers.md b/docs/src/content/docs/reference/footers.md index e0748186d61..72b17e7af57 100644 --- a/docs/src/content/docs/reference/footers.md +++ b/docs/src/content/docs/reference/footers.md @@ -75,6 +75,10 @@ safe-outputs: The global body footer is added to any handler-specific `body-footer`; both are appended even when `footer: false` and remain separate from the generated attribution footer. Body footers from imported agentic workflows are additive, in import order. The template supports `{workflow_name}` and `{run_url}` placeholders. +## Provenance + +When a generated footer has a history search, it includes a separate subscript provenance line. The caller workflow ID is the link text and links to the same search previously exposed by the clock icon. + ## 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: From 77e68f243de97193ba197b8cc09fb132434d0244 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:40:01 +0000 Subject: [PATCH 9/9] Standardize legacy footer history links Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/messages.test.cjs | 5 +++-- actions/setup/js/messages_footer.cjs | 14 +++++++------- docs/src/content/docs/reference/footers.md | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs index 0bc030f4028..a8e35ab3fd0 100644 --- a/actions/setup/js/messages.test.cjs +++ b/actions/setup/js/messages.test.cjs @@ -321,7 +321,7 @@ describe("messages.cjs", () => { expect(result).not.toContain("◷"); }); - it("should expose {history_link} placeholder in custom footer templates", async () => { + it("should replace the legacy {history_link} placeholder with standardized provenance", async () => { process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> 🤖 *Generated by [{workflow_name}]({run_url})*{history_link}", }); @@ -335,7 +335,8 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)* · [◷](${historyUrl})\n> Provenance: [Test Workflow](${historyUrl})`); + expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)*\n> Provenance: [Test Workflow](${historyUrl})`); + expect(result).not.toContain("◷"); }); it("should render empty string for {history_link} when historyUrl is not provided", async () => { diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 49f378ee3ea..072af30b2c7 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -162,7 +162,7 @@ function getAICFromEnv() { * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow * @property {"issue"|"PR"|"discussion"} [triggeringType] - Triggering item type used in the default footer * @property {string} [historyUrl] - GitHub search URL for items created by this workflow - * @property {string} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable + * @property {string} [historyLink] - Deprecated history link placeholder, always "" * @property {number|string} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) * @property {string} [slashCommand] - Slash command name (without leading slash) for the run-again hint, when applicable @@ -215,8 +215,8 @@ function getFooterMessage(ctx) { const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION || undefined; const detectionReason = process.env.GH_AW_DETECTION_REASON || undefined; - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); @@ -432,8 +432,8 @@ function getFooterWorkflowRecompileCommentMessage(ctx) { function getFooterAgentFailureIssueMessage(ctx) { const messages = getMessages(); - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); @@ -520,8 +520,8 @@ function getFooterAgentFailureIssueMessage(ctx) { function getFooterAgentFailureCommentMessage(ctx) { const messages = getMessages(); - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); diff --git a/docs/src/content/docs/reference/footers.md b/docs/src/content/docs/reference/footers.md index 72b17e7af57..b84196809ce 100644 --- a/docs/src/content/docs/reference/footers.md +++ b/docs/src/content/docs/reference/footers.md @@ -108,7 +108,7 @@ safe-outputs: title-prefix: "[bot] " ``` -The `messages.footer` template supports variables like `{workflow_name}`, `{agentic_workflow_url}`, `{run_url}`, `{triggering_number}`, `{triggering_type}`, `{ai_credits_suffix}`, and more. `{triggering_type}` is `issue`, `PR`, or `discussion`, matching the type of item that triggered the run. `{agentic_workflow_url}` links directly to the agentic workflow file view for the run (equivalent to `{run_url}/agentic_workflow`), while `{run_url}` links to the plain Actions run page. `{ai_credits_suffix}` is a pre-formatted, always-safe suffix (e.g. `" · sonnet46 12.4 AIC"` or `""`) that you can place directly before `{history_link}`. When the run's engine model is known, the suffix is prefixed with a deterministic compact model identifier (`sonnet46`, `gpt55`, `opus47`, `haiku45`, `gem25`, …), and direct short aliases like `opus`, `sonnet`, and `haiku` are preserved. Individual cost components are also exposed: `{ai_model}` (full model name, e.g. `claude-sonnet-4.6`), `{ai_model_short}` (compact identifier, e.g. `sonnet46`), `{ai_credits}` (raw numeric cost), `{ai_credits_formatted}` (formatted cost), `{ai_credits_unit}` (always `AIC`), `{agent_ai_credits_formatted}`, `{evals_ai_credits_formatted}`, `{threat_detection_ai_credits_formatted}`, `{detection_conclusion}`, and `{detection_reason}`. See [Custom Messages](/gh-aw/reference/safe-outputs/#custom-messages-messages) for complete documentation on message templates and available variables. +The `messages.footer` template supports variables like `{workflow_name}`, `{agentic_workflow_url}`, `{run_url}`, `{triggering_number}`, `{triggering_type}`, `{ai_credits_suffix}`, and more. `{triggering_type}` is `issue`, `PR`, or `discussion`, matching the type of item that triggered the run. `{agentic_workflow_url}` links directly to the agentic workflow file view for the run (equivalent to `{run_url}/agentic_workflow`), while `{run_url}` links to the plain Actions run page. `{ai_credits_suffix}` is a pre-formatted, always-safe suffix (e.g. `" · sonnet46 12.4 AIC"` or `""`). The legacy `{history_link}` placeholder is retained for compatibility but now renders as an empty string; the standardized provenance line is appended automatically. When the run's engine model is known, the suffix is prefixed with a deterministic compact model identifier (`sonnet46`, `gpt55`, `opus47`, `haiku45`, `gem25`, …), and direct short aliases like `opus`, `sonnet`, and `haiku` are preserved. Individual cost components are also exposed: `{ai_model}` (full model name, e.g. `claude-sonnet-4.6`), `{ai_model_short}` (compact identifier, e.g. `sonnet46`), `{ai_credits}` (raw numeric cost), `{ai_credits_formatted}` (formatted cost), `{ai_credits_unit}` (always `AIC`), `{agent_ai_credits_formatted}`, `{evals_ai_credits_formatted}`, `{threat_detection_ai_credits_formatted}`, `{detection_conclusion}`, and `{detection_reason}`. See [Custom Messages](/gh-aw/reference/safe-outputs/#custom-messages-messages) for complete documentation on message templates and available variables. ## Learn More