openclaw/scripts/full-release-artifacts.mjs
Dallin Romney 14edef7f06
fix(release): retry failed npm qualification without restarting validation (#150991)
* fix(release): reject continuation after artifact producer failure

* fix(release): retry failed npm qualification and collect its receipt

* fix(release): verify npm recovery outside diagnostic attempt map
2026-09-17 11:03:08 -07:00

528 lines
19 KiB
JavaScript

#!/usr/bin/env node
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import {
candidateRequestSha256,
validateFullReleaseCandidateBinding,
validateFullReleaseCandidateRequest,
} from "./full-release-candidate-contract.mjs";
import {
downloadExactActionsArtifactArchive,
inspectActionsArtifactZip,
} from "./lib/actions-artifact-archive.mjs";
import { isRecord } from "./lib/record-shared.mjs";
import { parseReleaseVersion } from "./lib/release-version.mjs";
import {
QUALIFIED_NPM_PREFLIGHT_SCHEMA,
validatePreparedNpmBundleDescriptor,
verifyNpmBundleProducer,
} from "./npm-prepared-bundle.mjs";
import { runReleaseToolingGh } from "./release-tooling-identity.mjs";
const WORKFLOW = ".github/workflows/full-release-artifacts.yml";
const SCHEMA = "openclaw.full-release-artifact-receipt/v1";
const MAX_ARCHIVE_BYTES = 1024 * 1024;
const MAX_RECEIPT_BYTES = 256 * 1024;
const WAIT_MINUTES = 350;
const SHA = /^[a-f0-9]{40}$/u;
const SHA256 = /^[a-f0-9]{64}$/u;
const DECIMAL = /^[1-9][0-9]*$/u;
function requireValue(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function api(repository, path) {
return JSON.parse(runReleaseToolingGh(["api", `repos/${repository}/${path}`]));
}
function artifactRequest(env) {
const stage = env.ARTIFACT_STAGE;
const dispatchId = env.ARTIFACT_DISPATCH_ID;
requireValue(["npm", "candidate", "docker"].includes(stage), "Invalid artifact stage.");
requireValue(
new RegExp(`^full-release-validation-[1-9][0-9]*-[1-9][0-9]*-artifacts-${stage}$`, "u").test(
dispatchId,
),
"Artifact producer requires an exact FRV dispatch identity.",
);
requireValue(SHA.test(env.TARGET_SHA), "Invalid artifact source SHA.");
requireValue(SHA.test(env.PARENT_WORKFLOW_SHA), "Invalid artifact tooling SHA.");
requireValue(
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(env.GITHUB_REPOSITORY),
"Invalid artifact repository.",
);
requireValue(
typeof env.RELEASE_TAG === "string" &&
env.RELEASE_TAG.startsWith("v") &&
parseReleaseVersion(env.RELEASE_TAG.slice(1))?.version === env.RELEASE_TAG.slice(1),
"Invalid artifact release tag.",
);
requireValue(
["all", "prepare"].includes(env.PREFLIGHT_PHASE),
"Invalid artifact npm preparation phase.",
);
requireValue(
typeof env.CHILD_WORKFLOW_REF === "string" &&
/^\S+$/u.test(env.CHILD_WORKFLOW_REF) &&
[...env.CHILD_WORKFLOW_REF].every(
(char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127,
),
"Invalid artifact workflow ref.",
);
return {
stage,
dispatchId,
repository: env.GITHUB_REPOSITORY,
sourceSha: env.TARGET_SHA,
toolingSha: env.PARENT_WORKFLOW_SHA,
workflowRef: env.CHILD_WORKFLOW_REF,
releaseTag: env.RELEASE_TAG,
preflightPhase: env.PREFLIGHT_PHASE,
};
}
function validateArtifactParent(request, parent, env) {
const [, runId, attempt] = request.dispatchId.match(
/^full-release-validation-([0-9]+)-([0-9]+)-artifacts-/u,
);
requireValue(
String(parent.id) === runId &&
String(parent.run_attempt) === attempt &&
parent.event === "workflow_dispatch" &&
String(parent.path).split("@", 1)[0] === ".github/workflows/full-release-validation.yml" &&
parent.repository?.full_name === request.repository &&
parent.head_repository?.full_name === request.repository &&
parent.head_sha === request.toolingSha &&
parent.head_branch === request.workflowRef &&
// GitHub reports a parent as "queued" whenever any of its jobs is still
// waiting for a runner, even while this producer runs from it; both mean
// the parent is live and unfinished. A completed parent stays rejected.
(parent.status === "in_progress" || parent.status === "queued") &&
parent.conclusion === null &&
env.GITHUB_SHA === request.toolingSha &&
env.GITHUB_REF_NAME === request.workflowRef,
"Artifact preparation requires its exact active FRV parent and frozen tooling.",
);
}
function validateArtifactProducerTuple(runId, runAttempt) {
requireValue(
DECIMAL.test(String(runId)) && DECIMAL.test(String(runAttempt)),
"Invalid artifact producer tuple.",
);
}
export function validateArtifactProducerRun(
request,
run,
runId,
runAttempt,
{ allowNewerAttempts = false, allowFailure = false } = {},
) {
validateArtifactProducerTuple(runId, runAttempt);
requireValue(
String(run.id) === String(runId) &&
(allowNewerAttempts
? DECIMAL.test(String(run.run_attempt)) && BigInt(run.run_attempt) >= BigInt(runAttempt)
: String(run.run_attempt) === String(runAttempt)) &&
run.event === "workflow_dispatch" &&
String(run.path).split("@", 1)[0] === WORKFLOW &&
run.repository?.full_name === request.repository &&
run.head_repository?.full_name === request.repository &&
run.head_sha === request.toolingSha &&
run.head_branch === request.workflowRef &&
run.display_title === `Full Release Artifacts ${request.dispatchId}`,
"Artifact producer run identity changed.",
);
requireValue(
allowFailure || run.status !== "completed" || run.conclusion === "success",
`Artifact ${request.stage} producer failed: ${run.html_url}`,
);
return run;
}
function readArtifactRun(request, runId, runAttempt, options) {
validateArtifactProducerTuple(runId, runAttempt);
return validateArtifactProducerRun(
request,
api(request.repository, `actions/runs/${runId}`),
runId,
runAttempt,
options,
);
}
function validateArtifactReceipt(receipt, request, runId, runAttempt) {
const { outputs, ...identity } = receipt;
requireValue(
isDeepStrictEqual(identity, {
schema: SCHEMA,
...request,
runId: String(runId),
runAttempt: String(runAttempt),
}) && isRecord(outputs),
"Artifact receipt does not match its exact request and producer attempt.",
);
if (request.stage === "npm") {
const raw = JSON.parse(outputs.prepared_bundle_json);
validatePreparedNpmBundleDescriptor({
descriptor: raw,
repository: request.repository,
sourceSha: request.sourceSha,
toolingSha: request.toolingSha,
});
requireValue(
raw.producer.runId === String(runId) && BigInt(raw.producer.runAttempt) <= BigInt(runAttempt),
"Raw npm bundle came from another artifact producer.",
);
if (request.preflightPhase === "all") {
const qualified = JSON.parse(outputs.qualified_preflight_bundle_json);
requireValue(
qualified.schema === QUALIFIED_NPM_PREFLIGHT_SCHEMA &&
qualified.source.sha === request.sourceSha &&
qualified.producer.runId === String(runId) &&
DECIMAL.test(qualified.producer.runAttempt) &&
BigInt(qualified.producer.runAttempt) <= BigInt(runAttempt) &&
qualified.producer.workflowSha === request.toolingSha,
"Qualified npm bundle came from another artifact producer.",
);
}
} else if (request.stage === "candidate") {
const binding = validateFullReleaseCandidateBinding(JSON.parse(outputs.binding_json));
requireValue(
outputs.state === "ready" &&
binding.request.targetSha === request.sourceSha &&
binding.request.toolingSha === request.toolingSha &&
binding.request.repository === request.repository,
"Candidate receipt does not match the source and tooling.",
);
} else {
requireValue(
outputs.prepared_run_id === String(runId) &&
outputs.prepared_run_attempt === String(runAttempt) &&
SHA256.test(outputs.prepared_manifest_sha256) &&
typeof outputs.prepared_artifact_name === "string" &&
outputs.prepared_artifact_name,
"Docker receipt does not match its artifact producer.",
);
}
return outputs;
}
async function readArtifact(request, runId, name, fileName) {
const scope = runId ? `actions/runs/${runId}` : "actions";
const response = api(
request.repository,
`${scope}/artifacts?name=${encodeURIComponent(name)}&per_page=100`,
);
requireValue(
Array.isArray(response.artifacts) &&
Number.isSafeInteger(response.total_count) &&
response.total_count <= 100 &&
response.artifacts.length === response.total_count,
"Artifact descriptor inventory is incomplete.",
);
const matches = response.artifacts.filter((artifact) => artifact.name === name);
requireValue(matches.length <= 1, "Artifact descriptor is ambiguous.");
if (!matches.length) {
return undefined;
}
const metadata = matches[0];
const { archiveBytes } = await downloadExactActionsArtifactArchive({
expected: {
repository: request.repository,
artifactId: metadata.id,
artifactName: name,
artifactDigest: metadata.digest,
artifactSizeBytes: metadata.size_in_bytes,
artifactExpiresAt: metadata.expires_at,
runId: Number(runId ?? metadata.workflow_run?.id),
workflowSha: request.toolingSha,
},
maxArchiveBytes: MAX_ARCHIVE_BYTES,
token: process.env.GH_TOKEN,
});
const files = inspectActionsArtifactZip(archiveBytes, [fileName], {
maxArchiveBytes: MAX_ARCHIVE_BYTES,
maxCompressedEntryBytes: MAX_ARCHIVE_BYTES,
maxEntryBytes: MAX_RECEIPT_BYTES,
maxExpandedBytes: MAX_RECEIPT_BYTES,
});
return {
runId: String(metadata.workflow_run.id),
value: JSON.parse(files.get(fileName).toString("utf8")),
};
}
function output(values) {
for (const [key, value] of Object.entries(values)) {
requireValue(
/^[a-z][a-z0-9_]*$/u.test(key) && typeof value === "string" && !/[\r\n]/u.test(value),
"Artifact outputs must be single-line strings.",
);
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
}
}
async function resolveProducer(request, env) {
let record;
if (env.GITHUB_RUN_ATTEMPT === "1") {
record = { request, runId: env.ARTIFACT_RUN_ID, runAttempt: env.ARTIFACT_RUN_ATTEMPT };
} else {
const artifact = await readArtifact(
request,
undefined,
`${request.dispatchId}-dispatch`,
"dispatch.json",
);
record = artifact?.value;
requireValue(
record && artifact.runId === record.runId && isDeepStrictEqual(record.request, request),
"Original artifact dispatch is unavailable or changed; start a fresh FRV run.",
);
}
const run = readArtifactRun(request, record.runId, record.runAttempt, {
allowNewerAttempts: request.stage === "npm",
});
output({
run_id: record.runId,
run_attempt: String(run.run_attempt),
dispatch_id: request.dispatchId,
});
}
function artifactAttemptJobs(request, runId, attempt) {
validateArtifactProducerRun(
request,
api(request.repository, `actions/runs/${runId}/attempts/${attempt}`),
runId,
attempt,
{ allowFailure: true },
);
const jobs = [];
for (let page = 1; page <= 10; page += 1) {
const response = api(
request.repository,
`actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
);
requireValue(
Array.isArray(response.jobs) &&
Number.isSafeInteger(response.total_count) &&
response.total_count > 0 &&
response.total_count <= 1000,
"Artifact producer job inventory is incomplete.",
);
jobs.push(...response.jobs);
if (jobs.length === response.total_count) {
break;
}
requireValue(
response.jobs.length === 100 && page < 10 && jobs.length < response.total_count,
"Artifact producer job inventory is incomplete.",
);
}
requireValue(
jobs.every(
(job) =>
String(job.run_id) === String(runId) &&
String(job.run_attempt) === String(attempt) &&
job.head_sha === request.toolingSha,
),
"Artifact producer job identity changed.",
);
return jobs;
}
// Retried checks may reuse bytes, but cannot replace bytes already consumed by
// the immutable candidate and its successful diagnostic children.
function effectiveNpmPreparationAttempt(request, runId, runAttempt) {
readArtifactRun(request, runId, runAttempt);
requireValue(BigInt(runAttempt) <= 100n, "Artifact producer attempt history exceeds its limit.");
let selected;
for (let attempt = 1; attempt <= Number(runAttempt); attempt += 1) {
const matches = artifactAttemptJobs(request, runId, attempt).filter(
(job) => job.name === "Prepare npm artifacts / Prepare publishable npm package",
);
requireValue(matches.length <= 1, "Artifact preparation job is ambiguous.");
if (!matches.length) {
continue;
}
requireValue(
!selected || selected.conclusion !== "success",
"Successful npm preparation was replaced; its frozen bytes cannot be adopted.",
);
selected = matches[0];
}
requireValue(
selected && (selected.status !== "completed" || selected.conclusion === "success"),
"Raw npm bundle requires its unique exact completed producer job.",
);
return String(selected.run_attempt);
}
async function waitForArtifact(request, env) {
const runId = env.ARTIFACT_RUN_ID;
const runAttempt = env.ARTIFACT_RUN_ATTEMPT;
requireValue(["raw", "receipt"].includes(env.ARTIFACT_OUTPUT), "Invalid artifact output.");
const raw = env.ARTIFACT_OUTPUT === "raw";
requireValue(!raw || request.stage === "npm", "Only npm exposes early raw artifacts.");
const rawAttempt = raw
? String(runAttempt) === "1"
? "1"
: effectiveNpmPreparationAttempt(request, runId, runAttempt)
: undefined;
const name = raw
? `openclaw-npm-package-descriptor-${runId}-${rawAttempt}`
: `full-release-artifact-receipt-${runId}-${runAttempt}`;
const deadline = Date.now() + WAIT_MINUTES * 60_000;
let receipt;
while (Date.now() < deadline) {
const run = readArtifactRun(request, runId, runAttempt);
if (raw || run.status === "completed") {
let values;
receipt ??= (
await readArtifact(
request,
runId,
name,
raw ? "prepared-npm-bundle.json" : "artifact-receipt.json",
)
)?.value;
if (receipt && raw) {
validatePreparedNpmBundleDescriptor({
descriptor: receipt,
repository: request.repository,
sourceSha: request.sourceSha,
toolingSha: request.toolingSha,
});
requireValue(
receipt.producer.runId === String(runId) && receipt.producer.runAttempt === rawAttempt,
"Raw npm producer identity changed.",
);
const job = api(request.repository, `actions/jobs/${receipt.producer.jobId}`);
if (job.status === "completed") {
verifyNpmBundleProducer({
producer: receipt.producer,
repository: request.repository,
toolingSha: request.toolingSha,
});
values = { prepared_bundle_json: JSON.stringify(receipt) };
}
} else if (receipt) {
values = validateArtifactReceipt(receipt, request, runId, runAttempt);
if (request.stage === "npm") {
const prepared = JSON.parse(values.prepared_bundle_json);
requireValue(
prepared.producer.runAttempt ===
effectiveNpmPreparationAttempt(request, runId, runAttempt),
"Raw npm preparation attempt changed.",
);
verifyNpmBundleProducer({
producer: prepared.producer,
repository: request.repository,
toolingSha: request.toolingSha,
requireCompletedParent: true,
});
}
if (request.stage === "npm" && request.preflightPhase === "all") {
const qualified = JSON.parse(values.qualified_preflight_bundle_json);
verifyNpmBundleProducer({
producer: qualified.producer,
repository: request.repository,
toolingSha: request.toolingSha,
qualified: true,
requireCompletedParent: true,
});
}
if (request.stage === "candidate") {
const binding = JSON.parse(values.binding_json);
requireValue(
binding.requestSha256 ===
candidateRequestSha256(JSON.parse(env.CANDIDATE_REQUEST_JSON)),
"Candidate request changed during acquisition.",
);
}
}
if (values) {
// Downloads can outlive a producer rerun; historical job success does
// not authorize handing off evidence from a replaced attempt.
readArtifactRun(request, runId, runAttempt);
output(values);
return;
}
}
console.error(
`Waiting for ${request.stage} ${raw ? "package" : "qualification"}: ${run.html_url}`,
);
await new Promise((resolve) => {
setTimeout(resolve, 15_000);
});
}
throw new Error(`Artifact producer did not complete within ${WAIT_MINUTES} minutes: ${runId}`);
}
async function main() {
const env = process.env;
const request = artifactRequest(env);
const command = process.argv[2];
if (command === "admit") {
const parentId = request.dispatchId.match(/^full-release-validation-([0-9]+)-/u)[1];
validateArtifactParent(request, api(request.repository, `actions/runs/${parentId}`), env);
if (request.stage === "candidate") {
const candidate = validateFullReleaseCandidateRequest(JSON.parse(env.CANDIDATE_REQUEST_JSON));
requireValue(
candidate.repository === request.repository &&
candidate.targetSha === request.sourceSha &&
candidate.toolingSha === request.toolingSha,
"Candidate request source or tooling changed.",
);
output({ request_sha256: candidateRequestSha256(candidate) });
}
// The producer owns its discovery record too: a parent rerun must not need
// a cache or an artifact attached to the attempt it is replacing.
const directory = join(env.RUNNER_TEMP, "full-release-artifact-dispatch");
mkdirSync(directory, { recursive: true });
writeFileSync(
join(directory, "dispatch.json"),
`${JSON.stringify({
request,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
})}\n`,
);
output({ directory, dispatch_name: `${request.dispatchId}-dispatch` });
} else if (command === "receipt") {
const directory = join(env.RUNNER_TEMP, "full-release-artifact-receipt");
const receipt = {
schema: SCHEMA,
...request,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
outputs: JSON.parse(env.ARTIFACT_OUTPUTS_JSON),
};
validateArtifactReceipt(receipt, request, env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT);
const json = JSON.stringify(receipt);
requireValue(
Buffer.byteLength(json) <= MAX_RECEIPT_BYTES,
"Artifact receipt exceeds its byte limit.",
);
mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, "artifact-receipt.json"), `${json}\n`);
output({ directory });
} else if (command === "wait") {
await waitForArtifact(request, env);
} else if (command === "resolve") {
await resolveProducer(request, env);
} else {
throw new Error("Usage: full-release-artifacts.mjs <admit|resolve|receipt|wait>");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}