mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(release): report seeded changelog provenance truthfully (#119897)
* fix(release): report seeded changelog provenance truthfully * fix(release): reject legacy provenance formatting * fix(release): enforce complete provenance records
This commit is contained in:
@@ -69,11 +69,17 @@ every human `Thanks @...` attribution.
|
||||
audit; it contains every referenced PR, eligible contributor credit,
|
||||
inline issue context, every direct commit, and an editorial-eligibility
|
||||
classification for PRs and direct commits
|
||||
- schema version 3 is the required ephemeral manifest contract. Regenerate
|
||||
older manifests; version 2 is not a supported downstream-reader boundary
|
||||
- for a historical backfill, add `--seed-ref <pre-backfill-ref>` once so
|
||||
contribution records from the prior changelog are retained even when an
|
||||
older merged commit omitted its PR number; the verifier excludes records
|
||||
for work reverted after the base tag, including beta work reverted before
|
||||
the stable release
|
||||
- generated provenance reports in-range PRs separately from retained
|
||||
seed-only PRs, then states the unique row total. A PR present in both
|
||||
inventories counts as in-range; never describe the seed-inclusive total
|
||||
as work merged in the current release range
|
||||
- add repeatable `--shipped-ref <prior-shipped-tag>` when the reachable main
|
||||
closeout differs from the shipped tag or later forward-port commits
|
||||
re-associate PRs that were already released. Each tag is a cumulative
|
||||
@@ -150,6 +156,8 @@ every human `Thanks @...` attribution.
|
||||
PR references explicitly present in active commit subjects/bodies. It
|
||||
preserves author/co-author credit and any issue references in the original
|
||||
title
|
||||
- the provenance arithmetic and unique total must match the rendered PR
|
||||
rows exactly; candidate validation rejects malformed or forged counts
|
||||
- direct commits remain in the manifest with GitHub-resolved author,
|
||||
co-author, issue, and editorial-eligibility data. They inform grouped
|
||||
prose but are never rendered as a public `#### Direct commits` dump. Add
|
||||
|
||||
@@ -14,7 +14,9 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
extractChangelogReleaseSections,
|
||||
formatContributionRecordProvenance,
|
||||
formatShippedBaselineExclusions,
|
||||
parseContributionRecordProvenance,
|
||||
parseShippedBaselineExclusions,
|
||||
releaseNotesVersionForTag,
|
||||
verifyGithubReleaseNotes,
|
||||
@@ -671,10 +673,8 @@ export function contributionRecordTarget(section) {
|
||||
if (recordStart < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return section.source
|
||||
.slice(recordStart)
|
||||
.match(/^This audited record covers the complete \S+\.\.(?<target>[0-9a-f]{40}) history:/mu)
|
||||
?.groups?.target;
|
||||
const target = parseContributionRecordProvenance(section.source.slice(recordStart))?.target;
|
||||
return target && /^[0-9a-f]{40}$/u.test(target) ? target : undefined;
|
||||
}
|
||||
|
||||
export function pullRequestTitleFromCommitSubject(subject, number) {
|
||||
@@ -688,19 +688,12 @@ function completeContributionRecord(section, label) {
|
||||
fail(`${label} is missing ### Complete contribution record`);
|
||||
}
|
||||
const recordSource = section.source.slice(recordStart);
|
||||
const provenance = recordSource.match(
|
||||
/^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?<count>[0-9]+) merged PRs?\./mu,
|
||||
);
|
||||
if (!provenance?.groups?.count) {
|
||||
const provenance = parseContributionRecordProvenance(recordSource);
|
||||
if (!provenance || !/^[0-9a-f]{40}$/u.test(provenance.target)) {
|
||||
fail(`${label} is missing exact complete contribution record provenance`);
|
||||
}
|
||||
const record = contributionRecordFor(section);
|
||||
const declaredCount = Number(provenance.groups.count);
|
||||
if (record.pullRequests.size !== declaredCount) {
|
||||
fail(
|
||||
`${label} contribution record declares ${declaredCount} PRs but contains ${record.pullRequests.size}`,
|
||||
);
|
||||
}
|
||||
const declaredCount = provenance.uniquePullRequests;
|
||||
return { record, declaredCount };
|
||||
}
|
||||
|
||||
@@ -2069,6 +2062,10 @@ export function ledgerFor(
|
||||
!revertedReferences.has(entry.number),
|
||||
);
|
||||
const issues = entries.filter((entry) => entry.type === "Issue");
|
||||
const inRangePullRequestNumbers = new Set([
|
||||
...sourcePullRequests,
|
||||
...[...sourceReferences].filter((number) => nodes.get(number)?.__typename === "PullRequest"),
|
||||
]);
|
||||
const legacyIssues = legacyIssuesByPullRequest(priorRecord, nodes);
|
||||
const records = pullRequests.map((entry) => {
|
||||
const priorEntry = priorRecord.pullRequests.get(entry.number);
|
||||
@@ -2097,11 +2094,20 @@ export function ledgerFor(
|
||||
thanks,
|
||||
});
|
||||
});
|
||||
const inRangePullRequests = records.filter((entry) =>
|
||||
inRangePullRequestNumbers.has(entry.number),
|
||||
).length;
|
||||
const retainedSeedOnlyPullRequests = records.length - inRangePullRequests;
|
||||
const provenance = {
|
||||
inRangePullRequests,
|
||||
retainedSeedOnlyPullRequests,
|
||||
uniquePullRequests: records.length,
|
||||
};
|
||||
const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
|
||||
const ledger = [
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete ${base}..${target} history: ${records.length} merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`,
|
||||
`${formatContributionRecordProvenance({ base, target, ...provenance })} The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`,
|
||||
...(shippedBaselineLine ? ["", shippedBaselineLine] : []),
|
||||
"",
|
||||
"#### Pull requests",
|
||||
@@ -2113,6 +2119,7 @@ export function ledgerFor(
|
||||
issues,
|
||||
ledger,
|
||||
pullRequests: records,
|
||||
provenance,
|
||||
titleReferences: titleReferences(records),
|
||||
};
|
||||
}
|
||||
@@ -2176,6 +2183,22 @@ export function ledgerChecks(section, pullRequests, nodes, directCommits, shippe
|
||||
return errors;
|
||||
}
|
||||
const ledger = section.source.slice(ledgerStart);
|
||||
try {
|
||||
const provenance = parseContributionRecordProvenance(ledger);
|
||||
if (!provenance) {
|
||||
errors.push("missing exact complete contribution record provenance");
|
||||
} else if (
|
||||
section.expectedProvenance &&
|
||||
provenance.inRangePullRequests !== undefined &&
|
||||
(provenance.inRangePullRequests !== section.expectedProvenance.inRangePullRequests ||
|
||||
provenance.retainedSeedOnlyPullRequests !==
|
||||
section.expectedProvenance.retainedSeedOnlyPullRequests)
|
||||
) {
|
||||
errors.push("contribution record provenance partition does not match generated inventory");
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const expectedShippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
|
||||
try {
|
||||
const sectionShippedBaselineLine = formatShippedBaselineExclusions(
|
||||
@@ -2312,7 +2335,7 @@ function manifestFor(options, source, ledger, directCommitRecords) {
|
||||
}));
|
||||
const unlinkedCommits = directCommits.filter((commit) => commit.references.length === 0);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
base: options.base,
|
||||
target: options.target,
|
||||
mergeBase: source.mergeBase,
|
||||
@@ -2320,7 +2343,7 @@ function manifestFor(options, source, ledger, directCommitRecords) {
|
||||
shippedBaselines: source.shippedBaselines,
|
||||
source: {
|
||||
references: ledger.entries.length,
|
||||
pullRequests: ledger.pullRequests.length,
|
||||
...ledger.provenance,
|
||||
issues: ledger.issues.length,
|
||||
directCommits: directCommits.length,
|
||||
unlinkedCommits: unlinkedCommits.length,
|
||||
@@ -2589,7 +2612,7 @@ function main() {
|
||||
}
|
||||
|
||||
const errors = ledgerChecks(
|
||||
candidateSection,
|
||||
{ ...candidateSection, expectedProvenance: ledger.provenance },
|
||||
ledger.pullRequests,
|
||||
nodes,
|
||||
relationships.directCommits,
|
||||
@@ -2619,7 +2642,7 @@ function main() {
|
||||
shippedBaselines: source.shippedBaselines,
|
||||
source: {
|
||||
references: references.length,
|
||||
pullRequests: ledger.pullRequests.length,
|
||||
...ledger.provenance,
|
||||
issues: ledger.issues.length,
|
||||
directCommits: manifest.directCommits.length,
|
||||
unlinkedCommits: manifest.unlinkedCommits.length,
|
||||
@@ -2642,7 +2665,7 @@ function main() {
|
||||
? `, GitHub snapshot ${githubSnapshotState.hits} hits/${githubSnapshotState.misses} misses`
|
||||
: "";
|
||||
process.stdout.write(
|
||||
`${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`,
|
||||
`${options.version}: ${ledger.provenance.uniquePullRequests} unique PRs (${ledger.provenance.inRangePullRequests} in-range + ${ledger.provenance.retainedSeedOnlyPullRequests} retained seed-only), ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`,
|
||||
);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
extractChangelogReleaseSections,
|
||||
extractChangelogSection,
|
||||
formatShippedBaselineExclusions,
|
||||
parseContributionRecordProvenance,
|
||||
parseShippedBaselineExclusions,
|
||||
releaseNotesSectionForTag,
|
||||
releaseNotesVersionForTag,
|
||||
@@ -751,36 +752,13 @@ function candidateContributionRecordPullRequests(
|
||||
Number(match.groups.number),
|
||||
);
|
||||
const rows = new Set(rowNumbers);
|
||||
if (rows.size !== rowNumbers.length) {
|
||||
const seen = new Set();
|
||||
const duplicates = rowNumbers.filter((number) => {
|
||||
if (seen.has(number)) {
|
||||
return true;
|
||||
}
|
||||
seen.add(number);
|
||||
return false;
|
||||
});
|
||||
throw new Error(
|
||||
`${label} contains duplicate contribution record PR rows: ${[...new Set(duplicates)]
|
||||
.map((number) => `#${number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const provenance = parseContributionRecordProvenance(record);
|
||||
if (!requireExactProvenance) {
|
||||
return rows;
|
||||
}
|
||||
const provenance = record.match(
|
||||
/^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?<count>[0-9]+) merged PRs?\./mu,
|
||||
);
|
||||
if (!provenance?.groups?.count) {
|
||||
if (!provenance || !/^[0-9a-f]{40}$/u.test(provenance.target)) {
|
||||
throw new Error(`${label} is missing exact complete contribution record provenance`);
|
||||
}
|
||||
const declaredCount = Number(provenance.groups.count);
|
||||
if (rows.size !== declaredCount) {
|
||||
throw new Error(
|
||||
`${label} contribution record declares ${declaredCount} PRs but contains ${rows.size}`,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -884,12 +862,10 @@ export function validateCandidateChangelogProvenance({
|
||||
section,
|
||||
`CHANGELOG.md ## ${sectionVersion}`,
|
||||
);
|
||||
const provenance = record.match(
|
||||
/^This audited record covers the complete (?<base>\S+)\.\.(?<target>[0-9a-f]{40}) history:/mu,
|
||||
);
|
||||
const base = provenance?.groups?.base;
|
||||
const recordedTarget = provenance?.groups?.target;
|
||||
if (!base || !recordedTarget) {
|
||||
const provenance = parseContributionRecordProvenance(record);
|
||||
const base = provenance?.base;
|
||||
const recordedTarget = provenance?.target;
|
||||
if (!base || !recordedTarget || !/^[0-9a-f]{40}$/u.test(recordedTarget)) {
|
||||
throw new Error(
|
||||
`CHANGELOG.md ## ${sectionVersion} is missing exact complete contribution record provenance`,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,12 @@ export function extractChangelogSection(changelog: unknown, version: unknown): u
|
||||
export function releaseNotesVersionForTag(tag: unknown): unknown;
|
||||
export function formatShippedBaselineExclusions(baselines: ShippedBaselineExclusion[]): string;
|
||||
export function parseShippedBaselineExclusions(section: string): ShippedBaselineExclusion[];
|
||||
export function formatContributionRecordProvenance(
|
||||
provenance: ContributionRecordProvenance,
|
||||
): string;
|
||||
export function parseContributionRecordProvenance(
|
||||
section: string,
|
||||
): ContributionRecordProvenance | undefined;
|
||||
export function dedicatedSectionVersionForTag(tag: unknown): unknown;
|
||||
export function releaseNotesSectionForTag(
|
||||
changelog: unknown,
|
||||
@@ -69,3 +75,10 @@ export type ShippedBaselineExclusion = {
|
||||
count: number;
|
||||
pullRequests: number[];
|
||||
};
|
||||
export type ContributionRecordProvenance = {
|
||||
base: string;
|
||||
target: string;
|
||||
inRangePullRequests?: number;
|
||||
retainedSeedOnlyPullRequests?: number;
|
||||
uniquePullRequests: number;
|
||||
};
|
||||
|
||||
@@ -40,6 +40,62 @@ function validateTag(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContributionRecordProvenance(provenance) {
|
||||
const { base, target, inRangePullRequests, retainedSeedOnlyPullRequests } = provenance;
|
||||
if (inRangePullRequests === undefined || retainedSeedOnlyPullRequests === undefined) {
|
||||
fail("canonical contribution record provenance requires split PR counts");
|
||||
}
|
||||
const uniquePullRequests = inRangePullRequests + retainedSeedOnlyPullRequests;
|
||||
const count = (value) => value.toLocaleString("en-US");
|
||||
const prs = (value) => `PR${value === 1 ? "" : "s"}`;
|
||||
return `This audited record covers the complete ${base}..${target} history: ${count(inRangePullRequests)} in-range ${prs(inRangePullRequests)} + ${count(retainedSeedOnlyPullRequests)} retained seed-only ${prs(retainedSeedOnlyPullRequests)} = ${count(uniquePullRequests)} unique ${prs(uniquePullRequests)}.`;
|
||||
}
|
||||
export function parseContributionRecordProvenance(section) {
|
||||
const rows = Array.from(section.matchAll(/^- \*\*PR #(\d+)\*\*/gmu), (match) => Number(match[1]));
|
||||
const duplicate = rows.find((value, index) => rows.indexOf(value) !== index);
|
||||
if (duplicate !== undefined) {
|
||||
fail(`duplicate contribution record PR #${duplicate}`);
|
||||
}
|
||||
const line = section.match(/^This audited record covers the complete .+$/mu)?.[0];
|
||||
if (!line) {
|
||||
return undefined;
|
||||
}
|
||||
const canonical = line.match(
|
||||
/^This audited record covers the complete (?<base>\S+)\.\.(?<target>[0-9a-f]{40}) history: (?<inRange>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) in-range PRs? \+ (?<seedOnly>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) retained seed-only PRs? = (?<unique>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) unique PRs?\./u,
|
||||
);
|
||||
const legacy =
|
||||
canonical ??
|
||||
line.match(
|
||||
/^This audited record covers the complete (?<base>\S+)\.\.(?<target>\S+) history: (?<unique>[0-9]+) merged PRs?\./u,
|
||||
);
|
||||
if (!legacy?.groups) {
|
||||
fail("release contribution record provenance is malformed");
|
||||
}
|
||||
const number = (value) => Number(value.replaceAll(",", ""));
|
||||
const provenance = {
|
||||
base: legacy.groups.base,
|
||||
target: legacy.groups.target,
|
||||
uniquePullRequests: number(legacy.groups.unique),
|
||||
};
|
||||
if (canonical?.groups) {
|
||||
provenance.inRangePullRequests = number(canonical.groups.inRange);
|
||||
provenance.retainedSeedOnlyPullRequests = number(canonical.groups.seedOnly);
|
||||
if (
|
||||
provenance.inRangePullRequests + provenance.retainedSeedOnlyPullRequests !==
|
||||
provenance.uniquePullRequests
|
||||
) {
|
||||
fail("release contribution record provenance arithmetic is invalid");
|
||||
}
|
||||
}
|
||||
if (provenance.uniquePullRequests > 0 && !/^#### Pull requests\r?$/mu.test(section)) {
|
||||
fail("positive contribution record requires a Pull requests section");
|
||||
}
|
||||
if (rows.length !== provenance.uniquePullRequests) {
|
||||
fail(`contribution record row count ${rows.length} != ${provenance.uniquePullRequests}`);
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function githubReleaseBodySize(body) {
|
||||
return {
|
||||
characters: [...body].length,
|
||||
|
||||
@@ -358,7 +358,38 @@ describe("release candidate checklist", () => {
|
||||
targetSha,
|
||||
isAncestor: () => true,
|
||||
}),
|
||||
).toThrow("duplicate contribution record PR rows: #123");
|
||||
).toThrow("duplicate contribution record PR #123");
|
||||
});
|
||||
|
||||
it("rejects canonical provenance whose unique total does not match the PR rows", () => {
|
||||
const targetSha = "b".repeat(40);
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 1 in-range PR + 1 retained seed-only PR = 2 unique PRs.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
].join("\n");
|
||||
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha,
|
||||
isAncestor: () => true,
|
||||
}),
|
||||
).toThrow("contribution record row count 1 != 2");
|
||||
});
|
||||
|
||||
it("uses numbered historical record rows and skips Unreleased baseline rows", () => {
|
||||
@@ -379,7 +410,7 @@ describe("release candidate checklist", () => {
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"This audited record covers the complete base..HEAD history: 0 merged PRs.",
|
||||
"This audited record covers the complete base..HEAD history: 1 merged PR.",
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
|
||||
@@ -6,6 +6,52 @@ import {
|
||||
renderContributionRecordEntry,
|
||||
} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs";
|
||||
|
||||
const targetSha = "a".repeat(40);
|
||||
|
||||
function contributionLedger({
|
||||
nodes,
|
||||
seededPullRequests = [],
|
||||
sourcePullRequests = [],
|
||||
sourceReferences = [],
|
||||
}: {
|
||||
nodes: Map<number, Record<string, unknown>>;
|
||||
seededPullRequests?: number[];
|
||||
sourcePullRequests?: number[];
|
||||
sourceReferences?: number[];
|
||||
}) {
|
||||
return ledgerFor(
|
||||
"v2026.7.2-beta.7",
|
||||
targetSha,
|
||||
[...nodes.keys()],
|
||||
nodes,
|
||||
new Map(),
|
||||
new Map(),
|
||||
{ issuesByPullRequest: new Map() },
|
||||
{
|
||||
legacyIssues: new Map(),
|
||||
pullRequests: new Map(
|
||||
seededPullRequests.map((number) => [
|
||||
number,
|
||||
{ externalReferences: [], references: [], thanks: [] },
|
||||
]),
|
||||
),
|
||||
},
|
||||
new Set(sourcePullRequests),
|
||||
sourceReferences,
|
||||
[],
|
||||
[],
|
||||
new Set(),
|
||||
[],
|
||||
Date.parse("2026-08-05T00:00:00Z"),
|
||||
) as ReturnType<typeof ledgerFor> & {
|
||||
provenance: {
|
||||
inRangePullRequests: number;
|
||||
retainedSeedOnlyPullRequests: number;
|
||||
uniquePullRequests: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderContributionRecordEntry", () => {
|
||||
it("keeps external and linked issue references without repeating PR title references", () => {
|
||||
expect(
|
||||
@@ -101,7 +147,7 @@ describe("renderContributionRecordEntry", () => {
|
||||
|
||||
const result = ledgerFor(
|
||||
"v2026.6.11",
|
||||
"HEAD",
|
||||
targetSha,
|
||||
[125],
|
||||
nodes,
|
||||
new Map(),
|
||||
@@ -120,6 +166,126 @@ describe("renderContributionRecordEntry", () => {
|
||||
expect(result.ledger).toContain("- **PR #125** Thanks @carol and @alice and @bob.");
|
||||
});
|
||||
|
||||
it("counts associated and PR-typed source refs before retained seed-only rows", () => {
|
||||
const nodes = new Map(
|
||||
[1, 2, 3].map((number) => [
|
||||
number,
|
||||
{
|
||||
__typename: "PullRequest",
|
||||
closingIssuesReferences: { nodes: [] },
|
||||
mergedAt: "2026-08-04T00:00:00Z",
|
||||
title: `fix: contribution ${number}`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const result = contributionLedger({
|
||||
nodes,
|
||||
seededPullRequests: [1, 3],
|
||||
sourcePullRequests: [1],
|
||||
sourceReferences: [2],
|
||||
});
|
||||
|
||||
expect(result.provenance).toEqual({
|
||||
inRangePullRequests: 2,
|
||||
retainedSeedOnlyPullRequests: 1,
|
||||
uniquePullRequests: 3,
|
||||
});
|
||||
expect(result.ledger).toContain("2 in-range PRs + 1 retained seed-only PR = 3 unique PRs.");
|
||||
});
|
||||
|
||||
it("reports zero retained seed-only PRs when every row is in range", () => {
|
||||
const nodes = new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
__typename: "PullRequest",
|
||||
closingIssuesReferences: { nodes: [] },
|
||||
mergedAt: "2026-08-04T00:00:00Z",
|
||||
title: "fix: in-range contribution",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const result = contributionLedger({ nodes, sourcePullRequests: [1] });
|
||||
|
||||
expect(result.provenance).toMatchObject({
|
||||
inRangePullRequests: 1,
|
||||
retainedSeedOnlyPullRequests: 0,
|
||||
uniquePullRequests: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports all rows as retained seed-only when the release range has no PRs", () => {
|
||||
const nodes = new Map(
|
||||
[1, 2].map((number) => [
|
||||
number,
|
||||
{
|
||||
__typename: "PullRequest",
|
||||
closingIssuesReferences: { nodes: [] },
|
||||
mergedAt: "2026-08-04T00:00:00Z",
|
||||
title: `fix: seeded contribution ${number}`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const result = contributionLedger({ nodes, seededPullRequests: [1, 2] });
|
||||
|
||||
expect(result.provenance).toMatchObject({
|
||||
inRangePullRequests: 0,
|
||||
retainedSeedOnlyPullRequests: 2,
|
||||
uniquePullRequests: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a forged canonical range and seed partition", () => {
|
||||
const source = [
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- Highlight one.",
|
||||
"- Highlight two.",
|
||||
"- Highlight three.",
|
||||
"- Highlight four.",
|
||||
"- Highlight five.",
|
||||
"",
|
||||
"### Changes",
|
||||
"",
|
||||
"### Fixes",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 0 in-range PRs + 1 retained seed-only PR = 1 unique PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #456**",
|
||||
].join("\n");
|
||||
const entry = {
|
||||
number: 456,
|
||||
title: "fix: example",
|
||||
editorialEligible: true,
|
||||
priorReferences: [],
|
||||
externalReferences: [],
|
||||
linkedIssues: [],
|
||||
thanks: [],
|
||||
};
|
||||
|
||||
expect(
|
||||
ledgerChecks(
|
||||
{
|
||||
source,
|
||||
expectedProvenance: {
|
||||
inRangePullRequests: 1,
|
||||
retainedSeedOnlyPullRequests: 0,
|
||||
uniquePullRequests: 1,
|
||||
},
|
||||
},
|
||||
[entry],
|
||||
new Map([[456, { __typename: "PullRequest" }]]),
|
||||
[],
|
||||
),
|
||||
).toContain("contribution record provenance partition does not match generated inventory");
|
||||
});
|
||||
|
||||
it("retains references from a verbose record when the source title changes", () => {
|
||||
const record = contributionRecordFor({
|
||||
source: [
|
||||
@@ -159,6 +325,8 @@ describe("renderContributionRecordEntry", () => {
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #456** Related openclaw/imsg#141.",
|
||||
@@ -200,6 +368,8 @@ describe("renderContributionRecordEntry", () => {
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
line,
|
||||
@@ -239,6 +409,8 @@ describe("renderContributionRecordEntry", () => {
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
line,
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
GITHUB_RELEASE_BODY_MAX_BYTES,
|
||||
GITHUB_RELEASE_BODY_MAX_CHARACTERS,
|
||||
extractChangelogSection,
|
||||
formatContributionRecordProvenance,
|
||||
formatShippedBaselineExclusions,
|
||||
parseContributionRecordProvenance,
|
||||
parseShippedBaselineExclusions,
|
||||
releaseNotesVersionForTag,
|
||||
renderGithubReleaseNotes,
|
||||
@@ -40,6 +42,67 @@ function changelogFor(record: string): string {
|
||||
}
|
||||
|
||||
describe("GitHub release-note rendering", () => {
|
||||
it("round-trips canonical contribution provenance and accepts published legacy lines", () => {
|
||||
const target = "a".repeat(40);
|
||||
const singular = formatContributionRecordProvenance({
|
||||
base: "v2026.7.2-beta.7",
|
||||
target,
|
||||
inRangePullRequests: 1,
|
||||
retainedSeedOnlyPullRequests: 0,
|
||||
uniquePullRequests: 1,
|
||||
});
|
||||
const commaSeparated = formatContributionRecordProvenance({
|
||||
base: "v2026.7.2-beta.7",
|
||||
target,
|
||||
inRangePullRequests: 1_234,
|
||||
retainedSeedOnlyPullRequests: 56,
|
||||
uniquePullRequests: 1_290,
|
||||
});
|
||||
|
||||
expect(singular).toContain("1 in-range PR + 0 retained seed-only PRs = 1 unique PR.");
|
||||
expect(commaSeparated).toContain(
|
||||
"1,234 in-range PRs + 56 retained seed-only PRs = 1,290 unique PRs.",
|
||||
);
|
||||
expect(
|
||||
parseContributionRecordProvenance(
|
||||
[singular, "", "#### Pull requests", "", "- **PR #123** fix: canonical example."].join(
|
||||
"\n",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
base: "v2026.7.2-beta.7",
|
||||
target,
|
||||
inRangePullRequests: 1,
|
||||
retainedSeedOnlyPullRequests: 0,
|
||||
uniquePullRequests: 1,
|
||||
});
|
||||
const legacy = parseContributionRecordProvenance(
|
||||
[
|
||||
`This audited record covers the complete v2026.7.2-beta.6..02d06caeb0febe7ec3c0df1454b85c38f3fb27d1 history: 1 merged PR. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: legacy example.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(legacy).toMatchObject({ uniquePullRequests: 1 });
|
||||
expect(() => formatContributionRecordProvenance(legacy!)).toThrow("requires split PR counts");
|
||||
expect(() =>
|
||||
parseContributionRecordProvenance(
|
||||
commaSeparated.replace("= 1,290 unique PRs", "= 1,291 unique PRs"),
|
||||
),
|
||||
).toThrow("provenance arithmetic is invalid");
|
||||
expect(() =>
|
||||
parseContributionRecordProvenance(commaSeparated.replace("1,234", "1234")),
|
||||
).toThrow("provenance is malformed");
|
||||
expect(() =>
|
||||
parseContributionRecordProvenance(singular.replace("1 in-range", "001 in-range")),
|
||||
).toThrow("provenance is malformed");
|
||||
expect(() => parseContributionRecordProvenance(singular)).toThrow(
|
||||
"positive contribution record requires a Pull requests section",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the complete matching section including its version heading when it fits", () => {
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."),
|
||||
|
||||
@@ -74,7 +74,11 @@ describe("release-note verification", () => {
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${target} history: 1 merged PR.`,
|
||||
`This audited record covers the complete base..${target} history: 1 in-range PR + 0 retained seed-only PRs = 1 unique PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
].join("\n"),
|
||||
}),
|
||||
).toBe(target);
|
||||
@@ -948,7 +952,15 @@ describe("release-note verification", () => {
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("1 errors");
|
||||
expect(JSON.parse(readFileSync(manifestPath, "utf8")).version).toBe("2026.7.1");
|
||||
expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
version: "2026.7.1",
|
||||
source: {
|
||||
inRangePullRequests: 0,
|
||||
retainedSeedOnlyPullRequests: 0,
|
||||
uniquePullRequests: 0,
|
||||
},
|
||||
});
|
||||
expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toBe(changelog);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user