From 9f76c90d38bf4ab753255a5d85d3720f34bd3ed5 Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Tue, 25 Aug 2026 23:36:41 +0800 Subject: [PATCH 1/3] test: reproduce delayed npm publish visibility --- tests/release.test.mjs | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/release.test.mjs b/tests/release.test.mjs index cfd4d89..3cfda44 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -108,6 +108,73 @@ test('publishes a verified stable release under the default tag', async () => { }); }); +test('retries a transient post-publish lookup without publishing again', async () => { + const calls = []; + let verificationAttempt = 0; + const result = await publishRelease('1.2.3', { + publish: () => calls.push('publish'), + readPackage: async () => ({ name: 'diffsplain', version: '1.2.3' }), + readReceipt: async () => receipt, + readState: () => cleanState, + registryVersionExists: () => false, + requireNpmLogin: () => 'jling', + sha256: async () => 'verified-hash', + verifyPublished: () => { + calls.push('verify'); + verificationAttempt += 1; + if (verificationAttempt === 1) { + throw new Error('npm registry lookup failed'); + } + return '1.2.3'; + }, + wait: async (milliseconds) => calls.push(['wait', milliseconds]), + }); + + assert.deepEqual(calls, [ + 'publish', + 'verify', + ['wait', 1_000], + 'verify', + ]); + assert.deepEqual(result, { + account: 'jling', + package: 'diffsplain', + version: '1.2.3', + }); +}); + +test('stops after bounded post-publish verification attempts', async () => { + const calls = []; + await assert.rejects( + publishRelease('1.2.3', { + publish: () => calls.push('publish'), + readPackage: async () => ({ name: 'diffsplain', version: '1.2.3' }), + readReceipt: async () => receipt, + readState: () => cleanState, + registryVersionExists: () => false, + requireNpmLogin: () => 'jling', + sha256: async () => 'verified-hash', + verifyPublished: () => { + calls.push('verify'); + throw new Error('npm registry lookup failed'); + }, + wait: async (milliseconds) => calls.push(['wait', milliseconds]), + }), + /npm registry lookup failed/, + ); + + assert.deepEqual(calls, [ + 'publish', + 'verify', + ['wait', 1_000], + 'verify', + ['wait', 2_000], + 'verify', + ['wait', 4_000], + 'verify', + ]); +}); + test('publishes a verified prerelease under the next tag', async () => { const calls = []; const prerelease = { From 6327dd2cc74bad7a425cef6bac433ba29ae2f629 Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Tue, 25 Aug 2026 23:37:34 +0800 Subject: [PATCH 2/3] fix: retry npm publish verification --- scripts/release.mjs | 29 +++++++++++++++++++++++------ tests/release.test.mjs | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/scripts/release.mjs b/scripts/release.mjs index aef3c69..a6cf927 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; +import { setTimeout as wait } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -172,6 +173,7 @@ const defaults = { ); return result.stdout.replace(/^"|"$/g, ''); }, + wait, writeReceipt: (receipt) => writeFile( resolve(root, verificationReceipt), @@ -251,13 +253,28 @@ export async function publishRelease(expectedVersion, overrides = {}) { } deps.publish(publishArgs(pkg.version)); - const publishedVersion = deps.verifyPublished(pkg.name, pkg.version); - if (publishedVersion !== pkg.version) { - throw new Error( - `npm returned ${publishedVersion || 'no version'} after publishing ${pkg.version}.`, - ); + const verificationDelays = [1_000, 2_000, 4_000]; + for (let attempt = 0; attempt <= verificationDelays.length; attempt += 1) { + try { + const publishedVersion = deps.verifyPublished(pkg.name, pkg.version); + if (publishedVersion !== pkg.version) { + throw new Error( + `npm returned ${publishedVersion || 'no version'} after publishing ${pkg.version}.`, + ); + } + return { account, package: pkg.name, version: pkg.version }; + } catch (error) { + const delay = verificationDelays[attempt]; + if (delay === undefined) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `${pkg.name}@${pkg.version} was published, but post-publish verification failed: ${detail}`, + { cause: error }, + ); + } + await deps.wait(delay); + } } - return { account, package: pkg.name, version: pkg.version }; } function commandArguments(argv) { diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 3cfda44..af0c119 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -160,7 +160,7 @@ test('stops after bounded post-publish verification attempts', async () => { }, wait: async (milliseconds) => calls.push(['wait', milliseconds]), }), - /npm registry lookup failed/, + /diffsplain@1\.2\.3 was published, but post-publish verification failed: npm registry lookup failed/, ); assert.deepEqual(calls, [ From 63fd290d4002d0ffe3de578cf4524d709fa405aa Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Wed, 26 Aug 2026 00:11:44 +0800 Subject: [PATCH 3/3] Refactor publish verification retry --- scripts/release.mjs | 58 +++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/scripts/release.mjs b/scripts/release.mjs index a6cf927..97f3a40 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -120,6 +120,38 @@ function publishArgs(version) { ]; } +function requirePublishedVersion(deps, name, expectedVersion) { + const publishedVersion = deps.verifyPublished(name, expectedVersion); + if (publishedVersion !== expectedVersion) { + throw new Error( + `npm returned ${publishedVersion || 'no version'} after publishing ${expectedVersion}.`, + ); + } +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function verifyPublishedRelease(deps, name, version) { + const verificationDelays = [0, 1_000, 2_000, 4_000]; + let lastError; + for (const delay of verificationDelays) { + if (delay > 0) await deps.wait(delay); + try { + requirePublishedVersion(deps, name, version); + return; + } catch (error) { + lastError = error; + } + } + + throw new Error( + `${name}@${version} was published, but post-publish verification failed: ${errorMessage(lastError)}`, + { cause: lastError }, + ); +} + function registryVersionExists(name, version) { const result = runNpm( ['view', `${name}@${version}`, 'version', '--json', '--registry', registry], @@ -253,28 +285,8 @@ export async function publishRelease(expectedVersion, overrides = {}) { } deps.publish(publishArgs(pkg.version)); - const verificationDelays = [1_000, 2_000, 4_000]; - for (let attempt = 0; attempt <= verificationDelays.length; attempt += 1) { - try { - const publishedVersion = deps.verifyPublished(pkg.name, pkg.version); - if (publishedVersion !== pkg.version) { - throw new Error( - `npm returned ${publishedVersion || 'no version'} after publishing ${pkg.version}.`, - ); - } - return { account, package: pkg.name, version: pkg.version }; - } catch (error) { - const delay = verificationDelays[attempt]; - if (delay === undefined) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error( - `${pkg.name}@${pkg.version} was published, but post-publish verification failed: ${detail}`, - { cause: error }, - ); - } - await deps.wait(delay); - } - } + await verifyPublishedRelease(deps, pkg.name, pkg.version); + return { account, package: pkg.name, version: pkg.version }; } function commandArguments(argv) { @@ -306,7 +318,7 @@ if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { await main(process.argv.slice(2)); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + console.error(errorMessage(error)); process.exitCode = 2; } }