fix(release): harden publication validation

This commit is contained in:
Vincent Koc
2026-07-09 06:18:49 -07:00
parent 69bdd92a61
commit a486f3ab08
12 changed files with 2239 additions and 153 deletions

View File

@@ -21,7 +21,10 @@ every human `Thanks @...` attribution.
- Target base version: `YYYY.M.PATCH`, without beta suffix.
- Base tag: last reachable shipped release tag, usually the previous stable or
the previous beta train requested by the operator.
the previous beta train requested by the operator. It must be an ancestor of
the target; a newer but divergent tag is not a valid history boundary. Use
an explicit shipped/main-closeout SHA only when it is also reachable from the
target.
- Target ref: exact branch/SHA being released.
## Workflow
@@ -55,6 +58,15 @@ every human `Thanks @...` attribution.
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
- 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
shipped boundary: the verifier unions explicit PR rows from complete
contribution records in numbered release sections, excludes only overlapping PRs,
and ignores `Unreleased`. Never infer this boundary from the base SHA,
target prose, or target record. The manifest and generated provenance retain
each tag plus the exact excluded PR inventory and count for deterministic
candidate validation
- source PR discovery combines merged GitHub commit associations with merged
PR references explicitly present in active commit subjects/bodies so
cherry-picks and squash commits remain accounted for. Resolve every
@@ -165,7 +177,13 @@ every human `Thanks @...` attribution.
as shipped, when a source PR is absent from the contribution record, when
direct commits are rendered as a public record dump, when non-editorial
PRs appear in grouped prose, or when an eligible PR author or known
co-author is missing from that PR's `Thanks @...` credit
co-author is missing from that PR's `Thanks @...` credit. It also fails
before history collection when `--base` is not an ancestor of `--target`,
when `### Highlights` has fewer than five or more than eight top-level
bullets, or when the existing prose/record names a PR outside the source
range. Only an explicit `--seed-ref` may add historical PR inventory; an
explicit repeatable `--shipped-ref` may subtract PRs proven present in a
prior shipped tag
- when grouped prose names a PR, that same bullet must retain every
contributor and linked-reporter credit from its generated PR record
- unqualified `#NNN` references resolve against `openclaw/openclaw`;
@@ -183,12 +201,25 @@ every human `Thanks @...` attribution.
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check; the GitHub body must begin with the complete
`## YYYY.M.PATCH` changelog section, including its heading
- GitHub release bodies are limited to 125,000 characters. If the complete
source section plus an existing verification tail exceeds that limit, keep
the source section intact and omit the tail; never truncate the
contribution record
fails the check
- `scripts/render-github-release-notes.mjs` is the canonical release-body
renderer used by candidate validation, publish, and verification. When the
complete `## YYYY.M.PATCH` section fits GitHub's 125,000-character limit and
the renderer's matching 125,000-byte safety ceiling, the body must contain
that exact section including its heading
- when the complete source section exceeds either limit, the renderer keeps the exact
grouped editorial notes through the line before
`### Complete contribution record`, then emits that heading with a stable
link to the full contribution record in the tag-pinned `CHANGELOG.md`.
Never truncate a bullet or partial record, and never hand-author a different
compact form
- append `### Release verification` only when it fits after the canonical full
or compact body is chosen. If it does not fit, omit the body tail and retain
the immutable attached release evidence; never compact a fitting full
contribution record just to preserve the optional tail
- `pnpm release:candidate` performs this deterministic render check from the
exact tag before it dispatches Full Release Validation, including when local
generated checks are explicitly skipped
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`

View File

@@ -1,7 +1,15 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import {
extractChangelogReleaseSections,
formatShippedBaselineExclusions,
parseShippedBaselineExclusions,
releaseNotesVersionForTag,
verifyGithubReleaseNotes,
} from "../../../../scripts/render-github-release-notes.mjs";
const repo = "openclaw/openclaw";
const commitAssociationQueryBatchSize = 20;
@@ -58,6 +66,7 @@ Required:
Options:
--manifest <path> Read or write the complete contribution record ledger.
--seed-ref <ref> Use an existing release section as editorial input.
--shipped-ref <tag> Exclude PRs already recorded by this shipped tag; repeatable.
--write-ledger Write the verified ledger back into CHANGELOG.md.
--release-tag <tag> GitHub release tag to compare; repeatable with --check-github.
--check-github Require each supplied GitHub release body to match.
@@ -73,6 +82,7 @@ function parseArgs(argv) {
json: false,
manifestPath: undefined,
seedRef: undefined,
shippedRefs: [],
writeLedger: false,
};
@@ -93,6 +103,7 @@ function parseArgs(argv) {
arg === "--target" ||
arg === "--version" ||
arg === "--release-tag" ||
arg === "--shipped-ref" ||
arg === "--manifest" ||
arg === "--seed-ref"
) {
@@ -102,6 +113,8 @@ function parseArgs(argv) {
}
if (arg === "--release-tag") {
options.releaseTags.push(value);
} else if (arg === "--shipped-ref") {
options.shippedRefs.push(value);
} else if (arg === "--manifest") {
options.manifestPath = value;
} else if (arg === "--seed-ref") {
@@ -127,6 +140,11 @@ function parseArgs(argv) {
if (!options.help && options.checkGithub && options.releaseTags.length === 0) {
fail("--check-github requires at least one --release-tag");
}
const uniqueShippedRefs = new Set(options.shippedRefs);
if (uniqueShippedRefs.size !== options.shippedRefs.length) {
fail("--shipped-ref values must be unique");
}
options.shippedRefs = options.shippedRefs.toSorted((a, b) => (a === b ? 0 : a < b ? -1 : 1));
return options;
}
@@ -143,6 +161,29 @@ function git(args) {
return run("git", args).trimEnd();
}
function gitIsAncestor(base, target) {
const result = spawnSync(
"git",
["merge-base", "--is-ancestor", `${base}^{commit}`, `${target}^{commit}`],
{
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
stdio: ["ignore", "pipe", "pipe"],
},
);
if (result.status === 0) {
return true;
}
if (result.status === 1) {
return false;
}
fail(
`could not validate release range ancestry for ${base}..${target}: ${
result.stderr?.trim() || result.signal || result.status
}`,
);
}
function githubApi(args) {
try {
return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
@@ -221,6 +262,15 @@ function referencesIn(text) {
return references;
}
export function releaseNoteReferences(sectionSource, shippedBaselines) {
const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
// The baseline inventory proves subtraction; its PR ids are not release-note references.
const referenceSource = shippedBaselineLine
? sectionSource.replace(shippedBaselineLine, "")
: sectionSource;
return referencesIn(referenceSource);
}
function closingReferencesIn(text) {
const references = [];
for (const match of text.matchAll(
@@ -321,35 +371,103 @@ function contributionRecordFor(section) {
return result;
}
function mergeContributionRecords(...records) {
const merged = { legacyIssues: new Map(), pullRequests: new Map() };
for (const record of records) {
for (const [number, entry] of record.pullRequests) {
addContributionRecordEntry(merged.pullRequests, number, entry);
}
for (const [number, entry] of record.legacyIssues) {
addContributionRecordEntry(merged.legacyIssues, number, entry);
}
function completeContributionRecord(section, label) {
const recordStart = section.source.search(/\n### Complete contribution record\r?$/m);
if (recordStart < 0) {
fail(`${label} is missing ### Complete contribution record`);
}
return merged;
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) {
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}`,
);
}
return { record, declaredCount };
}
function withoutRevertedContributionRecords(record, revertedReferences) {
if (revertedReferences.size === 0) {
export function cumulativeShippedPullRequests(changelog, label) {
const sections = extractChangelogReleaseSections(changelog).filter(
(section) =>
section.version !== "Unreleased" &&
section.source.includes("\n### Complete contribution record"),
);
if (sections.length === 0) {
fail(`${label} is missing ### Complete contribution record`);
}
const pullRequests = new Set();
for (const section of sections) {
const record = contributionRecordFor(section);
for (const number of record.pullRequests.keys()) {
pullRequests.add(number);
}
}
return pullRequests;
}
function shippedBaselineFor(ref) {
const version = releaseNotesVersionForTag(ref);
const tagRef = `refs/tags/${ref}`;
git(["rev-parse", `${tagRef}^{commit}`]);
const changelog = git(["show", `${tagRef}:CHANGELOG.md`]);
completeContributionRecord(sectionFor(changelog, version), `shipped baseline ${ref}`);
return {
ref,
pullRequests: cumulativeShippedPullRequests(changelog, `shipped baseline ${ref}`),
};
}
export function subtractShippedPullRequests(source, baselines) {
const excluded = new Set();
const metadata = [];
for (const baseline of baselines.toSorted((a, b) =>
a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1,
)) {
const pullRequests = [];
for (const number of baseline.pullRequests) {
if (
!excluded.has(number) &&
(source.pullRequests.has(number) || source.references.includes(number))
) {
excluded.add(number);
pullRequests.push(number);
}
source.pullRequests.delete(number);
}
source.references = source.references.filter((number) => !baseline.pullRequests.has(number));
const sortedPullRequests = pullRequests.toSorted((a, b) => a - b);
metadata.push({
ref: baseline.ref,
count: sortedPullRequests.length,
pullRequests: sortedPullRequests,
});
}
return { baselines: metadata, pullRequests: excluded };
}
function withoutExcludedContributionRecords(record, excludedReferences) {
if (excludedReferences.size === 0) {
return record;
}
const filtered = { legacyIssues: new Map(), pullRequests: new Map() };
for (const [number, entry] of record.pullRequests) {
if (revertedReferences.has(number)) {
if (excludedReferences.has(number)) {
continue;
}
addContributionRecordEntry(filtered.pullRequests, number, {
...entry,
references: entry.references.filter((reference) => !revertedReferences.has(reference)),
references: entry.references.filter((reference) => !excludedReferences.has(reference)),
});
}
for (const [number, entry] of record.legacyIssues) {
if (!revertedReferences.has(number)) {
if (!excludedReferences.has(number)) {
addContributionRecordEntry(filtered.legacyIssues, number, entry);
}
}
@@ -369,6 +487,25 @@ function contributionRecordMetadataReferences(record) {
return references;
}
export function contaminatingPullRequestReferences({
noteReferences,
recordedReferences,
sourcePullRequests,
sourceReferences,
seededPullRequests,
nodes,
}) {
const allowed = new Set([...sourcePullRequests, ...seededPullRequests]);
for (const number of sourceReferences) {
if (nodes.get(number)?.__typename === "PullRequest") {
allowed.add(number);
}
}
return [...new Set([...noteReferences, ...recordedReferences])].filter(
(number) => nodes.get(number)?.__typename === "PullRequest" && !allowed.has(number),
);
}
function appendReferences(references, additions) {
const seen = new Set(references);
for (const number of additions) {
@@ -380,8 +517,12 @@ function appendReferences(references, additions) {
}
function sourceCommits(base, target) {
const mergeBase = git(["merge-base", base, target]);
const targetTimestamp = Date.parse(git(["show", "-s", "--format=%cI", `${target}^{commit}`]));
const targetCommit = git(["rev-parse", `${target}^{commit}`]);
if (!gitIsAncestor(base, targetCommit)) {
fail(`release range base ${base} must be an ancestor of target ${target}`);
}
const mergeBase = git(["merge-base", base, targetCommit]);
const targetTimestamp = Date.parse(git(["show", "-s", "--format=%cI", targetCommit]));
if (!Number.isFinite(targetTimestamp)) {
fail(`could not resolve timestamp for release target ${target}`);
}
@@ -390,7 +531,7 @@ function sourceCommits(base, target) {
"--first-parent",
"--reverse",
"--format=%H%x1f%s%x1f%an%x1f%ae%x1f%B%x1e",
`${mergeBase}..${target}`,
`${mergeBase}..${targetCommit}`,
]);
const commits = new Map();
const revertsByTarget = new Map();
@@ -611,6 +752,7 @@ function sourceCommits(base, target) {
pullRequests,
references,
revertedReferences,
target: targetCommit,
targetTimestamp,
};
}
@@ -1164,9 +1306,9 @@ function ledgerFor(
priorRecord,
sourcePullRequests,
sourceReferences,
noteReferences,
legacyIssuePullRequests,
revertedReferences,
shippedBaselines,
targetTimestamp,
) {
const entries = references.map((number) => {
@@ -1188,7 +1330,6 @@ function ledgerFor(
const recordedPullRequests = new Set([
...sourcePullRequests,
...sourceReferences,
...noteReferences,
...legacyIssuePullRequests,
...priorRecord.pullRequests.keys(),
]);
@@ -1239,10 +1380,12 @@ function ledgerFor(
: "";
return `- **PR #${entry.number}** ${withSentenceEnding(entry.title)}${relatedIssues}${attribution}`;
};
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.`,
...(shippedBaselineLine ? ["", shippedBaselineLine] : []),
"",
"#### Pull requests",
"",
@@ -1267,13 +1410,42 @@ function replaceLedger(changelog, section, ledger, pullRequests, directCommits)
return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`;
}
function ledgerChecks(section, pullRequests, nodes, directCommits) {
export function countTopLevelSectionBullets(sectionSource, heading) {
const headingMatch = new RegExp(`^### ${escapeRegExp(heading)}\\r?$`, "mu").exec(sectionSource);
if (!headingMatch || headingMatch.index === undefined) {
return 0;
}
const headingEnd = sectionSource.indexOf("\n", headingMatch.index);
const bodyStart = headingEnd < 0 ? sectionSource.length : headingEnd + 1;
const nextHeading = /^### /gmu;
nextHeading.lastIndex = bodyStart;
const end = nextHeading.exec(sectionSource)?.index ?? sectionSource.length;
return sectionSource
.slice(bodyStart, end)
.split("\n")
.filter((line) => line.startsWith("- ")).length;
}
export function highlightCountError(sectionSource) {
const count = countTopLevelSectionBullets(sectionSource, "Highlights");
return count >= 5 && count <= 8
? undefined
: `### Highlights must contain 5-8 top-level bullets; found ${count}`;
}
function ledgerChecks(section, pullRequests, nodes, directCommits, shippedBaselines) {
const errors = [];
let sectionReferences = referencesIn(section.source);
if (/@undefined\b/i.test(section.source)) {
errors.push("release section contains invalid @undefined contributor credit");
}
if (!section.source.includes("### Highlights")) {
errors.push("missing ### Highlights");
} else {
const error = highlightCountError(section.source);
if (error) {
errors.push(error);
}
}
if (!section.source.includes("### Changes")) {
errors.push("missing ### Changes");
@@ -1287,13 +1459,37 @@ function ledgerChecks(section, pullRequests, nodes, directCommits) {
return errors;
}
const ledger = section.source.slice(ledgerStart);
const expectedShippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines);
try {
const sectionShippedBaselineLine = formatShippedBaselineExclusions(
parseShippedBaselineExclusions(section.source),
);
const actualShippedBaselineLine = formatShippedBaselineExclusions(
parseShippedBaselineExclusions(ledger),
);
if (sectionShippedBaselineLine !== actualShippedBaselineLine) {
errors.push(
"shipped baseline exclusions must appear inside the complete contribution record",
);
} else if (actualShippedBaselineLine !== expectedShippedBaselineLine) {
errors.push(
`shipped baseline exclusions mismatch: expected ${
expectedShippedBaselineLine || "none"
}, found ${actualShippedBaselineLine || "none"}`,
);
} else {
sectionReferences = releaseNoteReferences(section.source, shippedBaselines);
}
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
if (ledger.includes("#### Linked issues")) {
errors.push("complete contribution record must not have a linked-issues inventory");
}
if (ledger.includes("#### Direct commits")) {
errors.push("complete contribution record must not list direct commits");
}
for (const number of new Set(referencesIn(section.source))) {
for (const number of new Set(sectionReferences)) {
if (!nodes.has(number)) {
errors.push(`unresolved release-note reference #${number}`);
}
@@ -1389,6 +1585,7 @@ function manifestFor(options, source, ledger, directCommitRecords) {
target: options.target,
mergeBase: source.mergeBase,
version: options.version,
shippedBaselines: source.shippedBaselines,
source: {
references: ledger.entries.length,
pullRequests: ledger.pullRequests.length,
@@ -1413,21 +1610,24 @@ function manifestFor(options, source, ledger, directCommitRecords) {
};
}
function releaseChecks(section, releaseTags) {
const expected = section.source;
function releaseChecks(changelog, version, releaseTags) {
const checks = [];
for (const tag of releaseTags) {
const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]);
const suffix = release.body.slice(expected.length).trimStart();
const matches =
release.body === expected ||
(release.body.startsWith(expected) &&
(suffix === "" || suffix.startsWith("### Release verification")));
const verification = verifyGithubReleaseNotes({
body: release.body ?? "",
changelog,
version,
tag,
repository: repo,
});
checks.push({
tag,
releaseId: release.id,
matches,
bodyLength: release.body.length,
matches: verification.matches,
mode: verification.mode,
bodyLength: verification.actualSize.characters,
bodyBytes: verification.actualSize.bytes,
});
}
return checks;
@@ -1442,6 +1642,9 @@ function main() {
let changelog = readFileSync("CHANGELOG.md", "utf8");
let section = sectionFor(changelog, options.version);
const source = sourceCommits(options.base, options.target);
const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor);
const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords);
source.shippedBaselines = shippedExclusions.baselines;
const preexistingNotes = section.source.replace(
/\n+### Complete contribution (?:ledger|record)[\s\S]*$/m,
"",
@@ -1457,9 +1660,6 @@ function main() {
.join(", ")}`,
);
}
const references = [...source.references];
appendReferences(references, noteReferences);
let nodes = resolveReferences(references);
const renderedRecord = contributionRecordFor(section);
const renderedRecordReferences = contributionRecordMetadataReferences(renderedRecord);
const revertedRenderedReferences = renderedRecordReferences.filter((number) =>
@@ -1472,13 +1672,17 @@ function main() {
.join(", ")}`,
);
}
let priorRecord = withoutRevertedContributionRecords(renderedRecord, source.revertedReferences);
let priorRecord = { legacyIssues: new Map(), pullRequests: new Map() };
if (options.seedRef) {
const seedChangelog = git(["show", `${options.seedRef}:CHANGELOG.md`]);
const seedSection = sectionFor(seedChangelog, options.version);
priorRecord = mergeContributionRecords(priorRecord, contributionRecordFor(seedSection));
priorRecord = contributionRecordFor(seedSection);
}
priorRecord = withoutRevertedContributionRecords(priorRecord, source.revertedReferences);
const excludedRecordedReferences = new Set([
...source.revertedReferences,
...shippedExclusions.pullRequests,
]);
priorRecord = withoutExcludedContributionRecords(priorRecord, excludedRecordedReferences);
const recordedReferences = contributionRecordMetadataReferences(priorRecord);
const revertedRecordedReferences = recordedReferences.filter((number) =>
source.revertedReferences.has(number),
@@ -1490,9 +1694,29 @@ function main() {
.join(", ")}`,
);
}
const references = [...source.references];
appendReferences(references, noteReferences);
appendReferences(references, renderedRecordReferences);
appendReferences(references, recordedReferences);
nodes = resolveReferences(references);
const legacyIssuePullRequests = [...legacyIssuesByPullRequest(priorRecord, nodes).keys()];
let nodes = resolveReferences(references);
const contamination = contaminatingPullRequestReferences({
noteReferences,
recordedReferences: renderedRecordReferences,
sourcePullRequests: source.pullRequests,
sourceReferences: source.references,
seededPullRequests: new Set(priorRecord.pullRequests.keys()),
nodes,
});
if (contamination.length > 0) {
fail(
`release section contains PRs outside ${options.base}..${options.target}: ${contamination
.map((number) => `#${number}`)
.join(", ")}; use --seed-ref only for an intentional historical backfill`,
);
}
const legacyIssuePullRequests = [...legacyIssuesByPullRequest(priorRecord, nodes).keys()].filter(
(number) => !shippedExclusions.pullRequests.has(number),
);
appendReferences(references, legacyIssuePullRequests);
nodes = resolveReferences(references);
const unresolvedSourceReferences = references.filter((number) => !nodes.has(number));
@@ -1551,7 +1775,7 @@ function main() {
);
const ledger = ledgerFor(
options.base,
options.target,
source.target,
references,
nodes,
source.coauthorsByReference,
@@ -1560,12 +1784,17 @@ function main() {
priorRecord,
source.pullRequests,
source.references,
noteReferences,
legacyIssuePullRequests,
source.revertedReferences,
source.shippedBaselines,
source.targetTimestamp,
);
const manifest = manifestFor(options, source, ledger, relationships.directCommits);
const manifest = manifestFor(
{ ...options, target: source.target },
source,
ledger,
relationships.directCommits,
);
if (options.manifestPath) {
writeFileSync(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -1583,8 +1812,16 @@ function main() {
section = sectionFor(changelog, options.version);
}
const errors = ledgerChecks(section, ledger.pullRequests, nodes, relationships.directCommits);
const github = options.checkGithub ? releaseChecks(section, options.releaseTags) : [];
const errors = ledgerChecks(
section,
ledger.pullRequests,
nodes,
relationships.directCommits,
source.shippedBaselines,
);
const github = options.checkGithub
? releaseChecks(changelog, options.version, options.releaseTags)
: [];
for (const check of github) {
if (!check.matches) {
errors.push(
@@ -1595,9 +1832,10 @@ function main() {
const result = {
base: options.base,
target: options.target,
target: source.target,
mergeBase: source.mergeBase,
version: options.version,
shippedBaselines: source.shippedBaselines,
source: {
references: references.length,
pullRequests: ledger.pullRequests.length,
@@ -1620,4 +1858,6 @@ function main() {
}
}
main();
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main();
}

View File

@@ -301,18 +301,38 @@ Stable publication is not complete until `main` carries the actual shipped relea
When grouped prose names a PR, keep every contributor and linked-reporter
credit from that PR's record on the same bullet.
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- GitHub limits release bodies to 125,000 characters. If a historical
`### Release verification` tail would exceed that cap, omit the tail and keep
the complete changelog section; do not truncate the contribution record.
- GitHub release and prerelease bodies use
`scripts/render-github-release-notes.mjs`. When the full matching
`CHANGELOG.md` version section fits GitHub's 125,000-character limit and
the renderer's matching 125,000-byte safety ceiling, publish the exact
`## YYYY.M.PATCH` block through the line before the next level-2 heading,
including the version heading.
- When that complete body exceeds either limit, keep the exact grouped
editorial notes through the line before `### Complete contribution record`,
then replace the oversized record with the canonical tag-pinned
`CHANGELOG.md` link emitted by the renderer. Never truncate bullets or emit a
partial contribution record. Candidate validation, publish, and
`verify-release-notes.mjs` must share this renderer so the compact form cannot
drift.
- Choose the full or compact changelog body before adding
`### Release verification`. Append that proof only when the final body still
fits; otherwise leave the immutable evidence assets attached and omit the
body tail. Do not discard a fitting full contribution record to make room
for proof.
- Before publishing or closing a release, run
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
and beta release tag in the train. Do not publish or leave a page live when
it is missing a source-history reference, eligible human credit, or the
complete matching changelog body.
- Treat the selected `--base` as a strict history boundary: it must be an
ancestor of the target, and existing changelog prose or contribution rows
cannot pull older PRs into the new release. Use `--seed-ref` only for an
intentional historical backfill. When a divergent prior release tag or later
forward-port re-associates already-shipped PRs, pass repeatable explicit
`--shipped-ref <tag>` values. They subtract only explicit PR rows in complete
contribution records from numbered sections of those tag snapshots, ignore
`Unreleased`, and retain the exact excluded PR inventory and count in
manifest/provenance for candidate checks.
- To update an existing GitHub Release body, resolve the numeric release id and
patch that resource with the notes file as the `body` field:
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then

View File

@@ -574,23 +574,46 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
local dispatch_body dispatch_response field key run_id run_url value
local inputs_json='{}'
while (( $# > 0 )); do
if [[ "$1" != "-f" && "$1" != "--raw-field" && "$1" != "-F" && "$1" != "--field" ]]; then
echo "Unsupported workflow dispatch argument for ${workflow}: $1" >&2
exit 1
fi
if (( $# < 2 )) || [[ "$2" != *=* ]]; then
echo "Workflow dispatch fields must use key=value syntax for ${workflow}." >&2
exit 1
fi
field="$2"
shift 2
key="${field%%=*}"
value="${field#*=}"
inputs_json="$(jq -cn \
--argjson inputs "$inputs_json" \
--arg key "$key" \
--arg value "$value" \
'$inputs + {($key): $value}')"
done
if [[ -z "$run_id" ]]; then
echo "gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
exit 1
fi
dispatch_body="$(jq -cn \
--arg ref "$workflow_ref" \
--argjson inputs "$inputs_json" \
'{ref: $ref, inputs: $inputs}')"
# API 2026-03-10 removed return_run_details and always returns the
# workflow_run_id, API run_url, and browser html_url in a 200 response.
dispatch_response="$(printf '%s' "$dispatch_body" | gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/dispatches" \
--input -)"
run_id="$(printf '%s' "$dispatch_response" | jq -er '.workflow_run_id')"
run_url="$(printf '%s' "$dispatch_response" | jq -er '.html_url')"
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
echo "Dispatched ${workflow}: ${run_url}" >&2
{
echo "- ${workflow}: dispatched (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id})"
echo "- ${workflow}: dispatched (${run_url})"
} >> "$GITHUB_STEP_SUMMARY"
printf '%s\n' "${run_id}"
}
@@ -821,7 +844,7 @@ jobs:
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
local release_version asset_name release_json is_draft has_sha has_proof has_asset has_canonical_body release_url release_body release_body_file
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
@@ -842,8 +865,18 @@ jobs:
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
release_body="$(printf '%s' "${release_json}" | jq -r '.body')"
release_body_file="${RUNNER_TEMP}/existing-public-release-body.md"
printf '%s' "${release_body}" > "${release_body_file}"
has_canonical_body="false"
if canonical_release_body_matches "${release_body_file}"; then
has_canonical_body="true"
fi
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
if [[ "${has_asset}" == "true" &&
"${has_sha}" == "true" &&
"${has_proof}" == "true" &&
"${has_canonical_body}" == "true" ]]; then
return 0
fi
@@ -942,39 +975,89 @@ jobs:
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
notes_version="${release_version}"
if [[ "${notes_version}" =~ ^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)-(alpha|beta)\.[1-9][0-9]*$ ]]; then
notes_version="${BASH_REMATCH[1]}"
fi
title="openclaw ${release_version}"
changelog_file="${RUNNER_TEMP}/CHANGELOG.md"
notes_file="${RUNNER_TEMP}/release-notes.md"
render_github_release_notes() {
local output_file="$1"
local verification_file="${2:-}"
local metadata_file="${3:-}"
local changelog_file="${RUNNER_TEMP}/CHANGELOG.md"
local -a render_args=(
node scripts/render-github-release-notes.mjs
--changelog "${changelog_file}"
--tag "${RELEASE_TAG}"
--repository "${GITHUB_REPOSITORY}"
--output "${output_file}"
)
git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"
awk -v version="${notes_version}" '
$0 == "## " version { in_section = 1; next }
/^## / && in_section { exit }
in_section { print }
' "${changelog_file}" > "${notes_file}"
if [[ ! -s "${notes_file}" ]] && [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
awk '
$0 == "## Unreleased" { in_section = 1; next }
/^## / && in_section { exit }
in_section { print }
' "${changelog_file}" > "${notes_file}"
if [[ -n "${verification_file}" ]]; then
render_args+=(--verification-file "${verification_file}")
fi
if [[ ! -s "${notes_file}" ]]; then
echo "CHANGELOG.md does not contain release notes for ${notes_version} or an Unreleased prerelease fallback." >&2
if [[ -n "${metadata_file}" ]]; then
render_args+=(--metadata-output "${metadata_file}")
fi
"${render_args[@]}"
}
verify_release_tag_target() {
local direct_sha peeled_sha remote_refs remote_sha
remote_refs="$(git ls-remote --tags origin \
"refs/tags/${RELEASE_TAG}" \
"refs/tags/${RELEASE_TAG}^{}")"
direct_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')"
peeled_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')"
remote_sha="${peeled_sha:-${direct_sha}}"
if [[ -z "${remote_sha}" ]]; then
echo "Release tag ${RELEASE_TAG} no longer exists on origin." >&2
exit 1
fi
if [[ "${remote_sha}" != "${TARGET_SHA}" ]]; then
echo "Release tag ${RELEASE_TAG} moved: expected ${TARGET_SHA}, found ${remote_sha}." >&2
exit 1
fi
}
prerelease_args=()
canonical_release_body_matches() {
local body_file="$1"
local changelog_file="${RUNNER_TEMP}/release-body-changelog.md"
git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"
RELEASE_BODY_FILE="${body_file}" \
RELEASE_CHANGELOG_FILE="${changelog_file}" \
RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \
RELEASE_TAG="${RELEASE_TAG}" \
node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import {
releaseNotesVersionForTag,
verifyGithubReleaseNotes,
} from "./scripts/render-github-release-notes.mjs";
const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8");
const result = verifyGithubReleaseNotes({
body,
changelog,
version: releaseNotesVersionForTag(process.env.RELEASE_TAG),
tag: process.env.RELEASE_TAG,
repository: process.env.RELEASE_REPOSITORY,
});
if (!result.matches) {
process.exitCode = 1;
}
NODE
}
create_or_update_github_release() {
local release_version title latest_arg prerelease_arg
verify_release_tag_target
release_version="${RELEASE_TAG#v}"
title="openclaw ${release_version}"
prerelease_arg="--prerelease=false"
latest_arg="--latest=false"
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
prerelease_args=(--prerelease)
prerelease_arg="--prerelease"
elif [[ "${RELEASE_NPM_DIST_TAG}" == "latest" ]]; then
latest_arg="--latest"
fi
@@ -982,21 +1065,24 @@ jobs:
if gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}"
--notes-file "${prepared_release_notes_file}" \
"${prerelease_arg}" \
"${latest_arg}"
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
--notes-file "${prepared_release_notes_file}" \
"${prerelease_arg}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
publish_github_release() {
local expected_prerelease release_json
verify_release_tag_target
if is_android_release; then
verify_android_release_asset_contract
fi
@@ -1004,6 +1090,16 @@ jobs:
verify_windows_release_asset_contract
fi
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease)"
expected_prerelease="false"
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
expected_prerelease="true"
fi
if [[ "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "false" ]] ||
[[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" != "${expected_prerelease}" ]]; then
echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2
exit 1
fi
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
@@ -1301,15 +1397,15 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path android_line windows_line
local release_version proof_file notes_file metadata_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path android_line windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
proof_file="${RUNNER_TEMP}/release-verification.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
metadata_file="${RUNNER_TEMP}/release-notes-with-proof.json"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
@@ -1329,8 +1425,7 @@ jobs:
android_line="- Android APK: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android.apk (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${android_release_run_id})"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_PROOF_FILE="${proof_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
@@ -1348,15 +1443,13 @@ jobs:
ANDROID_LINE="${android_line}" \
WINDOWS_LINE="${windows_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
import { writeFileSync } from "node:fs";
const bodyFile = process.env.RELEASE_BODY_FILE;
const notesFile = process.env.RELEASE_NOTES_FILE;
if (!bodyFile || !notesFile) {
throw new Error("Missing release notes file paths.");
const proofFile = process.env.RELEASE_PROOF_FILE;
if (!proofFile) {
throw new Error("Missing release proof file path.");
}
const body = readFileSync(bodyFile, "utf8").trimEnd();
const section = [
"### Release verification",
"",
@@ -1377,12 +1470,17 @@ jobs:
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
writeFileSync(proofFile, section);
NODE
render_github_release_notes "${notes_file}" "${proof_file}" "${metadata_file}"
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
if jq -e '.verificationIncluded == true' "${metadata_file}" >/dev/null; then
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Release verification proof omitted because the canonical release notes already reach GitHub's body limit."
echo "- Release proof: omitted from body at GitHub limit; immutable evidence remains attached" >> "$GITHUB_STEP_SUMMARY"
fi
}
{
@@ -1412,8 +1510,17 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md"
prepared_release_notes_metadata_file="${RUNNER_TEMP}/release-notes-prepublish.json"
verify_release_tag_target
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
render_github_release_notes \
"${prepared_release_notes_file}" \
"" \
"${prepared_release_notes_metadata_file}"
guard_existing_public_release
guard_openclaw_npm_not_already_published
fi
resolve_clawhub_release_plan
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")

76
.gitignore vendored
View File

@@ -125,31 +125,47 @@ USER.md
.crabbox/
# local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts
mantis/
/mantis/
# Local project-agent skill installs. Only repo-owned skills are visible by
# default; promoting a new repo skill should require an intentional `git add -f`.
# default; keep every tracked repo skill allowlisted for Git-aware sync tools.
.agents/skills/*
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/crabbox/
!.agents/skills/crabbox/**
!.agents/skills/clawdtributor/
!.agents/skills/clawdtributor/**
!.agents/skills/agent-transcript/
!.agents/skills/agent-transcript/**
!.agents/skills/autoreview/
!.agents/skills/autoreview/**
!.agents/skills/channel-message-flows/
!.agents/skills/channel-message-flows/**
!.agents/skills/claw-score/
!.agents/skills/claw-score/**
!.agents/skills/clawdtributor/
!.agents/skills/clawdtributor/**
!.agents/skills/clawsweeper/
!.agents/skills/clawsweeper/**
!.agents/skills/control-ui-e2e/
!.agents/skills/control-ui-e2e/**
!.agents/skills/crabbox/
!.agents/skills/crabbox/**
!.agents/skills/discord-clawd/
!.agents/skills/discord-clawd/**
!.agents/skills/discord-user-post/
!.agents/skills/discord-user-post/**
!.agents/skills/discrawl/
!.agents/skills/discrawl/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/technical-documentation/
!.agents/skills/technical-documentation/**
!.agents/skills/openclaw-refactor-docs/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/graincrawl/
!.agents/skills/graincrawl/**
!.agents/skills/notcrawl/
!.agents/skills/notcrawl/**
!.agents/skills/openclaw-changelog-update/
!.agents/skills/openclaw-changelog-update/**
!.agents/skills/openclaw-ci-limits/
!.agents/skills/openclaw-ci-limits/**
!.agents/skills/openclaw-debugging/
!.agents/skills/openclaw-debugging/**
!.agents/skills/openclaw-docker-e2e-authoring/
!.agents/skills/openclaw-docker-e2e-authoring/**
!.agents/skills/openclaw-ghsa-maintainer/
!.agents/skills/openclaw-ghsa-maintainer/**
!.agents/skills/openclaw-landable-bug-sweep/
@@ -158,32 +174,47 @@ mantis/
!.agents/skills/openclaw-parallels-smoke/**
!.agents/skills/openclaw-pr-maintainer/
!.agents/skills/openclaw-pr-maintainer/**
!.agents/skills/openclaw-refactor-docs/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/openclaw-qa-testing/
!.agents/skills/openclaw-qa-testing/**
!.agents/skills/openclaw-release-ci/
!.agents/skills/openclaw-release-ci/**
!.agents/skills/openclaw-release-maintainer/
!.agents/skills/openclaw-release-maintainer/**
!.agents/skills/openclaw-refactor-docs/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/openclaw-secret-scanning-maintainer/
!.agents/skills/openclaw-secret-scanning-maintainer/**
!.agents/skills/openclaw-small-bugfix-sweep/
!.agents/skills/openclaw-small-bugfix-sweep/**
!.agents/skills/openclaw-test-heap-leaks/
!.agents/skills/openclaw-test-heap-leaks/**
!.agents/skills/openclaw-test-performance/
!.agents/skills/openclaw-test-performance/**
!.agents/skills/openclaw-testing/
!.agents/skills/openclaw-testing/**
!.agents/skills/optimizetests/
!.agents/skills/optimizetests/**
!.agents/skills/parallels-discord-roundtrip/
!.agents/skills/parallels-discord-roundtrip/**
!.agents/skills/release-openclaw-announcement/
!.agents/skills/release-openclaw-announcement/**
!.agents/skills/release-openclaw-ci/
!.agents/skills/release-openclaw-ci/**
!.agents/skills/release-openclaw-mac/
!.agents/skills/release-openclaw-mac/**
!.agents/skills/release-openclaw-maintainer/
!.agents/skills/release-openclaw-maintainer/**
!.agents/skills/release-openclaw-nightly/
!.agents/skills/release-openclaw-nightly/**
!.agents/skills/release-openclaw-plugin-testing/
!.agents/skills/release-openclaw-plugin-testing/**
!.agents/skills/security-triage/
!.agents/skills/security-triage/**
!.agents/skills/slacrawl/
!.agents/skills/slacrawl/**
!.agents/skills/tag-duplicate-prs-issues/
!.agents/skills/tag-duplicate-prs-issues/**
!.agents/skills/autoreview/
!.agents/skills/autoreview/**
!.agents/skills/technical-documentation/
!.agents/skills/technical-documentation/**
!.agents/skills/telegram-crabbox-e2e-proof/
!.agents/skills/telegram-crabbox-e2e-proof/**
!.agents/skills/verify-release/
!.agents/skills/verify-release/**
.agents/skills/**/*.orig
.agents/skills/**/__pycache__/
.agents/skills/**/*.py[cod]
@@ -225,7 +256,6 @@ dist/protocol.schema.json
.dev-state
docs/superpowers
.superpowers/
.gitignore
test/config-form.analyze.telegram.test.ts
ui/src/ui/theme-variants.browser.test.ts
ui/src/ui/__screenshots__

View File

@@ -159,16 +159,16 @@ steps for this npm-only extended-stable path.
This checklist is the public shape of the release flow. Private credentials, signing, notarization, dist-tag recovery, and emergency rollback details stay in the maintainer-only release runbook.
1. Start from current `main`: pull latest, confirm the target commit is pushed, and confirm `main` CI is green enough to branch from.
2. Generate the top `CHANGELOG.md` section from merged PRs and all direct commits since the last reachable release tag. Keep entries user-facing, dedupe overlapping PR/direct-commit entries, commit, push, and rebase/pull once more before branching.
2. Generate the top `CHANGELOG.md` section from merged PRs and all direct commits since the last reachable release tag. Keep entries user-facing, dedupe overlapping PR/direct-commit entries, commit, push, and rebase/pull once more before branching. When a divergent shipped tag or later forward-port re-associates already-released PRs, pass that tag explicitly as `--shipped-ref`; the verifier uses explicit PR rows from complete contribution records in numbered sections of the tag snapshot, ignores `Unreleased`, and records the exact excluded PR inventory and count.
3. Review release compatibility records in `src/plugins/compat/registry.ts` and `src/commands/doctor/shared/deprecation-compat.ts`. Remove expired compatibility only when the upgrade path stays covered, or record why it is intentionally carried.
4. Create `release/YYYY.M.PATCH` from current `main`. Do not do normal release work directly on `main`.
5. Bump every required version location for the tag, then run `pnpm release:prep`. It refreshes plugin versions, npm shrinkwraps, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, and plugin SDK API baseline in order. Commit any generated drift before tagging, then run the local deterministic preflight: `pnpm check:test-types`, `pnpm check:architecture`, `pnpm build && pnpm ui:build`, and `pnpm release:check`.
6. Run `OpenClaw NPM Release` with `preflight_only=true`. Before a tag exists, a full 40-character release-branch SHA is allowed for validation-only preflight. The preflight generates dependency release evidence for the exact checked-out dependency graph and stores it in the npm preflight artifact. Save the successful `preflight_run_id`.
7. Kick off all pre-release tests with `Full Release Validation` for the release branch, tag, or full commit SHA. This is the one manual entrypoint for the four big release test boxes: Vitest, Docker, QA Lab, and Package. Save the `full_release_validation_run_id`; it is required input for both `OpenClaw NPM Release` and `OpenClaw Release Publish`.
8. If validation fails, fix on the release branch and rerun the smallest failed file, lane, workflow job, package profile, provider, or model allowlist that proves the fix. Rerun the full umbrella only when the changed surface makes prior evidence stale.
9. For a tagged beta candidate, run `pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. For stable, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. The helper runs the local generated-release checks, dispatches or verifies full release validation and npm preflight evidence, runs Parallels fresh/update proof against the exact prepared tarball plus Telegram package proof, records plugin npm and ClawHub plans, and prints the exact `OpenClaw Release Publish` command only after the evidence bundle is green.
9. For a tagged beta candidate, run `pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. For stable, also pass the required Windows source release: `pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`. Before it dispatches the full validation matrix, the helper deterministically renders the exact tag's GitHub release body and rejects a missing version heading, an over-limit body that cannot use the canonical compact form, or contribution-record base/target provenance that is not reachable from the tag. It also validates any explicit shipped-baseline exclusion metadata against the referenced cumulative tag records. It then runs the local generated-release checks, dispatches or verifies full release validation and npm preflight evidence, runs Parallels fresh/update proof against the exact prepared tarball plus Telegram package proof, records plugin npm and ClawHub plans, and prints the exact `OpenClaw Release Publish` command only after the evidence bundle is green.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. After the OpenClaw npm publish child succeeds, it creates or updates the matching GitHub release/prerelease page from the complete matching `CHANGELOG.md` section: stable releases published to npm `latest` become the GitHub latest release, stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, closes out the GitHub release and dependency evidence as soon as OpenClaw npm publish succeeds, waits for ClawHub whenever OpenClaw npm is being published, then runs `pnpm release:verify-beta` and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub path retries transient CLI dependency install failures, publishes preview-passing plugins even when one preview cell flakes, and ends with registry verification for every expected plugin version so partial publishes stay visible and retryable.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, closes out the GitHub release and dependency evidence as soon as OpenClaw npm publish succeeds, waits for ClawHub whenever OpenClaw npm is being published, then runs `pnpm release:verify-beta` and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub path retries transient CLI dependency install failures, publishes preview-passing plugins even when one preview cell flakes, and ends with registry verification for every expected plugin version so partial publishes stay visible and retryable.
Then run the post-publish package acceptance against the published `openclaw@YYYY.M.PATCH-beta.N` or `openclaw@beta` package. If a pushed or published prerelease needs a fix, cut the next matching prerelease number; never delete or rewrite the old one.

View File

@@ -7,6 +7,15 @@ import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs";
import { readBoundedResponseText } from "./lib/bounded-response.mjs";
import {
extractChangelogReleaseSections,
extractChangelogSection,
formatShippedBaselineExclusions,
parseShippedBaselineExclusions,
releaseNotesSectionForTag,
releaseNotesVersionForTag,
renderGithubReleaseNotes,
} from "./render-github-release-notes.mjs";
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_PROVIDER = "openai";
@@ -16,6 +25,7 @@ const DEFAULT_PLUGIN_SCOPE = "all-publishable";
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
const DEFAULT_GITHUB_API_RESPONSE_BODY_MAX_BYTES = 16 * 1024 * 1024;
const COMMAND_CAPTURE_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
const WINDOWS_NODE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u;
const WINDOWS_NODE_REPO = "openclaw/openclaw-windows-node";
const WINDOWS_NODE_REQUIRED_ASSETS = [
@@ -200,10 +210,11 @@ export function parseArgs(argv) {
return options;
}
function run(command, args, options = {}) {
export function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: "utf8",
maxBuffer: COMMAND_CAPTURE_MAX_BUFFER_BYTES,
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
@@ -340,6 +351,237 @@ function gitRevParse(ref) {
return run("git", ["rev-parse", ref], { capture: true }).trim();
}
export function validateCandidateCheckout({ targetSha, headSha, trackedStatus }) {
if (headSha !== targetSha) {
throw new Error(`release candidate tag resolves to ${targetSha}, but HEAD is ${headSha}`);
}
if (trackedStatus.trim()) {
throw new Error(
"release candidate validation requires a clean tracked worktree so the checked tooling matches the tag",
);
}
return { status: "passed", targetSha };
}
function gitIsAncestor(ancestor, target) {
const result = spawnSync(
"git",
["merge-base", "--is-ancestor", `${ancestor}^{commit}`, `${target}^{commit}`],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
if (result.status === 0) {
return true;
}
if (result.status === 1) {
return false;
}
throw new Error(
`could not validate changelog provenance ${ancestor}..${target}: ${
result.stderr?.trim() || result.signal || result.status
}`,
);
}
function candidateContributionRecordPullRequests(
section,
label,
{ requireExactProvenance = true } = {},
) {
const recordStart = section.search(/\n### Complete contribution record\r?$/m);
if (recordStart < 0) {
throw new Error(`${label} is missing ### Complete contribution record`);
}
const record = section.slice(recordStart);
const rowNumbers = [...record.matchAll(/^- \*\*PR #(?<number>[0-9]+)\*\*/gmu)].map((match) =>
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(", ")}`,
);
}
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) {
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;
}
export function candidateCumulativeShippedPullRequests(changelog, label) {
const pullRequests = new Set();
for (const section of extractChangelogReleaseSections(changelog)) {
if (
section.version === "Unreleased" ||
!section.source.includes("\n### Complete contribution record")
) {
continue;
}
for (const number of candidateContributionRecordPullRequests(
section.source,
`${label} section ${section.version}`,
{ requireExactProvenance: false },
)) {
pullRequests.add(number);
}
}
return pullRequests;
}
function loadCandidateShippedBaseline(ref) {
const tagRef = `refs/tags/${ref}`;
gitRevParse(`${tagRef}^{commit}`);
const changelog = run("git", ["show", `${tagRef}:CHANGELOG.md`], { capture: true });
const version = releaseNotesVersionForTag(ref);
candidateContributionRecordPullRequests(
extractChangelogSection(changelog, version),
`shipped baseline ${ref}`,
);
const pullRequests = candidateCumulativeShippedPullRequests(changelog, `shipped baseline ${ref}`);
return { ref, pullRequests };
}
export function validateCandidateReleaseNotes({ changelog, repository, tag }) {
const rendered = renderGithubReleaseNotes({
changelog,
version: releaseNotesVersionForTag(tag),
tag,
repository,
});
return {
status: "passed",
mode: rendered.mode,
characters: rendered.size.characters,
bytes: rendered.size.bytes,
};
}
export function validateCandidateChangelogProvenance({
changelog,
version,
tag,
targetSha,
isAncestor = gitIsAncestor,
loadShippedBaseline = loadCandidateShippedBaseline,
}) {
let section;
let usesAlphaUnreleasedFallback = false;
try {
section = extractChangelogSection(changelog, version);
} catch (error) {
if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) {
throw error;
}
section = releaseNotesSectionForTag(changelog, version, tag);
usesAlphaUnreleasedFallback = true;
}
const recordStart = section.search(/\n### Complete contribution record\r?$/m);
if (recordStart < 0) {
if (usesAlphaUnreleasedFallback) {
return {
status: "skipped",
reason: "alpha release uses the explicit Unreleased fallback",
shippedBaselines: [],
};
}
throw new Error(`CHANGELOG.md ## ${version} is missing ### Complete contribution record`);
}
const record = section.slice(recordStart);
const recordedPullRequests = candidateContributionRecordPullRequests(
section,
`CHANGELOG.md ## ${version}`,
);
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) {
throw new Error(
`CHANGELOG.md ## ${version} is missing exact complete contribution record provenance`,
);
}
const shippedBaselines = parseShippedBaselineExclusions(record);
const sectionShippedBaselines = parseShippedBaselineExclusions(section);
if (
formatShippedBaselineExclusions(sectionShippedBaselines) !==
formatShippedBaselineExclusions(shippedBaselines)
) {
throw new Error(
"shipped baseline exclusions must appear inside the complete contribution record",
);
}
if (!isAncestor(base, recordedTarget)) {
throw new Error(
`CHANGELOG.md contribution record base ${base} is not an ancestor of recorded target ${recordedTarget}`,
);
}
// The record is generated before its own changelog/finalization commit. Require
// reachability so the tag can contain that bounded release-only follow-up.
if (!isAncestor(recordedTarget, targetSha)) {
throw new Error(
`CHANGELOG.md contribution record target ${recordedTarget} is not reachable from release tag ${targetSha}`,
);
}
// The verifier persists associated and text-linked PR exclusions together.
// Revalidate that exact inventory here instead of rediscovering a narrower set from git text.
const excludedPullRequests = new Set();
for (const baseline of shippedBaselines) {
const loaded = loadShippedBaseline(baseline.ref);
if (!(loaded.pullRequests instanceof Set)) {
throw new Error(`shipped baseline ${baseline.ref} did not provide a PR inventory`);
}
const duplicateExclusions = baseline.pullRequests.filter((number) =>
excludedPullRequests.has(number),
);
if (duplicateExclusions.length > 0) {
throw new Error(
`release contribution record repeats shipped PR exclusions across baselines: ${duplicateExclusions.map((number) => `#${number}`).join(", ")}`,
);
}
const absent = baseline.pullRequests.filter((number) => !loaded.pullRequests.has(number));
if (absent.length > 0) {
throw new Error(
`release contribution record lists PRs absent from shipped baseline ${baseline.ref}: ${absent.map((number) => `#${number}`).join(", ")}`,
);
}
const retained = [...recordedPullRequests].filter((number) => loaded.pullRequests.has(number));
if (retained.length > 0) {
throw new Error(
`release contribution record still contains shipped PRs from ${baseline.ref}: ${retained.map((number) => `#${number}`).join(", ")}`,
);
}
for (const number of baseline.pullRequests) {
excludedPullRequests.add(number);
}
}
return { status: "passed", base, target: recordedTarget, shippedBaselines };
}
async function runArtifacts(repo, runId) {
const data = await githubApi(`repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`);
return (data.artifacts ?? []).map((artifact) => ({
@@ -763,6 +1005,26 @@ async function main() {
options.workflowRef ||= currentBranch();
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`);
validateCandidateCheckout({
targetSha,
headSha: gitRevParse("HEAD"),
trackedStatus: run("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
capture: true,
}),
});
const releaseChangelog = run("git", ["show", `${targetSha}:CHANGELOG.md`], { capture: true });
const releaseNotesVersion = releaseNotesVersionForTag(options.tag);
const releaseNotesCheck = validateCandidateReleaseNotes({
changelog: releaseChangelog,
repository: options.repo,
tag: options.tag,
});
const releaseNotesProvenance = validateCandidateChangelogProvenance({
changelog: releaseChangelog,
version: releaseNotesVersion,
tag: options.tag,
targetSha,
});
const windowsNodeSourceRelease = options.windowsNodeTag
? await validateWindowsSourceRelease(options.windowsNodeTag)
: undefined;
@@ -895,6 +1157,8 @@ async function main() {
npmPreflight: npmArtifactName,
fullReleaseValidation: fullArtifactName,
},
releaseNotesCheck,
releaseNotesProvenance,
localGeneratedCheck,
tarball: {
name: basename(tarballPath),
@@ -929,6 +1193,14 @@ async function main() {
: []),
`- npm preflight artifact: ${npmArtifactName}`,
`- full release artifact: ${fullArtifactName}`,
`- GitHub release notes: ${releaseNotesCheck.status} (${releaseNotesCheck.mode}, ${releaseNotesCheck.characters} characters, ${releaseNotesCheck.bytes} bytes)`,
releaseNotesProvenance.status === "passed"
? `- changelog provenance: passed (${releaseNotesProvenance.base}..${releaseNotesProvenance.target})`
: `- changelog provenance: skipped (${releaseNotesProvenance.reason})`,
`- ${
formatShippedBaselineExclusions(releaseNotesProvenance.shippedBaselines) ||
"Shipped baseline exclusions: none"
}`,
`- local generated release checks: ${localGeneratedCheck.status}${
localGeneratedCheck.reason ? ` (${localGeneratedCheck.reason})` : ""
}`,

View File

@@ -0,0 +1,411 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
export const GITHUB_RELEASE_BODY_MAX_CHARACTERS = 125_000;
export const GITHUB_RELEASE_BODY_MAX_BYTES = 125_000;
const CONTRIBUTION_RECORD_HEADING = "### Complete contribution record";
const RELEASE_VERIFICATION_HEADING = "### Release verification";
const SHIPPED_BASELINE_EXCLUSIONS_PREFIX = "Shipped baseline exclusions:";
const OPENCLAW_RELEASE_TAG_PATTERN =
/^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?$/u;
const RELEASE_HEADING_PATTERN =
/^## (?<version>Unreleased|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?)\r?$/u;
function fail(message) {
throw new Error(message);
}
function normalizeTail(value) {
return value?.trim() ?? "";
}
function joinBody(notes, tail) {
const normalizedNotes = notes.trimEnd();
const normalizedTail = normalizeTail(tail);
return normalizedTail ? `${normalizedNotes}\n\n${normalizedTail}` : normalizedNotes;
}
function validateRepository(repository) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
fail(`invalid GitHub repository: ${repository}`);
}
}
function validateTag(tag) {
if (!OPENCLAW_RELEASE_TAG_PATTERN.test(tag)) {
fail(`invalid release tag: ${tag}`);
}
}
export function githubReleaseBodySize(body) {
return {
characters: [...body].length,
bytes: Buffer.byteLength(body, "utf8"),
};
}
export function fitsGithubReleaseBody(body) {
const size = githubReleaseBodySize(body);
return (
size.characters <= GITHUB_RELEASE_BODY_MAX_CHARACTERS &&
size.bytes <= GITHUB_RELEASE_BODY_MAX_BYTES
);
}
function releaseSections(changelog) {
const headings = [];
let offset = 0;
let fence;
for (const segment of changelog.split(/(?<=\n)/u)) {
const line = segment.replace(/\n$/u, "");
const fenceMatch = line.match(/^\s*(?<marker>`{3,}|~{3,})/u);
if (fenceMatch?.groups?.marker) {
const marker = fenceMatch.groups.marker;
if (!fence) {
fence = marker;
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
fence = undefined;
}
offset += segment.length;
continue;
}
if (!fence) {
if (line.startsWith("## ")) {
const releaseHeading = line.match(RELEASE_HEADING_PATTERN);
headings.push({ version: releaseHeading?.groups?.version, start: offset });
}
}
offset += segment.length;
}
return headings
.map((heading, index) => ({
version: heading.version,
start: heading.start,
end: headings[index + 1]?.start ?? changelog.length,
}))
.filter((heading) => heading.version);
}
export function extractChangelogReleaseSections(changelog) {
return releaseSections(changelog).map(({ version, start, end }) => ({
version,
source: changelog.slice(start, end).trimEnd(),
}));
}
export function extractChangelogSection(changelog, version) {
const section = releaseSections(changelog).find((candidate) => candidate.version === version);
if (!section) {
fail(`CHANGELOG.md does not contain ## ${version}`);
}
return changelog.slice(section.start, section.end).trimEnd();
}
export function releaseNotesVersionForTag(tag) {
validateTag(tag);
return tag.replace(/^v/u, "").replace(/-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*)$/u, "");
}
function validateShippedBaselineRef(ref) {
if (!OPENCLAW_RELEASE_TAG_PATTERN.test(ref)) {
fail(`invalid shipped release tag: ${ref}`);
}
}
export function formatShippedBaselineExclusions(baselines) {
if (baselines.length === 0) {
return "";
}
const normalized = baselines
.map(({ ref, count, pullRequests }) => {
validateShippedBaselineRef(ref);
if (!Array.isArray(pullRequests)) {
fail(`missing shipped baseline PR inventory for ${ref}`);
}
const normalizedPullRequests = pullRequests.toSorted((a, b) => a - b);
if (
normalizedPullRequests.some((number) => !Number.isSafeInteger(number) || number < 1) ||
new Set(normalizedPullRequests).size !== normalizedPullRequests.length
) {
fail(`invalid shipped baseline PR inventory for ${ref}`);
}
if (!Number.isSafeInteger(count) || count < 0 || count !== normalizedPullRequests.length) {
fail(`invalid shipped baseline exclusion count for ${ref}: ${count}`);
}
return { ref, count, pullRequests: normalizedPullRequests };
})
.toSorted((a, b) => (a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1));
const seen = new Set();
for (const baseline of normalized) {
if (seen.has(baseline.ref)) {
fail(`duplicate shipped baseline exclusion: ${baseline.ref}`);
}
seen.add(baseline.ref);
}
return `${SHIPPED_BASELINE_EXCLUSIONS_PREFIX} ${normalized
.map(({ ref, count, pullRequests }) =>
count === 0
? `${ref} (0 PRs)`
: `${ref} (${count} PRs: ${pullRequests.map((number) => `#${number}`).join(", ")})`,
)
.join("; ")}.`;
}
export function parseShippedBaselineExclusions(section) {
const lines = section.split(/\r?\n/u).filter((line) => line.startsWith("Shipped baseline"));
if (lines.length === 0) {
return [];
}
if (lines.length > 1) {
fail("release contribution record contains multiple shipped baseline exclusion lines");
}
const match = lines[0].match(/^Shipped baseline exclusions: (?<entries>.+)\.$/u);
if (!match?.groups?.entries) {
fail("release contribution record contains malformed shipped baseline exclusions");
}
const baselines = match.groups.entries.split("; ").map((entry) => {
const item = entry.match(
/^(?<ref>\S+) \((?<count>0|[1-9][0-9]*) PRs(?:: (?<pullRequests>#[1-9][0-9]*(?:, #[1-9][0-9]*)*))?\)$/u,
);
if (!item?.groups?.ref || item.groups.count === undefined) {
fail(`release contribution record contains malformed shipped baseline exclusion: ${entry}`);
}
const count = Number(item.groups.count);
const pullRequests = item.groups.pullRequests
? item.groups.pullRequests.split(", ").map((number) => Number(number.slice(1)))
: [];
return { ref: item.groups.ref, count, pullRequests };
});
if (formatShippedBaselineExclusions(baselines) !== lines[0]) {
fail("release contribution record shipped baseline exclusions are not canonical");
}
return baselines;
}
export function tagPinnedContributionRecordUrl(repository, tag) {
validateRepository(repository);
validateTag(tag);
return `https://github.com/${repository}/blob/${tag}/CHANGELOG.md#complete-contribution-record`;
}
function headingIndexOutsideFences(markdown, heading) {
let offset = 0;
let fence;
for (const segment of markdown.split(/(?<=\n)/u)) {
const line = segment.replace(/\n$/u, "");
const fenceMatch = line.match(/^\s*(?<marker>`{3,}|~{3,})/u);
if (fenceMatch?.groups?.marker) {
const marker = fenceMatch.groups.marker;
if (!fence) {
fence = marker;
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
fence = undefined;
}
} else if (!fence && line === heading) {
return offset;
}
offset += segment.length;
}
return -1;
}
function compactReleaseNotes(section, repository, tag) {
const recordIndex = headingIndexOutsideFences(section, CONTRIBUTION_RECORD_HEADING);
if (recordIndex < 0) {
fail(
"release notes exceed GitHub's body limit and cannot be compacted without a complete contribution record",
);
}
const editorialNotes = section.slice(0, recordIndex).trimEnd();
const contributionRecordUrl = tagPinnedContributionRecordUrl(repository, tag);
return [
editorialNotes,
"",
CONTRIBUTION_RECORD_HEADING,
"",
`The full contribution record is available in the tag-pinned [CHANGELOG.md](${contributionRecordUrl}).`,
].join("\n");
}
export function releaseNotesSectionForTag(changelog, version, tag) {
try {
return extractChangelogSection(changelog, version);
} catch (error) {
if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) {
throw error;
}
const unreleased = extractChangelogSection(changelog, "Unreleased");
return unreleased.replace(/^## Unreleased\r?$/mu, `## ${version}`);
}
}
export function renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
verification = "",
}) {
validateRepository(repository);
validateTag(tag);
const tagVersion = releaseNotesVersionForTag(tag);
if (tagVersion !== version) {
fail(`release tag ${tag} requires CHANGELOG.md version ${tagVersion}, got ${version}`);
}
const section = releaseNotesSectionForTag(changelog, version, tag);
const mode = fitsGithubReleaseBody(section) ? "full" : "compact";
const baseBody = mode === "full" ? section : compactReleaseNotes(section, repository, tag);
if (!fitsGithubReleaseBody(baseBody)) {
const size = githubReleaseBodySize(baseBody);
fail(
`compacted release notes are still too large for GitHub: ${size.characters} characters, ${size.bytes} bytes`,
);
}
const normalizedVerification = normalizeTail(verification);
const bodyWithVerification = joinBody(baseBody, normalizedVerification);
const verificationIncluded =
normalizedVerification !== "" && fitsGithubReleaseBody(bodyWithVerification);
const body = verificationIncluded ? bodyWithVerification : baseBody;
return {
body,
mode,
size: githubReleaseBodySize(body),
verificationIncluded,
verificationOmitted: normalizedVerification !== "" && !verificationIncluded,
};
}
export function verifyGithubReleaseNotes({ body, changelog, version, tag, repository }) {
const normalizedBody = body.trimEnd();
const base = renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
});
if (normalizedBody === base.body) {
return {
...base,
matches: true,
actualSize: githubReleaseBodySize(normalizedBody),
};
}
const verificationPrefix = `${base.body}\n\n${RELEASE_VERIFICATION_HEADING}`;
const verification = normalizedBody.startsWith(verificationPrefix)
? normalizedBody.slice(base.body.length + 2)
: "";
const expected = verification
? renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
verification,
})
: base;
return {
...expected,
matches: normalizedBody === expected.body,
actualSize: githubReleaseBodySize(normalizedBody),
};
}
function usage() {
return `Usage:
node scripts/render-github-release-notes.mjs \\
--changelog <path> --tag <tag> --repository <owner/repo> \\
[--version <version>] [--verification-file <path>] [--output <path>] \\
[--metadata-output <path>]
`;
}
function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (
arg === "--changelog" ||
arg === "--version" ||
arg === "--tag" ||
arg === "--repository" ||
arg === "--verification-file" ||
arg === "--output" ||
arg === "--metadata-output"
) {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
fail(`${arg} requires a value`);
}
const key = arg.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase());
options[key] = value;
index += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
if (!options.help) {
for (const name of ["changelog", "tag", "repository"]) {
if (!options[name]) {
fail(`--${name} is required`);
}
}
if (options.metadataOutput && !options.output) {
fail("--metadata-output requires --output");
}
}
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(usage());
return;
}
const changelog = readFileSync(options.changelog, "utf8");
const verification = options.verificationFile
? readFileSync(options.verificationFile, "utf8")
: "";
const rendered = renderGithubReleaseNotes({
changelog,
version: options.version ?? releaseNotesVersionForTag(options.tag),
tag: options.tag,
repository: options.repository,
verification,
});
if (options.output) {
writeFileSync(options.output, rendered.body);
if (options.metadataOutput) {
const metadata = {
mode: rendered.mode,
size: rendered.size,
verificationIncluded: rendered.verificationIncluded,
verificationOmitted: rendered.verificationOmitted,
};
writeFileSync(options.metadataOutput, `${JSON.stringify(metadata, null, 2)}\n`);
}
process.stderr.write(
`release-notes: ${rendered.mode} body, ${rendered.size.characters} characters, ${rendered.size.bytes} bytes${
rendered.verificationOmitted ? ", verification omitted at GitHub limit" : ""
}\n`,
);
return;
}
process.stdout.write(rendered.body);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

View File

@@ -1,4 +1,5 @@
// Package Acceptance Workflow tests cover package acceptance workflow script behavior.
import { execFileSync, spawnSync } from "node:child_process";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
@@ -601,6 +602,44 @@ describe("package acceptance workflow", () => {
});
describe("package artifact reuse", () => {
it("keeps every tracked repository skill visible to Git-aware syncs", () => {
const gitignore = readFileSync(".gitignore", "utf8");
const skillFiles = execFileSync("git", ["ls-files", ".agents/skills/*/SKILL.md"], {
encoding: "utf8",
})
.trim()
.split("\n");
const skillDirs = skillFiles.map((path) => path.split("/").slice(0, 3).join("/"));
for (const skillDir of skillDirs) {
expect(gitignore).toContain(`!${skillDir}/`);
expect(gitignore).toContain(`!${skillDir}/**`);
}
const ignored = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], {
encoding: "utf8",
input: `${skillFiles.join("\n")}\n`,
});
expect(ignored.status).toBe(1);
expect(ignored.stdout).toBe("");
expect(ignored.stderr).toBe("");
});
it("keeps tracked sync metadata and QA Mantis sources visible to remote full syncs", () => {
for (const path of [
".gitignore",
"apps/android/.gitignore",
"extensions/qa-lab/src/mantis/cli.ts",
]) {
const result = spawnSync("git", ["check-ignore", "--no-index", path], {
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
}
});
it("lets reusable Docker E2E consume an already resolved package artifact", () => {
const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8");
const packageJson = readFileSync(PACKAGE_JSON, "utf8");
@@ -1980,8 +2019,56 @@ describe("package artifact reuse", () => {
expect(npmTelegramWorkflow).toContain("package artifact digest mismatch");
expect(workflow).toContain("Checkout release SHA");
expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"');
expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }');
expect(workflow).toContain("Unreleased prerelease fallback");
expect(workflow).toContain("render_github_release_notes()");
expect(workflow).toContain("node scripts/render-github-release-notes.mjs");
expect(workflow).toContain('--tag "${RELEASE_TAG}"');
expect(workflow).toContain('--repository "${GITHUB_REPOSITORY}"');
expect(workflow).toContain('render_args+=(--verification-file "${verification_file}")');
expect(workflow).toContain('render_args+=(--metadata-output "${metadata_file}")');
expect(workflow).toContain('--notes-file "${prepared_release_notes_file}"');
expect(workflow).not.toContain("return_run_details: true");
expect(workflow).toContain("API 2026-03-10 removed return_run_details");
expect(workflow).toContain("Accept: application/vnd.github+json");
expect(workflow).toContain("X-GitHub-Api-Version: 2026-03-10");
expect(workflow).toContain("'.workflow_run_id'");
expect(workflow).toContain("'.html_url'");
expect(workflow).not.toContain("gh workflow run --repo");
expect(workflow).toContain("verify_release_tag_target()");
expect(workflow).toContain("git ls-remote --tags origin");
expect(workflow).toContain("Release tag ${RELEASE_TAG} moved:");
expect(workflow).toContain("canonical_release_body_matches()");
expect(workflow).toContain('has_canonical_body="true"');
expect(workflow).toContain('"${has_canonical_body}" == "true"');
expect(workflow).toContain('"${has_sha}" == "true"');
expect(workflow).toContain('"${has_proof}" == "true"');
expect(workflow).toContain('"${has_asset}" == "true"');
expect(workflow).toContain('prerelease_arg="--prerelease=false"');
expect(workflow).toContain('latest_arg="--latest=false"');
expect(workflow).toContain("--json isDraft,isPrerelease");
expect(workflow).toContain("verificationIncluded == true");
expect(workflow).toContain(
"Release verification proof omitted because the canonical release notes already reach GitHub's body limit.",
);
expect(workflow).not.toContain('$0 == "## " version { in_section = 1; next }');
expect(workflow).not.toContain("Unreleased prerelease fallback");
const releaseNotesPreflightIndex = workflow.indexOf(
'prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md"',
);
const publishGateIndex = workflow.indexOf(
'if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then',
releaseNotesPreflightIndex,
);
const releaseNotesRenderIndex = workflow.indexOf(
"render_github_release_notes \\",
releaseNotesPreflightIndex,
);
const firstPublishDispatchIndex = workflow.indexOf(
'plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml',
);
expect(releaseNotesPreflightIndex).toBeGreaterThanOrEqual(0);
expect(publishGateIndex).toBeGreaterThan(releaseNotesPreflightIndex);
expect(releaseNotesRenderIndex).toBeGreaterThan(publishGateIndex);
expect(firstPublishDispatchIndex).toBeGreaterThan(releaseNotesPreflightIndex);
expect(workflow).not.toContain("gh api --repo");
expect(workflow).not.toContain("timeout-minutes: 360");
});
@@ -2370,9 +2457,12 @@ describe("package artifact reuse", () => {
expect(clawHubMetadataIndex).toBeGreaterThan(clawHubSetupIndex);
expect(releaseWorkflow).toContain("Plugin npm run ID");
expect(releaseWorkflow).toContain("Plugin ClawHub run ID");
expect(releaseWorkflow).toContain(
expect(releaseWorkflow).not.toContain(
"did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs",
);
expect(releaseWorkflow).not.toContain("return_run_details: true");
expect(releaseWorkflow).toContain("'.workflow_run_id'");
expect(releaseWorkflow).toContain("'.html_url'");
expect(releaseWorkflow).not.toContain("BEFORE_IDS=");
expect(releaseWorkflow).not.toContain("before_json");
expect(releaseWorkflow).toContain("plugin-clawhub-new.yml");

View File

@@ -1,7 +1,9 @@
// Release Candidate Checklist tests cover release candidate checklist script behavior.
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import {
buildPublishCommand,
candidateCumulativeShippedPullRequests,
candidateParallelsArgs,
candidateParallelsShellCommand,
githubApi,
@@ -9,6 +11,10 @@ import {
parseRunIdFromDispatchOutput,
resolveArtifactName,
requireRunIdFromDispatchOutput,
run,
validateCandidateChangelogProvenance,
validateCandidateCheckout,
validateCandidateReleaseNotes,
validateFullManifest,
validatePreflightManifest,
validateWindowsSourceRelease,
@@ -33,6 +39,290 @@ async function withGithubApiTimeoutEnv<T>(value: string, fn: () => Promise<T>):
}
describe("release candidate checklist", () => {
it("captures changelogs larger than the Node spawnSync default buffer", () => {
const output = run(
process.execPath,
["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"],
{ capture: true },
);
expect(output).toHaveLength(2 * 1024 * 1024);
});
it("requires candidate tooling to come from the clean tagged checkout", () => {
expect(
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "a".repeat(40),
trackedStatus: "",
}),
).toEqual({ status: "passed", targetSha: "a".repeat(40) });
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "b".repeat(40),
trackedStatus: "",
}),
).toThrow("but HEAD is");
expect(() =>
validateCandidateCheckout({
targetSha: "a".repeat(40),
headSha: "a".repeat(40),
trackedStatus: " M scripts/release-candidate-checklist.mjs",
}),
).toThrow("requires a clean tracked worktree");
});
it("validates the exact tag changelog before dispatching the release matrix", () => {
const check = validateCandidateReleaseNotes({
changelog: [
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- User-facing notes.",
"",
"### Complete contribution record",
"",
`- **PR #123** ${"record ".repeat(20_000)}`,
].join("\n"),
repository: "openclaw/openclaw",
tag: "v2026.7.1-beta.3",
});
const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8");
const validationIndex = source.indexOf(
"const releaseNotesCheck = validateCandidateReleaseNotes",
);
const fullMatrixDispatchIndex = source.indexOf(
"if (!options.fullReleaseRunId && !options.skipDispatch)",
);
expect(check).toMatchObject({ status: "passed", mode: "compact" });
expect(validationIndex).toBeGreaterThanOrEqual(0);
expect(fullMatrixDispatchIndex).toBeGreaterThan(validationIndex);
expect(source).toContain('run("git", ["show", `${targetSha}:CHANGELOG.md`]');
});
it("rejects contribution-record provenance outside the release tag history", () => {
const base = "v2026.6.11";
const recordedTarget = "a".repeat(40);
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}..${recordedTarget} history: 1 merged PR.`,
"",
"#### Pull requests",
"",
"- **PR #123** fix: example.",
].join("\n");
const reachable = vi.fn((ancestor: string, target: string) => {
return ancestor === base && target === recordedTarget;
});
expect(() =>
validateCandidateChangelogProvenance({
changelog,
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha,
isAncestor: reachable,
}),
).toThrow(`contribution record target ${recordedTarget} is not reachable`);
expect(reachable).toHaveBeenCalledWith(base, recordedTarget);
expect(reachable).toHaveBeenCalledWith(recordedTarget, targetSha);
});
it("rejects duplicate contribution record rows even when the declared count matches", () => {
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 merged PR.`,
"",
"#### Pull requests",
"",
"- **PR #123** fix: example.",
"- **PR #123** fix: duplicate.",
].join("\n");
expect(() =>
validateCandidateChangelogProvenance({
changelog,
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha,
isAncestor: () => true,
}),
).toThrow("duplicate contribution record PR rows: #123");
});
it("uses numbered historical record rows and skips Unreleased baseline rows", () => {
const changelog = [
"# Changelog",
"",
"## Unreleased",
"",
"### Complete contribution record",
"",
"This audited record covers the complete base..HEAD history: 99 merged PRs.",
"",
"#### Pull requests",
"",
"- **PR #1** fix: not shipped.",
"",
"## 2026.6.11",
"",
"### Complete contribution record",
"",
"This audited record covers the complete base..HEAD history: 0 merged PRs.",
"",
"#### Pull requests",
"",
"- **PR #2** fix: shipped.",
].join("\n");
expect([...candidateCumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]);
});
it("validates cumulative shipped baseline exclusion metadata", () => {
const base = "66e676d29b92d040716376a75aca32bad655cfac";
const recordedTarget = "a".repeat(40);
const changelog = [
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- User-facing notes.",
"",
"### Complete contribution record",
"",
`This audited record covers the complete ${base}..${recordedTarget} history: 1 merged PR.`,
"",
"Shipped baseline exclusions: v2026.6.11 (8 PRs: #101, #102, #103, #104, #105, #106, #107, #108).",
"",
"#### Pull requests",
"",
"- **PR #123** fix: example.",
].join("\n");
const shippedPullRequests = new Set([101, 102, 103, 104, 105, 106, 107, 108]);
const loadShippedBaseline = vi.fn(() => ({
ref: "v2026.6.11",
pullRequests: shippedPullRequests,
}));
expect(
validateCandidateChangelogProvenance({
changelog,
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha: recordedTarget,
isAncestor: () => true,
loadShippedBaseline,
}),
).toEqual({
status: "passed",
base,
target: recordedTarget,
shippedBaselines: [
{
ref: "v2026.6.11",
count: 8,
pullRequests: [101, 102, 103, 104, 105, 106, 107, 108],
},
],
});
expect(loadShippedBaseline).toHaveBeenCalledWith("v2026.6.11");
expect(() =>
validateCandidateChangelogProvenance({
changelog: changelog.replace("8 PRs:", "8 pull requests:"),
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha: recordedTarget,
isAncestor: () => true,
loadShippedBaseline,
}),
).toThrow("malformed shipped baseline exclusion");
expect(() =>
validateCandidateChangelogProvenance({
changelog,
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha: recordedTarget,
isAncestor: () => true,
loadShippedBaseline: () => ({
ref: "v2026.6.11",
pullRequests: new Set([...shippedPullRequests].slice(1)),
}),
}),
).toThrow("lists PRs absent from shipped baseline v2026.6.11: #101");
expect(() =>
validateCandidateChangelogProvenance({
changelog: changelog.replace(
"- **PR #123** fix: example.",
"- **PR #101** fix: already shipped.",
),
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha: recordedTarget,
isAncestor: () => true,
loadShippedBaseline,
}),
).toThrow("still contains shipped PRs from v2026.6.11: #101");
});
it("requires contribution records for beta candidates but permits alpha Unreleased fallback", () => {
const betaChangelog = [
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- User-facing notes.",
].join("\n");
expect(() =>
validateCandidateChangelogProvenance({
changelog: betaChangelog,
version: "2026.7.1",
tag: "v2026.7.1-beta.3",
targetSha: "a".repeat(40),
}),
).toThrow("missing ### Complete contribution record");
const alpha = validateCandidateChangelogProvenance({
changelog: betaChangelog.replace("## 2026.7.1", "## Unreleased"),
version: "2026.7.1",
tag: "v2026.7.1-alpha.1",
targetSha: "a".repeat(40),
});
expect(alpha).toEqual({
status: "skipped",
reason: "alpha release uses the explicit Unreleased fallback",
shippedBaselines: [],
});
});
it("infers validation profiles from candidate tags", () => {
expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).releaseProfile).toBe("beta");
expect(parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "v0.6.3"]).releaseProfile).toBe(

View File

@@ -0,0 +1,345 @@
import { describe, expect, it } from "vitest";
import {
GITHUB_RELEASE_BODY_MAX_BYTES,
GITHUB_RELEASE_BODY_MAX_CHARACTERS,
extractChangelogSection,
formatShippedBaselineExclusions,
parseShippedBaselineExclusions,
releaseNotesVersionForTag,
renderGithubReleaseNotes,
verifyGithubReleaseNotes,
} from "../../scripts/render-github-release-notes.mjs";
const repository = "openclaw/openclaw";
const tag = "v2026.7.1-beta.3";
const version = "2026.7.1";
function changelogFor(record: string): string {
return [
"# Changelog",
"",
`## ${version}`,
"",
"### Highlights",
"",
"- A grouped user-facing highlight.",
"",
"### Fixes",
"",
"- A grouped user-facing fix.",
"",
"### Complete contribution record",
"",
record,
"",
"## 2026.6.11",
"",
"- Previous release.",
"",
].join("\n");
}
describe("GitHub release-note rendering", () => {
it("emits the complete matching section including its version heading when it fits", () => {
const rendered = renderGithubReleaseNotes({
changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."),
version,
tag,
repository,
});
expect(rendered.mode).toBe("full");
expect(rendered.body).toBe(
[
`## ${version}`,
"",
"### Highlights",
"",
"- A grouped user-facing highlight.",
"",
"### Fixes",
"",
"- A grouped user-facing fix.",
"",
"### Complete contribution record",
"",
"- **PR #123** fix: example. Thanks @contributor.",
].join("\n"),
);
});
it("replaces an oversized contribution record with a tag-pinned link", () => {
const oversizedRecord = `- **PR #123** ${"record-only-detail ".repeat(9_000)}`;
const rendered = renderGithubReleaseNotes({
changelog: changelogFor(oversizedRecord),
version,
tag,
repository,
});
expect(rendered.mode).toBe("compact");
expect(rendered.body).toContain(`## ${version}\n\n### Highlights`);
expect(rendered.body).toContain("- A grouped user-facing fix.");
expect(rendered.body).toContain("### Complete contribution record");
expect(rendered.body).toContain(
"https://github.com/openclaw/openclaw/blob/v2026.7.1-beta.3/CHANGELOG.md#complete-contribution-record",
);
expect(rendered.body).not.toContain("record-only-detail");
expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
});
it("keeps a fitting full section and omits only a proof tail that would overflow", () => {
const nearlyFullRecord = `- **PR #123** ${"x".repeat(124_500)}`;
const changelog = changelogFor(nearlyFullRecord);
const withoutProof = renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
});
const withProof = renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
verification: `### Release verification\n\n- proof: ${"y".repeat(1_000)}`,
});
expect(withoutProof.mode).toBe("full");
expect(withProof.mode).toBe("full");
expect(withProof.verificationIncluded).toBe(false);
expect(withProof.verificationOmitted).toBe(true);
expect(withProof.body).toBe(withoutProof.body);
expect(withProof.body).not.toContain("### Release verification");
expect(withProof.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
expect(withProof.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
});
it("uses the full form at exactly 125,000 bytes and compacts at 125,001", () => {
const seed = renderGithubReleaseNotes({
changelog: changelogFor("x"),
version,
tag,
repository,
});
const exactRecordLength =
GITHUB_RELEASE_BODY_MAX_BYTES - seed.size.bytes + Buffer.byteLength("x");
const exact = renderGithubReleaseNotes({
changelog: changelogFor("x".repeat(exactRecordLength)),
version,
tag,
repository,
});
const over = renderGithubReleaseNotes({
changelog: changelogFor("x".repeat(exactRecordLength + 1)),
version,
tag,
repository,
});
expect(exact.mode).toBe("full");
expect(exact.size).toEqual({
characters: GITHUB_RELEASE_BODY_MAX_CHARACTERS,
bytes: GITHUB_RELEASE_BODY_MAX_BYTES,
});
expect(over.mode).toBe("compact");
});
it("compacts when multibyte text exceeds the byte limit before the character limit", () => {
const rendered = renderGithubReleaseNotes({
changelog: changelogFor("é".repeat(63_000)),
version,
tag,
repository,
});
expect(rendered.mode).toBe("compact");
expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
});
it("normalizes correction tags to the stable changelog section", () => {
expect(releaseNotesVersionForTag("v2026.7.1-2")).toBe("2026.7.1");
const rendered = renderGithubReleaseNotes({
changelog: changelogFor("- **PR #123** fix: correction."),
version,
tag: "v2026.7.1-2",
repository,
});
expect(rendered.body).toContain("## 2026.7.1");
});
it("round-trips canonical shipped baseline exclusions and rejects malformed metadata", () => {
const line = formatShippedBaselineExclusions([
{ ref: "v2026.6.11", count: 2, pullRequests: [108, 101] },
{ ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] },
]);
expect(line).toBe(
"Shipped baseline exclusions: v2026.6.10-beta.2 (0 PRs); v2026.6.11 (2 PRs: #101, #108).",
);
expect(parseShippedBaselineExclusions(line)).toEqual([
{ ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] },
{ ref: "v2026.6.11", count: 2, pullRequests: [101, 108] },
]);
expect(() =>
parseShippedBaselineExclusions("Shipped baseline exclusion: v2026.6.11 (8 PRs)."),
).toThrow("malformed shipped baseline exclusion");
expect(() =>
parseShippedBaselineExclusions("Shipped baseline exclusions: v2026.6.11 (2 PRs: #101)."),
).toThrow("invalid shipped baseline exclusion count");
});
it("rejects tag/version drift and legacy oversized ledger anchors", () => {
expect(() =>
renderGithubReleaseNotes({
changelog: changelogFor("- **PR #123** fix: example."),
version,
tag: "v2026.7.2-beta.1",
repository,
}),
).toThrow("requires CHANGELOG.md version 2026.7.2");
expect(() =>
renderGithubReleaseNotes({
changelog: changelogFor(
`### Complete contribution ledger\n\n${"legacy ".repeat(20_000)}`,
).replace("### Complete contribution record\n\n", ""),
version,
tag,
repository,
}),
).toThrow("cannot be compacted without a complete contribution record");
});
it("permits the Unreleased fallback only for alpha tags", () => {
const changelog = changelogFor("- **PR #123** fix: example.").replace(
"## 2026.7.1",
"## Unreleased",
);
const rendered = renderGithubReleaseNotes({
changelog,
version,
tag: "v2026.7.1-alpha.1",
repository,
});
expect(rendered.body).toContain("## 2026.7.1");
expect(rendered.body).not.toContain("## Unreleased");
expect(() =>
renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
}),
).toThrow("CHANGELOG.md does not contain ## 2026.7.1");
expect(() =>
renderGithubReleaseNotes({
changelog,
version: "foo",
tag: "vfoo-alpha.1",
repository,
}),
).toThrow("invalid release tag");
});
it("ignores fenced pseudo-headings and handles a release heading at EOF", () => {
const fenced = [
`## ${version}`,
"",
"```md",
"## 2099.1.1",
"```",
"",
"### Fixes",
"",
"- Still in the current release.",
"",
"## 2026.6.11",
].join("\n");
expect(extractChangelogSection(fenced, version)).toContain("- Still in the current release.");
expect(extractChangelogSection(`## ${version}`, version)).toBe(`## ${version}`);
});
it("compacts at the real contribution record instead of a fenced pseudo-heading", () => {
const changelog = changelogFor(`- **PR #123** ${"record-only-detail ".repeat(9_000)}`).replace(
"### Fixes",
["```md", "### Complete contribution record", "```", "", "### Fixes"].join("\n"),
);
const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository });
expect(rendered.mode).toBe("compact");
expect(rendered.body).toContain("```md\n### Complete contribution record\n```");
expect(rendered.body).toContain("- A grouped user-facing fix.");
expect(rendered.body).not.toContain("record-only-detail");
});
it("verifies the exact generated compact body and optional proof tail", () => {
const changelog = changelogFor(`- **PR #123** ${"z".repeat(130_000)}`);
const verification = "### Release verification\n\n- release SHA: `abc123`";
const rendered = renderGithubReleaseNotes({
changelog,
version,
tag,
repository,
verification,
});
expect(
verifyGithubReleaseNotes({
body: rendered.body,
changelog,
version,
tag,
repository,
}),
).toMatchObject({ matches: true, mode: "compact" });
expect(
verifyGithubReleaseNotes({
body: rendered.body.replace("tag-pinned", "mutable"),
changelog,
version,
tag,
repository,
}).matches,
).toBe(false);
expect(
verifyGithubReleaseNotes({
body: rendered.body,
changelog,
version,
tag: "v2026.7.1-beta.2",
repository,
}).matches,
).toBe(false);
});
it("does not treat fenced verification headings as appended proof", () => {
const changelog = changelogFor(
[
"```md",
"### Release verification",
"",
"- Example only.",
"```",
"",
"- **PR #123** fix: example.",
].join("\n"),
);
const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository });
expect(
verifyGithubReleaseNotes({
body: rendered.body,
changelog,
version,
tag,
repository,
}),
).toMatchObject({ matches: true, verificationIncluded: false });
});
});

View File

@@ -0,0 +1,250 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
contaminatingPullRequestReferences,
countTopLevelSectionBullets,
cumulativeShippedPullRequests,
highlightCountError,
releaseNoteReferences,
subtractShippedPullRequests,
} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs";
const verifier = resolve(
".agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs",
);
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "OpenClaw Test",
GIT_AUTHOR_EMAIL: "test@openclaw.invalid",
GIT_COMMITTER_NAME: "OpenClaw Test",
GIT_COMMITTER_EMAIL: "test@openclaw.invalid",
},
}).trim();
}
describe("release-note verification", () => {
it("counts only top-level Highlights bullets and enforces the 5-8 policy input", () => {
const highlights = [
"### Highlights",
"",
"- One",
" - nested detail",
"- Two",
"- Three",
"- Four",
"- Five",
"",
"### Changes",
"",
"- Not a highlight",
].join("\n");
const overLimit = highlights.replace("- Five", "- Five\n- Six\n- Seven\n- Eight\n- Nine");
expect(countTopLevelSectionBullets(highlights, "Highlights")).toBe(5);
expect(countTopLevelSectionBullets(overLimit, "Highlights")).toBe(9);
expect(highlightCountError(highlights)).toBeUndefined();
expect(highlightCountError(overLimit)).toBe(
"### Highlights must contain 5-8 top-level bullets; found 9",
);
});
it("rejects prior-release PRs from prose or the existing record unless explicitly seeded", () => {
const nodes = new Map([
[97118, { __typename: "PullRequest" }],
[102000, { __typename: "PullRequest" }],
[98565, { __typename: "Issue" }],
]);
const params = {
noteReferences: [97118, 98565],
recordedReferences: [97118, 102000],
sourcePullRequests: new Set([102000]),
sourceReferences: [102000, 98565],
seededPullRequests: new Set<number>(),
nodes,
};
expect(contaminatingPullRequestReferences(params)).toEqual([97118]);
expect(
contaminatingPullRequestReferences({
...params,
seededPullRequests: new Set([97118]),
}),
).toEqual([]);
});
it("excludes Unreleased records from a cumulative shipped tag boundary", () => {
const changelog = [
"# Changelog",
"",
"## Unreleased",
"",
"### Complete contribution record",
"",
`This audited record covers the complete base..${"a".repeat(40)} history: 1 merged PR.`,
"",
"#### Pull requests",
"",
"- **PR #1** fix: not shipped.",
"",
"## 2026.6.11",
"",
"### Complete contribution record",
"",
"This audited record covers the complete base..HEAD history: 0 merged PRs.",
"",
"#### Pull requests",
"",
"- **PR #2** fix: shipped.",
].join("\n");
expect([...cumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]);
});
it("subtracts cumulative shipped PRs deterministically from the source inventory", () => {
const source = {
pullRequests: new Set([1, 2, 3]),
references: [1, 2, 4],
};
const result = subtractShippedPullRequests(source, [
{ ref: "v2026.6.11", pullRequests: new Set([1, 2]) },
{ ref: "v2026.6.10", pullRequests: new Set([2, 4]) },
]);
expect([...source.pullRequests]).toEqual([3]);
expect(source.references).toEqual([]);
expect(result.baselines).toEqual([
{ ref: "v2026.6.10", count: 2, pullRequests: [2, 4] },
{ ref: "v2026.6.11", count: 1, pullRequests: [1] },
]);
expect([...result.pullRequests].toSorted((a, b) => a - b)).toEqual([1, 2, 4]);
});
it("does not treat the shipped baseline inventory as current release-note references", () => {
const baselines = [{ ref: "v2026.6.11", count: 2, pullRequests: [1, 2] }];
const section = [
"## 2026.7.1",
"",
"- Fixes #1 in the current range.",
"",
"### Complete contribution record",
"",
"Shipped baseline exclusions: v2026.6.11 (2 PRs: #1, #2).",
"",
"- **PR #3** fix: current work.",
].join("\n");
expect(releaseNoteReferences(section, baselines)).toEqual([1, 3]);
});
it("records a canonical target SHA when --target is symbolic", () => {
const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-"));
try {
git(cwd, ["init", "-q"]);
writeFileSync(
join(cwd, "CHANGELOG.md"),
[
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- One.",
"- Two.",
"- Three.",
"- Four.",
"- Five.",
"",
"### Changes",
"",
"### Fixes",
].join("\n"),
);
git(cwd, ["add", "CHANGELOG.md"]);
git(cwd, ["commit", "-qm", "initial"]);
const targetSha = git(cwd, ["rev-parse", "HEAD"]);
const result = spawnSync(
process.execPath,
[
verifier,
"--base",
"HEAD",
"--target",
"HEAD",
"--version",
"2026.7.1",
"--write-ledger",
"--json",
],
{ cwd, encoding: "utf8" },
);
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).target).toBe(targetSha);
expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toContain(
`This audited record covers the complete HEAD..${targetSha} history:`,
);
} 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 {
git(cwd, ["init", "-q"]);
writeFileSync(
join(cwd, "CHANGELOG.md"),
[
"# Changelog",
"",
"## 2026.7.1",
"",
"### Highlights",
"",
"- Test release.",
"",
"### Complete contribution record",
"",
].join("\n"),
);
git(cwd, ["add", "CHANGELOG.md"]);
git(cwd, ["commit", "-qm", "initial"]);
git(cwd, ["branch", "target"]);
writeFileSync(join(cwd, "base.txt"), "base\n");
git(cwd, ["add", "base.txt"]);
git(cwd, ["commit", "-qm", "base"]);
git(cwd, ["tag", "base-ref"]);
git(cwd, ["checkout", "-q", "target"]);
writeFileSync(join(cwd, "target.txt"), "target\n");
git(cwd, ["add", "target.txt"]);
git(cwd, ["commit", "-qm", "target"]);
const result = spawnSync(
process.execPath,
[verifier, "--base", "base-ref", "--target", "HEAD", "--version", "2026.7.1"],
{ cwd, encoding: "utf8" },
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"release range base base-ref must be an ancestor of target HEAD",
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});