fix(release): avoid duplicated contributor thanks

This commit is contained in:
morluto
2026-10-11 11:49:49 +08:00
parent 4003151ec1
commit 6b043dc3de
5 changed files with 97 additions and 26 deletions
+18 -11
View File
@@ -25,6 +25,7 @@ node scripts/release-notes.mjs inventory \
--manifest /tmp/rea-VERSION.contributions.json
node scripts/release-notes.mjs record \
--manifest /tmp/rea-VERSION.contributions.json \
--summary \
--output /tmp/rea-VERSION.record.md
```
@@ -56,25 +57,31 @@ version/date/link heading. Use single-line Markdown list items under:
PR/commit references required by the existing checkpoint validator.
Group related work by analyst workflow or user impact. Explain the resulting
behavior; internal cleanup, tests, CI, and routine documentation generally
belong only in the contribution record. Read ambiguous PRs/diffs before making
behavior in short, concrete entries. Highlights summarize the release; avoid
repeating their full descriptions in Changes. Internal cleanup, tests, CI, and
routine documentation generally belong only in the provenance file. Read ambiguous PRs/diffs before making
claims. Distinguish simulated-provider checks from real-provider verification.
Each bullet covering a PR carries its link and `Thanks @...` for its verified
human author, known co-authors, and confirmed closing-issue reporters. Grouping
must preserve **all** those credits in the same bullet. Include the issue
links. Direct-commit bullets carry the commit link and known human credit.
Credit each represented contribution once in the public notes with its link
and `Thanks @...` for its verified human author, known co-authors, and confirmed
closing-issue reporters. Grouping preserves all those credits in one line.
When Highlights, detailed entries or migrations reference the same work,
put its thanks in one of those entries, not all of them. Include issue links.
Direct-commit bullets carry the commit link and known human credit.
Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
type `User`; the credit policy excludes them while retaining their metadata.
Do not copy OpenClaw's maintainer-specific exclusions.
At the REA maintainer's request, omit `@morluto` self-thanks while preserving
authorship in the inventory. Other human contributors remain eligible.
Use explicit GitHub links for cross-repository references to avoid ambiguous
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
Append the generated `### Complete contribution record` verbatim as the last
section of this release. It accounts for every discovered in-range PR and
direct contribution, including internal work omitted from the prose. Do not
hand-edit generated credits to make validation pass. Refresh the inventory
Append the generated `record --summary` provenance link verbatim at the end
of this release. The linked inventory accounts for every discovered in-range
PR and direct contribution, including internal work omitted from the prose.
Do not paste the full `record` output into public notes: it duplicates thanks
and obscures the user-facing changes. Full records remain available for review.
Do not hand-edit generated credits to make validation pass. Refresh the inventory
when product history changes; a prose-only correction can reuse the snapshot.
## Finalize with Release Please
+5 -1
View File
@@ -21,7 +21,11 @@ Use [the rea-changelog-update skill](https://github.com/morluto/rea/blob/main/.a
to turn the bot draft into grouped Highlights, Changes, and Fixes with inline
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full
selected Git range and paginated GitHub PR/author/closing-issue metadata;
`record` produces the complete contribution record, `check` validates credits
`record --summary` produces a compact provenance link for public notes, while
`record` produces the full record for review. Credit a contribution once even
when it appears in highlights, details and migrations; omit maintainer
`@morluto` self-thanks. Keep complete authorship metadata in the inventory
instead of duplicating a credit ledger in the release body. `check` validates credits
and optional PR-body equality, and `render` writes the verified release section.
Run a subcommand without its required options to see its usage. GitHub reads
require an authenticated `gh`; record/check/render work offline from the saved
+33 -6
View File
@@ -101,10 +101,13 @@ export async function readReleaseHistory(run, base, target) {
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
// Keep those source identities in the inventory while excluding automation credit.
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]);
// REA's maintainer requested external-contributor thanks without self-credit.
const MAINTAINER_ACCOUNTS = new Set(["morluto"]);
const human = (value) =>
value?.__typename === "User" &&
!value.login.endsWith("[bot]") &&
!AGENT_ACCOUNTS.has(value.login.toLowerCase())
!AGENT_ACCOUNTS.has(value.login.toLowerCase()) &&
!MAINTAINER_ACCOUNTS.has(value.login.toLowerCase())
? value.login
: undefined;
const unique = (values) =>
@@ -163,6 +166,12 @@ export function contributionRecord(raw) {
return `${lines.join("\n")}\n`;
}
/** Keep the full authorship snapshot available without duplicating public thanks. */
export function contributionSummary(raw) {
const inventory = releaseInventorySchema.parse(raw);
return `Full contribution record: [${inventory.commits.length} commits and ${inventory.pullRequests.length} merged PR${inventory.pullRequests.length === 1 ? "" : "s"}](https://github.com/${inventory.repository}/blob/rea-agents-${inventory.version}/docs/releases/${inventory.version}.contributions.json).\n`;
}
export function selectedReleaseNotes(text, version) {
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
if (headings[0]?.[1] !== version)
@@ -236,7 +245,8 @@ function requiredCredits(inventory, bullet, references) {
if (matches[0].parents.length > 1) continue;
for (const identity of matches[0].authors) {
const handle = human(identity.user);
if (handle) required.push({ handle, context: `commit ${match[2]}` });
if (handle)
required.push({ handle, context: `commit ${matches[0].sha}` });
}
}
return required;
@@ -264,6 +274,8 @@ function verifyEditorialCredits(inventory, prose) {
]).map((handle) => handle.toLowerCase()),
);
const errors = [];
const requiredByContext = new Map();
const thankedByContext = new Map();
for (const bullet of proseBullets(prose)) {
const references = bulletPrs(bullet, inventory.repository);
const start = bullet.indexOf("Thanks ");
@@ -282,13 +294,21 @@ function verifyEditorialCredits(inventory, prose) {
bullet,
references,
)) {
if (!thanked.has(handle.toLowerCase()))
errors.push(`Missing Thanks @${handle} in bullet for ${context}`);
const required = requiredByContext.get(context) ?? new Set();
required.add(handle);
requiredByContext.set(context, required);
const credited = thankedByContext.get(context) ?? new Set();
for (const login of thanked) credited.add(login);
thankedByContext.set(context, credited);
}
for (const handle of thanked)
if (!eligible.has(handle))
errors.push(`Unverified human credit @${handle}`);
}
for (const [context, required] of requiredByContext)
for (const handle of required)
if (!thankedByContext.get(context)?.has(handle.toLowerCase()))
errors.push(`Missing Thanks @${handle} for ${context}`);
return errors;
}
@@ -305,8 +325,15 @@ export function verifyReleaseNotes(raw, text, body) {
"Release notes exceed GitHub's body budget; shorten prose without dropping credit",
);
const record = contributionRecord(inventory).trim();
const start = notes.indexOf("### Complete contribution record");
if (start < 0 || notes.slice(start).trim() !== record)
const summary = contributionSummary(inventory).trim();
const start = notes.endsWith(summary)
? notes.length - summary.length
: notes.indexOf("### Complete contribution record");
if (
start < 0 ||
(notes.slice(start).trim() !== record &&
notes.slice(start).trim() !== summary)
)
throw new Error(
"Contribution record differs from inventory; regenerate it with release-notes record",
);
+8 -3
View File
@@ -6,6 +6,7 @@ import { collectGitHubCredits } from "./lib/release-notes-github.mjs";
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
import {
contributionRecord,
contributionSummary,
readReleaseHistory,
releaseInventorySchema,
selectedReleaseNotes,
@@ -73,6 +74,7 @@ async function main() {
"pr-body": { type: "string" },
source: { type: "string" },
output: { type: "string" },
summary: { type: "boolean", default: false },
},
});
const [command] = positionals;
@@ -92,7 +94,7 @@ async function main() {
!values.manifest
)
throw new Error(
"Usage: release-notes inventory|record|check|render --manifest FILE [--repository OWNER/REPO --base TAG --target SHA --version VERSION] [--notes FILE --pr-body FILE --source SHA --output FILE]",
"Usage: release-notes inventory|record|check|render --manifest FILE [--repository OWNER/REPO --base TAG --target SHA --version VERSION] [--notes FILE --pr-body FILE --source SHA --output FILE] [--summary for record]",
);
if (command === "inventory") {
await createInventory(values);
@@ -103,11 +105,14 @@ async function main() {
);
await verifyInventoryHistory(run, inventory, values.source);
if (command === "record") {
const record = values.summary
? contributionSummary(inventory)
: contributionRecord(inventory);
if (values.output)
await writeFile(values.output, contributionRecord(inventory), {
await writeFile(values.output, record, {
flag: "wx",
});
else process.stdout.write(contributionRecord(inventory));
else process.stdout.write(record);
return;
}
const notes = await readFile(values.notes, "utf8");
+33 -5
View File
@@ -83,6 +83,7 @@ async function fixture() {
author("platformbot", "Bot"),
author("claude"),
author("cursoragent"),
author("morluto"),
]),
associatedPullRequests: page([]),
},
@@ -186,7 +187,7 @@ it("inventories side branches and paginated credits, then checks and renders syn
);
expect(f.record).toContain("Thanks @dana.");
expect(f.record).not.toMatch(
/@(?:merger|automation|platformbot|claude|cursoragent)/u,
/@(?:merger|automation|platformbot|claude|cursoragent|morluto)/u,
);
const body = join(f.directory, "body.md");
await writeFile(
@@ -211,6 +212,37 @@ it("inventories side branches and paginated credits, then checks and renders syn
await expect(
f.invoke(["render", "--manifest", f.manifest, "--output", output]),
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
const { stdout: summary } = await f.invoke([
"record",
"--manifest",
f.manifest,
"--summary",
]);
expect(summary).toContain("3 commits and 1 merged PR]");
expect(summary).toContain(
"/blob/rea-agents-6.4.0/docs/releases/6.4.0.contributions.json",
);
expect(summary).not.toContain("Thanks");
const conciseNotes = f.notes
.replace(f.record, summary)
.replace(
"### Changes\n",
"### Changes\n\n- Capability details (#101).\n- Related migration (#51).\n",
);
await writeFile(join(f.directory, "CHANGELOG.md"), conciseNotes);
await f.invoke(["check", "--manifest", f.manifest]);
await writeFile(
join(f.directory, "CHANGELOG.md"),
conciseNotes.replace(
"6.4.0.contributions.json",
"6.3.0.contributions.json",
),
);
await expect(
f.invoke(["check", "--manifest", f.manifest]),
).rejects.toMatchObject({
stderr: expect.stringContaining("Contribution record differs"),
});
await expect(
f.invoke([
"inventory",
@@ -245,10 +277,6 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
f.bullet + `\n- Fixed direct behavior (${directLink}).`,
"Missing Thanks @dana",
],
[
f.bullet + "\n- Fixed the reported problem (#51).",
"Missing Thanks @reporter",
],
[
f.bullet + " Thanks @automation[bot].",
"Unverified human credit @automation[bot]",