Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3ef165f
Initial plan
Copilot Sep 25, 2026
2c9bc8f
Preserve review supersession provenance
Copilot Sep 25, 2026
01c17f4
Test review provenance encoding
Copilot Sep 25, 2026
5f819d8
Match review provenance exactly
Copilot Sep 25, 2026
81b7e3a
Harden review provenance matching
Copilot Sep 25, 2026
fcf4e69
Validate durable review provenance
Copilot Sep 25, 2026
1a23225
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
0e9a9ec
Emit review provenance independent of footer mode
Copilot Sep 26, 2026
157578d
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
bdc8e54
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
44e0412
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
594e652
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
6e4b53d
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 26, 2026
8449b0d
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 27, 2026
4442f2e
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 27, 2026
eadb24d
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 27, 2026
802a31f
Standardize generated footer provenance
Copilot Sep 28, 2026
b589cc3
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
36ef3ee
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
3721f59
Merge remote-tracking branch 'origin/main' into copilot/preserve-revi…
Copilot Sep 28, 2026
87b4754
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
6502371
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
e812503
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
77e68f2
Standardize legacy footer history links
Copilot Sep 28, 2026
bb4b5c2
Merge branch 'main' into copilot/preserve-review-provenance-marker
github-actions[bot] Sep 28, 2026
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
59 changes: 59 additions & 0 deletions actions/setup/js/generate_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,63 @@ function generateWorkflowCallIdMarker(callerWorkflowId) {
return `<!-- gh-aw-workflow-call-id: ${callerWorkflowId} -->`;
}

/**
* 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) {

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] matchesWorkflowCallId duplicates the same body-scanning logic that matchesWorkflowId (below, unseen in this diff) already implements for the legacy marker. Consider factoring a shared matchesExactMarkerLine(body, marker) helper so both functions share one line-matching primitive instead of re-implementing split/trim/compare independently, reducing future drift when a third marker format is added.

💡 Suggested shape
function matchesExactMarkerLine(body, marker) {
  if (!body || !marker) return false;
  return body.split((r/redacted)?\n/).some(line => line.trim() === marker);
}

Then matchesWorkflowCallId could call this for the legacy check and layer the durable-marker regex check on top.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0e9a9ec: added someMarkerLine/matchesExactMarkerLine helpers. matchesWorkflowCallId now uses them for both the legacy check and the durable regex check. I left matchesWorkflowId as it is because it uses substring matching on purpose for the combined XML marker format.

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
Expand Down Expand Up @@ -294,6 +351,8 @@ module.exports = {
generateXMLMarker,
generateWorkflowIdMarker,
generateWorkflowCallIdMarker,
generateWorkflowCallIdReviewMarker,
matchesWorkflowCallId,
getWorkflowIdMarkerContent,
matchesWorkflowId,
isValidWorkflowId,
Expand Down
25 changes: 25 additions & 0 deletions actions/setup/js/generate_footer.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ describe("generate_footer.cjs", () => {
let generateXMLMarker;
let generateWorkflowIdMarker;
let generateWorkflowCallIdMarker;
let generateWorkflowCallIdReviewMarker;
let matchesWorkflowCallId;
let getWorkflowIdMarkerContent;
let normalizeCloseOlderKey;

Expand All @@ -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;
});
Expand Down Expand Up @@ -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<!-- gh-aw-workflow-call-id: owner/repo/Caller -->", 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");
Expand Down
37 changes: 26 additions & 11 deletions actions/setup/js/messages.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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> <sub>Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)</sub>");
});

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("> <sub>Provenance: [owner/repo/test-workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)</sub>");
});

it("should include both triggering number and history link when both are provided", async () => {
Expand All @@ -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> <sub>Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)</sub>");
});

it("should not append history link when historyUrl is not provided", async () => {
Expand All @@ -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}",
});
Expand All @@ -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> <sub>Provenance: [Test Workflow](${historyUrl})</sub>`);
expect(result).not.toContain("◷");
});

it("should render empty string for {history_link} when historyUrl is not provided", async () => {
Expand Down Expand Up @@ -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> <sub>Provenance: [Test Workflow](${historyUrl})</sub>`);
});

it("should include AI Credits without AI Credits when GH_AW_AIC is set", async () => {
Expand Down Expand Up @@ -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> <sub>Comment <em>/deploy</em> to run again</sub>`);
expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> <sub>Provenance: [Test Workflow](${historyUrl})</sub>\n> <sub>Comment <em>/deploy</em> to run again</sub>`);
});

it("should include slash command hint in custom footer templates", async () => {
Expand Down Expand Up @@ -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";

Expand All @@ -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> <sub>Provenance: [Test Workflow](${historyUrl})</sub>`);
});

it("should include AIC and ambient context in default footer when available", async () => {
Expand Down Expand Up @@ -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";

Expand All @@ -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> <sub>Provenance: [Test Workflow](${historyUrl})</sub>`);
});

it("should include explicit context AIC in the default footer", async () => {
Expand Down
50 changes: 26 additions & 24 deletions actions/setup/js/messages_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,27 @@ 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
* @property {string} [slashCommandPlaceholder] - Custom hint text appended after the command name (replaces default "to run again")
* @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> <sub>Provenance: [${identifier}](${ctx.historyUrl})</sub>`;
}

/**
* Get the footer message, using custom template if configured.
* @param {FooterContext} ctx - Context for footer generation
Expand Down Expand Up @@ -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` : "");
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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` : "");
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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` : "");
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading