mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 14:28:12 +00:00
fix(release): tolerate npm propagation after publish
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user