mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 06:26:08 +00:00
fix(release): harden publication validation
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
229
.github/workflows/openclaw-release-publish.yml
vendored
229
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
76
.gitignore
vendored
@@ -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__
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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})` : ""
|
||||
}`,
|
||||
|
||||
411
scripts/render-github-release-notes.mjs
Normal file
411
scripts/render-github-release-notes.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
345
test/scripts/render-github-release-notes.test.ts
Normal file
345
test/scripts/render-github-release-notes.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
250
test/scripts/verify-release-notes.test.ts
Normal file
250
test/scripts/verify-release-notes.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user