fix(release): keep verifier output valid and deterministic (#106953)

* fix(release): validate changelog before atomic write

* fix(release): defer optional provenance main lookup

* fix(release): suppress overridden release PR subjects

* fix(release): satisfy verifier lint

* test(release): declare verifier suppression helper

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-07-13 19:25:01 -07:00
committed by GitHub
parent f7ea3c776c
commit 568e4d95e7
3 changed files with 126 additions and 13 deletions

View File

@@ -1,7 +1,15 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
@@ -853,6 +861,21 @@ export function resolvedReleasePullRequests(
);
}
export function releasePullRequestReferencesToSuppress(
currentPullRequests,
subject,
associatedPullRequests,
hasProvenanceOverride,
) {
const candidates = new Set(currentPullRequests);
if (hasProvenanceOverride) {
for (const match of subject.matchAll(/\(#(\d+)\)\s*$/g)) {
candidates.add(Number(match[1]));
}
}
return [...candidates].filter((number) => !associatedPullRequests.includes(number));
}
function changedPathsForCommit(hash) {
return new Set(
git(["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", hash, "--"])
@@ -1130,7 +1153,7 @@ function sourceCommits(base, target, mainRef) {
);
const provenanceOverrides = collectReleaseProvenanceOverrides(activeCommits);
const mainCommits = canonicalMainCommits(base, mainRef);
const mainCommit = gitCommit(mainRef, true);
const mainCommit = provenanceOverrides.size > 0 ? gitCommit(mainRef, true) : undefined;
const mainCommitsByHash = new Map(mainCommits.map((commit) => [commit.hash, commit]));
const mainCommitsBySubject = new Map();
for (const commit of mainCommits) {
@@ -1233,15 +1256,21 @@ function sourceCommits(base, target, mainRef) {
(hash) => canonicalMainPullRequests.get(hash) ?? [],
);
const matchedMainCommits = canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? [];
const provenanceOverride = provenanceOverrides.get(commit.hash);
const associatedPullRequests = resolvedReleasePullRequests(
currentPullRequests,
mainPullRequests,
matchedMainCommits.length > 0,
provenanceOverrides.get(commit.hash),
provenanceOverride,
);
commit.pullRequests = associatedPullRequests;
const suppressedBackportPullRequests = new Set(
currentPullRequests.filter((number) => !associatedPullRequests.includes(number)),
releasePullRequestReferencesToSuppress(
currentPullRequests,
commit.subject,
associatedPullRequests,
provenanceOverride !== undefined,
),
);
commit.references = commit.references.filter(
(number) => !suppressedBackportPullRequests.has(number),
@@ -2227,6 +2256,19 @@ function releaseChecks(changelog, version, releaseTags) {
return checks;
}
function writeFileAtomic(filePath, contents) {
const directory = path.dirname(filePath);
mkdirSync(directory, { recursive: true });
const tempDirectory = mkdtempSync(path.join(directory, `.${path.basename(filePath)}.tmp-`));
const tempPath = path.join(tempDirectory, path.basename(filePath));
try {
writeFileSync(tempPath, contents);
renameSync(tempPath, filePath);
} finally {
rmSync(tempDirectory, { force: true, recursive: true });
}
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
@@ -2234,8 +2276,8 @@ function main() {
return;
}
githubSnapshotState = initializeGithubSnapshot(options);
let changelog = readFileSync("CHANGELOG.md", "utf8");
let section = sectionFor(changelog, options.version);
const changelog = readFileSync("CHANGELOG.md", "utf8");
const section = sectionFor(changelog, options.version);
const source = sourceCommits(options.base, options.target, options.mainRef ?? "origin/main");
const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor);
const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords);
@@ -2396,32 +2438,32 @@ function main() {
ledger,
relationships.directCommits,
);
if (options.manifestPath) {
writeFileSync(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
writeFileAtomic(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
let candidateChangelog = changelog;
let candidateSection = section;
if (options.writeLedger) {
changelog = replaceLedger(
candidateChangelog = replaceLedger(
changelog,
section,
ledger.ledger,
ledger.pullRequests,
relationships.directCommits,
);
writeFileSync("CHANGELOG.md", changelog);
section = sectionFor(changelog, options.version);
candidateSection = sectionFor(candidateChangelog, options.version);
}
const errors = ledgerChecks(
section,
candidateSection,
ledger.pullRequests,
nodes,
relationships.directCommits,
source.shippedBaselines,
);
const github = options.checkGithub
? releaseChecks(changelog, options.version, options.releaseTags)
? releaseChecks(candidateChangelog, options.version, options.releaseTags)
: [];
for (const check of github) {
if (!check.matches) {
@@ -2430,6 +2472,11 @@ function main() {
);
}
}
if (errors.length === 0) {
if (options.writeLedger) {
writeFileAtomic("CHANGELOG.md", candidateChangelog);
}
}
const result = {
base: options.base,

View File

@@ -118,6 +118,12 @@ declare module "*openclaw-changelog-update/scripts/verify-release-notes.mjs" {
hasCanonicalMainCommit: boolean,
provenanceOverride?: number[],
): number[];
export function releasePullRequestReferencesToSuppress(
currentPullRequests: number[],
subject: string,
associatedPullRequests: number[],
hasProvenanceOverride: boolean,
): number[];
export function validateReleaseProvenanceOverrides(
provenanceOverrides: Map<string, number[]>,
nodes: Map<number, unknown>,

View File

@@ -16,6 +16,7 @@ import {
highlightCountError,
persistGithubSnapshot,
releaseNoteReferences,
releasePullRequestReferencesToSuppress,
releaseProvenanceMarkers,
renderedContributionRecordReferences,
resolvedReleasePullRequests,
@@ -78,6 +79,14 @@ describe("release-note verification", () => {
expect(resolvedReleasePullRequests([104939], [], false, [104905, 102980, 104956])).toEqual([
104905, 102980, 104956,
]);
expect(
releasePullRequestReferencesToSuppress(
[],
"test(live): harden GPT-5.6 nonce retries for July (#104939)",
[104905, 102980, 104956],
true,
),
).toEqual([104939]);
});
it("rejects malformed, out-of-range, or conflicting release provenance markers", () => {
@@ -720,6 +729,57 @@ describe("release-note verification", () => {
}
});
it("leaves CHANGELOG.md untouched when the rendered ledger fails validation", () => {
const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-"));
try {
git(cwd, ["init", "-q"]);
const changelog = [
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- Only one highlight.",
"",
"### Changes",
"",
"### Fixes",
"",
].join("\n");
writeFileSync(join(cwd, "CHANGELOG.md"), changelog);
git(cwd, ["add", "CHANGELOG.md"]);
git(cwd, ["commit", "-qm", "initial"]);
const manifestPath = join(cwd, "release-manifest.json");
const result = spawnSync(
process.execPath,
[
verifier,
"--base",
"HEAD",
"--target",
"HEAD",
"--main-ref",
"HEAD",
"--manifest",
manifestPath,
"--version",
"2026.7.1",
"--write-ledger",
],
{ cwd, encoding: "utf8" },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("1 errors");
expect(JSON.parse(readFileSync(manifestPath, "utf8")).version).toBe("2026.7.1");
expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toBe(changelog);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("rejects a release base that is not an ancestor of the target", () => {
const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-"));
try {