diff --git a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs index 96a77f98605c..0391a0b72b9a 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -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, diff --git a/test/external-script-modules.d.ts b/test/external-script-modules.d.ts index 7e99e5257e69..d40c162c34cb 100644 --- a/test/external-script-modules.d.ts +++ b/test/external-script-modules.d.ts @@ -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, nodes: Map, diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index 5fbddc8bfba0..2bc3b9836ffa 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -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 {