openclaw/scripts/lib/release-stable-state.mts
Peter Steinberger 4b9e0b3866
refactor: consolidate cross-directory duplicate code (#161352)
* refactor: consolidate cross-directory duplicate code

Reuse canonical parsing, bounded-map, session-scope and service-binding owners. Share Markdown breakpoint scanning and Responses web-search detection; route lazy WebSocket consumers through the existing portable transport and remove their obsolete build rewrites. Preserve SDK, wire and persisted-state contracts.

* refactor: reuse allow-from projections in channel setup

* refactor: share literal schema merging through an internal owner

Keep schema guards and provider-specific flattening intact. Isolate the shared web-search predicate from exported projection types so its private declaration does not enter SDK type closures.

* refactor: reuse shared listeners and remove forwarding helpers

Reuse existing listener, normalization, setup-visibility and cache-control owners. Preserve callback ordering, error propagation, string filtering and public SDK exports. Remove obsolete internal aliases and their stale test mocks.

* refactor: retain SDK-reachable setup visibility wrapper

Drop the setup-visibility cleanup group after the strict SDK comparison identified its namespace declaration in 123 reachable export closures. Preserve the existing API without acknowledgement or baseline changes.

* refactor: reuse canonical helpers across runtime consumers

Reuse result, numeric, string, filesystem, blocked-key, and map-eviction owners while preserving caller policy and output. Remove redundant private branches and repeated comments. Doctor inputs, transformations, warnings, and persisted state remain unchanged.

* refactor: consolidate shared runtime and tooling operations

Preserve per-caller validation, ordering, diagnostics and public SDK contracts while reusing canonical owners. Correct the Discord plural reply-count diagnostic with an existing handler regression.

* refactor: narrow consolidation to retained runtime owners

Drop the new SQLite scope and cache invalidation extractions after their local fixture preparation exceeded the maintainer quick-validation budget.

* refactor: keep Discord cleanup behavior neutral

Drop the optional diagnostic spelling change and its unvalidated new test under the maintainer quick-validation limit. Runtime import edges are unchanged; the reverted test only removes an import edge from the verified acyclic graph.

* fix: repair consolidation checks and restart fixture

Remove two obsolete imports, tighten assertion counts after cast removal, and load the real null-writer in the restart outcome VM fixture. The focused restart fixture passes all 19 existing native cases.

* refactor: drop unproven session-runtime clone groups

Keep the original Codex environment reader and session-runtime helpers after an unattributed worker teardown failure. Preserve the test assertions and leave worker lifecycle repairs to their owner.

* refactor: preserve main sandbox string filtering
2026-09-29 23:05:36 -07:00

563 lines
18 KiB
TypeScript

import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline/promises";
import { setTimeout as delay } from "node:timers/promises";
import { z } from "zod";
import { isRecord } from "./record-shared.mjs";
export const RELEASE_PHASES = [
"cut",
"validate",
"publish",
"flip-github",
"sync-beta",
"macos",
"closeout",
] as const;
export type ReleasePhase = (typeof RELEASE_PHASES)[number];
const phaseSchema = z.enum(RELEASE_PHASES);
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const id = z.string().regex(/^[1-9][0-9]*$/u);
const timestamp = z
.string()
.refine((value) => Number.isFinite(Date.parse(value)), "Invalid timestamp");
const phaseState = z.strictObject({
status: z.enum(["pending", "running", "completed", "refused"]),
startedAt: timestamp.optional(),
completedAt: timestamp.optional(),
error: z.string().optional(),
});
const releaseStateSchema = z.strictObject({
version: z.literal(1),
release: z.string().regex(/^\d{4}\.\d{1,2}\.\d+$/u),
tag: z.string(),
branch: z.string(),
repo: z.string(),
releasesRepo: z.string(),
startedAt: timestamp,
operator: z.strictObject({
name: z.string(),
login: z.string().min(1).optional(),
cutShaConfirmed: sha.nullable(),
publicationApproved: timestamp.nullable(),
}),
capabilities: z
.strictObject({
parentSyncsBetaDistTag: z.boolean(),
parentSweepsStaleChildren: z.boolean().optional(),
childNpmPublishEnvironment: z.boolean().optional(),
probedAt: timestamp,
toolingSha: sha,
})
.optional(),
phases: z.strictObject({
cut: phaseState,
validate: phaseState,
publish: phaseState,
"sync-beta": phaseState,
"flip-github": phaseState,
macos: phaseState,
closeout: phaseState,
}),
cut: z.strictObject({ cutSha: sha.optional(), releaseSha: sha.optional() }),
validate: z.strictObject({
toolingSha: sha.optional(),
toolingTag: z.string().optional(),
requestFile: z.string().optional(),
runId: id.optional(),
runAttempt: z.number().int().positive().optional(),
}),
publish: z.strictObject({
candidateDir: z.string().optional(),
publishRunId: id.optional(),
dispatchedAt: timestamp.optional(),
approvedGates: z.array(z.string()),
npmVisibleAt: timestamp.optional(),
macosValidateRunId: id.optional(),
macosPreflightRunId: id.optional(),
}),
syncBeta: z.strictObject({ runId: id.optional(), verifiedAt: timestamp.optional() }),
flipGithub: z.strictObject({
flippedBy: z.enum(["parent", "orchestrator"]).optional(),
verifiedAt: timestamp.optional(),
}),
macos: z.strictObject({
validateRunId: id.optional(),
preflightRunId: id.optional(),
publishRunId: id.optional(),
appcastVerifiedAt: timestamp.optional(),
}),
closeout: z.strictObject({
publishRunConclusion: z.string().optional(),
runId: id.optional(),
verifiedAt: timestamp.optional(),
}),
history: z.array(
z.strictObject({ at: timestamp, phase: phaseSchema, event: z.string(), detail: z.string() }),
),
});
// Older writers recorded retry and tooling-capability metadata even for waiver-free
// releases. Discard only that bookkeeping; actual waiver fields still fail parsing.
const releaseStateReadSchema = releaseStateSchema.extend({
capabilities: releaseStateSchema.shape.capabilities
.unwrap()
.extend({
closeoutResolvesWaivers: z.boolean().optional(),
parentApprovalReceipt: z.boolean().optional(),
})
.transform(
({
closeoutResolvesWaivers: _closeoutResolvesWaivers,
parentApprovalReceipt: _parentApprovalReceipt,
...capabilities
}) => capabilities,
)
.optional(),
validate: releaseStateSchema.shape.validate
.extend({ continues: z.number().int().min(0).optional() })
.transform(({ continues: _continues, ...validation }) => validation),
});
export type ReleaseState = z.infer<typeof releaseStateSchema>;
export type ReleaseOptions = {
release: string;
repo: string;
releasesRepo: string;
stateDir: string;
operator: string;
from?: ReleasePhase;
dryRun: boolean;
status: boolean;
approvePublication: boolean;
cutSha?: string;
confirmCutSha?: string;
toolingSha?: string;
pluginSdkApiAcknowledgement?: string;
macosPreflightRunId?: string;
macosValidateRunId?: string;
};
export class ReleaseRefusal extends Error {
next: string[];
constructor(message: string, next: string[]) {
super(message);
this.name = "ReleaseRefusal";
this.next = next;
}
}
export function acquireReleaseLock(stateDir: string): () => void {
const path = join(stateDir, "state.lock");
const next = [`# wait for it or remove ${path} if that pid is gone`];
const contents = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
mkdirSync(stateDir, { recursive: true });
let acquired = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
writeFileSync(path, contents, { flag: "wx", mode: 0o600 });
acquired = true;
break;
} catch (error) {
if (!isRecord(error) || error.code !== "EEXIST") {
throw error;
}
}
let previous: string;
try {
previous = readFileSync(path, "utf8");
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
continue;
}
throw error;
}
let lock: unknown;
try {
lock = JSON.parse(previous);
} catch {
throw new ReleaseRefusal(`Cannot read release lock ${path}; inspect its owner.`, next);
}
if (
!isRecord(lock) ||
typeof lock.pid !== "number" ||
!Number.isSafeInteger(lock.pid) ||
lock.pid <= 0
) {
throw new ReleaseRefusal(`Invalid process ID in release lock ${path}.`, next);
}
let alive = true;
try {
process.kill(lock.pid, 0);
} catch (error) {
if (isRecord(error) && error.code === "ESRCH") {
alive = false;
} else if (!isRecord(error) || error.code !== "EPERM") {
throw error;
}
}
if (alive) {
throw new ReleaseRefusal(
`Another release:stable process (pid ${lock.pid}) owns ${stateDir}`,
next,
);
}
try {
if (readFileSync(path, "utf8") === previous) {
rmSync(path);
}
} catch (error) {
if (!isRecord(error) || error.code !== "ENOENT") {
throw error;
}
}
continue;
}
if (!acquired) {
throw new ReleaseRefusal(`Could not acquire release lock ${path} after three attempts.`, next);
}
const release = () => {
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
rmSync(path, { force: true });
};
const onSignal = (signal: NodeJS.Signals) => {
try {
release();
} finally {
process.kill(process.pid, signal);
}
};
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
return release;
}
export function isReleasePhase(value: string): value is ReleasePhase {
return phaseSchema.safeParse(value).success;
}
export function shellCommand(bin: string, args: string[]): string {
const quote = (value: string) =>
/^[a-zA-Z0-9_./:@=-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
return [bin, ...args].map(quote).join(" ");
}
export function resumeCommand(
options: ReleaseOptions,
phase?: ReleasePhase,
extra: string[] = [],
): string {
const args = ["release:stable", options.release];
if (phase) {
args.push("--from", phase);
}
const retained: [string, string | undefined][] = [
["repo", options.repo !== "openclaw/openclaw" ? options.repo : undefined],
[
"releases-repo",
options.releasesRepo !== "openclaw/releases" ? options.releasesRepo : undefined,
],
["state-dir", options.stateDir],
["operator", options.operator],
["cut-sha", options.cutSha],
["confirm-cut-sha", options.confirmCutSha],
["tooling-sha", options.toolingSha],
["plugin-sdk-api-acknowledgement", options.pluginSdkApiAcknowledgement],
["macos-preflight-run-id", phase === "macos" ? options.macosPreflightRunId : undefined],
["macos-validate-run-id", phase === "macos" ? options.macosValidateRunId : undefined],
];
for (const [name, value] of retained) {
if (value !== undefined && !extra.includes(`--${name}`)) {
args.push(`--${name}`, value);
}
}
for (const [name, enabled] of [
["approve-publication", options.approvePublication],
["dry-run", options.dryRun],
["status", options.status],
] as const) {
if (enabled && !extra.includes(`--${name}`)) {
args.push(`--${name}`);
}
}
return shellCommand("pnpm", [...args, ...extra]);
}
function createReleaseState(options: ReleaseOptions): ReleaseState {
const pending = (): ReleaseState["phases"]["cut"] => ({ status: "pending" });
return {
version: 1,
release: options.release,
tag: `v${options.release}`,
branch: `release/${options.release}`,
repo: options.repo,
releasesRepo: options.releasesRepo,
startedAt: new Date().toISOString(),
operator: { name: options.operator, cutShaConfirmed: null, publicationApproved: null },
phases: {
cut: pending(),
validate: pending(),
publish: pending(),
"sync-beta": pending(),
"flip-github": pending(),
macos: pending(),
closeout: pending(),
},
cut: {},
validate: {},
publish: { approvedGates: [] },
syncBeta: {},
flipGithub: {},
macos: {},
closeout: {},
history: [],
};
}
export function loadReleaseState(options: ReleaseOptions): ReleaseState {
const path = join(options.stateDir, "state.json");
if (!existsSync(path)) {
return createReleaseState(options);
}
try {
const state = releaseStateReadSchema.parse(JSON.parse(readFileSync(path, "utf8")));
if (
state.release !== options.release ||
state.tag !== `v${options.release}` ||
state.branch !== `release/${options.release}` ||
state.repo !== options.repo ||
state.releasesRepo !== options.releasesRepo
) {
throw new Error("release or repository does not match this command");
}
return state;
} catch (error) {
throw new ReleaseRefusal(
`Invalid state file ${path}: ${error instanceof Error ? error.message : String(error)}. Move the file aside before retrying.`,
[shellCommand("mv", [path, `${path}.invalid-${Date.now()}`]), resumeCommand(options)],
);
}
}
export function saveReleaseState(options: ReleaseOptions, state: ReleaseState): void {
if (options.dryRun) {
return;
}
releaseStateSchema.parse(state);
mkdirSync(options.stateDir, { recursive: true });
const temporary = join(options.stateDir, `state.json.tmp-${randomUUID()}`);
try {
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
renameSync(temporary, join(options.stateDir, "state.json"));
} finally {
rmSync(temporary, { force: true });
}
}
export function getReleasePhaseData(state: ReleaseState) {
return {
cut: state.cut,
validate: state.validate,
publish: state.publish,
"sync-beta": state.syncBeta,
"flip-github": state.flipGithub,
macos: state.macos,
closeout: state.closeout,
};
}
export function resetReleasePhases(state: ReleaseState, from: ReleasePhase): void {
const data = getReleasePhaseData(state);
for (const phase of RELEASE_PHASES.slice(RELEASE_PHASES.indexOf(from))) {
state.history.push({
at: new Date().toISOString(),
phase,
event: "reset",
detail: JSON.stringify({ phase: state.phases[phase], data: data[phase] }),
});
state.phases[phase] = { status: "pending" };
}
}
type CommandResult = { stdout: string; stderr: string; exitCode: number };
type RunOptions = { allowFailure?: boolean; dryRunStdout?: string };
export type ReleaseRunner = (
bin: string,
args: string[],
options?: RunOptions,
) => Promise<CommandResult>;
export type ReleaseContext = {
options: ReleaseOptions;
state: ReleaseState;
run: ReleaseRunner;
save: () => void;
log: (phase: ReleasePhase, message: string) => void;
resume: (phase?: ReleasePhase, extra?: string[]) => string;
};
function durationFromEnv(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined) {
return fallback;
}
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(Number(value))) {
throw new Error(`${name} must be a nonnegative integer`);
}
return Number(value);
}
function spawnCommand(bin: string, args: string[]): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
});
}
function isGhGet(bin: string, args: string[]): boolean {
if (bin !== "gh" || args[0] !== "api") {
return false;
}
const methodIndex = args.findIndex(
(arg) => arg.startsWith("-X") || arg === "--method" || arg.startsWith("--method="),
);
const method = args[methodIndex];
if (method !== undefined) {
const value =
method === "-X" || method === "--method"
? args[methodIndex + 1]
: method.replace(/^(?:-X|--method=)/u, "");
return value?.toUpperCase() === "GET";
}
if (args[1] === "graphql") {
return false;
}
return !args.some(
(arg) => /^-[fF]/u.test(arg) || /^(?:--field|--raw-field|--input)(?:=|$)/u.test(arg),
);
}
export function createReleaseRunner(
dryRun: boolean,
execute: ReleaseRunner = spawnCommand,
): ReleaseRunner {
return async (bin, args, options = {}) => {
if (dryRun) {
console.log(`+ ${shellCommand(bin, args)}`);
return { stdout: options.dryRunStdout ?? "", stderr: "", exitCode: 0 };
}
const attempts = isGhGet(bin, args) ? 4 : 1;
let result: CommandResult = { stdout: "", stderr: "", exitCode: 1 };
for (let attempt = 0; attempt < attempts; attempt++) {
try {
result = await execute(bin, args);
} catch (error) {
result = {
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
exitCode: 1,
};
}
if (result.exitCode === 0) {
return result;
}
if (attempt + 1 < attempts) {
await delay(durationFromEnv("OPENCLAW_RELEASE_STABLE_BACKOFF_MS", 2000) * 2 ** attempt);
}
}
if (!options.allowFailure) {
throw new Error(
`${shellCommand(bin, args)} failed (${result.exitCode}):\n${result.stderr || result.stdout}`,
);
}
return result;
};
}
export async function confirmRelease(
ctx: ReleaseContext,
question: string,
next: string[],
): Promise<void> {
if (ctx.options.dryRun) {
console.log(`[release-stable] Would ask: ${question}`);
return;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new ReleaseRefusal(`Non-interactive confirmation required: ${question}`, next);
}
const prompt = createInterface({ input: process.stdin, output: process.stdout });
try {
if (!/^y(?:es)?$/iu.test((await prompt.question(`${question} `)).trim())) {
throw new ReleaseRefusal("Operator declined confirmation", next);
}
} finally {
prompt.close();
}
}
export async function probeCapabilities(ctx: ReleaseContext, toolingSha: string): Promise<void> {
if (
!ctx.options.dryRun &&
ctx.state.capabilities?.toolingSha === toolingSha &&
ctx.state.capabilities.parentSweepsStaleChildren !== undefined &&
ctx.state.capabilities.childNpmPublishEnvironment !== undefined
) {
return;
}
const publisher = await ctx.run(
"git",
["show", `${toolingSha}:.github/workflows/openclaw-release-publish.yml`],
{ allowFailure: true },
);
const children = await ctx.run(
"git",
["show", `${toolingSha}:scripts/lib/release-publish-children.sh`],
{ allowFailure: true },
);
const npmPublisher = await ctx.run(
"git",
["show", `${toolingSha}:.github/workflows/openclaw-npm-release.yml`],
{ allowFailure: true },
);
ctx.state.capabilities = {
parentSweepsStaleChildren:
!ctx.options.dryRun &&
children.exitCode === 0 &&
children.stdout.includes("sweep_superseded_children"),
parentSyncsBetaDistTag:
!ctx.options.dryRun &&
((publisher.exitCode === 0 && publisher.stdout.includes("sync_beta_to_stable")) ||
(children.exitCode === 0 && children.stdout.includes("sync_beta_to_stable"))),
childNpmPublishEnvironment:
!ctx.options.dryRun &&
npmPublisher.exitCode === 0 &&
npmPublisher.stdout.includes("environment: npm-publish"),
probedAt: new Date().toISOString(),
toolingSha,
};
ctx.save();
}
export async function pollRelease<T>(
ctx: ReleaseContext,
options: {
label: string;
timeoutMs: number;
next?: string[];
probe: () => Promise<T | undefined>;
},
): Promise<T> {
const deadline = Date.now() + options.timeoutMs;
do {
const result = await options.probe();
if (result !== undefined) {
return result;
}
if (ctx.options.dryRun) {
throw new Error(`Missing dry-run placeholder for ${options.label}`);
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
break;
}
await delay(Math.min(durationFromEnv("OPENCLAW_RELEASE_STABLE_POLL_MS", 30000), remaining));
} while (Date.now() < deadline);
throw new ReleaseRefusal(
`Timed out waiting for ${options.label}`,
options.next ?? [ctx.resume()],
);
}