Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 15 additions & 3 deletions .github/actions/coverage/steps/update-pr-coverage/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { getInput, getEventPayload, floorToFixed } from "../../../common/common/

const START = "<!-- coverage-start -->";
const END = "<!-- coverage-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 = "<!-- co-authors -->";

// Legacy marker pair from the original implementation. Strip any stray
// `coverage-badge-*` block from in-flight PR bodies on first run after
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -885,6 +885,45 @@ async function buildContributorMentionsDetails(commits, token, enablePullRequest
return `\n\n<details>\n<summary>πŸ‘₯ Contributors</summary>\n\n${mentionLines}\n\n</details>`;
}

/**
* 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
* `<!-- co-authors -->` 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 `<!-- co-authors -->` 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 ? `<!-- co-authors -->\n\n${lines.join("\n")}` : "";
}

/**
* Look up GitHub username from email address using GitHub API
* @param {string} email - Email address to look up
Expand Down Expand 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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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
Expand All @@ -1300,4 +1348,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
}

// Export functions for testing
export { generateComprehensiveChangelog, readVersionChangelogFile };
export { generateComprehensiveChangelog, readVersionChangelogFile, buildCoAuthorTrailers };
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- co-authors -->` 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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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@example.com>" } // Carol via body trailer
];
const out = buildCoAuthorTrailers(commits, { name: "cldmv-bot", email: "bot@cldmv.net" });
ok(out.startsWith("<!-- co-authors -->\n\n"), "leads with the <!-- co-authors --> 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 <alice@example.com>"), "credits a commit author");
ok(out.includes("Co-authored-by: Bob <bob@example.com>"), "credits a commit author (Bob)");
ok(out.includes("Co-authored-by: Carol <carol@example.com>"), "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) {
Expand Down
1 change: 1 addition & 0 deletions .github/actions/github/jobs/update-release-pr/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion .github/actions/github/steps/update-pr-changelog/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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...`);
Expand All @@ -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 `<!-- co-authors -->` 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}`);
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/github/steps/update-pr-changelog/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading