diff --git a/.github/actions/coverage/steps/update-pr-coverage/action.mjs b/.github/actions/coverage/steps/update-pr-coverage/action.mjs
index 977e07da..a2b4348d 100644
--- a/.github/actions/coverage/steps/update-pr-coverage/action.mjs
+++ b/.github/actions/coverage/steps/update-pr-coverage/action.mjs
@@ -12,6 +12,10 @@ import { getInput, getEventPayload, floorToFixed } from "../../../common/common/
const START = "";
const END = "";
+// Leading marker of update-pr-changelog's Co-authored-by trailer block. When the badge is first
+// appended, it must land ABOVE this marker: GitHub only credits co-author trailers that sit in the
+// commit message's final paragraph, so the trailer block has to stay last in the PR body.
+const COAUTHOR_MARKER = "";
// Legacy marker pair from the original implementation. Strip any stray
// `coverage-badge-*` block from in-flight PR bodies on first run after
@@ -98,9 +102,17 @@ try {
const after = currentBody.slice(currentBody.indexOf(END) + END.length);
newBody = before + block + after;
} else {
- // Append the block, separated by a horizontal rule when the body is non-empty.
- const separator = currentBody.trimEnd().length > 0 ? "\n\n---\n\n" : "";
- newBody = currentBody + separator + block;
+ const coAuthorAt = currentBody.indexOf(COAUTHOR_MARKER);
+ if (coAuthorAt !== -1) {
+ // Insert ABOVE the Co-authored-by trailer block so it stays the final paragraph.
+ const head = currentBody.slice(0, coAuthorAt).replace(/\s+$/, "");
+ const tail = currentBody.slice(coAuthorAt);
+ newBody = `${head ? `${head}\n\n---\n\n` : ""}${block}\n\n${tail}`;
+ } else {
+ // Append the block, separated by a horizontal rule when the body is non-empty.
+ const separator = currentBody.trimEnd().length > 0 ? "\n\n---\n\n" : "";
+ newBody = currentBody + separator + block;
+ }
}
if (newBody === currentBody) {
diff --git a/.github/actions/git/steps/generate-comprehensive-changelog/action.mjs b/.github/actions/git/steps/generate-comprehensive-changelog/action.mjs
index 55014418..0c22b9ad 100644
--- a/.github/actions/git/steps/generate-comprehensive-changelog/action.mjs
+++ b/.github/actions/git/steps/generate-comprehensive-changelog/action.mjs
@@ -1,7 +1,7 @@
import { appendFileSync, readFileSync, existsSync, realpathSync } from "fs";
import path from "node:path";
import { gitCommand } from "../../utilities/git-utils.mjs";
-import { getHumanContributors } from "../../../common/utilities/bot-detection.mjs";
+import { getHumanContributors, isBotAuthor } from "../../../common/utilities/bot-detection.mjs";
import { categorizeCommits } from "../get-commit-range/action.mjs";
import { filterBotCommits, isDependencyUpdate } from "../../../common/utilities/bot-detection.mjs";
import { api } from "../../../github/api/_api/core.mjs";
@@ -885,6 +885,45 @@ async function buildContributorMentionsDetails(commits, token, enablePullRequest
return `\n\n\n👥 Contributors
\n\n${mentionLines}\n\n `;
}
+/**
+ * Stash for the release commit's `Co-authored-by:` trailer block. Set at each site that builds the
+ * contributor @mention details (same commits, same execution path), then emitted by `main()` as the
+ * `co-authors` output so `update-pr-changelog` can place it dead-last in the PR body.
+ * @type {string}
+ */
+let coAuthorsBlock = "";
+
+/**
+ * Build the release commit's `Co-authored-by:` trailer block for the human contributors — a markdown
+ * @mention gives no contributor-graph credit, only a trailer does. Returned with a leading
+ * `` marker line and the trailers as the final paragraph; the marker is a separate
+ * preceding paragraph (blank line between), so it lets other body editors (e.g. update-pr-coverage)
+ * find and stay ABOVE this block without breaking GitHub's rule that co-author trailers must be the
+ * commit message's last paragraph. Deduped by email; bots and the caller's own signing bot excluded.
+ * @param {Array<{author?: string, email?: string, body?: string}>} commits - Release-range commits.
+ * @param {{name?: string, email?: string}} [knownBot] - Caller's signing-bot identity, excluded.
+ * @returns {string} The `` block, or "" when there are no human contributors.
+ */
+function buildCoAuthorTrailers(commits, knownBot) {
+ const byEmail = new Map();
+ const add = (author, email) => {
+ const name = (author || "").trim();
+ const mail = (email || "").trim();
+ if (!name || !mail) return;
+ if (isBotAuthor(name, mail, knownBot)) return;
+ const key = mail.toLowerCase();
+ if (!byEmail.has(key)) byEmail.set(key, `Co-authored-by: ${name} <${mail}>`);
+ };
+ // Commit authors (getHumanContributors already drops bots + internal placeholders) plus any
+ // Co-authored-by trailers carried in the commit bodies, deduped by email.
+ for (const c of getHumanContributors(commits || [], knownBot)) add(c.author, c.email);
+ for (const c of commits || []) {
+ for (const co of extractCoAuthorIdentitiesFromBody(c?.body || "")) add(co.author, co.email);
+ }
+ const lines = Array.from(byEmail.values()).sort((a, b) => a.localeCompare(b));
+ return lines.length ? `\n\n${lines.join("\n")}` : "";
+}
+
/**
* Look up GitHub username from email address using GitHub API
* @param {string} email - Email address to look up
@@ -1043,6 +1082,7 @@ async function generateComprehensiveChangelog(
const syntheticCommit = [{ subject, body: body || "", author: "", email: "" }];
const contributorDetails = await buildContributorMentionsDetails(syntheticCommit, token, true, KNOWN_BOT);
+ coAuthorsBlock = buildCoAuthorTrailers(syntheticCommit, KNOWN_BOT);
if (contributorDetails) {
releaseNotes += contributorDetails;
}
@@ -1094,6 +1134,7 @@ async function generateComprehensiveChangelog(
singleCommitChangelog = stripInternalContributorLines(singleCommitChangelog);
singleCommitChangelog = neutralizeJsdocTagMentions(singleCommitChangelog);
const contributorDetails = await buildContributorMentionsDetails(commits, token, true, KNOWN_BOT);
+ coAuthorsBlock = buildCoAuthorTrailers(commits, KNOWN_BOT);
if (contributorDetails) {
singleCommitChangelog += contributorDetails;
}
@@ -1252,6 +1293,7 @@ async function generateComprehensiveChangelog(
}
const contributorDetails = await buildContributorMentionsDetails(commits, token, false, KNOWN_BOT);
+ coAuthorsBlock = buildCoAuthorTrailers(commits, KNOWN_BOT);
if (contributorDetails) {
changelog += contributorDetails + "\n";
}
@@ -1290,6 +1332,12 @@ if (import.meta.url === `file://${process.argv[1]}`) {
// Output the changelog content using a unique delimiter
const delimiter = `EOF_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
appendFileSync(process.env.GITHUB_OUTPUT, `changelog-content<<${delimiter}\n${changelog}\n${delimiter}\n`);
+
+ // Output the Co-authored-by trailer block separately (set by generateComprehensiveChangelog
+ // alongside the contributor @mentions). update-pr-changelog places it dead-last in the PR body,
+ // below the sticky (coverage) blocks, so GitHub credits the contributors on the release commit.
+ const coDelimiter = `EOF_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ appendFileSync(process.env.GITHUB_OUTPUT, `co-authors<<${coDelimiter}\n${coAuthorsBlock}\n${coDelimiter}\n`);
}
// Run the main function
@@ -1300,4 +1348,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
}
// Export functions for testing
-export { generateComprehensiveChangelog, readVersionChangelogFile };
+export { generateComprehensiveChangelog, readVersionChangelogFile, buildCoAuthorTrailers };
diff --git a/.github/actions/git/steps/generate-comprehensive-changelog/action.yml b/.github/actions/git/steps/generate-comprehensive-changelog/action.yml
index 6f2be50c..f235415a 100644
--- a/.github/actions/git/steps/generate-comprehensive-changelog/action.yml
+++ b/.github/actions/git/steps/generate-comprehensive-changelog/action.yml
@@ -51,6 +51,9 @@ outputs:
changelog-content:
description: "Generated changelog content in markdown format"
value: ${{ steps.changelog.outputs.changelog-content }}
+ co-authors:
+ description: "Co-authored-by: trailer block (leading `` marker + trailers) for the release-range human contributors, or empty. Placed dead-last in the release-PR body by update-pr-changelog so GitHub credits contributors on the squashed release commit."
+ value: ${{ steps.changelog.outputs.co-authors }}
runs:
using: "composite"
diff --git a/.github/actions/git/steps/generate-comprehensive-changelog/test.mjs b/.github/actions/git/steps/generate-comprehensive-changelog/test.mjs
index 18001ae1..01d47573 100644
--- a/.github/actions/git/steps/generate-comprehensive-changelog/test.mjs
+++ b/.github/actions/git/steps/generate-comprehensive-changelog/test.mjs
@@ -12,7 +12,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { readVersionChangelogFile } from "./action.mjs";
+import { readVersionChangelogFile, buildCoAuthorTrailers } from "./action.mjs";
let failures = 0;
function eq(actual, expected, label) {
@@ -114,6 +114,28 @@ console.log("\nreadVersionChangelogFile — log sanitization (no CR/LF / workflo
);
}
+// --- buildCoAuthorTrailers: release-commit Co-authored-by trailer block ---
+{
+ console.log("\nbuildCoAuthorTrailers — release-commit contributor credit:");
+ const commits = [
+ { author: "Alice", email: "alice@example.com", body: "" },
+ { author: "Alice", email: "alice@example.com", body: "" }, // repeat → deduped
+ { author: "cldmv-bot", email: "bot@cldmv.net", body: "" }, // caller's signing bot (knownBot)
+ { author: "dependabot[bot]", email: "49699333+dependabot[bot]@users.noreply.github.com", body: "" }, // static-pattern bot
+ { author: "Bob", email: "bob@example.com", body: "Co-authored-by: Carol " } // Carol via body trailer
+ ];
+ const out = buildCoAuthorTrailers(commits, { name: "cldmv-bot", email: "bot@cldmv.net" });
+ ok(out.startsWith("\n\n"), "leads with the marker");
+ ok(/\n\nCo-authored-by:[^\n]+(\nCo-authored-by:[^\n]+)*$/.test(out), "trailers are the final paragraph (GitHub-creditable)");
+ ok(out.includes("Co-authored-by: Alice "), "credits a commit author");
+ ok(out.includes("Co-authored-by: Bob "), "credits a commit author (Bob)");
+ ok(out.includes("Co-authored-by: Carol "), "credits a body Co-authored-by contributor");
+ ok((out.match(/alice@example\.com/g) || []).length === 1, "dedupes a repeated contributor");
+ ok(!out.includes("bot@cldmv.net"), "excludes the caller's signing bot (knownBot)");
+ ok(!out.toLowerCase().includes("dependabot"), "excludes a static-pattern bot (dependabot)");
+ eq(buildCoAuthorTrailers([], undefined), "", "empty when there are no human contributors");
+}
+
fs.rmSync(scratch, { recursive: true, force: true });
if (failures > 0) {
diff --git a/.github/actions/github/jobs/update-release-pr/action.yml b/.github/actions/github/jobs/update-release-pr/action.yml
index 29380326..6c19fbf8 100644
--- a/.github/actions/github/jobs/update-release-pr/action.yml
+++ b/.github/actions/github/jobs/update-release-pr/action.yml
@@ -246,6 +246,7 @@ runs:
new-version: ${{ steps.display-version.outputs.version }}
title-suffix: ${{ steps.release-check.outputs.title-suffix }}
changelog-content: ${{ steps.changelog.outputs.changelog-content }}
+ co-authors: ${{ steps.changelog.outputs.co-authors }}
# Extract the set of conventional-commit types present in the PR range so a
# mixed PR (e.g. feat: + fix:) gets BOTH type: feature + type: bug
diff --git a/.github/actions/github/steps/update-pr-changelog/action.mjs b/.github/actions/github/steps/update-pr-changelog/action.mjs
index 9316faf1..1cd8c024 100644
--- a/.github/actions/github/steps/update-pr-changelog/action.mjs
+++ b/.github/actions/github/steps/update-pr-changelog/action.mjs
@@ -72,6 +72,7 @@ try {
const newVersion = getInput("new-version", { required: true });
const titleSuffix = (getInput("title-suffix") || "").trim();
const changelog = getInput("changelog-content");
+ const coAuthors = (getInput("co-authors") || "").trim();
const { owner, repo } = parseRepo(process.env.GITHUB_REPOSITORY);
console.log(`📝 Updating PR #${prNumber} title and description...`);
@@ -91,7 +92,14 @@ try {
}
const stickyBlocks = extractStickyBlocks(currentBody);
- const composedBody = stickyBlocks.length ? `${changelogBody}\n\n---\n\n${stickyBlocks.join("\n\n")}` : changelogBody;
+ let composedBody = stickyBlocks.length ? `${changelogBody}\n\n---\n\n${stickyBlocks.join("\n\n")}` : changelogBody;
+
+ // Co-authored-by trailers (from generate-comprehensive-changelog's `co-authors` output) must be
+ // the FINAL paragraph of the body — GitHub only credits co-author trailers that sit in the commit
+ // message's last paragraph. Append them AFTER the sticky (coverage) blocks. The block carries a
+ // leading `` marker (its own paragraph) so update-pr-coverage can insert its
+ // badge ABOVE this block on a first add without displacing the trailers from last position.
+ if (coAuthors) composedBody = `${composedBody.replace(/\s+$/, "")}\n\n${coAuthors}`;
const title = titleSuffix ? `release: v${newVersion} - ${titleSuffix}` : `release: v${newVersion}`;
console.log(`📝 Updating PR title to: ${title}`);
diff --git a/.github/actions/github/steps/update-pr-changelog/action.yml b/.github/actions/github/steps/update-pr-changelog/action.yml
index ccc18c75..594a31a3 100644
--- a/.github/actions/github/steps/update-pr-changelog/action.yml
+++ b/.github/actions/github/steps/update-pr-changelog/action.yml
@@ -20,6 +20,10 @@ inputs:
description: "Generated changelog markdown for the PR body"
required: false
default: ""
+ co-authors:
+ description: "Co-authored-by: trailer block (from generate-comprehensive-changelog's co-authors output) appended as the final paragraph of the PR body, below the sticky (coverage) blocks, so GitHub credits contributors on the squashed release commit. Empty = none."
+ required: false
+ default: ""
runs:
using: "node24"