openclaw/scripts/release-approval-receipt.mjs
Peter Steinberger 14cc3a4da8
chore(release): retire the internal Tideclaw alpha release track (#160261)
* chore(release): retire the Tideclaw alpha release track

Tideclaw alpha/nightly publication is retired. Alpha remains readable as
history (existing v*-alpha tags, published versions, changelog and
upgrade-survivor baselines, product version ordering), but it can no longer
authorize a release.

Every active release boundary now rejects an alpha version or -alpha.N tag,
the alpha npm dist-tag, and tideclaw/alpha/* workflow or tooling routes:
preparation, Full Release Validation publication selection, npm preflight,
approval receipts, core/plugin npm and ClawHub publication, native handoffs,
and finalization. Channel mappings throw for alpha instead of falling through
to latest. The Tideclaw branch routes, alpha dist-tag options, the alpha FRV
publication route, and the alpha-only Docker runtime-assets job are removed.
Beta, stable, extended-stable, and correction releases are unchanged.

The release-openclaw-nightly skill is deleted and the release skills and docs
no longer describe the alpha track.

* test(release): drop retired alpha preparation and finalization expectations

* test(release): drop remaining retired alpha references
2026-09-28 10:01:49 -07:00

448 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import {
downloadExactActionsArtifactArchive,
inspectActionsArtifactZipWithPolicy,
} from "./lib/actions-artifact-archive.mjs";
import { isRecord } from "./lib/record-shared.mjs";
import {
runReleaseToolingGh,
validateReleasePublishParentRun,
} from "./release-tooling-identity.mjs";
const REPOSITORY = "openclaw/openclaw";
const PARENT_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
const PARENT_APPROVAL_JOB = "Publish plugins, then OpenClaw";
const PARENT_RECEIPT_STEP = "Write release approval receipt";
const APPROVAL_ENVIRONMENT = "npm-release";
const MAX_RECEIPT_BYTES = 8 * 1024;
const SHA = /^[a-f0-9]{40}$/u;
const ID = /^[1-9][0-9]*$/u;
const RELEASE_TAG = /^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-beta\.[1-9][0-9]*|-[1-9][0-9]*)?$/u;
const RECEIPT_KEYS =
"version kind repository parentWorkflow parentRunId parentRunAttempt toolingRef toolingFullRef toolingSha releaseTag targetSha npmDistTag environment approvalJob approver".split(
" ",
);
function pattern(value, expression, label) {
if (typeof value !== "string" || !expression.test(value)) {
throw new Error(`${label} is invalid.`);
}
return value;
}
function refIdentity(ref, fullRef, sha) {
pattern(sha, SHA, "Workflow SHA");
if (fullRef === "refs/heads/main" && ref === "main") {
return;
}
if (
!/^release-publish\/[a-f0-9]{12}-[1-9][0-9]*$/u.test(ref) ||
fullRef !== `refs/tags/${ref}` ||
!ref.startsWith(`release-publish/${sha.slice(0, 12)}-`)
) {
throw new Error(
"Release approval tooling must use main or an exact protected release-publish tag.",
);
}
}
function npmApprovals(approvals) {
if (!Array.isArray(approvals)) {
throw new Error("Release approval history must be an array.");
}
return approvals.filter(
(entry) =>
entry?.state === "approved" &&
Array.isArray(entry.environments) &&
entry.environments.some((environment) => environment?.name === APPROVAL_ENVIRONMENT),
);
}
export function releaseApprovalArtifactName({ parentRunId, parentRunAttempt }) {
pattern(parentRunId, ID, "Parent run id");
pattern(parentRunAttempt, ID, "Parent run attempt");
return `openclaw-release-approval-v1-${parentRunId}-${parentRunAttempt}`;
}
export function validateReleaseApprovalReceipt(value) {
if (
!isRecord(value) ||
Object.keys(value).length !== RECEIPT_KEYS.length ||
RECEIPT_KEYS.some((key) => !Object.hasOwn(value, key))
) {
throw new Error("Release approval receipt fields are invalid.");
}
if (value.version !== 1) {
throw new Error("Release approval receipt version must be 1.");
}
for (const key of RECEIPT_KEYS.slice(1)) {
if (typeof value[key] !== "string") {
throw new Error(`Release approval receipt ${key} must be a string.`);
}
}
for (const [key, expected] of Object.entries({
kind: "openclaw-release-approval",
repository: REPOSITORY,
parentWorkflow: PARENT_WORKFLOW,
environment: APPROVAL_ENVIRONMENT,
approvalJob: PARENT_APPROVAL_JOB,
})) {
if (value[key] !== expected) {
throw new Error(`Release approval receipt ${key} mismatch.`);
}
}
for (const key of ["parentRunId", "parentRunAttempt"]) {
pattern(value[key], ID, key);
}
if (
value.releaseTag.includes("-alpha.") ||
value.npmDistTag === "alpha" ||
value.toolingRef.includes("tideclaw/alpha/") ||
value.toolingFullRef.includes("tideclaw/alpha/")
) {
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
}
refIdentity(value.toolingRef, value.toolingFullRef, value.toolingSha);
pattern(value.releaseTag, RELEASE_TAG, "Release tag");
pattern(value.targetSha, SHA, "Target SHA");
if (!["latest", "beta", "extended-stable"].includes(value.npmDistTag)) {
throw new Error("Release approval npm dist-tag is invalid.");
}
if (
typeof value.approver !== "string" ||
!value.approver.trim() ||
/\[bot\]$/iu.test(value.approver)
) {
throw new Error("Release approval approver must be a human login.");
}
if (Buffer.byteLength(JSON.stringify(value)) + 1 > MAX_RECEIPT_BYTES) {
throw new Error("Release approval receipt exceeds 8 KiB.");
}
return value;
}
export function createReleaseApprovalReceipt(env, runGhJson = api) {
if (env.GITHUB_EVENT_NAME !== "workflow_dispatch") {
throw new Error("Release approval requires workflow_dispatch.");
}
if (env.GITHUB_WORKFLOW_REF !== `${REPOSITORY}/${PARENT_WORKFLOW}@${env.GITHUB_REF}`) {
throw new Error("Release approval workflow ref does not match the executing parent.");
}
const parentRunId = pattern(env.GITHUB_RUN_ID, ID, "Parent run id");
const approvals = npmApprovals(runGhJson(`actions/runs/${parentRunId}/approvals`));
if (approvals.length === 0) {
throw new Error("Release approval requires an approved npm-release environment entry.");
}
return validateReleaseApprovalReceipt({
version: 1,
kind: "openclaw-release-approval",
repository: env.GITHUB_REPOSITORY,
parentWorkflow: PARENT_WORKFLOW,
parentRunId,
parentRunAttempt: env.GITHUB_RUN_ATTEMPT,
toolingRef: env.GITHUB_REF_NAME,
toolingFullRef: env.GITHUB_REF,
toolingSha: env.GITHUB_WORKFLOW_SHA,
releaseTag: env.RELEASE_TAG,
targetSha: env.TARGET_SHA,
npmDistTag: env.RELEASE_NPM_DIST_TAG,
environment: APPROVAL_ENVIRONMENT,
approvalJob: PARENT_APPROVAL_JOB,
approver: approvals.at(-1).user?.login,
});
}
export function verifyReleaseApprovalReceipt({
receipt,
expected,
parentRun,
parentJobs,
approvals,
artifact,
}) {
validateReleaseApprovalReceipt(receipt);
for (const key of [
"repository",
"parentRunId",
"parentRunAttempt",
"toolingRef",
"toolingFullRef",
"toolingSha",
...["releaseTag", "targetSha", "npmDistTag"].filter((field) => expected[field] !== undefined),
]) {
if (receipt[key] !== expected[key]) {
throw new Error(`Release approval receipt ${key} does not match this publication.`);
}
}
if (artifact?.name !== releaseApprovalArtifactName(receipt)) {
throw new Error("Release approval artifact name mismatch.");
}
if (artifact.expired !== false) {
throw new Error("Release approval artifact is expired.");
}
if (String(artifact.workflow_run?.id) !== receipt.parentRunId) {
throw new Error("Release approval artifact parent run mismatch.");
}
if (artifact.workflow_run.head_sha !== receipt.toolingSha) {
throw new Error("Release approval artifact tooling SHA mismatch.");
}
if (
artifact.workflow_run.repository_id === undefined ||
artifact.workflow_run.repository_id !== artifact.workflow_run.head_repository_id
) {
throw new Error("Release approval artifact repository mismatch.");
}
if (!Array.isArray(parentJobs?.jobs) || parentJobs.total_count !== parentJobs.jobs.length) {
throw new Error("Release approval parent job inventory is incomplete.");
}
const jobs = parentJobs.jobs.filter((job) => job?.name === PARENT_APPROVAL_JOB);
if (jobs.length !== 1) {
throw new Error("Release approval parent job must be unique.");
}
const [job] = jobs;
if (
job.head_sha !== receipt.toolingSha ||
String(job.run_id) !== receipt.parentRunId ||
job.run_attempt !== Number(receipt.parentRunAttempt) ||
!["in_progress", "completed"].includes(job.status)
) {
throw new Error("Release approval parent job identity or status mismatch.");
}
const steps = job.steps?.filter((step) => step?.name === PARENT_RECEIPT_STEP);
if (
!Array.isArray(steps) ||
steps.length !== 1 ||
steps[0].status !== "completed" ||
steps[0].conclusion !== "success"
) {
throw new Error("Release approval receipt step did not complete successfully.");
}
if (!npmApprovals(approvals).some((entry) => entry.user?.login === receipt.approver)) {
throw new Error("Release approval approver is not in the approved npm-release history.");
}
validateReleasePublishParentRun({
identity: { sha: receipt.toolingSha },
releasePublishRef: receipt.toolingRef,
releasePublishFullRef: receipt.toolingFullRef,
releasePublishRunId: receipt.parentRunId,
releasePublishRunAttempt: receipt.parentRunAttempt,
repository: receipt.repository,
releasePublishParentStatePolicy: expected.parentStatePolicy ?? "active",
run: parentRun,
});
return receipt;
}
function api(path) {
const raw = runReleaseToolingGh(["api", `repos/${REPOSITORY}/${path}`, "--method", "GET"]);
if (Buffer.byteLength(raw) > 4 * 1024 * 1024) {
throw new Error("GitHub metadata exceeds limit.");
}
return JSON.parse(raw);
}
async function readReleaseApprovalReceipt({ expected, token, runGhJson = api, fetchImpl }) {
const name = releaseApprovalArtifactName(expected);
const runPath = `actions/runs/${expected.parentRunId}/attempts/${expected.parentRunAttempt}`;
const deadline = Date.now() + 5 * 60 * 1000;
let listed;
for (;;) {
listed = runGhJson(`actions/runs/${expected.parentRunId}/artifacts?name=${name}&per_page=100`);
if (listed.total_count !== 0 || listed.artifacts?.length !== 0) {
break;
}
if (runGhJson(runPath).status !== "in_progress" || Date.now() >= deadline) {
throw new Error(
"Release approval artifact is missing; parent is inactive or receipt wait timed out.",
);
}
await new Promise((resolve) => {
setTimeout(resolve, Math.min(10000, deadline - Date.now()));
});
}
if (listed.total_count !== 1 || listed.artifacts?.length !== 1) {
throw new Error("Exact release approval artifact is missing or ambiguous.");
}
const artifact = listed.artifacts[0];
const maxArchiveBytes = MAX_RECEIPT_BYTES * 4;
const { archiveBytes } = await downloadExactActionsArtifactArchive({
expected: {
repository: expected.repository,
artifactId: artifact.id,
artifactName: name,
artifactDigest: artifact.digest,
artifactSizeBytes: artifact.size_in_bytes,
artifactExpiresAt: artifact.expires_at,
runId: Number(expected.parentRunId),
workflowSha: expected.toolingSha,
},
token,
fetchImpl,
maxArchiveBytes,
retryAttempts: 1,
});
const files = inspectActionsArtifactZipWithPolicy(archiveBytes, {
expectedEntries: ["approval.json"],
maxArchiveBytes,
maxExpandedBytes: MAX_RECEIPT_BYTES,
maxEntryBytes: () => MAX_RECEIPT_BYTES,
});
const receiptBytes = files.get("approval.json");
const receipt = verifyReleaseApprovalReceipt({
receipt: JSON.parse(receiptBytes.toString("utf8")),
expected,
parentRun: runGhJson(runPath),
parentJobs: runGhJson(`${runPath}/jobs?per_page=100`),
approvals: runGhJson(`actions/runs/${expected.parentRunId}/approvals`),
artifact,
});
return { receipt, artifact, receiptBytes };
}
export async function downloadReleaseApprovalReceipt(params) {
const { receipt, artifact } = await readReleaseApprovalReceipt(params);
return { receipt, artifact };
}
// ClawHub may finish after its parent; npm publication requires a live parent
// even when its approval artifact already exists.
export async function awaitParentAuthorization({
parentRunId,
parentRunAttempt,
expectedArtifactName: name,
requireInProgress = false,
toolingSha,
runGhJson = api,
sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
deadlineMs = 90 * 60 * 1000,
}) {
pattern(toolingSha, SHA, "Tooling SHA");
for (const [value, label] of [
[parentRunId, "Parent run id"],
[parentRunAttempt, "Parent run attempt"],
]) {
pattern(value, ID, label);
}
const deadline = Date.now() + deadlineMs;
for (;;) {
const listed = runGhJson(`actions/runs/${parentRunId}/artifacts?name=${name}&per_page=100`);
const artifacts = Array.isArray(listed.artifacts) ? listed.artifacts : [];
if (listed.total_count > 1 || artifacts.length > 1) {
throw new Error(`Release parent authorization ${name} is ambiguous.`);
}
const [artifact] = artifacts;
if (artifact) {
if (
artifact.name !== name ||
artifact.expired !== false ||
String(artifact.workflow_run?.id) !== parentRunId ||
artifact.workflow_run.head_sha !== toolingSha
) {
throw new Error(`Release parent authorization ${name} does not belong to the parent.`);
}
if (!requireInProgress) {
return artifact;
}
}
const run = runGhJson(`actions/runs/${parentRunId}/attempts/${parentRunAttempt}`);
if (run.status !== "in_progress" || run.conclusion !== null) {
throw new Error(
`Release parent ${parentRunId}/${parentRunAttempt} is ${run.status}/${run.conclusion ?? "none"} without authorizing publication.`,
);
}
if (artifact) {
return artifact;
}
if (Date.now() >= deadline) {
throw new Error(`Release parent authorization ${name} did not appear before the deadline.`);
}
await sleep(Math.min(15000, deadline - Date.now()));
}
}
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: { output: { type: "string" } },
});
const env = process.env;
if (["wait-clawhub-authorization", "wait-npm-authorization"].includes(positionals[0])) {
const npm = positionals[0] === "wait-npm-authorization";
const parentRunId = env.RELEASE_PUBLISH_RUN_ID;
const parentRunAttempt = env.RELEASE_PUBLISH_RUN_ATTEMPT;
const expectedArtifactName = npm
? releaseApprovalArtifactName({ parentRunId, parentRunAttempt })
: `openclaw-clawhub-parent-authorization-v2-${parentRunId}-${parentRunAttempt}-${pattern(env.GITHUB_RUN_ID, ID, "Child run id")}-${pattern(env.GITHUB_RUN_ATTEMPT, ID, "Child run attempt")}`;
const artifact = await awaitParentAuthorization({
parentRunId,
parentRunAttempt,
expectedArtifactName,
requireInProgress: npm,
toolingSha: env.EXPECTED_WORKFLOW_SHA,
});
console.log(
`Release parent authorized ${npm ? "npm publication" : "ClawHub transactions"}: ${artifact.name}`,
);
return;
}
if (!values.output || positionals.length !== 1) {
throw new Error(
"Expected create, verify --output <path>, wait-clawhub-authorization, or wait-npm-authorization.",
);
}
let bytes;
let output;
let message;
if (positionals[0] === "create") {
const receipt = createReleaseApprovalReceipt(env);
bytes = `${JSON.stringify(receipt)}\n`;
output = `artifact_name=${releaseApprovalArtifactName(receipt)}\n`;
} else if (positionals[0] === "verify") {
const { receipt, artifact, receiptBytes } = await readReleaseApprovalReceipt({
expected: {
repository: env.GITHUB_REPOSITORY,
parentRunId: env.RELEASE_PUBLISH_RUN_ID,
parentRunAttempt: env.RELEASE_PUBLISH_RUN_ATTEMPT,
toolingRef: env.EXPECTED_WORKFLOW_BRANCH,
toolingFullRef: env.EXPECTED_WORKFLOW_FULL_REF,
toolingSha: env.EXPECTED_WORKFLOW_SHA,
...(env.RELEASE_TAG ? { releaseTag: env.RELEASE_TAG } : {}),
...(env.RELEASE_TARGET_SHA ? { targetSha: env.RELEASE_TARGET_SHA } : {}),
...(env.RELEASE_NPM_DIST_TAG ? { npmDistTag: env.RELEASE_NPM_DIST_TAG } : {}),
parentStatePolicy: env.RELEASE_PUBLISH_PARENT_STATE_POLICY || "active",
},
token: env.GH_TOKEN,
});
// Attestation verification must receive the downloaded bytes, including whitespace.
bytes = receiptBytes;
output = "parent_approval=receipt\n";
message = `Verified release approval receipt ${artifact.name} approved by ${receipt.approver}`;
} else {
throw new Error("Expected create or verify.");
}
mkdirSync(dirname(values.output), { recursive: true });
writeFileSync(values.output, bytes, { flag: "wx" });
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, output);
}
if (message) {
console.log(message);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}