From 867f4d0a45e8dbfccf7a4ef53b8e4c36facee0d5 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 11 Oct 2026 01:04:12 +1100 Subject: [PATCH 1/2] chore(release): wait for npm availability before announcement --- .github/workflows/release.yml | 3 + scripts/release/npm-availability.ts | 115 ++++++++++++++++++++++++++ tests/loader/npm-availability.test.ts | 68 +++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 scripts/release/npm-availability.ts create mode 100644 tests/loader/npm-availability.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbc70513..91fb8ffc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,6 +195,9 @@ jobs: if test "$(node scripts/release/native-packages.mjs package-publish packages/harness)" = true; then pnpm --dir packages/harness publish --access public --no-git-checks --tag "${{ steps.npm_tag.outputs.value }}" fi + - name: Wait for npm availability + timeout-minutes: 17 + run: node --experimental-strip-types scripts/release/npm-availability.ts "$GITHUB_REF_NAME" - name: Publish GitHub release shell: bash env: diff --git a/scripts/release/npm-availability.ts b/scripts/release/npm-availability.ts new file mode 100644 index 00000000..b3a2f082 --- /dev/null +++ b/scripts/release/npm-availability.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { setTimeout } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { nativePackageSpecs, npmTagForVersion, verifyReleaseVersions } from './native-packages.mjs' + +export interface NpmPackage { + name: string + version: string + tag: string +} + +interface WaitOptions { + packages: readonly NpmPackage[] + fetch?: typeof fetch + now?: () => number + sleep?: (ms: number) => Promise + onProgress?: (message: string) => void + timeoutMs?: number + intervalMs?: number +} + +type Probe = { _tag: 'Available' } | { _tag: 'Pending', reason: string } + +/** npm scans uploads before making versions and tags available to consumers. */ +export async function waitForNpmPackages(options: WaitOptions): Promise { + const { packages, onProgress } = options + const fetchRegistry = options.fetch ?? globalThis.fetch + const now = options.now ?? Date.now + const sleep = options.sleep ?? (async (ms) => { + await setTimeout(ms) + }) + const timeoutMs = options.timeoutMs ?? 15 * 60_000 + const intervalMs = options.intervalMs ?? 30_000 + if (timeoutMs <= 0 || intervalMs <= 0 || !Number.isFinite(timeoutMs) || !Number.isFinite(intervalMs)) + throw new Error('npm availability requires positive, finite wait times.') + const deadline = now() + timeoutMs + let pending = [...packages] + while (now() < deadline) { + const results = await Promise.all(pending.map(async (pkg) => { + for (const selector of [pkg.version, pkg.tag]) { + if (now() >= deadline) + return pkg + const result = await probe(pkg, selector, fetchRegistry, Math.max(1, Math.min(10_000, deadline - now()))) + if (now() >= deadline) + return pkg + if (result._tag === 'Pending') { + onProgress?.(`${pkg.name}@${pkg.version}: ${result.reason}`) + return pkg + } + } + return null + })) + pending = results.filter((pkg): pkg is NpmPackage => pkg !== null) + if (pending.length === 0) { + onProgress?.('All release packages are available from npm.') + return + } + const remaining = deadline - now() + if (remaining > 0) + await sleep(Math.min(intervalMs, remaining)) + } + throw new Error(`npm availability timed out: ${pending.map(pkg => `${pkg.name}@${pkg.version}`).join(', ')}`) +} + +async function probe(pkg: NpmPackage, selector: string, fetchRegistry: typeof fetch, timeoutMs: number): Promise { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${encodeURIComponent(selector)}` + const fetched = await fetchRegistry(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error', headers: { accept: 'application/json' } }) + .then(response => ({ _tag: 'Response', response }) as const, error => ({ _tag: 'Pending', reason: `Registry request failed: ${String(error)}` }) as const) + if (fetched._tag === 'Pending') + return fetched + const { response } = fetched + if (response.status === 404 || response.status === 429 || response.status >= 500) + return { _tag: 'Pending', reason: `${selector}: HTTP ${response.status}` } + if (response.status !== 200) + throw new Error(`${pkg.name}: npm registry returned HTTP ${response.status}.`) + if (Number(response.headers.get('content-length')) > 1024 * 1024) + throw new Error(`${pkg.name}: npm metadata exceeds its byte limit.`) + const read = await response.text().then(text => ({ _tag: 'Text', text }) as const, error => ({ _tag: 'Pending', reason: `Registry body failed: ${String(error)}` }) as const) + if (read._tag === 'Pending') + return read + if (Buffer.byteLength(read.text) > 1024 * 1024) + throw new Error(`${pkg.name}: npm metadata exceeds its byte limit.`) + const body: unknown = JSON.parse(read.text) + if (typeof body !== 'object' || body === null || !('name' in body) || body.name !== pkg.name) + throw new Error(`${pkg.name}: npm returned a different package.`) + if (!('version' in body) || typeof body.version !== 'string') + throw new Error(`${pkg.name}: npm returned invalid version metadata.`) + if (body.version === pkg.version) + return { _tag: 'Available' } + if (selector === pkg.version) + throw new Error(`${pkg.name}: npm returned a different package version.`) + return { _tag: 'Pending', reason: `${selector} still points to ${body.version}.` } +} + +async function main(): Promise { + const root = process.cwd() + const tag = process.argv[2] + await verifyReleaseVersions(root, tag) + const directories = [root, join(root, 'packages/harness'), ...nativePackageSpecs.map(spec => join(root, 'packages', spec.directory))] + const packages = await Promise.all(directories.map(async (directory): Promise => { + const manifest: unknown = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) + if (typeof manifest !== 'object' || manifest === null || !('name' in manifest) || typeof manifest.name !== 'string' || !('version' in manifest) || typeof manifest.version !== 'string') + throw new Error(`Invalid release manifest: ${directory}`) + return { name: manifest.name, version: manifest.version, tag: npmTagForVersion(manifest.version) } + })) + await waitForNpmPackages({ packages, onProgress: console.log }) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/tests/loader/npm-availability.test.ts b/tests/loader/npm-availability.test.ts new file mode 100644 index 00000000..8db5aa11 --- /dev/null +++ b/tests/loader/npm-availability.test.ts @@ -0,0 +1,68 @@ +import { expect, it, vi } from 'vitest' +import { waitForNpmPackages } from '../../scripts/release/npm-availability' + +const packages = [{ name: 'skilld', version: '3.6.8', tag: 'latest' }, { name: 'skilld-native', version: '3.6.8', tag: 'latest' }] + +it.each(['version', 'tag'])('waits for the last package %s after npm accepts the uploads', async (pending) => { + let now = 0 + let ready = false + const sleep = vi.fn(async (ms: number) => { + now += ms + ready = true + }) + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = String(input) + if (!ready && url.includes('skilld-native') && pending === 'version') + return new Response(null, { status: 404 }) + return Response.json({ name: url.includes('skilld-native') ? 'skilld-native' : 'skilld', version: !ready && url.includes('skilld-native/latest') ? '3.6.7' : '3.6.8' }) + }) + await waitForNpmPackages({ packages, fetch: fetcher, now: () => now, sleep, timeoutMs: 100, intervalMs: 10 }) + expect(sleep).toHaveBeenCalledOnce() + expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('skilld-native/latest'))).toBe(true) +}) + +it('fails with the pending package when npm does not make it available before the deadline', async () => { + let now = 0 + await expect(waitForNpmPackages({ + packages, + fetch: async () => new Response(null, { status: 404 }), + now: () => now, + sleep: async (ms) => { + now += ms + }, + timeoutMs: 20, + intervalMs: 10, + })).rejects.toThrow(/skilld@3.6.8/) + expect(now).toBe(20) +}) + +it('retries transient registry failures without hiding the reason', async () => { + let now = 0 + const progress = vi.fn() + const fetcher = vi.fn(async () => now === 0 ? new Response(null, { status: 503 }) : Response.json({ name: 'skilld', version: '3.6.8' })) + await waitForNpmPackages({ + packages: [packages[0]!], fetch: fetcher, now: () => now, + sleep: async (ms) => { + now += ms + }, + onProgress: progress, timeoutMs: 20, intervalMs: 10, + }) + expect(progress).toHaveBeenCalledWith(expect.stringContaining('HTTP 503')) +}) + +it('rejects metadata for a different package instead of reporting readiness', async () => { + await expect(waitForNpmPackages({ packages, fetch: async () => Response.json({ name: 'other', version: '3.6.8' }) })).rejects.toThrow(/different package/) +}) + +it('rejects availability that arrives after the deadline', async () => { + let now = 0 + await expect(waitForNpmPackages({ + packages: [packages[0]!], + now: () => now, + timeoutMs: 20, + fetch: async () => { + now = 21 + return Response.json({ name: 'skilld', version: '3.6.8' }) + }, + })).rejects.toThrow(/timed out/) +}) From 1f68fdc53dcd53a486bac5ca4b4ce22fcff54826 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 11 Oct 2026 01:04:31 +1100 Subject: [PATCH 2/2] style(release): format npm availability checks --- scripts/release/npm-availability.ts | 1 + tests/loader/npm-availability.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/release/npm-availability.ts b/scripts/release/npm-availability.ts index b3a2f082..feeb158d 100644 --- a/scripts/release/npm-availability.ts +++ b/scripts/release/npm-availability.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' import { readFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { setTimeout } from 'node:timers/promises' diff --git a/tests/loader/npm-availability.test.ts b/tests/loader/npm-availability.test.ts index 8db5aa11..e02c9d82 100644 --- a/tests/loader/npm-availability.test.ts +++ b/tests/loader/npm-availability.test.ts @@ -41,11 +41,15 @@ it('retries transient registry failures without hiding the reason', async () => const progress = vi.fn() const fetcher = vi.fn(async () => now === 0 ? new Response(null, { status: 503 }) : Response.json({ name: 'skilld', version: '3.6.8' })) await waitForNpmPackages({ - packages: [packages[0]!], fetch: fetcher, now: () => now, + packages: [packages[0]!], + fetch: fetcher, + now: () => now, sleep: async (ms) => { now += ms }, - onProgress: progress, timeoutMs: 20, intervalMs: 10, + onProgress: progress, + timeoutMs: 20, + intervalMs: 10, }) expect(progress).toHaveBeenCalledWith(expect.stringContaining('HTTP 503')) })