Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
116 changes: 116 additions & 0 deletions scripts/release/npm-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Buffer } from 'node:buffer'
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<void>
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<void> {
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<Probe> {
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<void> {
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<NpmPackage> => {
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
})
}
72 changes: 72 additions & 0 deletions tests/loader/npm-availability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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/)
})