From 03077d89e9b0873f71ff606f1be85b0bc83ac4a9 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 22 Oct 2026 10:13:13 -0500 Subject: [PATCH] fix: recover CLI releases from npm registry delays --- docs/releases.md | 2 ++ scripts/release-auto.mjs | 21 ++--------------- scripts/release-registry.mjs | 45 ++++++++++++++++++++++++++++++++++++ tests/release.test.ts | 42 ++++++++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 scripts/release-registry.mjs diff --git a/docs/releases.md b/docs/releases.md index 770fa95..26c43ce 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,6 +12,8 @@ The job updates the version only in its temporary build checkout. Both npm manif Releases run serially. A job whose source is no longer current `master` stops before publication. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward. +After an upload, verification waits up to three minutes for npm to expose the version and its distribution tag. Registry reads retry temporary connection failures and use fresh connections after the build. Package identity, source, archive integrity, alias dependency and channel checks still have to pass before the release succeeds. + The workflow retains both archives and `release.json` under the `cli-release--` GitHub artifact for 90 days, including evidence available after a failure. The receipt has source SHA, workflow run ID, version, channel, integrity digests and a `verified` result. A successful job requires verification of both packages. The run summary links to both exact npm versions. ## One-time access setup diff --git a/scripts/release-auto.mjs b/scripts/release-auto.mjs index 3cd0842..2d1894b 100644 --- a/scripts/release-auto.mjs +++ b/scripts/release-auto.mjs @@ -3,8 +3,8 @@ import { execFileSync } from 'node:child_process'; import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { setTimeout } from 'node:timers/promises'; import { planRelease, releasePackages, verifyPublishedPackage } from './release-plan.mjs'; +import { registry, waitForPublishedPackage } from './release-registry.mjs'; const root = fileURLToPath(new URL('..', import.meta.url)); const [mode, ...extra] = process.argv.slice(2); @@ -25,16 +25,6 @@ if (mode === 'publish') { assertCurrentMaster(); } -async function registry(name) { - const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { - headers: { accept: 'application/json', 'cache-control': 'no-cache' }, - redirect: 'error', signal: AbortSignal.timeout(30_000), - }); - assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`); - const metadata = await response.json(); - assert(metadata.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`); - return metadata; -} const registries = Object.fromEntries(await Promise.all(releasePackages.map(async name => [name, await registry(name)]))); const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); const plan = planRelease(manifest.version, sourceSha, registries); @@ -67,14 +57,7 @@ try { execute('npm', ['publish', resolve(output, artifact.filename), '--ignore-scripts', '--access', 'public', '--registry', 'https://registry.npmjs.org', '--tag', plan.tag, ...(mode === 'dry-run' ? ['--dry-run'] : [])]); } if (mode === 'publish') { - // Public registry replicas may briefly lag the successful publish response. - let metadata; - for (let attempt = 0; attempt < 6; attempt++) { - metadata = await registry(artifact.name); - if (metadata.versions?.[plan.version] && metadata['dist-tags']?.[plan.tag] === plan.version) break; - if (attempt < 5) await setTimeout(2_000); - } - verifyPublishedPackage(artifact, metadata, plan, { requireTag: true }); + await waitForPublishedPackage(artifact, plan); } } if (mode === 'publish') { diff --git a/scripts/release-registry.mjs b/scripts/release-registry.mjs new file mode 100644 index 0000000..1e7e2d8 --- /dev/null +++ b/scripts/release-registry.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { verifyPublishedPackage } from './release-plan.mjs'; + +const pause = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + +export async function registry(name, deadline = Date.now() + 90_000) { + let failure; + for (let attempt = 0; attempt < 3; attempt++) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + try { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { + // A synchronous build can outlive an idle registry connection. + headers: { accept: 'application/json', 'cache-control': 'no-cache', connection: 'close' }, + redirect: 'error', signal: AbortSignal.timeout(Math.min(30_000, remaining)), + }); + assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`); + const metadata = await response.json(); + assert(metadata?.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`); + return metadata; + } catch (error) { + if (!(error instanceof TypeError || error?.name === 'TimeoutError') || attempt === 2) throw error; + failure = error; + const delay = Math.min(1_000 * (attempt + 1), deadline - Date.now()); + if (delay > 0) await pause(delay); + } + } + throw failure ?? new Error(`Timed out reading ${name} registry metadata. No release decision was made.`); +} + +export async function waitForPublishedPackage(artifact, plan) { + const deadline = Date.now() + 180_000; + let metadata; + while (Date.now() < deadline) { + metadata = await registry(artifact.name, deadline); + if (metadata.versions?.[plan.version]) { + verifyPublishedPackage(artifact, metadata, plan); + if (metadata['dist-tags']?.[plan.tag] === plan.version) return metadata; + } + const remaining = deadline - Date.now(); + if (remaining > 0) await pause(Math.min(2_000, remaining)); + } + verifyPublishedPackage(artifact, metadata ?? {}, plan, { requireTag: true }); + return metadata; +} diff --git a/tests/release.test.ts b/tests/release.test.ts index 6a3cf5a..c84eae5 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { planRelease, verifyPublishedPackage } from '../scripts/release-plan.mjs'; +import { registry as readRegistry, waitForPublishedPackage } from '../scripts/release-registry.mjs'; const sourceSha = 'a'.repeat(40); const otherSha = 'b'.repeat(40); @@ -8,6 +9,7 @@ const published = (sha = otherSha, integrity = 'sha512-reviewed') => ({ dist: { integrity }, dependencies: { '@dealmachine/cli': '0.4.1' }, }); + const registry = (versions = {}, latest = '0.3.0') => ({ versions, 'dist-tags': { latest } }); const registries = (implementation = registry(), alias = registry()) => ({ '@dealmachine/cli': implementation, dealmachine: alias }); @@ -56,3 +58,41 @@ describe('automatic CLI release contract', () => { )).toThrow(error); }); }); + +describe('npm registry recovery', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const response = (available = true) => new Response(JSON.stringify({ + name: '@dealmachine/cli', + ...registry(available ? { '0.4.1': published(sourceSha) } : {}, available ? '0.4.1' : '0.3.0'), + }), { headers: { 'content-type': 'application/json' } }); + + it('finishes reading a published version after the registry connection drops', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn() + .mockRejectedValueOnce(new TypeError('fetch failed', { cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) })) + .mockImplementation(async () => response())); + const pending = readRegistry('@dealmachine/cli').then(value => ({ value, error: null }), error => ({ value: null, error })); + await vi.advanceTimersByTimeAsync(180_000); + const result = await pending; + expect(result.error).toBeNull(); + expect(result.value?.versions['0.4.1'].dealmachineRelease.sourceSha).toBe(sourceSha); + }); + + it('verifies a release when npm processing takes longer than ten seconds', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + vi.stubGlobal('fetch', async () => response(Date.now() >= 12_000)); + const pending = waitForPublishedPackage( + { name: '@dealmachine/cli', integrity: 'sha512-reviewed' }, + { version: '0.4.1', tag: 'latest', sourceSha }, + ).then(value => ({ value, error: null }), error => ({ value: null, error })); + await vi.advanceTimersByTimeAsync(180_000); + const result = await pending; + expect(result.error).toBeNull(); + expect(result.value?.['dist-tags'].latest).toBe('0.4.1'); + }); +});