fix(release): tolerate npm propagation after publish

This commit is contained in:
Vincent Koc
2026-06-16 09:51:42 +08:00
parent 3c65127827
commit e71cf0ffcb
5 changed files with 106 additions and 9 deletions

View File

@@ -552,6 +552,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- npm registry metadata is eventually consistent immediately after trusted
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
retries, and carry that verified tarball/integrity metadata into later proof
steps instead of reading the registry again. If the OpenClaw npm child
succeeded but the parent publish workflow failed on an immediate exact-version
`E404`, verify the exact version with a cache-bypassed registry read, run the
standalone postpublish verifier and the full beta verifier with the original
successful child run IDs, then finalize the draft, dependency evidence asset,
and release proof manually. Never rerun the publish workflow for that
already-published version.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.PATCH`.
@@ -720,8 +730,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the recovery probe:
verifier command remains the first recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
For a failed postpublish parent after successful publish children, also run
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
with the original child run IDs and an evidence output path before manually
recreating the workflow's draft, dependency evidence asset, proof section,
and publish step.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to

View File

@@ -1112,13 +1112,14 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then

View File

@@ -39,6 +39,7 @@ export type NpmViewFields = {
version?: string;
distTagVersion?: string;
integrity?: string;
tarball?: string;
};
type WorkflowRunSummary = {
@@ -52,6 +53,10 @@ const DEFAULT_REPO = "openclaw/openclaw";
const DEFAULT_CLAWHUB_REGISTRY = "https://clawhub.ai";
const CLAWHUB_REQUEST_TIMEOUT_MS = 20_000;
const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
// Trusted publish can finish before npm registry metadata converges. Keep the
// verifier on the same release train instead of forcing a republish/correction.
const NPM_VIEW_ATTEMPTS = 30;
const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -83,6 +88,35 @@ function runCommandInherited(command: string, args: string[]): void {
});
}
export async function runNpmViewWithRetry(
args: string[],
options: {
attempts?: number;
delay?: (delayMs: number) => Promise<void>;
run?: (args: string[]) => string;
} = {},
): Promise<string> {
const attempts = options.attempts ?? NPM_VIEW_ATTEMPTS;
const delay =
options.delay ??
((delayMs: number) => new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)));
const run = options.run ?? ((npmArgs: string[]) => runCommand("npm", npmArgs));
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return run([...args, "--prefer-online"]);
} catch (error) {
lastError = error;
}
if (attempt < attempts) {
await delay(Math.min(attempt * 1000, NPM_VIEW_RETRY_MAX_DELAY_MS));
}
}
throw lastError;
}
function parseJson(raw: string, label: string): unknown {
try {
return JSON.parse(raw) as unknown;
@@ -99,6 +133,7 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields
version: readString(parsed[0]),
distTagVersion: readString(parsed[1]),
integrity: readString(parsed[2]),
tarball: readString(parsed[3]),
};
}
if (!isRecord(parsed)) {
@@ -110,6 +145,7 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields
version: readString(parsed.version),
distTagVersion: readString(parsed[`dist-tags.${distTag}`]) ?? readString(distTags?.[distTag]),
integrity: readString(parsed["dist.integrity"]) ?? readString(dist?.integrity),
tarball: readString(parsed["dist.tarball"]) ?? readString(dist?.tarball),
};
}
@@ -269,13 +305,18 @@ async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promis
return response.status;
}
function verifyNpmPackage(packageName: string, version: string, distTag: string): NpmViewFields {
const raw = runCommand("npm", [
async function verifyNpmPackage(
packageName: string,
version: string,
distTag: string,
): Promise<NpmViewFields> {
const raw = await runNpmViewWithRetry([
"view",
`${packageName}@${version}`,
"version",
`dist-tags.${distTag}`,
"dist.integrity",
"dist.tarball",
"--json",
]);
const fields = parseNpmViewFields(raw, distTag);
@@ -292,6 +333,9 @@ function verifyNpmPackage(packageName: string, version: string, distTag: string)
if (fields.integrity === undefined) {
throw new Error(`${packageName}: npm dist.integrity missing for ${version}.`);
}
if (fields.tarball === undefined) {
throw new Error(`${packageName}: npm dist.tarball missing for ${version}.`);
}
return fields;
}
@@ -500,7 +544,7 @@ export async function verifyBetaRelease(
lines.push(`GitHub release OK: ${releaseUrl}`);
}
const openclawNpm = verifyNpmPackage("openclaw", args.version, args.distTag);
const openclawNpm = await verifyNpmPackage("openclaw", args.version, args.distTag);
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
if (!args.skipPostpublish) {
@@ -522,7 +566,7 @@ export async function verifyBetaRelease(
packages: npmPlugins,
});
for (const plugin of npmPlugins) {
verifyNpmPackage(plugin.packageName, args.version, args.distTag);
await verifyNpmPackage(plugin.packageName, args.version, args.distTag);
}
lines.push(`plugin npm OK: ${npmPlugins.length}`);
@@ -644,6 +688,7 @@ export async function verifyBetaRelease(
npmDistTag: args.distTag,
pluginSelection: args.pluginSelection,
openclawNpmIntegrity: openclawNpm.integrity,
openclawNpmTarball: openclawNpm.tarball,
githubReleaseUrl: releaseUrl ?? null,
pluginNpmPackageCount: npmPlugins.length,
clawHubPackageCount: clawHubPlugins.length,

View File

@@ -1853,6 +1853,8 @@ describe("package artifact reuse", () => {
"already has a public GitHub release page without complete postpublish evidence",
);
expect(releaseWorkflow).toContain("registry tarball");
expect(releaseWorkflow).toContain("openclawNpmTarball");
expect(releaseWorkflow).not.toContain('npm view "openclaw@${release_version}" dist.tarball');
expect(releaseWorkflow).toContain("release SHA");
expect(clawHubReleasePlanScript).toContain("not awaited by this proof");
expect(releaseWorkflow).toContain("wait_for_job_success");

View File

@@ -4,6 +4,7 @@ import {
parseNpmViewFields,
parseReleaseVerifyBetaArgs,
readBoundedJsonResponse,
runNpmViewWithRetry,
} from "../../scripts/lib/release-beta-verifier.ts";
describe("parseReleaseVerifyBetaArgs", () => {
@@ -90,6 +91,7 @@ describe("parseNpmViewFields", () => {
version: "2026.5.10-beta.3",
"dist-tags.beta": "2026.5.10-beta.3",
"dist.integrity": "sha512-test",
"dist.tarball": "https://registry.example/openclaw.tgz",
}),
"beta",
),
@@ -97,6 +99,7 @@ describe("parseNpmViewFields", () => {
version: "2026.5.10-beta.3",
distTagVersion: "2026.5.10-beta.3",
integrity: "sha512-test",
tarball: "https://registry.example/openclaw.tgz",
});
});
@@ -106,7 +109,10 @@ describe("parseNpmViewFields", () => {
JSON.stringify({
version: "2026.5.10-beta.3",
"dist-tags": { beta: "2026.5.10-beta.3" },
dist: { integrity: "sha512-test" },
dist: {
integrity: "sha512-test",
tarball: "https://registry.example/openclaw.tgz",
},
}),
"beta",
),
@@ -114,10 +120,38 @@ describe("parseNpmViewFields", () => {
version: "2026.5.10-beta.3",
distTagVersion: "2026.5.10-beta.3",
integrity: "sha512-test",
tarball: "https://registry.example/openclaw.tgz",
});
});
});
describe("runNpmViewWithRetry", () => {
it("retries transient registry failures with online metadata reads", async () => {
const calls: string[][] = [];
const delays: number[] = [];
await expect(
runNpmViewWithRetry(["view", "openclaw@2026.5.10-beta.3", "version", "--json"], {
attempts: 3,
delay: async (delayMs) => {
delays.push(delayMs);
},
run: (args) => {
calls.push(args);
if (calls.length < 3) {
throw new Error("npm registry has not propagated the release yet");
}
return '"2026.5.10-beta.3"';
},
}),
).resolves.toBe('"2026.5.10-beta.3"');
expect(calls).toHaveLength(3);
expect(calls.every((args) => args.at(-1) === "--prefer-online")).toBe(true);
expect(delays).toEqual([1000, 2000]);
});
});
describe("readBoundedJsonResponse", () => {
it("parses JSON bodies within the release verifier limit", async () => {
await expect(