mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(release): avoid duplicated contributor thanks
This commit is contained in:
1 parent
4003151ec1
commit
6b043dc3de
5 files changed
+97
-26
No files matched your search
@@ -25,6 +25,7 @@ node scripts/release-notes.mjs inventory \
|
|||||||
--manifest /tmp/rea-VERSION.contributions.json
|
--manifest /tmp/rea-VERSION.contributions.json
|
||||||
node scripts/release-notes.mjs record \
|
node scripts/release-notes.mjs record \
|
||||||
--manifest /tmp/rea-VERSION.contributions.json \
|
--manifest /tmp/rea-VERSION.contributions.json \
|
||||||
|
--summary \
|
||||||
--output /tmp/rea-VERSION.record.md
|
--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.
|
PR/commit references required by the existing checkpoint validator.
|
||||||
|
|
||||||
Group related work by analyst workflow or user impact. Explain the resulting
|
Group related work by analyst workflow or user impact. Explain the resulting
|
||||||
behavior; internal cleanup, tests, CI, and routine documentation generally
|
behavior in short, concrete entries. Highlights summarize the release; avoid
|
||||||
belong only in the contribution record. Read ambiguous PRs/diffs before making
|
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.
|
claims. Distinguish simulated-provider checks from real-provider verification.
|
||||||
|
|
||||||
Each bullet covering a PR carries its link and `Thanks @...` for its verified
|
Credit each represented contribution once in the public notes with its link
|
||||||
human author, known co-authors, and confirmed closing-issue reporters. Grouping
|
and `Thanks @...` for its verified human author, known co-authors, and confirmed
|
||||||
must preserve **all** those credits in the same bullet. Include the issue
|
closing-issue reporters. Grouping preserves all those credits in one line.
|
||||||
links. Direct-commit bullets carry the commit link and known human credit.
|
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
|
Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
|
||||||
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
|
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
|
||||||
type `User`; the credit policy excludes them while retaining their metadata.
|
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
|
Use explicit GitHub links for cross-repository references to avoid ambiguous
|
||||||
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
|
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
|
||||||
|
|
||||||
Append the generated `### Complete contribution record` verbatim as the last
|
Append the generated `record --summary` provenance link verbatim at the end
|
||||||
section of this release. It accounts for every discovered in-range PR and
|
of this release. The linked inventory accounts for every discovered in-range
|
||||||
direct contribution, including internal work omitted from the prose. Do not
|
PR and direct contribution, including internal work omitted from the prose.
|
||||||
hand-edit generated credits to make validation pass. Refresh the inventory
|
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.
|
when product history changes; a prose-only correction can reuse the snapshot.
|
||||||
|
|
||||||
## Finalize with Release Please
|
## Finalize with Release Please
|
||||||
|
|||||||
+5
-1
@@ -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
|
to turn the bot draft into grouped Highlights, Changes, and Fixes with inline
|
||||||
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full
|
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full
|
||||||
selected Git range and paginated GitHub PR/author/closing-issue metadata;
|
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.
|
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
|
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
|
require an authenticated `gh`; record/check/render work offline from the saved
|
||||||
|
|||||||
@@ -101,10 +101,13 @@ export async function readReleaseHistory(run, base, target) {
|
|||||||
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
|
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
|
||||||
// Keep those source identities in the inventory while excluding automation credit.
|
// Keep those source identities in the inventory while excluding automation credit.
|
||||||
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]);
|
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) =>
|
const human = (value) =>
|
||||||
value?.__typename === "User" &&
|
value?.__typename === "User" &&
|
||||||
!value.login.endsWith("[bot]") &&
|
!value.login.endsWith("[bot]") &&
|
||||||
!AGENT_ACCOUNTS.has(value.login.toLowerCase())
|
!AGENT_ACCOUNTS.has(value.login.toLowerCase()) &&
|
||||||
|
!MAINTAINER_ACCOUNTS.has(value.login.toLowerCase())
|
||||||
? value.login
|
? value.login
|
||||||
: undefined;
|
: undefined;
|
||||||
const unique = (values) =>
|
const unique = (values) =>
|
||||||
@@ -163,6 +166,12 @@ export function contributionRecord(raw) {
|
|||||||
return `${lines.join("\n")}\n`;
|
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) {
|
export function selectedReleaseNotes(text, version) {
|
||||||
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
|
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
|
||||||
if (headings[0]?.[1] !== version)
|
if (headings[0]?.[1] !== version)
|
||||||
@@ -236,7 +245,8 @@ function requiredCredits(inventory, bullet, references) {
|
|||||||
if (matches[0].parents.length > 1) continue;
|
if (matches[0].parents.length > 1) continue;
|
||||||
for (const identity of matches[0].authors) {
|
for (const identity of matches[0].authors) {
|
||||||
const handle = human(identity.user);
|
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;
|
return required;
|
||||||
@@ -264,6 +274,8 @@ function verifyEditorialCredits(inventory, prose) {
|
|||||||
]).map((handle) => handle.toLowerCase()),
|
]).map((handle) => handle.toLowerCase()),
|
||||||
);
|
);
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
const requiredByContext = new Map();
|
||||||
|
const thankedByContext = new Map();
|
||||||
for (const bullet of proseBullets(prose)) {
|
for (const bullet of proseBullets(prose)) {
|
||||||
const references = bulletPrs(bullet, inventory.repository);
|
const references = bulletPrs(bullet, inventory.repository);
|
||||||
const start = bullet.indexOf("Thanks ");
|
const start = bullet.indexOf("Thanks ");
|
||||||
@@ -282,13 +294,21 @@ function verifyEditorialCredits(inventory, prose) {
|
|||||||
bullet,
|
bullet,
|
||||||
references,
|
references,
|
||||||
)) {
|
)) {
|
||||||
if (!thanked.has(handle.toLowerCase()))
|
const required = requiredByContext.get(context) ?? new Set();
|
||||||
errors.push(`Missing Thanks @${handle} in bullet for ${context}`);
|
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)
|
for (const handle of thanked)
|
||||||
if (!eligible.has(handle))
|
if (!eligible.has(handle))
|
||||||
errors.push(`Unverified human credit @${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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +325,15 @@ export function verifyReleaseNotes(raw, text, body) {
|
|||||||
"Release notes exceed GitHub's body budget; shorten prose without dropping credit",
|
"Release notes exceed GitHub's body budget; shorten prose without dropping credit",
|
||||||
);
|
);
|
||||||
const record = contributionRecord(inventory).trim();
|
const record = contributionRecord(inventory).trim();
|
||||||
const start = notes.indexOf("### Complete contribution record");
|
const summary = contributionSummary(inventory).trim();
|
||||||
if (start < 0 || notes.slice(start).trim() !== record)
|
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(
|
throw new Error(
|
||||||
"Contribution record differs from inventory; regenerate it with release-notes record",
|
"Contribution record differs from inventory; regenerate it with release-notes record",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { collectGitHubCredits } from "./lib/release-notes-github.mjs";
|
|||||||
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
|
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
|
||||||
import {
|
import {
|
||||||
contributionRecord,
|
contributionRecord,
|
||||||
|
contributionSummary,
|
||||||
readReleaseHistory,
|
readReleaseHistory,
|
||||||
releaseInventorySchema,
|
releaseInventorySchema,
|
||||||
selectedReleaseNotes,
|
selectedReleaseNotes,
|
||||||
@@ -73,6 +74,7 @@ async function main() {
|
|||||||
"pr-body": { type: "string" },
|
"pr-body": { type: "string" },
|
||||||
source: { type: "string" },
|
source: { type: "string" },
|
||||||
output: { type: "string" },
|
output: { type: "string" },
|
||||||
|
summary: { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [command] = positionals;
|
const [command] = positionals;
|
||||||
@@ -92,7 +94,7 @@ async function main() {
|
|||||||
!values.manifest
|
!values.manifest
|
||||||
)
|
)
|
||||||
throw new Error(
|
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") {
|
if (command === "inventory") {
|
||||||
await createInventory(values);
|
await createInventory(values);
|
||||||
@@ -103,11 +105,14 @@ async function main() {
|
|||||||
);
|
);
|
||||||
await verifyInventoryHistory(run, inventory, values.source);
|
await verifyInventoryHistory(run, inventory, values.source);
|
||||||
if (command === "record") {
|
if (command === "record") {
|
||||||
|
const record = values.summary
|
||||||
|
? contributionSummary(inventory)
|
||||||
|
: contributionRecord(inventory);
|
||||||
if (values.output)
|
if (values.output)
|
||||||
await writeFile(values.output, contributionRecord(inventory), {
|
await writeFile(values.output, record, {
|
||||||
flag: "wx",
|
flag: "wx",
|
||||||
});
|
});
|
||||||
else process.stdout.write(contributionRecord(inventory));
|
else process.stdout.write(record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = await readFile(values.notes, "utf8");
|
const notes = await readFile(values.notes, "utf8");
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async function fixture() {
|
|||||||
author("platformbot", "Bot"),
|
author("platformbot", "Bot"),
|
||||||
author("claude"),
|
author("claude"),
|
||||||
author("cursoragent"),
|
author("cursoragent"),
|
||||||
|
author("morluto"),
|
||||||
]),
|
]),
|
||||||
associatedPullRequests: page([]),
|
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).toContain("Thanks @dana.");
|
||||||
expect(f.record).not.toMatch(
|
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");
|
const body = join(f.directory, "body.md");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -211,6 +212,37 @@ it("inventories side branches and paginated credits, then checks and renders syn
|
|||||||
await expect(
|
await expect(
|
||||||
f.invoke(["render", "--manifest", f.manifest, "--output", output]),
|
f.invoke(["render", "--manifest", f.manifest, "--output", output]),
|
||||||
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
|
).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(
|
await expect(
|
||||||
f.invoke([
|
f.invoke([
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -245,10 +277,6 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
|
|||||||
f.bullet + `\n- Fixed direct behavior (${directLink}).`,
|
f.bullet + `\n- Fixed direct behavior (${directLink}).`,
|
||||||
"Missing Thanks @dana",
|
"Missing Thanks @dana",
|
||||||
],
|
],
|
||||||
[
|
|
||||||
f.bullet + "\n- Fixed the reported problem (#51).",
|
|
||||||
"Missing Thanks @reporter",
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
f.bullet + " Thanks @automation[bot].",
|
f.bullet + " Thanks @automation[bot].",
|
||||||
"Unverified human credit @automation[bot]",
|
"Unverified human credit @automation[bot]",
|
||||||
|
|||||||
Reference in new issue
Block a user