diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs
index 7c1413d7c64..016e7e2b3a8 100644
--- a/actions/setup/js/generate_footer.cjs
+++ b/actions/setup/js/generate_footer.cjs
@@ -172,6 +172,63 @@ 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)}"`;
+}
+
+/**
+ * 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.
+ *
+ * @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 || !callerWorkflowId) return false;
+ 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 {
+ return decodeURIComponent(durableMatch[1]) === callerWorkflowId;
+ } catch {
+ return false;
+ }
+ });
+}
+
/**
* Normalizes a user-supplied close-older-key to identifier style.
* Converts to lowercase, replaces runs of non-alphanumeric/dash/underscore characters
@@ -294,6 +351,8 @@ module.exports = {
generateXMLMarker,
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 618ee17f1c2..0b01befbbc4 100644
--- a/actions/setup/js/generate_footer.test.cjs
+++ b/actions/setup/js/generate_footer.test.cjs
@@ -37,6 +37,8 @@ describe("generate_footer.cjs", () => {
let generateXMLMarker;
let generateWorkflowIdMarker;
let generateWorkflowCallIdMarker;
+ let generateWorkflowCallIdReviewMarker;
+ let matchesWorkflowCallId;
let getWorkflowIdMarkerContent;
let normalizeCloseOlderKey;
@@ -58,6 +60,8 @@ describe("generate_footer.cjs", () => {
generateXMLMarker = module.generateXMLMarker;
generateWorkflowIdMarker = module.generateWorkflowIdMarker;
generateWorkflowCallIdMarker = module.generateWorkflowCallIdMarker;
+ generateWorkflowCallIdReviewMarker = module.generateWorkflowCallIdReviewMarker;
+ matchesWorkflowCallId = module.matchesWorkflowCallId;
getWorkflowIdMarkerContent = module.getWorkflowIdMarkerContent;
normalizeCloseOlderKey = module.normalizeCloseOlderKey;
});
@@ -246,6 +250,27 @@ 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('[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);
+ });
+ });
+
describe("getWorkflowIdMarkerContent", () => {
it("should return marker content without XML wrapper", () => {
const result = getWorkflowIdMarkerContent("test-workflow");
diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs
index 880c97bbf3c..a8e35ab3fd0 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 () => {
@@ -307,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}",
});
@@ -321,7 +335,8 @@ 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})`);
+ expect(result).not.toContain("◷");
});
it("should render empty string for {history_link} when historyUrl is not provided", async () => {
@@ -410,7 +425,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 +681,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 +1229,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 +1241,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 +1331,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 +1343,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..072af30b2c7 100644
--- a/actions/setup/js/messages_footer.cjs
+++ b/actions/setup/js/messages_footer.cjs
@@ -161,8 +161,8 @@ 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} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable
+ * @property {string} [historyUrl] - GitHub search URL for items created by this workflow
+ * @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
@@ -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
@@ -203,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` : "");
@@ -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);
}
/**
@@ -422,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` : "");
@@ -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);
}
/**
@@ -514,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` : "");
@@ -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/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs
index 3dfb6975fdb..bf2c3f6ef34 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, 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");
@@ -365,11 +365,6 @@ function createReviewBuffer() {
undefined,
{ skipDetectionCaution: true }
);
-
- const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || "";
- if (callerWorkflowId) {
- body += "\n" + generateWorkflowCallIdMarker(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} */
@@ -531,7 +537,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,8 +568,8 @@ 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) {
- return review.body?.includes(workflowCallMarker) || false;
+ if (workflowCallId) {
+ return matchesWorkflowCallId(review.body, workflowCallId);
}
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..c80e47659c1 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,49 @@ 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;
+ } else {
+ process.env.GH_AW_CALLER_WORKFLOW_ID = previousCallerWorkflowId;
+ }
+ }
+ });
+
+ 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;
@@ -1240,7 +1283,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 +1291,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 +1310,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 +1322,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);
diff --git a/docs/src/content/docs/reference/footers.md b/docs/src/content/docs/reference/footers.md
index e0748186d61..b84196809ce 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:
@@ -104,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