Files
openclaw/scripts/release-candidate-checklist.mjs

1523 lines
52 KiB
JavaScript

#!/usr/bin/env node
// Coordinates release-candidate validation runs and emits the publish command
// only after required local, CI, npm, plugin, and E2E evidence is green.
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs";
import { readBoundedResponseText } from "./lib/bounded-response.mjs";
import {
dedicatedSectionVersionForTag,
extractChangelogReleaseSections,
extractChangelogSection,
formatShippedBaselineExclusions,
parseShippedBaselineExclusions,
releaseNotesSectionForTag,
releaseNotesVersionForTag,
renderGithubReleaseNotes,
} from "./render-github-release-notes.mjs";
import {
isShaPinnedReleaseValidationBranch,
runStrictReleaseEvidenceValidation,
validateFullReleaseValidationEvidence,
} from "./validate-full-release-validation-evidence.mjs";
const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_PROVIDER = "openai";
const DEFAULT_MODE = "both";
const DEFAULT_NPM_DIST_TAG = "beta";
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 TOOLING_ROOT = fileURLToPath(new URL("../", import.meta.url));
const TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN =
/^tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$/u;
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 = [
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe",
];
const SHA256_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const RELEASE_CANDIDATE_STATE_VERSION = 1;
const RELEASE_CANDIDATE_STATE_FILE = "release-candidate-state.json";
const RELEASE_CANDIDATE_STATE_KEYS = [
"repo",
"tag",
"targetSha",
"toolingSha",
"workflowRef",
"provider",
"mode",
"releaseProfile",
"npmDistTag",
"pluginPublishScope",
"plugins",
"windowsNodeTag",
"skipParallels",
"skipTelegram",
"telegramProviderMode",
];
function usage() {
return `Usage: pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N [options]
Dispatches or consumes release validation runs, validates the prepared npm tarball,
builds plugin publish plans, writes a green evidence bundle, then prints the exact
OpenClaw Release Publish command only after everything is green.
Options:
--tag <tag> Release tag to validate.
--workflow-ref <ref> Trusted workflow ref. Default: main; matching Tideclaw branch required for alpha.
--repo <owner/repo> GitHub repo. Default: ${DEFAULT_REPO}
--full-release-run <id> Reuse successful Full Release Validation run.
--npm-preflight-run <id> Reuse successful OpenClaw NPM Release preflight run.
--windows-node-tag <tag> Exact Windows Node release tag. Required for stable.
--skip-dispatch Require both run ids; do not dispatch workflows.
--skip-local-generated-check Do not run local generated release baseline checks before dispatch.
--skip-parallels Do not run local Parallels fresh/update candidate smoke.
--skip-telegram Do not run NPM Telegram E2E against the prepared tarball.
--telegram-provider-mode <mode> mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE}
--provider <provider> Full validation provider. Default: ${DEFAULT_PROVIDER}
--mode <fresh|upgrade|both> Full validation cross-OS mode. Default: ${DEFAULT_MODE}
--release-profile <beta|stable|full> Default: beta for prereleases; stable otherwise.
--npm-dist-tag <alpha|beta|latest> Default: ${DEFAULT_NPM_DIST_TAG}
--plugin-publish-scope <scope> selected|all-publishable. Default: ${DEFAULT_PLUGIN_SCOPE}
--plugins <names> Required when plugin scope is selected.
--output-dir <dir> Evidence output dir. Default: .artifacts/release-candidate/<tag>
`;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith("-")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
export function releaseBranchForTag(tag) {
if (tag.includes("-alpha.")) {
return "";
}
const version = tag.replace(/^v/u, "").split("-", 1)[0];
return `release/${version}`;
}
/**
* Parses release-candidate validation options and enforces publish-scope policy.
*/
export function parseArgs(argv) {
const args = stripLeadingPackageManagerSeparator(argv);
const options = {
repo: DEFAULT_REPO,
provider: DEFAULT_PROVIDER,
mode: DEFAULT_MODE,
releaseProfile: "",
npmDistTag: DEFAULT_NPM_DIST_TAG,
pluginPublishScope: DEFAULT_PLUGIN_SCOPE,
plugins: "",
skipDispatch: false,
skipLocalGeneratedCheck: false,
skipParallels: false,
skipTelegram: false,
telegramProviderMode: DEFAULT_TELEGRAM_PROVIDER_MODE,
tag: "",
workflowRef: "",
fullReleaseRunId: "",
npmPreflightRunId: "",
windowsNodeTag: "",
windowsNodeInstallerDigests: "",
outputDir: "",
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seen.add(flag);
options[key] = value;
};
parseArgv: for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--":
break parseArgv;
case "--tag":
setOnce(arg, "tag", requireValue(args, ++index, arg));
break;
case "--workflow-ref":
setOnce(arg, "workflowRef", requireValue(args, ++index, arg));
break;
case "--repo":
setOnce(arg, "repo", requireValue(args, ++index, arg));
break;
case "--full-release-run":
setOnce(arg, "fullReleaseRunId", requireValue(args, ++index, arg));
break;
case "--npm-preflight-run":
setOnce(arg, "npmPreflightRunId", requireValue(args, ++index, arg));
break;
case "--windows-node-tag":
setOnce(arg, "windowsNodeTag", requireValue(args, ++index, arg));
break;
case "--skip-dispatch":
setOnce(arg, "skipDispatch", true);
break;
case "--skip-local-generated-check":
setOnce(arg, "skipLocalGeneratedCheck", true);
break;
case "--skip-parallels":
setOnce(arg, "skipParallels", true);
break;
case "--skip-telegram":
setOnce(arg, "skipTelegram", true);
break;
case "--telegram-provider-mode":
setOnce(arg, "telegramProviderMode", requireValue(args, ++index, arg));
break;
case "--provider":
setOnce(arg, "provider", requireValue(args, ++index, arg));
break;
case "--mode":
setOnce(arg, "mode", requireValue(args, ++index, arg));
break;
case "--release-profile":
setOnce(arg, "releaseProfile", requireValue(args, ++index, arg));
break;
case "--npm-dist-tag":
setOnce(arg, "npmDistTag", requireValue(args, ++index, arg));
break;
case "--plugin-publish-scope":
setOnce(arg, "pluginPublishScope", requireValue(args, ++index, arg));
break;
case "--plugins":
setOnce(arg, "plugins", requireValue(args, ++index, arg));
break;
case "--output-dir":
setOnce(arg, "outputDir", requireValue(args, ++index, arg));
break;
case "-h":
case "--help":
process.stdout.write(usage());
process.exit(0);
default:
throw new Error(`unknown option: ${arg}`);
}
}
if (!options.tag) {
throw new Error("--tag is required");
}
if (options.tag.includes("-alpha.")) {
if (!TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(options.workflowRef)) {
throw new Error(
"--workflow-ref must be the matching tideclaw/alpha/YYYY-MM-DD-HHMMZ branch for alpha release candidates",
);
}
} else {
options.workflowRef ||= "main";
}
if (!options.tag.includes("-alpha.") && options.workflowRef !== "main") {
throw new Error("--workflow-ref must be main for regular beta and stable release candidates");
}
options.releaseProfile ||=
options.tag.includes("-alpha.") || options.tag.includes("-beta.") ? "beta" : "stable";
if (!["beta", "stable", "full"].includes(options.releaseProfile)) {
throw new Error("--release-profile must be beta, stable, or full");
}
if (options.skipDispatch && (!options.fullReleaseRunId || !options.npmPreflightRunId)) {
throw new Error("--skip-dispatch requires --full-release-run and --npm-preflight-run");
}
if (options.pluginPublishScope === "selected" && !options.plugins.trim()) {
throw new Error("--plugin-publish-scope selected requires --plugins");
}
if (options.pluginPublishScope === "selected") {
throw new Error(
"--plugin-publish-scope selected is only for plugin-only repair publishes; release candidates publish OpenClaw with --plugin-publish-scope all-publishable",
);
}
if (options.pluginPublishScope === "all-publishable" && options.plugins.trim()) {
throw new Error("--plugins is only valid with --plugin-publish-scope selected");
}
if (options.windowsNodeTag && !WINDOWS_NODE_TAG_PATTERN.test(options.windowsNodeTag)) {
throw new Error("--windows-node-tag must be an explicit version tag, not latest");
}
if (
!options.tag.includes("-alpha.") &&
!options.tag.includes("-beta.") &&
!options.windowsNodeTag
) {
throw new Error("stable release candidates require --windows-node-tag");
}
if (!["mock-openai", "live-frontier"].includes(options.telegramProviderMode)) {
throw new Error("--telegram-provider-mode must be mock-openai or live-frontier");
}
return 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) {
throw new Error(
`${command} ${args.join(" ")} failed with ${result.status ?? result.signal}\n${result.stderr ?? ""}`,
);
}
return result.stdout ?? "";
}
function readJson(path, label) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
throw new Error(
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
export function buildReleaseCandidateState(options, { targetSha, toolingSha }) {
return {
version: RELEASE_CANDIDATE_STATE_VERSION,
phase: "validated",
repo: options.repo,
tag: options.tag,
targetSha,
toolingSha,
workflowRef: options.workflowRef,
provider: options.provider,
mode: options.mode,
releaseProfile: options.releaseProfile,
npmDistTag: options.npmDistTag,
pluginPublishScope: options.pluginPublishScope,
plugins: options.plugins,
windowsNodeTag: options.windowsNodeTag,
skipParallels: options.skipParallels,
skipTelegram: options.skipTelegram,
telegramProviderMode: options.telegramProviderMode,
fullReleaseRunId: options.fullReleaseRunId,
npmPreflightRunId: options.npmPreflightRunId,
};
}
export function reconcileReleaseCandidateState(saved, expected) {
if (!saved) {
return expected;
}
if (
typeof saved !== "object" ||
Array.isArray(saved) ||
saved.version !== RELEASE_CANDIDATE_STATE_VERSION
) {
throw new Error("release candidate state has an unsupported schema");
}
for (const key of RELEASE_CANDIDATE_STATE_KEYS) {
if (!isDeepStrictEqual(saved[key], expected[key])) {
throw new Error(
`release candidate state mismatch for ${key}: saved=${JSON.stringify(saved[key])} current=${JSON.stringify(expected[key])}`,
);
}
}
for (const key of ["fullReleaseRunId", "npmPreflightRunId"]) {
if (saved[key] && expected[key] && saved[key] !== expected[key]) {
throw new Error(`release candidate state mismatch for ${key}`);
}
}
return {
...expected,
phase: typeof saved.phase === "string" ? saved.phase : expected.phase,
fullReleaseRunId: expected.fullReleaseRunId || saved.fullReleaseRunId || "",
npmPreflightRunId: expected.npmPreflightRunId || saved.npmPreflightRunId || "",
};
}
function writeReleaseCandidateState(path, state) {
mkdirSync(join(path, ".."), { recursive: true });
const temporaryPath = `${path}.tmp-${process.pid}`;
writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`);
renameSync(temporaryPath, path);
}
function updateReleaseCandidateState(path, state, phase, runIds = {}) {
const next = { ...state, ...runIds, phase };
writeReleaseCandidateState(path, next);
return next;
}
function githubApiTimeoutMs() {
const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS;
if (!raw) {
return DEFAULT_GITHUB_API_TIMEOUT_MS;
}
if (!/^[1-9]\d*$/u.test(raw)) {
throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer");
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer");
}
return value;
}
function githubApiTimedOut(error) {
return (
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")
);
}
/**
* Calls the GitHub REST API with the gh-auth token and a bounded timeout.
*/
export async function githubApi(path, options = {}) {
const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim();
const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs();
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_GITHUB_API_RESPONSE_BODY_MAX_BYTES;
const controller = new AbortController();
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort(new DOMException("request timed out", "TimeoutError"));
reject(new DOMException("request timed out", "TimeoutError"));
}, timeoutMs);
timeout.unref?.();
});
try {
const response = await Promise.race([
(options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
}),
timeoutPromise,
]);
const text = await readBoundedResponseText(response, `GitHub API ${path}`, maxBodyBytes, {
signal: controller.signal,
timeoutPromise,
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed with ${response.status}: ${text}`);
}
return JSON.parse(text);
} catch (error) {
if (githubApiTimedOut(error)) {
throw new Error(`GitHub API ${path} timed out after ${timeoutMs}ms`, { cause: error });
}
throw error;
} finally {
clearTimeout(timeout);
}
}
/**
* Validates the immutable Windows source release contract for a stable candidate.
*/
export async function validateWindowsSourceRelease(tag, options = {}) {
const release = await githubApi(
`repos/${WINDOWS_NODE_REPO}/releases/tags/${encodeURIComponent(tag)}`,
options,
);
if (release.tag_name !== tag) {
throw new Error(
`Windows source release tag mismatch: expected ${tag}, got ${release.tag_name}`,
);
}
if (release.draft) {
throw new Error(`Windows source release ${tag} must be published`);
}
if (release.prerelease) {
throw new Error(`Windows source release ${tag} must not be a prerelease`);
}
const assets = WINDOWS_NODE_REQUIRED_ASSETS.map((name) => {
const matches = (release.assets ?? []).filter((entry) => entry.name === name);
if (matches.length !== 1) {
throw new Error(
`Windows source release ${tag} must contain exactly one required asset ${name}; found ${matches.length}`,
);
}
const [asset] = matches;
if (!SHA256_DIGEST_PATTERN.test(asset.digest ?? "")) {
throw new Error(`Windows source release ${tag} asset ${name} is missing its SHA-256 digest`);
}
return { name, digest: asset.digest };
});
return {
tag,
url: release.html_url,
assets,
};
}
function gitRevParse(ref, cwd) {
return run("git", ["rev-parse", ref], { capture: true, cwd }).trim();
}
function gitTopLevel(cwd) {
return run("git", ["rev-parse", "--show-toplevel"], { capture: true, cwd }).trim();
}
function gitTrackedStatus(cwd) {
return run("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
capture: true,
cwd,
});
}
function fetchTrustedWorkflowSha(workflowRef, toolingRoot) {
const remoteRef = `refs/remotes/origin/${workflowRef}`;
run("git", ["fetch", "--no-tags", "origin", `+refs/heads/${workflowRef}:${remoteRef}`], {
cwd: toolingRoot,
});
return gitRevParse(`${remoteRef}^{commit}`, toolingRoot);
}
function runFromTrustedTooling(argv, { targetRoot, workflowRef }) {
const trustedToolingSha = fetchTrustedWorkflowSha(workflowRef, targetRoot);
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-release-tooling-"));
const toolingRoot = join(tempRoot, "checkout");
let worktreeAdded = false;
try {
run("git", ["worktree", "add", "--detach", toolingRoot, trustedToolingSha], {
cwd: targetRoot,
});
worktreeAdded = true;
const result = spawnSync(
process.execPath,
[join(toolingRoot, "scripts/release-candidate-checklist.mjs"), ...argv],
{
cwd: targetRoot,
env: process.env,
stdio: "inherit",
},
);
if (result.status !== 0) {
throw new Error(
`trusted release candidate tooling failed with ${result.status ?? result.signal}`,
);
}
} finally {
if (worktreeAdded) {
const cleanup = spawnSync("git", ["worktree", "remove", "--force", toolingRoot], {
cwd: targetRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (cleanup.status !== 0) {
console.warn(
`could not remove temporary trusted tooling worktree: ${cleanup.stderr?.trim() || cleanup.signal || cleanup.status}`,
);
}
}
rmSync(tempRoot, { force: true, recursive: true });
}
}
export function validateCandidateCheckout({
targetSha,
targetHeadSha,
targetTrackedStatus,
toolingSha,
trustedToolingSha,
toolingTrackedStatus,
workflowRef,
}) {
if (targetHeadSha !== targetSha) {
throw new Error(
`release candidate tag resolves to ${targetSha}, but target worktree HEAD is ${targetHeadSha}`,
);
}
if (targetTrackedStatus.trim()) {
throw new Error(
"release candidate validation requires a clean tracked target worktree at the release tag",
);
}
if (toolingSha !== trustedToolingSha) {
throw new Error(
`release candidate tooling HEAD ${toolingSha} does not match trusted ${workflowRef} ${trustedToolingSha}`,
);
}
if (toolingTrackedStatus.trim()) {
throw new Error(
"release candidate validation requires a clean tracked tooling checkout at the trusted workflow ref",
);
}
return { status: "passed", targetSha, toolingSha, workflowRef };
}
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,
}) {
// Validate the same section the renderer publishes: alpha and correction
// tags may carry their own heading, and alpha tags may fall back to
// Unreleased.
let section;
let sectionVersion = version;
let usesAlphaUnreleasedFallback = false;
const dedicatedVersion = dedicatedSectionVersionForTag(tag);
if (dedicatedVersion && dedicatedVersion !== version) {
try {
section = extractChangelogSection(changelog, dedicatedVersion);
sectionVersion = dedicatedVersion;
} catch {
// No dedicated section; validate the base section.
}
}
if (section === undefined) {
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 ## ${sectionVersion} is missing ### Complete contribution record`,
);
}
const record = section.slice(recordStart);
const recordedPullRequests = candidateContributionRecordPullRequests(
section,
`CHANGELOG.md ## ${sectionVersion}`,
);
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 ## ${sectionVersion} 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) => ({
name: artifact.name,
expired: artifact.expired,
}));
}
/**
* Chooses the expected artifact name, allowing one same-prefix fallback per run.
*/
export function resolveArtifactName(artifacts, preferredName, prefix) {
const available = artifacts
.filter((artifact) => artifact.expired !== true)
.map((artifact) => artifact.name);
if (available.includes(preferredName)) {
return preferredName;
}
const candidates = available.filter((name) => name.startsWith(prefix));
if (candidates.length === 1) {
console.warn(`artifact ${preferredName} not found; using ${candidates[0]} from the same run`);
return candidates[0];
}
const candidateList =
available.length > 0 ? available.map((name) => `- ${name}`).join("\n") : "- <none>";
throw new Error(
`artifact ${preferredName} not found in run. Expected ${preferredName} or exactly one ${prefix}* fallback.\nAvailable artifacts:\n${candidateList}`,
);
}
async function resolveRunArtifactName(repo, runId, preferredName, prefix) {
return resolveArtifactName(await runArtifacts(repo, runId), preferredName, prefix);
}
function runAndEcho(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed with ${result.status ?? result.signal}\n${
result.stderr ?? ""
}`,
);
}
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
}
function runLocalGeneratedCheckIfNeeded(options) {
if (options.skipLocalGeneratedCheck) {
return { status: "skipped", reason: "operator skipped --skip-local-generated-check" };
}
run("pnpm", ["release:generated:check"]);
return { status: "passed", command: "pnpm release:generated:check" };
}
/**
* Extracts a GitHub Actions run id from gh workflow dispatch output.
*/
export function parseRunIdFromDispatchOutput(output) {
return output.match(/actions\/runs\/([0-9]+)/u)?.[1] ?? "";
}
export function requireRunIdFromDispatchOutput(output, workflowFile) {
const runId = parseRunIdFromDispatchOutput(output);
if (!runId) {
throw new Error(
`gh workflow run ${workflowFile} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs`,
);
}
return runId;
}
async function wait(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function dispatchWorkflow(repo, workflowFile, workflowRef, fields) {
const args = ["workflow", "run", workflowFile, "--repo", repo, "--ref", workflowRef];
for (const [key, value] of Object.entries(fields)) {
args.push("-f", `${key}=${String(value)}`);
}
return requireRunIdFromDispatchOutput(runAndEcho("gh", args), workflowFile);
}
async function runInfo(repo, runId) {
const [runData, jobsData] = await Promise.all([
githubApi(`repos/${repo}/actions/runs/${runId}`),
githubApi(`repos/${repo}/actions/runs/${runId}/jobs?per_page=100`),
]);
return {
databaseId: runData.id,
runAttempt: runData.run_attempt,
workflowName: runData.name,
workflowPath: runData.path,
repository: runData.repository?.full_name,
headBranch: runData.head_branch,
headSha: runData.head_sha,
event: runData.event,
status: runData.status,
conclusion: runData.conclusion,
url: runData.html_url,
jobs: (jobsData.jobs ?? []).map((job) => ({
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.html_url,
})),
};
}
async function pendingDeployments(repo, runId) {
try {
return await githubApi(`repos/${repo}/actions/runs/${runId}/pending_deployments`);
} catch {
return [];
}
}
function summarizePendingDeployments(repo, runId, deployments) {
if (!Array.isArray(deployments) || deployments.length === 0) {
return "";
}
return deployments
.map((deployment) => {
const environment = deployment.environment ?? {};
return [
`- pending approval: env=${environment.name ?? "<unknown>"} canApprove=${String(deployment.current_user_can_approve ?? "<unknown>")}`,
` approve: gh api -X POST repos/${repo}/actions/runs/${runId}/pending_deployments -F 'environment_ids[]=${environment.id ?? "<id>"}' -f state=approved -f comment='Approve release gate'`,
].join("\n");
})
.join("\n");
}
function summarizeFailedRun(info) {
const failedJobs = (info.jobs ?? []).filter(
(job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped",
);
return [
`${info.workflowName} ${info.databaseId} ended ${info.status}/${info.conclusion}: ${info.url}`,
...failedJobs.map((job) => `- ${job.name}: ${job.conclusion} ${job.url ?? ""}`),
].join("\n");
}
async function waitForSuccessfulRun(repo, runId, expected) {
let lastState = "";
for (;;) {
const info = await runInfo(repo, runId);
const state = `${info.status}:${info.conclusion ?? ""}`;
if (state !== lastState) {
console.log(
`${info.workflowName} ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} ${info.url}`,
);
const pending = summarizePendingDeployments(
repo,
runId,
await pendingDeployments(repo, runId),
);
if (pending) {
console.log(pending);
}
lastState = state;
}
if (info.status === "completed") {
if (info.conclusion !== "success") {
throw new Error(summarizeFailedRun(info));
}
if (info.workflowName !== expected.workflowName) {
throw new Error(
`run ${runId} workflow mismatch: expected ${expected.workflowName}, got ${info.workflowName}`,
);
}
const acceptsPinnedWorkflow =
expected.allowShaPinnedWorkflowRef && isShaPinnedReleaseValidationBranch(info.headBranch);
if (info.headBranch !== expected.workflowRef && !acceptsPinnedWorkflow) {
throw new Error(
`run ${runId} branch mismatch: expected ${expected.workflowRef}, got ${info.headBranch}`,
);
}
return info;
}
await wait(30_000);
}
}
function downloadArtifact(repo, runId, name, dir) {
rmSync(dir, { force: true, recursive: true });
mkdirSync(dir, { recursive: true });
run("gh", ["run", "download", runId, "--repo", repo, "--name", name, "--dir", dir]);
}
async function downloadResolvedArtifact(repo, runId, preferredName, prefix, dir) {
const name = await resolveRunArtifactName(repo, runId, preferredName, prefix);
downloadArtifact(repo, runId, name, dir);
return name;
}
function sha256(path) {
return run("shasum", ["-a", "256", path], { capture: true }).trim().split(/\s+/u)[0] ?? "";
}
function pluginPlanArgs(options) {
const args = ["--selection-mode", options.pluginPublishScope];
if (options.pluginPublishScope === "selected") {
args.push("--plugins", options.plugins);
}
return args;
}
function collectPluginPlan(script, options) {
return JSON.parse(
run("node", ["--import", "tsx", script, ...pluginPlanArgs(options)], { capture: true }),
);
}
async function collectPluginPlanWithRetry(script, options) {
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return collectPluginPlan(script, options);
} catch (error) {
lastError = error;
if (attempt === 3) {
break;
}
console.warn(
`${script} failed on attempt ${attempt}; retrying: ${
error instanceof Error ? error.message : String(error)
}`,
);
await wait(5_000 * attempt);
}
}
throw lastError;
}
function shellQuote(value) {
return `'${String(value).replace(/'/gu, "'\\''")}'`;
}
/**
* Builds the final release publish workflow command once validation evidence is ready.
*/
export function buildPublishCommand(options) {
const workflowRef = options.tag.includes("-alpha.") ? options.workflowRef : "main";
if (options.tag.includes("-alpha.") && !TIDECLAW_ALPHA_WORKFLOW_REF_PATTERN.test(workflowRef)) {
throw new Error(
"alpha release publish requires a matching tideclaw/alpha/YYYY-MM-DD-HHMMZ workflow ref",
);
}
const fields = [
["tag", options.tag],
["preflight_run_id", options.npmPreflightRunId],
["full_release_validation_run_id", options.fullReleaseRunId],
["full_release_validation_run_attempt", options.fullReleaseRunAttempt],
["npm_dist_tag", options.npmDistTag],
["plugin_publish_scope", options.pluginPublishScope],
["publish_openclaw_npm", "true"],
["release_profile", "from-validation"],
["wait_for_clawhub", "false"],
];
if (options.npmTelegramRunId) {
fields.push(["npm_telegram_run_id", options.npmTelegramRunId]);
}
if (options.windowsNodeTag) {
fields.push(["windows_node_tag", options.windowsNodeTag]);
}
if (options.windowsNodeInstallerDigests) {
fields.push(["windows_node_installer_digests", options.windowsNodeInstallerDigests]);
}
if (options.plugins.trim()) {
fields.push(["plugins", options.plugins]);
}
return [
"gh",
"workflow",
"run",
"openclaw-release-publish.yml",
"--repo",
options.repo,
"--ref",
workflowRef,
...fields.flatMap(([key, value]) => ["-f", `${key}=${value}`]),
]
.map(shellQuote)
.join(" ");
}
export function validatePreflightManifest(manifest, params) {
if (manifest.releaseTag !== params.tag) {
throw new Error(
`npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`,
);
}
if (manifest.releaseSha !== params.targetSha) {
throw new Error(
`npm preflight SHA mismatch: expected ${params.targetSha}, got ${manifest.releaseSha}`,
);
}
if (manifest.npmDistTag !== params.npmDistTag) {
throw new Error(
`npm preflight dist-tag mismatch: expected ${params.npmDistTag}, got ${manifest.npmDistTag}`,
);
}
if (!manifest.tarballName || !manifest.tarballSha256) {
throw new Error("npm preflight manifest missing tarball metadata");
}
if (!Array.isArray(manifest.dependencyTarballs)) {
throw new Error("npm preflight manifest missing dependency tarball metadata");
}
for (const dependency of manifest.dependencyTarballs) {
if (
!dependency?.packageName ||
!dependency.packageVersion ||
!dependency.tarballName ||
!dependency.tarballSha256 ||
dependency.tarballName !== basename(dependency.tarballName)
) {
throw new Error("npm preflight manifest contains invalid dependency tarball metadata");
}
}
}
export function validateFullManifest(manifest, params) {
if (manifest.workflowName !== "Full Release Validation") {
throw new Error(`full validation workflow mismatch: ${manifest.workflowName}`);
}
if (manifest.targetSha !== params.targetSha) {
throw new Error(
`full validation SHA mismatch: expected ${params.targetSha}, got ${manifest.targetSha}`,
);
}
if (manifest.releaseProfile !== params.releaseProfile) {
throw new Error(
`full validation profile mismatch: expected ${params.releaseProfile}, got ${manifest.releaseProfile}`,
);
}
if (manifest.rerunGroup !== "all") {
throw new Error(`full validation must use rerun_group=all, got ${manifest.rerunGroup}`);
}
if (
(params.releaseProfile === "stable" || params.releaseProfile === "full") &&
manifest.runReleaseSoak !== "true"
) {
throw new Error(
`full validation must record runReleaseSoak=true for ${params.releaseProfile} release candidates`,
);
}
if (manifest.controls?.performanceBlocking !== true) {
throw new Error("full validation manifest must record blocking product performance evidence");
}
}
export function candidateParallelsArgs(tarballPath, dependencyTarballPaths = []) {
return [
"test:parallels:npm-update",
"--",
"--target-tarball",
tarballPath,
...dependencyTarballPaths.flatMap((dependency) => ["--dependency-tarball", dependency]),
"--json",
];
}
export function candidateParallelsShellCommand(
tarballPath,
timeoutBin,
dependencyTarballPaths = [],
) {
return [
'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;',
"exec",
shellQuote(timeoutBin),
"--foreground",
"150m",
"pnpm",
...candidateParallelsArgs(tarballPath, dependencyTarballPaths).map(shellQuote),
].join(" ");
}
async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths) {
if (options.skipParallels) {
return { status: "skipped", reason: "operator skipped --skip-parallels" };
}
const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], {
capture: true,
}).trim();
const command = candidateParallelsShellCommand(tarballPath, timeoutBin, dependencyTarballPaths);
run("bash", ["-lc", command]);
return {
status: "passed",
command,
};
}
async function runTelegramIfNeeded(options, artifactName) {
if (options.skipTelegram) {
return { status: "skipped" };
}
const workflowFile = "npm-telegram-beta-e2e.yml";
const runId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
package_spec: `openclaw@${options.tag.replace(/^v/u, "")}`,
package_label: options.tag,
package_artifact_name: artifactName,
package_artifact_run_id: options.npmPreflightRunId,
harness_ref: options.workflowRef,
provider_mode: options.telegramProviderMode,
});
const runLocal = await waitForSuccessfulRun(options.repo, runId, {
workflowName: "NPM Telegram Beta E2E",
workflowRef: options.workflowRef,
});
return {
status: "passed",
runId,
url: runLocal.url,
artifactName,
providerMode: options.telegramProviderMode,
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const targetRoot = gitTopLevel(process.cwd());
const toolingRoot = gitTopLevel(TOOLING_ROOT);
if (targetRoot === toolingRoot) {
runFromTrustedTooling(process.argv.slice(2), {
targetRoot,
workflowRef: options.workflowRef,
});
return;
}
options.outputDir ||= join(".artifacts", "release-candidate", options.tag);
const targetSha = gitRevParse(`${options.tag}^{}`, targetRoot);
const toolingSha = gitRevParse("HEAD", TOOLING_ROOT);
const trustedToolingSha = fetchTrustedWorkflowSha(options.workflowRef, TOOLING_ROOT);
validateCandidateCheckout({
targetSha,
targetHeadSha: gitRevParse("HEAD", targetRoot),
targetTrackedStatus: gitTrackedStatus(targetRoot),
toolingSha,
trustedToolingSha,
toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT),
workflowRef: options.workflowRef,
});
const statePath = join(options.outputDir, RELEASE_CANDIDATE_STATE_FILE);
const expectedState = buildReleaseCandidateState(options, { targetSha, toolingSha });
let candidateState = reconcileReleaseCandidateState(
existsSync(statePath) ? readJson(statePath, "release candidate state") : undefined,
expectedState,
);
options.fullReleaseRunId = candidateState.fullReleaseRunId;
options.npmPreflightRunId = candidateState.npmPreflightRunId;
writeReleaseCandidateState(statePath, candidateState);
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;
options.windowsNodeInstallerDigests = windowsNodeSourceRelease
? JSON.stringify(
Object.fromEntries(
windowsNodeSourceRelease.assets.map((asset) => [asset.name, asset.digest]),
),
)
: "";
const localGeneratedCheck = runLocalGeneratedCheckIfNeeded(options);
if (!options.fullReleaseRunId && !options.skipDispatch) {
const workflowFile = "full-release-validation.yml";
const targetContextRef = releaseBranchForTag(options.tag);
options.fullReleaseRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
ref: options.tag,
...(targetContextRef ? { target_context_ref: targetContextRef } : {}),
provider: options.provider,
mode: options.mode,
release_profile: options.releaseProfile,
run_release_soak:
options.releaseProfile === "stable" || options.releaseProfile === "full" ? "true" : "false",
rerun_group: "all",
});
candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", {
fullReleaseRunId: options.fullReleaseRunId,
});
}
if (!options.npmPreflightRunId && !options.skipDispatch) {
const workflowFile = "openclaw-npm-release.yml";
options.npmPreflightRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
tag: options.tag,
preflight_only: "true",
npm_dist_tag: options.npmDistTag,
});
candidateState = updateReleaseCandidateState(statePath, candidateState, "dispatching", {
npmPreflightRunId: options.npmPreflightRunId,
});
}
candidateState = updateReleaseCandidateState(statePath, candidateState, "waiting", {
fullReleaseRunId: options.fullReleaseRunId,
npmPreflightRunId: options.npmPreflightRunId,
});
const fullRun = await waitForSuccessfulRun(options.repo, options.fullReleaseRunId, {
workflowName: "Full Release Validation",
workflowRef: options.workflowRef,
allowShaPinnedWorkflowRef: true,
});
const npmRun = await waitForSuccessfulRun(options.repo, options.npmPreflightRunId, {
workflowName: "OpenClaw NPM Release",
workflowRef: options.workflowRef,
});
if (npmRun.headSha !== targetSha) {
throw new Error(`run SHA mismatch: tag=${targetSha} npm=${npmRun.headSha}`);
}
const npmDir = join(options.outputDir, "npm-preflight");
const fullDir = join(options.outputDir, "full-release-validation");
const npmArtifactName = await downloadResolvedArtifact(
options.repo,
options.npmPreflightRunId,
`openclaw-npm-preflight-${options.tag}`,
"openclaw-npm-preflight-",
npmDir,
);
if (!Number.isInteger(fullRun.runAttempt) || fullRun.runAttempt < 1) {
throw new Error(`Full Release Validation run ${options.fullReleaseRunId} has invalid attempt.`);
}
const fullArtifactName = `full-release-validation-${options.fullReleaseRunId}-${fullRun.runAttempt}`;
downloadArtifact(options.repo, options.fullReleaseRunId, fullArtifactName, fullDir);
const npmManifest = readJson(join(npmDir, "preflight-manifest.json"), "npm preflight manifest");
const fullManifest = readJson(
join(fullDir, "full-release-validation-manifest.json"),
"full validation manifest",
);
run("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"], {
capture: true,
});
const fullValidationEvidence = validateFullReleaseValidationEvidence({
run: fullRun,
manifest: fullManifest,
expectedRepository: options.repo,
expectedRunId: options.fullReleaseRunId,
expectedTargetSha: targetSha,
expectedWorkflowBranch: options.workflowRef,
isTrustedMainAncestor: (sha) => gitIsAncestor(sha, "refs/remotes/origin/main"),
validateEvidenceReuseStrictly: ({ repository, runId }) =>
runStrictReleaseEvidenceValidation({ repository, runId }),
});
if (fullValidationEvidence.source === "direct" && fullRun.headSha !== targetSha) {
throw new Error(`run SHA mismatch: tag=${targetSha} full=${fullRun.headSha}`);
}
validatePreflightManifest(npmManifest, {
tag: options.tag,
targetSha,
npmDistTag: options.npmDistTag,
});
validateFullManifest(fullManifest, {
targetSha,
releaseProfile: options.releaseProfile,
});
const tarballPath = join(npmDir, npmManifest.tarballName);
if (!existsSync(tarballPath)) {
throw new Error(`prepared tarball missing: ${tarballPath}`);
}
const actualTarballSha = sha256(tarballPath);
if (actualTarballSha !== npmManifest.tarballSha256) {
throw new Error(
`prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`,
);
}
const dependencyTarballPaths = npmManifest.dependencyTarballs.map((dependency) => {
const dependencyPath = join(npmDir, dependency.tarballName);
if (!existsSync(dependencyPath)) {
throw new Error(`prepared dependency tarball missing: ${dependencyPath}`);
}
const actualDependencySha = sha256(dependencyPath);
if (actualDependencySha !== dependency.tarballSha256) {
throw new Error(
`prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`,
);
}
return dependencyPath;
});
const parallels = await runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
options.npmTelegramRunId = npmTelegram.runId ?? "";
const pluginNpmPlan = await collectPluginPlanWithRetry(
"scripts/plugin-npm-release-plan.ts",
options,
);
const pluginClawHubPlan = await collectPluginPlanWithRetry(
"scripts/plugin-clawhub-release-plan.ts",
options,
);
const publishCommand = buildPublishCommand({
...options,
fullReleaseRunAttempt: fullRun.runAttempt,
});
const evidence = {
version: 1,
tag: options.tag,
targetSha,
workflowRef: options.workflowRef,
npmDistTag: options.npmDistTag,
fullReleaseValidationRunId: options.fullReleaseRunId,
fullReleaseValidationRunAttempt: fullRun.runAttempt,
npmPreflightRunId: options.npmPreflightRunId,
windowsNodeTag: options.windowsNodeTag || undefined,
windowsNodeSourceRelease,
fullReleaseValidationUrl: fullRun.url,
fullReleaseValidationControls: fullManifest.controls,
npmPreflightUrl: npmRun.url,
artifacts: {
npmPreflight: npmArtifactName,
fullReleaseValidation: fullArtifactName,
},
releaseNotesCheck,
releaseNotesProvenance,
localGeneratedCheck,
tarball: {
name: basename(tarballPath),
sha256: actualTarballSha,
path: tarballPath,
},
parallels,
npmTelegram,
pluginNpmPlan,
pluginClawHubPlan,
publishCommand,
};
mkdirSync(options.outputDir, { recursive: true });
const evidencePath = join(options.outputDir, "release-candidate-evidence.json");
const evidenceMarkdownPath = join(options.outputDir, "release-candidate-evidence.md");
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
writeFileSync(
evidenceMarkdownPath,
[
`# ${options.tag} release candidate evidence`,
"",
`- target SHA: ${targetSha}`,
`- full release validation: ${options.fullReleaseRunId} ${fullRun.url}`,
`- npm preflight: ${options.npmPreflightRunId} ${npmRun.url}`,
...(windowsNodeSourceRelease
? [
`- Windows Node source release: ${windowsNodeSourceRelease.tag} ${windowsNodeSourceRelease.url}`,
...windowsNodeSourceRelease.assets.map(
(asset) => `- Windows Node source asset: ${asset.name} ${asset.digest}`,
),
]
: []),
`- 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})` : ""
}`,
`- tarball: ${basename(tarballPath)}`,
`- tarball sha256: ${actualTarballSha}`,
`- npm dist-tag: ${options.npmDistTag}`,
`- plugin npm plan: ${pluginNpmPlan.packages?.length ?? 0} packages`,
`- ClawHub plan: ${pluginClawHubPlan.packages?.length ?? 0} packages`,
`- Parallels: ${parallels.status}${parallels.reason ? ` (${parallels.reason})` : ""}`,
`- NPM Telegram E2E: ${npmTelegram.status}${
npmTelegram.runId ? ` ${npmTelegram.runId} ${npmTelegram.url}` : ""
}`,
"",
"Publish command:",
"",
"```bash",
publishCommand,
"```",
"",
].join("\n"),
);
updateReleaseCandidateState(statePath, candidateState, "completed");
console.log(`release candidate evidence: ${evidencePath}`);
console.log(`release candidate summary: ${evidenceMarkdownPath}`);
console.log("publish command:");
console.log(publishCommand);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
},
);
}