mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-01 04:58:23 +00:00
check-dependencies reported internal declarations exported without production consumers after recent refactors (Google video duration constants, session archive suffix helper, X work origin, model diagnostic observation state, chat run record) plus script exports. Internal declarations become private and the unused Google constant is removed; the real release-workflow consumer of checkPostgresCatalog is modeled in the scripts Knip config instead of being deleted. No runtime capability, public SDK, schema or behavior change.
894 lines
32 KiB
JavaScript
894 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
appendFileSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parseArgs, isDeepStrictEqual } from "node:util";
|
|
import {
|
|
preparedClawHubArtifactName,
|
|
resolvePreparedClawHubMatrix,
|
|
restorePreparedClawHubPackage,
|
|
} from "./clawhub-prepared-artifact.mjs";
|
|
import {
|
|
downloadActionsArtifactArchive,
|
|
inspectActionsArtifactZipWithPolicy,
|
|
readBoundedRegularFile,
|
|
} from "./lib/actions-artifact-archive.mjs";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs";
|
|
import {
|
|
consumePreparedNpmPackage,
|
|
downloadPreparedNpmRelease,
|
|
validatePreparedNpmArtifactDescriptor,
|
|
} from "./plugin-npm-prepared-release.mjs";
|
|
import { runReleaseToolingGh, verifyReleaseToolingIdentity } from "./release-tooling-identity.mjs";
|
|
|
|
const REPOSITORY = "openclaw/openclaw";
|
|
const PREPARE_WORKFLOW = ".github/workflows/openclaw-release-prepare.yml";
|
|
const PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
|
|
const READY_FILE = "release-ready.json";
|
|
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
const SHA = /^[a-f0-9]{40}$/u;
|
|
const INPUTS = new Set([
|
|
"tag",
|
|
"preflight_run_id",
|
|
"plugin_sdk_api_acknowledgement",
|
|
"full_release_validation_run_id",
|
|
"full_release_validation_run_attempt",
|
|
"windows_node_tag",
|
|
"windows_node_installer_digests",
|
|
"npm_telegram_run_id",
|
|
"openclaw_npm_resume_run_id",
|
|
"npm_dist_tag",
|
|
"plugin_publish_scope",
|
|
"plugins",
|
|
"publish_openclaw_npm",
|
|
"publish_docker_only",
|
|
"release_profile",
|
|
"release_evidence_mode",
|
|
"wait_for_clawhub",
|
|
]);
|
|
|
|
function requireValue(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function api(path, args = []) {
|
|
return JSON.parse(runReleaseToolingGh(["api", `repos/${REPOSITORY}/${path}`, ...args]));
|
|
}
|
|
|
|
function git(args, cwd = ".") {
|
|
return execFileSync("git", args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
timeout: 60_000,
|
|
maxBuffer: MAX_MANIFEST_BYTES,
|
|
}).trim();
|
|
}
|
|
|
|
function output(name, value) {
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
`${name}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeJson(path, value) {
|
|
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
requireValue(Buffer.byteLength(text) <= MAX_MANIFEST_BYTES, "Release request exceeds its limit.");
|
|
writeFileSync(path, text, { flag: "wx", mode: 0o600 });
|
|
}
|
|
|
|
function replaceJson(path, value) {
|
|
const nextPath = path.replace(/\.json$/u, ".next.json");
|
|
writeJson(nextPath, value);
|
|
renameSync(nextPath, path);
|
|
}
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(
|
|
readBoundedRegularFile(path, { label: path, maxBytes: MAX_MANIFEST_BYTES }).toString("utf8"),
|
|
);
|
|
}
|
|
|
|
/** @returns {Record<string, string>} */
|
|
export function validateReleaseButtonInputs(value) {
|
|
requireValue(
|
|
isRecord(value) && Object.keys(value).every((key) => INPUTS.has(key)),
|
|
"Prepared release contains unsupported publication inputs.",
|
|
);
|
|
const inputs = Object.fromEntries(
|
|
Object.entries(value).map(([key, entry]) => {
|
|
requireValue(
|
|
(typeof entry === "string" || typeof entry === "boolean") &&
|
|
!/[\r\n\0]/u.test(String(entry)),
|
|
`Invalid release input: ${key}.`,
|
|
);
|
|
return [key, String(entry)];
|
|
}),
|
|
);
|
|
const version = parseReleaseVersion(inputs.tag?.slice(1) ?? "");
|
|
requireValue(
|
|
version !== null && inputs.tag === `v${version.version}`,
|
|
"Prepared release requires an exact version tag.",
|
|
);
|
|
if (version.channel === "alpha" || inputs.npm_dist_tag === "alpha") {
|
|
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
|
|
}
|
|
requireValue(
|
|
["beta", "stable"].includes(classifyReleaseTrain(version)) &&
|
|
inputs.npm_dist_tag !== "extended-stable",
|
|
"Extended-stable releases retain their existing owner workflow.",
|
|
);
|
|
const expectedChannel = version.channel === "stable" ? ["beta", "latest"] : [version.channel];
|
|
requireValue(
|
|
expectedChannel.includes(inputs.npm_dist_tag),
|
|
"Prepared release npm channel differs from its tag.",
|
|
);
|
|
for (const name of [
|
|
"preflight_run_id",
|
|
"full_release_validation_run_id",
|
|
"full_release_validation_run_attempt",
|
|
]) {
|
|
requireValue(
|
|
/^[1-9][0-9]*$/u.test(inputs[name] ?? ""),
|
|
`Prepared release requires exact ${name}.`,
|
|
);
|
|
}
|
|
requireValue(
|
|
(inputs.plugin_publish_scope ?? "all-publishable") === "all-publishable" && !inputs.plugins,
|
|
"The release button prepares the complete publishable plugin roster; use the existing publisher for selected repairs.",
|
|
);
|
|
requireValue(
|
|
(inputs.publish_openclaw_npm ?? "true") === "true" &&
|
|
(inputs.publish_docker_only ?? "false") === "false" &&
|
|
(inputs.release_evidence_mode ?? "full-release-validation") === "full-release-validation",
|
|
"The release button requires full release validation and core npm publication.",
|
|
);
|
|
return {
|
|
...inputs,
|
|
plugin_publish_scope: "all-publishable",
|
|
publish_openclaw_npm: "true",
|
|
publish_docker_only: "false",
|
|
release_evidence_mode: "full-release-validation",
|
|
wait_for_clawhub: "true",
|
|
};
|
|
}
|
|
|
|
function toolingIdentity(environment = process.env) {
|
|
requireValue(
|
|
environment.GITHUB_REPOSITORY === REPOSITORY,
|
|
"Release preparation must run in the canonical repository.",
|
|
);
|
|
const identity = verifyReleaseToolingIdentity({
|
|
repository: REPOSITORY,
|
|
workflowRef: environment.GITHUB_REF_NAME,
|
|
workflowFullRef: environment.GITHUB_REF,
|
|
workflowSha: environment.GITHUB_WORKFLOW_SHA,
|
|
});
|
|
requireValue(
|
|
identity.route === "protected-tag",
|
|
"Prepared publication requires a frozen protected release-publish tag.",
|
|
);
|
|
return { ref: identity.ref, fullRef: identity.fullRef, sha: identity.sha };
|
|
}
|
|
|
|
function producer(run, workflowPath, tooling) {
|
|
const [path, fullRef] = String(run.path).split("@");
|
|
requireValue(
|
|
run.repository?.full_name === REPOSITORY &&
|
|
path === workflowPath &&
|
|
(fullRef === undefined || fullRef === tooling.fullRef) &&
|
|
run.event === "workflow_dispatch" &&
|
|
run.head_branch === tooling.ref &&
|
|
run.head_sha === tooling.sha &&
|
|
Number.isSafeInteger(run.id) &&
|
|
run.id > 0 &&
|
|
Number.isSafeInteger(run.run_attempt) &&
|
|
run.run_attempt > 0,
|
|
"Release producer workflow identity mismatch.",
|
|
);
|
|
return {
|
|
repository: REPOSITORY,
|
|
runId: run.id,
|
|
runAttempt: run.run_attempt,
|
|
workflowPath,
|
|
workflowEvent: run.event,
|
|
workflowHeadBranch: run.head_branch,
|
|
workflowSha: run.head_sha,
|
|
};
|
|
}
|
|
|
|
export function readyArtifactName(sourceSha, runId, runAttempt) {
|
|
requireValue(
|
|
SHA.test(sourceSha) &&
|
|
Number.isSafeInteger(runId) &&
|
|
runId > 0 &&
|
|
Number.isSafeInteger(runAttempt) &&
|
|
runAttempt > 0,
|
|
"Invalid prepared release artifact identity.",
|
|
);
|
|
return `release-ready-${sourceSha.slice(0, 12)}-${runId}-${runAttempt}`;
|
|
}
|
|
|
|
function artifactFor(run, workflow, tooling, name) {
|
|
const identity = producer(run, workflow, tooling);
|
|
const artifacts = [];
|
|
let totalCount;
|
|
for (let page = 1; page <= 20; page++) {
|
|
const result = api(`actions/runs/${run.id}/artifacts?per_page=100&page=${page}`);
|
|
requireValue(
|
|
Number.isSafeInteger(result.total_count) &&
|
|
result.total_count >= 0 &&
|
|
result.total_count <= 2000 &&
|
|
Array.isArray(result.artifacts),
|
|
"Prepared release artifact list is invalid or exceeds its limit.",
|
|
);
|
|
totalCount ??= result.total_count;
|
|
requireValue(
|
|
result.total_count === totalCount,
|
|
"Prepared release artifact count changed while reading it.",
|
|
);
|
|
artifacts.push(...result.artifacts);
|
|
if (artifacts.length === result.total_count) {
|
|
break;
|
|
}
|
|
requireValue(
|
|
result.artifacts.length > 0 && artifacts.length < result.total_count,
|
|
"Prepared release artifact list is incomplete.",
|
|
);
|
|
}
|
|
requireValue(
|
|
artifacts.length === totalCount && artifacts.length > 0,
|
|
"Prepared release artifact list is incomplete.",
|
|
);
|
|
const matches = artifacts.filter((artifact) => artifact.name === name);
|
|
requireValue(matches.length === 1, `Expected one exact prepared artifact: ${name}.`);
|
|
const [artifact] = matches;
|
|
requireValue(
|
|
artifact.expired === false &&
|
|
artifact.workflow_run?.id === run.id &&
|
|
artifact.workflow_run?.head_sha === tooling.sha &&
|
|
/^sha256:[a-f0-9]{64}$/u.test(artifact.digest ?? ""),
|
|
"Prepared artifact is expired or differs from its producer.",
|
|
);
|
|
return {
|
|
...identity,
|
|
artifactId: artifact.id,
|
|
artifactName: artifact.name,
|
|
artifactDigest: artifact.digest,
|
|
artifactSizeBytes: artifact.size_in_bytes,
|
|
};
|
|
}
|
|
|
|
async function waitForRun(runId, attempt, workflow, tooling, { allowFailure = false } = {}) {
|
|
const deadline = Date.now() + 110 * 60_000;
|
|
while (Date.now() < deadline) {
|
|
const run = api(`actions/runs/${runId}`);
|
|
producer(run, workflow, tooling);
|
|
requireValue(
|
|
run.id === runId && run.run_attempt === attempt,
|
|
"Release producer attempt changed; retain the original evidence and select an exact new attempt explicitly.",
|
|
);
|
|
if (run.status === "completed") {
|
|
requireValue(
|
|
run.conclusion === "success" || (allowFailure && run.conclusion === "failure"),
|
|
`${workflow} run ${runId}/${attempt} ended ${run.conclusion}; ${
|
|
workflow === PUBLISH_WORKFLOW
|
|
? "inspect child outcomes for owner recovery, then start a new button run with the same readiness receipt and openclaw_npm_resume_run_id when core npm succeeded. A published core version without a successful child requires core-owner reconciliation. This button remains bound to its original parent attempt."
|
|
: "rerun all jobs of this linked non-publishing child to produce a complete same-attempt package set, then rerun only the outer Verify and seal prepared publication job. Do not repeat preparation dispatch."
|
|
}`,
|
|
);
|
|
return run;
|
|
}
|
|
await delay(15_000);
|
|
}
|
|
throw new Error(
|
|
`Run ${runId}/${attempt} is still pending; resume verification, not publication.`,
|
|
);
|
|
}
|
|
|
|
function dispatch(workflow, inputs, tooling) {
|
|
verifyReleaseToolingIdentity({
|
|
repository: REPOSITORY,
|
|
workflowRef: tooling.ref,
|
|
workflowFullRef: tooling.fullRef,
|
|
workflowSha: tooling.sha,
|
|
});
|
|
const args = [
|
|
"--method",
|
|
"POST",
|
|
"-H",
|
|
"X-GitHub-Api-Version: 2026-03-10",
|
|
"-f",
|
|
`ref=${tooling.ref}`,
|
|
];
|
|
for (const [key, value] of Object.entries(inputs)) {
|
|
args.push("-f", `inputs[${key}]=${value}`);
|
|
}
|
|
// Dispatch is a mutation: never retry an uncertain response. A new request
|
|
// could create a second publisher even when the first response was lost.
|
|
const result = api(`actions/workflows/${workflow}/dispatches`, args);
|
|
requireValue(
|
|
Number.isSafeInteger(result.workflow_run_id) && result.workflow_run_id > 0,
|
|
"Dispatch response did not identify its run. Inspect Actions before dispatching again.",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function reportDispatch(workflow, runId) {
|
|
const link = `https://github.com/${REPOSITORY}/actions/runs/${runId}`;
|
|
console.log(`${workflow}: ${link}`);
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- ${workflow}: ${link}\n`);
|
|
}
|
|
}
|
|
|
|
export function validateReadyRelease(value, expected) {
|
|
requireValue(
|
|
isRecord(value) &&
|
|
value.schema === "openclaw.release-ready/v1" &&
|
|
value.repository === REPOSITORY &&
|
|
SHA.test(value.sourceSha) &&
|
|
value.sourceSha === expected.sourceSha &&
|
|
isDeepStrictEqual(value.tooling, expected.tooling),
|
|
"Prepared release source or tooling identity mismatch.",
|
|
);
|
|
const inputs = validateReleaseButtonInputs(value.inputs);
|
|
requireValue(
|
|
isDeepStrictEqual(inputs, value.inputs) &&
|
|
isRecord(value.plugins) &&
|
|
Object.keys(value.plugins).toSorted().join(",") === "clawhub,npm",
|
|
"Prepared release input or package handoff is invalid.",
|
|
);
|
|
for (const [target, path] of [
|
|
["npm", ".github/workflows/plugin-npm-release.yml"],
|
|
["clawhub", ".github/workflows/plugin-clawhub-release.yml"],
|
|
]) {
|
|
const descriptor = value.plugins[target];
|
|
requireValue(
|
|
descriptor?.repository === REPOSITORY &&
|
|
descriptor.workflowSha === value.tooling.sha &&
|
|
(target === "npm" || descriptor.workflowHeadBranch === value.tooling.ref) &&
|
|
descriptor.workflowPath === path,
|
|
`Prepared ${target} producer differs from the release tooling.`,
|
|
);
|
|
}
|
|
validatePreparedNpmArtifactDescriptor(value.plugins.npm, {
|
|
repository: REPOSITORY,
|
|
sourceSha: value.sourceSha,
|
|
workflowSha: value.tooling.sha,
|
|
});
|
|
return value;
|
|
}
|
|
|
|
async function loadPreparedPlugins({
|
|
plugins,
|
|
inputs,
|
|
sourceSha,
|
|
toolingSha,
|
|
sourceRoot,
|
|
outputDir,
|
|
token,
|
|
fetchImpl,
|
|
}) {
|
|
requireValue(
|
|
isRecord(plugins) && Object.keys(plugins).toSorted().join(",") === "clawhub,npm",
|
|
"Both npm and ClawHub preparation artifacts are required.",
|
|
);
|
|
mkdirSync(outputDir, { recursive: true });
|
|
const publisherPolicySha256 = createHash("sha256")
|
|
.update(readFileSync(new URL("../.github/workflows/plugin-npm-release.yml", import.meta.url)))
|
|
.digest("hex");
|
|
const common = { token, fetchImpl, sourceRoot, selectionMode: "all-publishable", plugins: "" };
|
|
const results = await Promise.allSettled([
|
|
downloadPreparedNpmRelease({
|
|
...common,
|
|
artifact: plugins.npm,
|
|
sourceSha,
|
|
workflowSha: toolingSha,
|
|
repository: REPOSITORY,
|
|
npmDistTag: inputs.npm_dist_tag === "extended-stable" ? "extended-stable" : "default",
|
|
publisherPolicySha256,
|
|
archivePath: join(outputDir, `npm-${plugins.npm.artifactId}.zip`),
|
|
}),
|
|
resolvePreparedClawHubMatrix({
|
|
...common,
|
|
descriptor: plugins.clawhub,
|
|
candidateSha: sourceSha,
|
|
toolingSha,
|
|
archivePath: join(outputDir, `clawhub-${plugins.clawhub.artifactId}.zip`),
|
|
}),
|
|
]);
|
|
const failure = results.find((result) => result.status === "rejected");
|
|
if (failure) {
|
|
throw failure.reason;
|
|
}
|
|
const [npm, clawhub] = results.map((result) => result.value);
|
|
return {
|
|
npm,
|
|
clawhub: { packages: clawhub.map((entry) => entry.prepared) },
|
|
publisherPolicySha256,
|
|
};
|
|
}
|
|
|
|
export async function prefetchPreparedPlugins(options) {
|
|
const manifests = await loadPreparedPlugins(options);
|
|
const { plugins, inputs, sourceSha, toolingSha, sourceRoot, outputDir, token, fetchImpl } =
|
|
options;
|
|
const admissionDir = mkdtempSync(join(outputDir, "verified-"));
|
|
const tasks = [
|
|
...manifests.npm.packages.map(
|
|
(entry) => () =>
|
|
consumePreparedNpmPackage({
|
|
package: entry,
|
|
repository: REPOSITORY,
|
|
sourceSha,
|
|
workflowSha: toolingSha,
|
|
npmDistTag: manifests.npm.npmDistTag,
|
|
publisherPolicySha256: manifests.publisherPolicySha256,
|
|
sourcePackageJson: join(sourceRoot, entry.packageDir, "package.json"),
|
|
cacheDir: join(outputDir, "npm"),
|
|
outputDir: join(admissionDir, `npm-${entry.extensionId}`),
|
|
token,
|
|
fetchImpl,
|
|
}),
|
|
),
|
|
...manifests.clawhub.packages.map(
|
|
(entry) => () =>
|
|
restorePreparedClawHubPackage({
|
|
descriptor: plugins.clawhub,
|
|
toolingSha,
|
|
entry,
|
|
outputDir: join(admissionDir, `clawhub-${entry.packageDir.split("/").at(-1)}`),
|
|
archivePath: join(outputDir, `clawhub-${entry.artifactId}.zip`),
|
|
token,
|
|
fetchImpl,
|
|
}),
|
|
),
|
|
];
|
|
// Drain the current batch before failing; no publication can race a transfer
|
|
// still being verified, and complete archives remain available for re-entry.
|
|
for (let index = 0; index < tasks.length; index += 4) {
|
|
const results = await Promise.allSettled(tasks.slice(index, index + 4).map((task) => task()));
|
|
const failure = results.find((result) => result.status === "rejected");
|
|
if (failure) {
|
|
throw failure.reason;
|
|
}
|
|
}
|
|
writeJson(join(admissionDir, "verified.json"), {
|
|
sourceSha,
|
|
toolingSha,
|
|
npm: manifests.npm.packages.length,
|
|
clawhub: manifests.clawhub.packages.length,
|
|
});
|
|
return { ...manifests, inputs };
|
|
}
|
|
|
|
async function readReady(descriptor, directory, tooling, token) {
|
|
requireValue(
|
|
descriptor?.workflowPath === PREPARE_WORKFLOW &&
|
|
descriptor.repository === REPOSITORY &&
|
|
descriptor.workflowSha === tooling.sha &&
|
|
descriptor.workflowHeadBranch === tooling.ref,
|
|
"Release readiness must come from the exact protected preparation workflow.",
|
|
);
|
|
const downloaded = await downloadActionsArtifactArchive({
|
|
token,
|
|
expected: { ...descriptor, runStatePolicy: "completed-success" },
|
|
maxArchiveBytes: MAX_MANIFEST_BYTES,
|
|
archivePath: join(directory, `${descriptor.artifactId}.zip`),
|
|
});
|
|
const files = inspectActionsArtifactZipWithPolicy(downloaded.archiveBytes, {
|
|
expectedEntries: [READY_FILE],
|
|
maxArchiveBytes: MAX_MANIFEST_BYTES,
|
|
maxExpandedBytes: MAX_MANIFEST_BYTES,
|
|
maxEntryBytes: () => MAX_MANIFEST_BYTES,
|
|
});
|
|
const value = JSON.parse(files.get(READY_FILE).toString("utf8"));
|
|
requireValue(
|
|
descriptor.artifactName ===
|
|
readyArtifactName(value.sourceSha, descriptor.runId, descriptor.runAttempt),
|
|
"Release readiness artifact name mismatch.",
|
|
);
|
|
const sourceSha = api(`commits/${encodeURIComponent(value.inputs?.tag)}`, ["--jq", "{sha}"]).sha;
|
|
return validateReadyRelease(value, { sourceSha, tooling });
|
|
}
|
|
|
|
function publicationInputs(ready, resumeRunId) {
|
|
const effectiveResumeRunId = resumeRunId || ready.inputs.openclaw_npm_resume_run_id || "";
|
|
requireValue(
|
|
effectiveResumeRunId === "" ||
|
|
(/^[1-9][0-9]*$/u.test(effectiveResumeRunId) &&
|
|
Number.isSafeInteger(Number(effectiveResumeRunId))),
|
|
"openclaw_npm_resume_run_id must be a positive safe integer.",
|
|
);
|
|
return {
|
|
...ready.inputs,
|
|
...(resumeRunId ? { openclaw_npm_resume_run_id: resumeRunId } : {}),
|
|
prepared_plugins: JSON.stringify(ready.plugins),
|
|
};
|
|
}
|
|
|
|
async function readPublicationRequest(path, directory, tooling, token) {
|
|
const request = readJson(path);
|
|
requireValue(
|
|
isRecord(request) &&
|
|
request.schema === "openclaw.release-dispatch/v1" &&
|
|
request.repository === REPOSITORY &&
|
|
request.workflowPath === PUBLISH_WORKFLOW &&
|
|
request.workflowEvent === "workflow_dispatch" &&
|
|
request.state === "acknowledged" &&
|
|
Number.isSafeInteger(request.button?.runId) &&
|
|
request.button.runId > 0 &&
|
|
request.button.runAttempt === 1 &&
|
|
request.expectedReleaseRunAttempt === 1 &&
|
|
Number.isSafeInteger(request.releaseRunId) &&
|
|
request.releaseRunId > 0 &&
|
|
request.releaseRunAttempt === 1 &&
|
|
SHA.test(request.sourceSha) &&
|
|
isDeepStrictEqual(request.tooling, tooling) &&
|
|
(request.openclawNpmResumeRunId === null ||
|
|
typeof request.openclawNpmResumeRunId === "string") &&
|
|
isDeepStrictEqual(request.producer, {
|
|
repository: REPOSITORY,
|
|
runId: request.releaseRunId,
|
|
runAttempt: request.releaseRunAttempt,
|
|
workflowPath: PUBLISH_WORKFLOW,
|
|
workflowEvent: "workflow_dispatch",
|
|
workflowHeadBranch: tooling.ref,
|
|
workflowSha: tooling.sha,
|
|
}),
|
|
"Publication request is unknown, unverified, malformed, or inconsistent; do not redispatch.",
|
|
);
|
|
const ready = await readReady(request.preparedArtifact, directory, tooling, token);
|
|
const inputs = publicationInputs(ready, request.openclawNpmResumeRunId ?? "");
|
|
requireValue(
|
|
ready.sourceSha === request.sourceSha &&
|
|
isDeepStrictEqual(inputs, request.inputs) &&
|
|
request.openclawNpmResumeRunId === (inputs.openclaw_npm_resume_run_id ?? null),
|
|
"Publication request differs from the frozen readiness inputs.",
|
|
);
|
|
return request;
|
|
}
|
|
|
|
async function verifyPublicationOutcome(run, request, tooling, token, directory) {
|
|
if (run.conclusion === "failure") {
|
|
const { verifyOpenClawCorePostpublish } = await import("./openclaw-core-postpublish.mjs");
|
|
const artifact = artifactFor(
|
|
run,
|
|
PUBLISH_WORKFLOW,
|
|
tooling,
|
|
`openclaw-release-postpublish-evidence-${request.inputs.tag}`,
|
|
);
|
|
await verifyOpenClawCorePostpublish({
|
|
parent: run,
|
|
artifact,
|
|
releaseTag: request.inputs.tag,
|
|
npmDistTag: request.inputs.npm_dist_tag,
|
|
token,
|
|
outputDir: join(directory, "core-postpublish-verification"),
|
|
});
|
|
}
|
|
const { verifyClawHubPostpublish } = await import("./clawhub-postpublish.mjs");
|
|
await verifyClawHubPostpublish({
|
|
event: { workflow_run: run },
|
|
parentStatePolicy: "sealed-producer",
|
|
verifierSha: tooling.sha,
|
|
token,
|
|
outputDir: join(directory, "clawhub-public-verification"),
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const { positionals, values } = parseArgs({
|
|
allowPositionals: true,
|
|
options: {
|
|
inputs: { type: "string" },
|
|
request: { type: "string" },
|
|
artifact: { type: "string" },
|
|
output: { type: "string" },
|
|
"openclaw-npm-resume-run-id": { type: "string" },
|
|
"source-root": { type: "string", default: "." },
|
|
},
|
|
});
|
|
const [operation] = positionals;
|
|
const directory = resolve(values.output ?? ".artifacts/release-ready");
|
|
mkdirSync(directory, { recursive: true });
|
|
const tooling = toolingIdentity();
|
|
const token = process.env.GH_TOKEN;
|
|
if (operation === "dispatch-prepare") {
|
|
requireValue(
|
|
values.request !== undefined || process.env.GITHUB_RUN_ATTEMPT === "1",
|
|
"Preparation dispatch already ran; recover failed linked non-publishing child runs with Re-run all jobs, then rerun only the outer seal. Do not repeat this dispatch.",
|
|
);
|
|
const inputs = validateReleaseButtonInputs(readJson(values.inputs));
|
|
const sourceSha = api(`commits/${encodeURIComponent(inputs.tag)}`, ["--jq", "{sha}"]).sha;
|
|
requireValue(SHA.test(sourceSha), "Release tag does not resolve to a commit.");
|
|
const packageJson = JSON.parse(git(["show", `${sourceSha}:package.json`]));
|
|
requireValue(
|
|
inputs.tag === `v${packageJson.version}`,
|
|
"Release tag and source package version differ.",
|
|
);
|
|
const { authenticateFullReleaseValidationEvidence } =
|
|
await import("./validate-full-release-validation-evidence.mjs");
|
|
const authenticated = await authenticateFullReleaseValidationEvidence({
|
|
run: api(
|
|
`actions/runs/${inputs.full_release_validation_run_id}/attempts/${inputs.full_release_validation_run_attempt}`,
|
|
),
|
|
expectedRepository: REPOSITORY,
|
|
expectedRunId: inputs.full_release_validation_run_id,
|
|
expectedRunAttempt: inputs.full_release_validation_run_attempt,
|
|
expectedTargetSha: sourceSha,
|
|
expectedReleaseTag: inputs.tag,
|
|
expectedTrustedWorkflowFullRef: tooling.fullRef,
|
|
expectedTrustedWorkflowSha: tooling.sha,
|
|
expectedPublicationSelection: {
|
|
route: "prepared",
|
|
npmDistTag: inputs.npm_dist_tag,
|
|
publishOpenclawNpm: true,
|
|
pluginPublishScope: "all-publishable",
|
|
plugins: [],
|
|
...(inputs.windows_node_tag
|
|
? {
|
|
windowsNodeTag: inputs.windows_node_tag,
|
|
windowsNodeInstallerDigests: JSON.parse(inputs.windows_node_installer_digests ?? ""),
|
|
}
|
|
: {}),
|
|
},
|
|
verifierSourceSha: tooling.sha,
|
|
verifierSourceContent: readFileSync(new URL("./release-ci-summary.mjs", import.meta.url)),
|
|
isTrustedMainAncestor: (sha) => {
|
|
const comparison = api(`compare/${sha}...main`, ["--jq", "{status,merge_base_commit}"]);
|
|
return (
|
|
["ahead", "identical"].includes(comparison.status) &&
|
|
comparison.merge_base_commit?.sha === sha
|
|
);
|
|
},
|
|
});
|
|
const npmArtifact = validatePreparedNpmArtifactDescriptor(
|
|
authenticated.evidence.current.manifest.publicationArtifacts?.pluginNpm,
|
|
{ repository: REPOSITORY, sourceSha, workflowSha: tooling.sha },
|
|
);
|
|
const request = {
|
|
schema: "openclaw.release-ready/v1",
|
|
repository: REPOSITORY,
|
|
sourceSha,
|
|
tooling,
|
|
inputs,
|
|
npmArtifact,
|
|
clawhubRunId: null,
|
|
};
|
|
const requestPath = join(directory, "request.json");
|
|
if (values.request !== undefined) {
|
|
const recovered = readJson(values.request);
|
|
requireValue(
|
|
isRecord(recovered) &&
|
|
isDeepStrictEqual({ ...recovered, clawhubRunId: null }, request) &&
|
|
Number.isSafeInteger(recovered.clawhubRunId) &&
|
|
recovered.clawhubRunId > 0,
|
|
"Preparation recovery differs from the frozen source, tooling, or publication inputs.",
|
|
);
|
|
producer(
|
|
api(`actions/runs/${recovered.clawhubRunId}`),
|
|
".github/workflows/plugin-clawhub-release.yml",
|
|
tooling,
|
|
);
|
|
request.clawhubRunId = recovered.clawhubRunId;
|
|
}
|
|
writeJson(requestPath, request);
|
|
if (values.request === undefined) {
|
|
// npm bytes are already qualified by FRV. Persist before the remaining
|
|
// mutation so an uncertain ClawHub dispatch is never repeated blindly.
|
|
request.clawhubRunId = dispatch(
|
|
"plugin-clawhub-release.yml",
|
|
{ ref: sourceSha, publish_scope: "all-publishable", dry_run: "true" },
|
|
tooling,
|
|
).workflow_run_id;
|
|
replaceJson(requestPath, request);
|
|
reportDispatch("plugin-clawhub-release.yml", request.clawhubRunId);
|
|
}
|
|
output("clawhub_run_id", String(request.clawhubRunId));
|
|
output("request", request);
|
|
output("source_sha", sourceSha);
|
|
return;
|
|
}
|
|
if (operation === "seal") {
|
|
const request = readJson(values.request);
|
|
requireValue(
|
|
isDeepStrictEqual(request.tooling, tooling),
|
|
"Preparation tooling moved before sealing.",
|
|
);
|
|
// Retrying a failed seal job may follow an explicit rerun of the original
|
|
// producers. Resolve each current attempt once, never fall back to an older success.
|
|
const clawhubAttempt = api(`actions/runs/${request.clawhubRunId}`).run_attempt;
|
|
const clawhubRun = await waitForRun(
|
|
request.clawhubRunId,
|
|
clawhubAttempt,
|
|
".github/workflows/plugin-clawhub-release.yml",
|
|
tooling,
|
|
);
|
|
const clawhubProducer = producer(
|
|
clawhubRun,
|
|
".github/workflows/plugin-clawhub-release.yml",
|
|
tooling,
|
|
);
|
|
const plugins = {
|
|
npm: validatePreparedNpmArtifactDescriptor(request.npmArtifact, {
|
|
repository: REPOSITORY,
|
|
sourceSha: request.sourceSha,
|
|
workflowSha: tooling.sha,
|
|
}),
|
|
clawhub: artifactFor(
|
|
clawhubRun,
|
|
clawhubProducer.workflowPath,
|
|
tooling,
|
|
preparedClawHubArtifactName(request.sourceSha, clawhubProducer),
|
|
),
|
|
};
|
|
const ready = validateReadyRelease(
|
|
{
|
|
schema: request.schema,
|
|
repository: REPOSITORY,
|
|
inputs: request.inputs,
|
|
sourceSha: request.sourceSha,
|
|
tooling,
|
|
plugins,
|
|
},
|
|
request,
|
|
);
|
|
await prefetchPreparedPlugins({
|
|
plugins,
|
|
inputs: ready.inputs,
|
|
sourceSha: ready.sourceSha,
|
|
toolingSha: tooling.sha,
|
|
sourceRoot: resolve(values["source-root"]),
|
|
outputDir: join(directory, "packages"),
|
|
token,
|
|
});
|
|
writeJson(join(directory, READY_FILE), ready);
|
|
output(
|
|
"artifact_name",
|
|
readyArtifactName(
|
|
ready.sourceSha,
|
|
Number(process.env.GITHUB_RUN_ID),
|
|
Number(process.env.GITHUB_RUN_ATTEMPT),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (operation === "dispatch-publish") {
|
|
requireValue(
|
|
process.env.GITHUB_RUN_ATTEMPT === "1",
|
|
"Publication may already have been dispatched. Rerun failed verification jobs, not the successful dispatch job.",
|
|
);
|
|
const resumeRunId = values["openclaw-npm-resume-run-id"] ?? "";
|
|
requireValue(
|
|
resumeRunId === "" ||
|
|
(/^[1-9][0-9]*$/u.test(resumeRunId) && Number.isSafeInteger(Number(resumeRunId))),
|
|
"openclaw_npm_resume_run_id must be a positive safe integer.",
|
|
);
|
|
const descriptor = readJson(values.artifact);
|
|
const ready = await readReady(descriptor, directory, tooling, token);
|
|
// Recovery changes only the existing owner's resume selector. The parent
|
|
// verifies its authority and canonical core bytes before starting writers.
|
|
const inputs = publicationInputs(ready, resumeRunId);
|
|
const request = {
|
|
schema: "openclaw.release-dispatch/v1",
|
|
repository: REPOSITORY,
|
|
workflowPath: PUBLISH_WORKFLOW,
|
|
workflowEvent: "workflow_dispatch",
|
|
state: "unknown",
|
|
button: {
|
|
runId: Number(process.env.GITHUB_RUN_ID),
|
|
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
|
|
},
|
|
expectedReleaseRunAttempt: 1,
|
|
releaseRunId: null,
|
|
releaseRunAttempt: null,
|
|
producer: null,
|
|
sourceSha: ready.sourceSha,
|
|
tooling,
|
|
preparedArtifact: descriptor,
|
|
inputs,
|
|
openclawNpmResumeRunId: inputs.openclaw_npm_resume_run_id ?? null,
|
|
};
|
|
requireValue(
|
|
Number.isSafeInteger(request.button.runId) && request.button.runId > 0,
|
|
"Publication requires an exact initiating button run.",
|
|
);
|
|
const requestPath = join(directory, "dispatch.json");
|
|
// A retained unknown is intentional: a crash even before POST cannot prove
|
|
// non-execution. Existing intent never authorizes another mutation.
|
|
writeJson(requestPath, request);
|
|
try {
|
|
request.releaseRunId = dispatch(
|
|
"openclaw-release-publish.yml",
|
|
inputs,
|
|
tooling,
|
|
).workflow_run_id;
|
|
request.state = "unverified";
|
|
replaceJson(requestPath, request);
|
|
const run = api(`actions/runs/${request.releaseRunId}`);
|
|
const observed = producer(run, PUBLISH_WORKFLOW, tooling);
|
|
requireValue(
|
|
observed.runId === request.releaseRunId &&
|
|
observed.runAttempt === request.expectedReleaseRunAttempt,
|
|
"Publication acknowledgement run or attempt differs from the original dispatch.",
|
|
);
|
|
request.producer = observed;
|
|
request.releaseRunAttempt = observed.runAttempt;
|
|
request.state = "acknowledged";
|
|
replaceJson(requestPath, request);
|
|
reportDispatch("openclaw-release-publish.yml", request.releaseRunId);
|
|
console.log(
|
|
`Retained request: ${requestPath}; button ${request.button.runId}/${request.button.runAttempt}. Verify only: node scripts/openclaw-release-ready.mjs verify --request ${JSON.stringify(requestPath)}`,
|
|
);
|
|
output("release_run_id", String(request.releaseRunId));
|
|
output("source_sha", ready.sourceSha);
|
|
output("release_tag", ready.inputs.tag);
|
|
output("npm_dist_tag", ready.inputs.npm_dist_tag);
|
|
output("request", request);
|
|
} catch (error) {
|
|
console.error(
|
|
`Retained request: ${requestPath}; button ${request.button.runId}/${request.button.runAttempt}; publisher ${request.releaseRunId ?? "unconfirmed"}. Dispatch or handoff outcome unknown; do not redispatch. Inspect the retained request and any dispatch.next.json before manual reconciliation.`,
|
|
);
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (operation === "verify" || operation === "validate-request") {
|
|
const request = await readPublicationRequest(values.request, directory, tooling, token);
|
|
if (operation === "validate-request") {
|
|
const run = api(`actions/runs/${request.releaseRunId}`);
|
|
const observed = producer(run, PUBLISH_WORKFLOW, tooling);
|
|
requireValue(
|
|
isDeepStrictEqual(observed, request.producer) &&
|
|
run.status === "completed" &&
|
|
["success", "failure"].includes(run.conclusion),
|
|
"Publication request no longer identifies an allowed terminal exact publisher.",
|
|
);
|
|
if (run.conclusion === "failure") {
|
|
await verifyPublicationOutcome(run, request, tooling, token, directory);
|
|
}
|
|
return;
|
|
}
|
|
const run = await waitForRun(
|
|
request.releaseRunId,
|
|
request.releaseRunAttempt,
|
|
PUBLISH_WORKFLOW,
|
|
tooling,
|
|
{ allowFailure: true },
|
|
);
|
|
await verifyPublicationOutcome(run, request, tooling, token, directory);
|
|
output("verified", "true");
|
|
return;
|
|
}
|
|
throw new Error(
|
|
"Expected dispatch-prepare, seal, dispatch-publish, verify, or validate-request.",
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
console.error("[openclaw-release-ready] FAILED (exit 1)");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|