mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-29 06:41:14 +00:00
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:
committed by
GitHub
parent
f7ea3c776c
commit
568e4d95e7
@@ -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,
|
||||
|
||||
6
test/external-script-modules.d.ts
vendored
6
test/external-script-modules.d.ts
vendored
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user