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"