From e71cf0ffcbe69f69bb0aaecb5eb08459cbe8f0bb Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:51:42 +0800 Subject: [PATCH] fix(release): tolerate npm propagation after publish --- .../release-openclaw-maintainer/SKILL.md | 17 +++++- .../workflows/openclaw-release-publish.yml | 7 +-- scripts/lib/release-beta-verifier.ts | 53 +++++++++++++++++-- .../package-acceptance-workflow.test.ts | 2 + test/scripts/release-beta-verifier.test.ts | 36 ++++++++++++- 5 files changed, 106 insertions(+), 9 deletions(-) diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 6e84a798f51..43c9d9730d4 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -552,6 +552,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - `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 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 `. + For a failed postpublish parent after successful publish children, also run + `pnpm release:verify-beta -- ... --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 diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 3a58135bef0..0c71afa6009 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -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 diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index fc5dd4fda16..cb9efa8bf5e 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -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; + run?: (args: string[]) => string; + } = {}, +): Promise { + 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 { + 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, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 82a71ac68b8..605bfb68f4c 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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"); diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index a14e8827f68..e7269b1fa5e 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -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(