From 954f31b4354d63dc1b2bc644a2836117d49b61d7 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 3 Sep 2026 10:16:53 +0800 Subject: [PATCH 1/6] ci: gate npm publishes on dependency propagation --- .../__tests__/publish-npm-package.spec.ts | 104 ++++++++++ .../__tests__/wait-for-npm-packages.spec.ts | 157 ++++++++++++++ .github/scripts/publish-npm-package.ts | 154 ++++++++++++++ .github/scripts/wait-for-npm-packages.ts | 192 ++++++++++++++++++ .github/workflows/release.yml | 29 ++- packages/cli/publish-native-addons.ts | 73 ++++--- 6 files changed, 676 insertions(+), 33 deletions(-) create mode 100644 .github/scripts/__tests__/publish-npm-package.spec.ts create mode 100644 .github/scripts/__tests__/wait-for-npm-packages.spec.ts create mode 100644 .github/scripts/publish-npm-package.ts create mode 100644 .github/scripts/wait-for-npm-packages.ts diff --git a/.github/scripts/__tests__/publish-npm-package.spec.ts b/.github/scripts/__tests__/publish-npm-package.spec.ts new file mode 100644 index 0000000000..c925971dc6 --- /dev/null +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -0,0 +1,104 @@ +/// + +import { describe, expect, test, vi } from 'vitest'; + +import { + type PublishCommandRunner, + type PublishNpmPackageOptions, + isAlreadyPublishedError, + publishNpmPackage, +} from '../publish-npm-package.ts'; +import type { FetchLike } from '../wait-for-npm-packages.ts'; + +const pkg = { name: '@scope/pkg', version: '1.2.3' }; + +function response(status: number, body: unknown = undefined) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function options(fetchImpl: FetchLike, runCommand: PublishCommandRunner): PublishNpmPackageOptions { + return { + pkg, + command: 'npm', + args: ['publish'], + cwd: '/workspace/pkg', + registry: 'https://registry.npmjs.org', + fetchImpl, + runCommand, + log: vi.fn(), + warn: vi.fn(), + }; +} + +describe('publishNpmPackage', () => { + test('skips an exact version that is already visible', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + response(200, { + versions: { '1.2.3': {} }, + }), + ); + const runCommand = vi.fn(); + + await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe( + 'already-published', + ); + expect(runCommand).not.toHaveBeenCalled(); + }); + + test('publishes a version that is not visible', async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(404)); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 0, + output: 'published', + }); + + await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe('published'); + expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg'); + }); + + test('recovers when npm accepted a version that scanning still hides', async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(404)); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 1, + output: + 'npm error 403 Forbidden - You cannot publish over the previously published versions: 1.2.3.', + }); + + await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe( + 'already-published', + ); + }); + + test('does not swallow unrelated publish failures', async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(404)); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 1, + output: 'npm error 403 Authentication failed', + }); + + await expect(publishNpmPackage(options(fetchImpl, runCommand))).rejects.toThrow( + 'Failed to publish @scope/pkg@1.2.3: exit code 1', + ); + }); + + test('publishes after a transient preflight read failure', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('registry unavailable')); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 0, + output: 'published', + }); + const publishOptions = options(fetchImpl, runCommand); + + await expect(publishNpmPackage(publishOptions)).resolves.toBe('published'); + expect(publishOptions.warn).toHaveBeenCalledOnce(); + }); +}); + +test('recognizes npm immutable-version errors only', () => { + expect(isAlreadyPublishedError('npm ERR! code EPUBLISHCONFLICT')).toBe(true); + expect(isAlreadyPublishedError('npm error 403 Authentication failed')).toBe(false); +}); diff --git a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts new file mode 100644 index 0000000000..45d0362ba4 --- /dev/null +++ b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts @@ -0,0 +1,157 @@ +/// + +import { describe, expect, test, vi } from 'vitest'; + +import { + type FetchLike, + isNpmPackageAvailable, + parseNpmPackageSpec, + waitForNpmPackages, +} from '../wait-for-npm-packages.ts'; + +function response(status: number, body: unknown = undefined) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe('isNpmPackageAvailable', () => { + test('checks the abbreviated packument and its tarball', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + response(200, { + versions: { + '1.2.3': { + dist: { + tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', + }, + }, + }, + }), + ) + .mockResolvedValueOnce(response(200)); + + await expect( + isNpmPackageAvailable( + { name: '@scope/pkg', version: '1.2.3' }, + { registry: 'https://registry.npmjs.org/', fetchImpl }, + ), + ).resolves.toBe(true); + + expect(fetchImpl).toHaveBeenNthCalledWith(1, 'https://registry.npmjs.org/@scope%2fpkg', { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + }); + expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', { + method: 'HEAD', + }); + }); + + test('is unavailable while the version or tarball is missing', async () => { + const missingVersion = vi.fn().mockResolvedValue( + response(200, { + versions: { '1.2.2': {} }, + }), + ); + await expect( + isNpmPackageAvailable( + { name: 'pkg', version: '1.2.3' }, + { registry: 'https://registry.npmjs.org', fetchImpl: missingVersion }, + ), + ).resolves.toBe(false); + + const missingTarball = vi + .fn() + .mockResolvedValueOnce( + response(200, { + versions: { + '1.2.3': { + dist: { + tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', + }, + }, + }, + }), + ) + .mockResolvedValueOnce(response(404)); + await expect( + isNpmPackageAvailable( + { name: 'pkg', version: '1.2.3' }, + { registry: 'https://registry.npmjs.org', fetchImpl: missingTarball }, + ), + ).resolves.toBe(false); + }); +}); + +describe('waitForNpmPackages', () => { + test('polls until available and always settles after the successful read', async () => { + let currentTime = 0; + const sleep = vi.fn(async (milliseconds: number) => { + currentTime += milliseconds; + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(404)) + .mockResolvedValueOnce( + response(200, { + versions: { + '1.2.3': { + dist: { + tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', + }, + }, + }, + }), + ) + .mockResolvedValueOnce(response(200)); + + await waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], { + registry: 'https://registry.npmjs.org', + fetchImpl, + minSeconds: 60, + timeoutSeconds: 600, + pollSeconds: 5, + sleep, + now: () => currentTime, + log: vi.fn(), + }); + + expect(sleep.mock.calls).toEqual([[5_000], [60_000]]); + }); + + test('retries transient read failures until the timeout', async () => { + let currentTime = 0; + const fetchImpl = vi.fn().mockRejectedValue(new Error('temporary failure')); + + await expect( + waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], { + registry: 'https://registry.npmjs.org', + fetchImpl, + minSeconds: 0, + timeoutSeconds: 5, + pollSeconds: 5, + sleep: async (milliseconds) => { + currentTime += milliseconds; + }, + now: () => currentTime, + log: vi.fn(), + }), + ).rejects.toThrow('Timed out after 5s waiting for npm propagation: pkg@1.2.3'); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); + +test('parseNpmPackageSpec supports scoped and unscoped package names', () => { + expect(parseNpmPackageSpec('@scope/pkg@1.2.3')).toEqual({ + name: '@scope/pkg', + version: '1.2.3', + }); + expect(parseNpmPackageSpec('pkg@1.2.3')).toEqual({ + name: 'pkg', + version: '1.2.3', + }); + expect(() => parseNpmPackageSpec('@scope/pkg')).toThrow('name@version'); +}); diff --git a/.github/scripts/publish-npm-package.ts b/.github/scripts/publish-npm-package.ts new file mode 100644 index 0000000000..dd438b41c4 --- /dev/null +++ b/.github/scripts/publish-npm-package.ts @@ -0,0 +1,154 @@ +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + type FetchLike, + type NpmPackageVersion, + DEFAULT_NPM_REGISTRY, + isNpmPackagePublished, + parseNpmPackageSpec, +} from './wait-for-npm-packages.ts'; + +export interface PublishCommandResult { + exitCode: number | null; + output: string; + error?: Error; +} + +export type PublishCommandRunner = ( + command: string, + args: readonly string[], + cwd: string, +) => Promise; + +export interface PublishNpmPackageOptions { + pkg: NpmPackageVersion; + command: string; + args: readonly string[]; + cwd: string; + registry: string; + fetchImpl: FetchLike; + runCommand: PublishCommandRunner; + log: (message: string) => void; + warn: (message: string) => void; +} + +export type PublishNpmPackageResult = 'published' | 'already-published'; + +/** Matches npm's immutable-version errors without swallowing unrelated 403s. */ +export function isAlreadyPublishedError(output: string): boolean { + return ( + /you cannot publish over the previously published versions?/i.test(output) || + /EPUBLISHCONFLICT/i.test(output) + ); +} + +/** + * Publishes one package idempotently. A registry lookup handles normal reruns; + * the error check handles the scan window where npm has accepted the version + * but still hides it from registry reads. + */ +export async function publishNpmPackage( + options: PublishNpmPackageOptions, +): Promise { + const spec = `${options.pkg.name}@${options.pkg.version}`; + + try { + if ( + await isNpmPackagePublished(options.pkg, { + registry: options.registry, + fetchImpl: options.fetchImpl, + }) + ) { + options.log(`${spec} is already published; skipping upload.`); + return 'already-published'; + } + } catch (error) { + // A transient read failure must not prevent the publish attempt. If this is + // a rerun, npm's immutable-version response is handled below. + options.warn(`Could not check whether ${spec} is published; trying upload (${String(error)})`); + } + + const result = await options.runCommand(options.command, options.args, options.cwd); + if (result.exitCode === 0) { + return 'published'; + } + if (isAlreadyPublishedError(result.output)) { + options.log(`${spec} was accepted by an earlier attempt; skipping upload.`); + return 'already-published'; + } + + const detail = result.error?.message ?? `exit code ${String(result.exitCode)}`; + throw new Error(`Failed to publish ${spec}: ${detail}`); +} + +function runPublishCommand( + command: string, + args: readonly string[], + cwd: string, +): Promise { + return new Promise((resolveResult) => { + const child = spawn(command, args, { cwd, env: process.env }); + let output = ''; + + child.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + process.stderr.write(chunk); + }); + child.on('error', (error) => { + resolveResult({ exitCode: null, output, error }); + }); + child.on('close', (exitCode) => { + resolveResult({ exitCode, output }); + }); + }); +} + +export async function publishNpmPackageFromEnv( + pkg: NpmPackageVersion, + command: string, + args: readonly string[], + cwd = process.cwd(), +): Promise { + return publishNpmPackage({ + pkg, + command, + args, + cwd, + registry: process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY, + fetchImpl: fetch, + runCommand: runPublishCommand, + log: console.log, + warn: console.warn, + }); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const separator = args.indexOf('--'); + if (separator !== 1 || args.length <= separator + 1) { + throw new Error( + 'Usage: node .github/scripts/publish-npm-package.ts -- [args...]', + ); + } + + const spec = args[0]; + const command = args[separator + 1]; + if (!spec || !command) { + throw new Error('A package version and publish command are required.'); + } + await publishNpmPackageFromEnv(parseNpmPackageSpec(spec), command, args.slice(separator + 2)); +} + +const invokedPath = process.argv[1]; +if (invokedPath && pathToFileURL(resolve(invokedPath)).href === import.meta.url) { + main().catch((error: unknown) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/wait-for-npm-packages.ts b/.github/scripts/wait-for-npm-packages.ts new file mode 100644 index 0000000000..2e208957c0 --- /dev/null +++ b/.github/scripts/wait-for-npm-packages.ts @@ -0,0 +1,192 @@ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** The registry used by the release workflow. */ +export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; + +/** + * npm installs resolve versions from the abbreviated packument, which is + * cached separately from the full package document. + */ +const ABBREVIATED_PACKUMENT_ACCEPT = 'application/vnd.npm.install-v1+json'; + +export interface NpmPackageVersion { + name: string; + version: string; +} + +interface AbbreviatedPackument { + versions?: Record; +} + +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record }, +) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +export interface WaitForNpmPackagesOptions { + registry: string; + fetchImpl: FetchLike; + /** Wait this long after all versions become available. */ + minSeconds: number; + timeoutSeconds: number; + pollSeconds: number; + sleep: (milliseconds: number) => Promise; + now: () => number; + log: (message: string) => void; +} + +function escapePackageName(name: string): string { + return name.replace('/', '%2f'); +} + +async function fetchNpmPackument( + name: string, + options: Pick, +): Promise { + const registry = options.registry.replace(/\/+$/, ''); + const response = await options.fetchImpl(`${registry}/${escapePackageName(name)}`, { + headers: { accept: ABBREVIATED_PACKUMENT_ACCEPT }, + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`registry returned HTTP ${response.status}`); + } + return (await response.json()) as AbbreviatedPackument; +} + +/** Checks whether npm has made an immutable package version visible. */ +export async function isNpmPackagePublished( + pkg: NpmPackageVersion, + options: Pick, +): Promise { + const packument = await fetchNpmPackument(pkg.name, options); + return packument?.versions?.[pkg.version] !== undefined; +} + +/** + * Checks the same metadata document an npm install uses, then verifies that + * the tarball referenced by that document can also be fetched. + */ +export async function isNpmPackageAvailable( + pkg: NpmPackageVersion, + options: Pick, +): Promise { + const packument = await fetchNpmPackument(pkg.name, options); + const tarball = packument?.versions?.[pkg.version]?.dist?.tarball; + if (!tarball) { + return false; + } + + const tarballResponse = await options.fetchImpl(tarball, { method: 'HEAD' }); + return tarballResponse.ok; +} + +/** + * Waits until every package version is installable before the release moves + * on to a package that pins it as a dependency. + */ +export async function waitForNpmPackages( + packages: readonly NpmPackageVersion[], + options: WaitForNpmPackagesOptions, +): Promise { + if (packages.length === 0) { + return; + } + + const start = options.now(); + const deadline = start + options.timeoutSeconds * 1000; + const pending = new Map(packages.map((pkg) => [pkg.name, pkg.version])); + + options.log(`Waiting for ${pending.size} npm package version(s) to become installable...`); + + while (pending.size > 0) { + for (const [name, version] of pending) { + try { + if (await isNpmPackageAvailable({ name, version }, options)) { + options.log(` ${name}@${version}: available`); + pending.delete(name); + } + } catch (error) { + options.log(` ${name}@${version}: check failed, retrying (${String(error)})`); + } + } + + if (pending.size === 0) { + break; + } + if (options.now() >= deadline) { + const packageList = [...pending].map(([name, version]) => `${name}@${version}`).join(', '); + throw new Error( + `Timed out after ${options.timeoutSeconds}s waiting for npm propagation: ${packageList}`, + ); + } + await options.sleep(options.pollSeconds * 1000); + } + + if (options.minSeconds > 0) { + const elapsedSeconds = Math.round((options.now() - start) / 1000); + options.log( + `All versions are installable after ${elapsedSeconds}s; settling for a further ${options.minSeconds}s.`, + ); + await options.sleep(options.minSeconds * 1000); + } +} + +export function parseNpmPackageSpec(spec: string): NpmPackageVersion { + const separator = spec.lastIndexOf('@'); + if (separator <= 0 || separator === spec.length - 1) { + throw new Error(`Expected a package argument in the form name@version, received ${spec}`); + } + return { name: spec.slice(0, separator), version: spec.slice(separator + 1) }; +} + +function readNonNegativeInteger(name: string, fallback: number): number { + const value = process.env[name]; + if (value === undefined || value.trim() === '') { + return fallback; + } + if (!/^\d+$/.test(value)) { + throw new Error(`Expected ${name} to be a non-negative integer, received ${value}`); + } + return Number(value); +} + +/** Uses the release workflow's propagation settings. */ +export async function waitForNpmPackagesFromEnv( + packages: readonly NpmPackageVersion[], +): Promise { + if (process.env.PUBLISH_SKIP_PROPAGATION_WAIT === 'true') { + console.log('Skipping npm propagation wait.'); + return; + } + + await waitForNpmPackages(packages, { + registry: process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY, + fetchImpl: fetch, + minSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_MIN_SECONDS', 60), + timeoutSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_TIMEOUT_SECONDS', 600), + pollSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_POLL_SECONDS', 5), + sleep: (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)), + now: Date.now, + log: console.log, + }); +} + +async function main(): Promise { + const specs = process.argv.slice(2); + if (specs.length === 0) { + throw new Error('Usage: node .github/scripts/wait-for-npm-packages.ts [...]'); + } + await waitForNpmPackagesFromEnv(specs.map(parseNpmPackageSpec)); +} + +const invokedPath = process.argv[1]; +if (invokedPath && pathToFileURL(resolve(invokedPath)).href === import.meta.url) { + main().catch((error: unknown) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a2d5f6714..35bb2b612a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,11 @@ jobs: id-token: write # Required for OIDC env: VERSION: ${{ needs.check.outputs.version }} + # npm publish-time scanning makes a successful upload temporarily + # unavailable to installs. After each dependency tier becomes visible on + # this runner's registry edge, allow time for other CDN edges to settle. + PUBLISH_PROPAGATION_MIN_SECONDS: '60' + PUBLISH_PROPAGATION_TIMEOUT_SECONDS: '600' steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - uses: ./.github/actions/clone @@ -159,11 +164,31 @@ jobs: - name: Prepare and publish native addons run: node ./packages/cli/publish-native-addons.ts --mode npm - - name: Publish - run: | + - name: Publish core dependency tier + run: >- + node ./.github/scripts/publish-npm-package.ts + "@voidzero-dev/vite-plus-core@${VERSION}" + -- pnpm publish --filter=./packages/core --tag latest --access public --no-git-checks + + # The CLI pins core via workspace:* (rewritten to this exact version). + # Wait for both its install metadata and tarball before publishing the CLI. + - name: Wait for core to propagate + run: >- + node ./.github/scripts/wait-for-npm-packages.ts + "@voidzero-dev/vite-plus-core@${VERSION}" + + - name: Publish Vite+ + run: >- + node ./.github/scripts/publish-npm-package.ts + "vite-plus@${VERSION}" + -- pnpm publish --filter=./packages/cli --tag latest --access public --no-git-checks + # Downstream jobs build Docker images by installing this release from npm. + - name: Wait for Vite+ to propagate + run: node ./.github/scripts/wait-for-npm-packages.ts "vite-plus@${VERSION}" + - name: Create release body env: REPOSITORY: ${{ github.repository }} diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index b4eb1abad1..eeefb677ab 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -1,4 +1,3 @@ -import { execSync } from 'node:child_process'; import { copyFileSync, existsSync, chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; @@ -6,6 +5,8 @@ import { fileURLToPath } from 'node:url'; import { NapiCli, parseTriple } from '@napi-rs/cli'; +import { publishNpmPackageFromEnv } from '../../.github/scripts/publish-npm-package.ts'; +import { waitForNpmPackagesFromEnv } from '../../.github/scripts/wait-for-npm-packages.ts'; import pkg from './package.json' with { type: 'json' }; import { editJsonFile, readJsonFile } from './src/utils/json.ts'; @@ -93,6 +94,8 @@ const cliPackageJson = readJsonFile(join(currentDir, 'package.json')) as { repository?: unknown; optionalDependencies?: Record; }; +// Lockstep versioning: every generated platform package uses the CLI version. +const cliVersion = cliPackageJson.version; // napi-rs prePublish injects the platform packages into this package's // `optionalDependencies`. Release builds of core rewrite bundled Rolldown's @@ -120,37 +123,29 @@ editJsonFile(join(repoRoot, 'packages', 'core', 'package.json'), (corePkgJson) = ...nativePlatformPins, }, })); +const publishedPlatformPackages = Object.keys(nativePlatformPins).map((name) => ({ + name, + version: cliVersion, +})); // Publish each NAPI platform package (without vp binary) const npmTag = process.env.NPM_TAG || 'latest'; if (!skipNpmPublish) { for (const file of platformDirs) { - try { - const output = execSync(`npm publish --tag ${npmTag} --access public`, { - cwd: join(currentDir, 'npm', file), - env: process.env, - stdio: 'pipe', - }); - process.stdout.write(output); - } catch (e) { - if ( - e instanceof Error && - e.message.includes('You cannot publish over the previously published versions') - ) { - // eslint-disable-next-line no-console - console.info(e.message); - // eslint-disable-next-line no-console - console.warn(`${file} has been published, skipping`); - } else { - throw e; - } - } + const platformDir = join(currentDir, 'npm', file); + const platformPackageJson = readJsonFile(join(platformDir, 'package.json')) as { + name: string; + version: string; + }; + await publishNpmPackageFromEnv( + { name: platformPackageJson.name, version: platformPackageJson.version }, + 'npm', + ['publish', '--tag', npmTag, '--access', 'public'], + platformDir, + ); } } -// Lockstep versioning: the CLI platform packages publish at the same version. -const cliVersion = cliPackageJson.version; - // Create and publish separate @voidzero-dev/vite-plus-cli-{platform} packages const cliNpmDir = join(currentDir, 'cli-npm'); for (const napiTarget of pkg.napi.targets) { @@ -206,6 +201,10 @@ for (const napiTarget of pkg.napi.targets) { repository: cliPackageJson.repository, }; writeFileSync(join(platformCliDir, 'package.json'), JSON.stringify(cliPackage, null, 2) + '\n'); + publishedPlatformPackages.push({ + name: cliPackage.name, + version: cliVersion, + }); if (skipNpmPublish) { // eslint-disable-next-line no-console @@ -216,14 +215,26 @@ for (const napiTarget of pkg.napi.targets) { } // Publish CLI package - execSync(`npm publish --tag ${npmTag} --access public`, { - cwd: platformCliDir, - env: process.env, - stdio: 'inherit', - }); + const result = await publishNpmPackageFromEnv( + { name: cliPackage.name, version: cliVersion }, + 'npm', + ['publish', '--tag', npmTag, '--access', 'public'], + platformCliDir, + ); + + if (result === 'published') { + // eslint-disable-next-line no-console + console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`); + } +} - // eslint-disable-next-line no-console - console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`); +// `npm publish` returns when npm accepts an upload, before publish-time scanning +// necessarily makes that version installable. Core and the main CLI pin the +// native packages at this exact version, while the installers fetch the CLI +// platform packages directly. Do not continue the release until every +// platform packument and tarball can be fetched. +if (!skipNpmPublish) { + await waitForNpmPackagesFromEnv(publishedPlatformPackages); } // Clean up cli-npm directory (skipped when caller still needs the prepared dirs). From 729f5405f3306fc32e9a9afeb1aac50e1a52e968 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 3 Sep 2026 22:59:13 +0800 Subject: [PATCH 2/6] test: use optional response bodies --- .github/scripts/__tests__/publish-npm-package.spec.ts | 2 +- .github/scripts/__tests__/wait-for-npm-packages.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/__tests__/publish-npm-package.spec.ts b/.github/scripts/__tests__/publish-npm-package.spec.ts index c925971dc6..02a1216fee 100644 --- a/.github/scripts/__tests__/publish-npm-package.spec.ts +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -12,7 +12,7 @@ import type { FetchLike } from '../wait-for-npm-packages.ts'; const pkg = { name: '@scope/pkg', version: '1.2.3' }; -function response(status: number, body: unknown = undefined) { +function response(status: number, body?: unknown) { return { ok: status >= 200 && status < 300, status, diff --git a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts index 45d0362ba4..959981952e 100644 --- a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts +++ b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts @@ -9,7 +9,7 @@ import { waitForNpmPackages, } from '../wait-for-npm-packages.ts'; -function response(status: number, body: unknown = undefined) { +function response(status: number, body?: unknown) { return { ok: status >= 200 && status < 300, status, From beffcce7d90a3273f4d9f8ea20fcf7d3124ac5a1 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Sun, 6 Sep 2026 10:54:54 +0800 Subject: [PATCH 3/6] fix: enforce npm propagation timeout --- .../__tests__/wait-for-npm-packages.spec.ts | 78 ++++++++++++++++++- .github/scripts/wait-for-npm-packages.ts | 53 ++++++++++--- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts index 959981952e..4b8bb3d5d6 100644 --- a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts +++ b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts @@ -43,9 +43,11 @@ describe('isNpmPackageAvailable', () => { expect(fetchImpl).toHaveBeenNthCalledWith(1, 'https://registry.npmjs.org/@scope%2fpkg', { headers: { accept: 'application/vnd.npm.install-v1+json' }, + signal: undefined, }); expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', { method: 'HEAD', + signal: undefined, }); }); @@ -140,7 +142,81 @@ describe('waitForNpmPackages', () => { }), ).rejects.toThrow('Timed out after 5s waiting for npm propagation: pkg@1.2.3'); - expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('checks the deadline before each package', async () => { + let currentTime = 0; + const fetchImpl = vi.fn(async () => { + currentTime = 5_000; + return response(404); + }); + + await expect( + waitForNpmPackages( + [ + { name: 'first', version: '1.2.3' }, + { name: 'second', version: '1.2.3' }, + ], + { + registry: 'https://registry.npmjs.org', + fetchImpl, + minSeconds: 0, + timeoutSeconds: 5, + pollSeconds: 1, + sleep: async () => {}, + now: () => currentTime, + log: vi.fn(), + }, + ), + ).rejects.toThrow('Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3'); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('aborts a stalled request at the deadline and skips later packages', async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('missing abort signal')); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ); + + const result = waitForNpmPackages( + [ + { name: 'first', version: '1.2.3' }, + { name: 'second', version: '1.2.3' }, + ], + { + registry: 'https://registry.npmjs.org', + fetchImpl, + minSeconds: 0, + timeoutSeconds: 5, + pollSeconds: 1, + sleep: async () => {}, + now: Date.now, + log: vi.fn(), + }, + ); + const error = result.catch((caught: unknown) => caught); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(error).resolves.toEqual( + new Error('Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3'), + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/.github/scripts/wait-for-npm-packages.ts b/.github/scripts/wait-for-npm-packages.ts index 2e208957c0..2be6123ccc 100644 --- a/.github/scripts/wait-for-npm-packages.ts +++ b/.github/scripts/wait-for-npm-packages.ts @@ -21,7 +21,7 @@ interface AbbreviatedPackument { export type FetchLike = ( url: string, - init?: { method?: string; headers?: Record }, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, ) => Promise<{ ok: boolean; status: number; json: () => Promise }>; export interface WaitForNpmPackagesOptions { @@ -40,13 +40,18 @@ function escapePackageName(name: string): string { return name.replace('/', '%2f'); } +type NpmRegistryRequestOptions = Pick & { + signal?: AbortSignal; +}; + async function fetchNpmPackument( name: string, - options: Pick, + options: NpmRegistryRequestOptions, ): Promise { const registry = options.registry.replace(/\/+$/, ''); const response = await options.fetchImpl(`${registry}/${escapePackageName(name)}`, { headers: { accept: ABBREVIATED_PACKUMENT_ACCEPT }, + signal: options.signal, }); if (response.status === 404) { return null; @@ -60,7 +65,7 @@ async function fetchNpmPackument( /** Checks whether npm has made an immutable package version visible. */ export async function isNpmPackagePublished( pkg: NpmPackageVersion, - options: Pick, + options: NpmRegistryRequestOptions, ): Promise { const packument = await fetchNpmPackument(pkg.name, options); return packument?.versions?.[pkg.version] !== undefined; @@ -72,7 +77,7 @@ export async function isNpmPackagePublished( */ export async function isNpmPackageAvailable( pkg: NpmPackageVersion, - options: Pick, + options: NpmRegistryRequestOptions, ): Promise { const packument = await fetchNpmPackument(pkg.name, options); const tarball = packument?.versions?.[pkg.version]?.dist?.tarball; @@ -80,10 +85,23 @@ export async function isNpmPackageAvailable( return false; } - const tarballResponse = await options.fetchImpl(tarball, { method: 'HEAD' }); + const tarballResponse = await options.fetchImpl(tarball, { + method: 'HEAD', + signal: options.signal, + }); return tarballResponse.ok; } +function propagationTimeoutError( + pending: ReadonlyMap, + timeoutSeconds: number, +): Error { + const packageList = [...pending].map(([name, version]) => `${name}@${version}`).join(', '); + return new Error( + `Timed out after ${timeoutSeconds}s waiting for npm propagation: ${packageList}`, + ); +} + /** * Waits until every package version is installable before the release moves * on to a package that pins it as a dependency. @@ -104,13 +122,27 @@ export async function waitForNpmPackages( while (pending.size > 0) { for (const [name, version] of pending) { + const remainingMilliseconds = deadline - options.now(); + if (remainingMilliseconds <= 0) { + throw propagationTimeoutError(pending, options.timeoutSeconds); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), remainingMilliseconds); try { - if (await isNpmPackageAvailable({ name, version }, options)) { + if ( + await isNpmPackageAvailable({ name, version }, { ...options, signal: controller.signal }) + ) { options.log(` ${name}@${version}: available`); pending.delete(name); } } catch (error) { + if (controller.signal.aborted || options.now() >= deadline) { + throw propagationTimeoutError(pending, options.timeoutSeconds); + } options.log(` ${name}@${version}: check failed, retrying (${String(error)})`); + } finally { + clearTimeout(timeout); } } @@ -118,12 +150,11 @@ export async function waitForNpmPackages( break; } if (options.now() >= deadline) { - const packageList = [...pending].map(([name, version]) => `${name}@${version}`).join(', '); - throw new Error( - `Timed out after ${options.timeoutSeconds}s waiting for npm propagation: ${packageList}`, - ); + throw propagationTimeoutError(pending, options.timeoutSeconds); } - await options.sleep(options.pollSeconds * 1000); + await options.sleep( + Math.min(options.pollSeconds * 1000, Math.max(0, deadline - options.now())), + ); } if (options.minSeconds > 0) { From 60a050cee3373d255074ae37862ff2010dc023a5 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Sun, 6 Sep 2026 11:25:43 +0800 Subject: [PATCH 4/6] fix: bound npm pre-publish lookup --- .../__tests__/publish-npm-package.spec.ts | 32 +++++++++++++++++++ .github/scripts/publish-npm-package.ts | 7 ++++ 2 files changed, 39 insertions(+) diff --git a/.github/scripts/__tests__/publish-npm-package.spec.ts b/.github/scripts/__tests__/publish-npm-package.spec.ts index 02a1216fee..657cf8ee8a 100644 --- a/.github/scripts/__tests__/publish-npm-package.spec.ts +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -96,6 +96,38 @@ describe('publishNpmPackage', () => { await expect(publishNpmPackage(publishOptions)).resolves.toBe('published'); expect(publishOptions.warn).toHaveBeenCalledOnce(); }); + + test('publishes after a stalled preflight read times out', async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('missing abort signal')); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 0, + output: 'published', + }); + const publishOptions = options(fetchImpl, runCommand); + const result = publishNpmPackage(publishOptions); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(result).resolves.toBe('published'); + expect(publishOptions.warn).toHaveBeenCalledOnce(); + expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg'); + expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + } finally { + vi.useRealTimers(); + } + }); }); test('recognizes npm immutable-version errors only', () => { diff --git a/.github/scripts/publish-npm-package.ts b/.github/scripts/publish-npm-package.ts index dd438b41c4..36bb6fc3f0 100644 --- a/.github/scripts/publish-npm-package.ts +++ b/.github/scripts/publish-npm-package.ts @@ -10,6 +10,8 @@ import { parseNpmPackageSpec, } from './wait-for-npm-packages.ts'; +const NPM_PACKAGE_LOOKUP_TIMEOUT_MS = 10_000; + export interface PublishCommandResult { exitCode: number | null; output: string; @@ -53,12 +55,15 @@ export async function publishNpmPackage( options: PublishNpmPackageOptions, ): Promise { const spec = `${options.pkg.name}@${options.pkg.version}`; + const controller = new AbortController(); + const lookupTimeout = setTimeout(() => controller.abort(), NPM_PACKAGE_LOOKUP_TIMEOUT_MS); try { if ( await isNpmPackagePublished(options.pkg, { registry: options.registry, fetchImpl: options.fetchImpl, + signal: controller.signal, }) ) { options.log(`${spec} is already published; skipping upload.`); @@ -68,6 +73,8 @@ export async function publishNpmPackage( // A transient read failure must not prevent the publish attempt. If this is // a rerun, npm's immutable-version response is handled below. options.warn(`Could not check whether ${spec} is published; trying upload (${String(error)})`); + } finally { + clearTimeout(lookupTimeout); } const result = await options.runCommand(options.command, options.args, options.cwd); From 95785feee89f2b49f9f04f0b12bab142e05255d7 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Sun, 6 Sep 2026 11:35:56 +0800 Subject: [PATCH 5/6] refactor: simplify npm release helpers --- .github/scripts/__tests__/npm-registry.ts | 21 ++ .../__tests__/publish-npm-package.spec.ts | 57 ++--- .../__tests__/wait-for-npm-packages.spec.ts | 229 ++++++------------ .github/scripts/publish-npm-package.ts | 25 +- .github/scripts/wait-for-npm-packages.ts | 24 +- packages/cli/publish-native-addons.ts | 47 ++-- 6 files changed, 156 insertions(+), 247 deletions(-) create mode 100644 .github/scripts/__tests__/npm-registry.ts diff --git a/.github/scripts/__tests__/npm-registry.ts b/.github/scripts/__tests__/npm-registry.ts new file mode 100644 index 0000000000..9dc23bddd7 --- /dev/null +++ b/.github/scripts/__tests__/npm-registry.ts @@ -0,0 +1,21 @@ +import type { FetchLike } from '../wait-for-npm-packages.ts'; + +export function response(status: number, body?: unknown): Awaited> { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +export function stalledFetch(_url: string, init?: Parameters[1]): ReturnType { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('missing abort signal')); + return; + } + signal.throwIfAborted(); + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} diff --git a/.github/scripts/__tests__/publish-npm-package.spec.ts b/.github/scripts/__tests__/publish-npm-package.spec.ts index 657cf8ee8a..5d31116960 100644 --- a/.github/scripts/__tests__/publish-npm-package.spec.ts +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -9,17 +9,10 @@ import { publishNpmPackage, } from '../publish-npm-package.ts'; import type { FetchLike } from '../wait-for-npm-packages.ts'; +import { response, stalledFetch } from './npm-registry.ts'; const pkg = { name: '@scope/pkg', version: '1.2.3' }; -function response(status: number, body?: unknown) { - return { - ok: status >= 200 && status < 300, - status, - json: async () => body, - }; -} - function options(fetchImpl: FetchLike, runCommand: PublishCommandRunner): PublishNpmPackageOptions { return { pkg, @@ -97,36 +90,28 @@ describe('publishNpmPackage', () => { expect(publishOptions.warn).toHaveBeenCalledOnce(); }); - test('publishes after a stalled preflight read times out', async () => { + test('publishes after a stalled preflight read times out', async ({ onTestFinished }) => { vi.useFakeTimers(); - try { - const fetchImpl = vi.fn( - (_url, init) => - new Promise((_resolve, reject) => { - const signal = init?.signal; - if (!signal) { - reject(new Error('missing abort signal')); - return; - } - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }), - ); - const runCommand = vi.fn().mockResolvedValue({ - exitCode: 0, - output: 'published', - }); - const publishOptions = options(fetchImpl, runCommand); - const result = publishNpmPackage(publishOptions); - - await vi.advanceTimersByTimeAsync(10_000); - - await expect(result).resolves.toBe('published'); - expect(publishOptions.warn).toHaveBeenCalledOnce(); - expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg'); - expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); - } finally { + onTestFinished(() => { vi.useRealTimers(); - } + }); + const fetchImpl = vi.fn(stalledFetch); + const runCommand = vi.fn().mockResolvedValue({ + exitCode: 0, + output: 'published', + }); + const publishOptions = options(fetchImpl, runCommand); + const result = publishNpmPackage(publishOptions); + + await vi.advanceTimersByTimeAsync(9_999); + expect(runCommand).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await expect(result).resolves.toBe('published'); + expect(publishOptions.warn).toHaveBeenCalledOnce(); + expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg'); + expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts index 4b8bb3d5d6..e57896bf47 100644 --- a/.github/scripts/__tests__/wait-for-npm-packages.spec.ts +++ b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts @@ -1,19 +1,40 @@ /// -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { type FetchLike, + type WaitForNpmPackagesOptions, isNpmPackageAvailable, parseNpmPackageSpec, waitForNpmPackages, } from '../wait-for-npm-packages.ts'; - -function response(status: number, body?: unknown) { +import { response, stalledFetch } from './npm-registry.ts'; + +const pkg = { name: 'pkg', version: '1.2.3' }; +const tarball = 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz'; +const packument = { versions: { '1.2.3': { dist: { tarball } } } }; +const pendingPackages = [ + { name: 'first', version: '1.2.3' }, + { name: 'second', version: '1.2.3' }, +]; + +function options( + fetchImpl: FetchLike, + overrides: Partial = {}, +): WaitForNpmPackagesOptions { return { - ok: status >= 200 && status < 300, - status, - json: async () => body, + registry: 'https://registry.npmjs.org', + fetchImpl, + minSeconds: 0, + timeoutSeconds: 5, + pollSeconds: 1, + sleep: vi.fn( + (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + ), + now: Date.now, + log: vi.fn(), + ...overrides, }; } @@ -21,22 +42,12 @@ describe('isNpmPackageAvailable', () => { test('checks the abbreviated packument and its tarball', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce( - response(200, { - versions: { - '1.2.3': { - dist: { - tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', - }, - }, - }, - }), - ) + .mockResolvedValueOnce(response(200, packument)) .mockResolvedValueOnce(response(200)); await expect( isNpmPackageAvailable( - { name: '@scope/pkg', version: '1.2.3' }, + { ...pkg, name: '@scope/pkg' }, { registry: 'https://registry.npmjs.org/', fetchImpl }, ), ).resolves.toBe(true); @@ -45,178 +56,89 @@ describe('isNpmPackageAvailable', () => { headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: undefined, }); - expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', { + expect(fetchImpl).toHaveBeenNthCalledWith(2, tarball, { method: 'HEAD', signal: undefined, }); }); test('is unavailable while the version or tarball is missing', async () => { - const missingVersion = vi.fn().mockResolvedValue( - response(200, { - versions: { '1.2.2': {} }, - }), - ); - await expect( - isNpmPackageAvailable( - { name: 'pkg', version: '1.2.3' }, - { registry: 'https://registry.npmjs.org', fetchImpl: missingVersion }, - ), - ).resolves.toBe(false); + const missingVersion = vi + .fn() + .mockResolvedValue(response(200, { versions: { '1.2.2': {} } })); + await expect(isNpmPackageAvailable(pkg, options(missingVersion))).resolves.toBe(false); const missingTarball = vi .fn() - .mockResolvedValueOnce( - response(200, { - versions: { - '1.2.3': { - dist: { - tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', - }, - }, - }, - }), - ) + .mockResolvedValueOnce(response(200, packument)) .mockResolvedValueOnce(response(404)); - await expect( - isNpmPackageAvailable( - { name: 'pkg', version: '1.2.3' }, - { registry: 'https://registry.npmjs.org', fetchImpl: missingTarball }, - ), - ).resolves.toBe(false); + await expect(isNpmPackageAvailable(pkg, options(missingTarball))).resolves.toBe(false); }); }); describe('waitForNpmPackages', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + test('polls until available and always settles after the successful read', async () => { - let currentTime = 0; - const sleep = vi.fn(async (milliseconds: number) => { - currentTime += milliseconds; - }); const fetchImpl = vi .fn() .mockResolvedValueOnce(response(404)) - .mockResolvedValueOnce( - response(200, { - versions: { - '1.2.3': { - dist: { - tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', - }, - }, - }, - }), - ) + .mockResolvedValueOnce(response(200, packument)) .mockResolvedValueOnce(response(200)); + const waitOptions = options(fetchImpl, { minSeconds: 60, timeoutSeconds: 600, pollSeconds: 5 }); + const result = waitForNpmPackages([pkg], waitOptions); - await waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], { - registry: 'https://registry.npmjs.org', - fetchImpl, - minSeconds: 60, - timeoutSeconds: 600, - pollSeconds: 5, - sleep, - now: () => currentTime, - log: vi.fn(), - }); + await vi.advanceTimersByTimeAsync(65_000); + await result; - expect(sleep.mock.calls).toEqual([[5_000], [60_000]]); + expect(waitOptions.sleep).toHaveBeenCalledTimes(2); + expect(waitOptions.sleep).toHaveBeenNthCalledWith(1, 5_000); + expect(waitOptions.sleep).toHaveBeenNthCalledWith(2, 60_000); + expect(vi.getTimerCount()).toBe(0); }); test('retries transient read failures until the timeout', async () => { - let currentTime = 0; const fetchImpl = vi.fn().mockRejectedValue(new Error('temporary failure')); + const waitOptions = options(fetchImpl, { pollSeconds: 2 }); + const result = expect(waitForNpmPackages([pkg], waitOptions)).rejects.toThrow( + 'Timed out after 5s waiting for npm propagation: pkg@1.2.3', + ); - await expect( - waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], { - registry: 'https://registry.npmjs.org', - fetchImpl, - minSeconds: 0, - timeoutSeconds: 5, - pollSeconds: 5, - sleep: async (milliseconds) => { - currentTime += milliseconds; - }, - now: () => currentTime, - log: vi.fn(), - }), - ).rejects.toThrow('Timed out after 5s waiting for npm propagation: pkg@1.2.3'); + await vi.advanceTimersByTimeAsync(5_000); + await result; - expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(waitOptions.sleep).toHaveBeenLastCalledWith(1_000); + expect(vi.getTimerCount()).toBe(0); }); test('checks the deadline before each package', async () => { - let currentTime = 0; const fetchImpl = vi.fn(async () => { - currentTime = 5_000; + vi.setSystemTime(Date.now() + 5_000); return response(404); }); - await expect( - waitForNpmPackages( - [ - { name: 'first', version: '1.2.3' }, - { name: 'second', version: '1.2.3' }, - ], - { - registry: 'https://registry.npmjs.org', - fetchImpl, - minSeconds: 0, - timeoutSeconds: 5, - pollSeconds: 1, - sleep: async () => {}, - now: () => currentTime, - log: vi.fn(), - }, - ), - ).rejects.toThrow('Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3'); + await expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow( + 'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3', + ); expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); }); test('aborts a stalled request at the deadline and skips later packages', async () => { - vi.useFakeTimers(); - try { - const fetchImpl = vi.fn( - (_url, init) => - new Promise((_resolve, reject) => { - const signal = init?.signal; - if (!signal) { - reject(new Error('missing abort signal')); - return; - } - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }), - ); - - const result = waitForNpmPackages( - [ - { name: 'first', version: '1.2.3' }, - { name: 'second', version: '1.2.3' }, - ], - { - registry: 'https://registry.npmjs.org', - fetchImpl, - minSeconds: 0, - timeoutSeconds: 5, - pollSeconds: 1, - sleep: async () => {}, - now: Date.now, - log: vi.fn(), - }, - ); - const error = result.catch((caught: unknown) => caught); - - await vi.advanceTimersByTimeAsync(5_000); - - await expect(error).resolves.toEqual( - new Error('Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3'), - ); - expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); - } finally { - vi.useRealTimers(); - } + const fetchImpl = vi.fn(stalledFetch); + const result = expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow( + 'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3', + ); + + await vi.advanceTimersByTimeAsync(5_000); + await result; + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); }); }); @@ -225,9 +147,6 @@ test('parseNpmPackageSpec supports scoped and unscoped package names', () => { name: '@scope/pkg', version: '1.2.3', }); - expect(parseNpmPackageSpec('pkg@1.2.3')).toEqual({ - name: 'pkg', - version: '1.2.3', - }); + expect(parseNpmPackageSpec('pkg@1.2.3')).toEqual(pkg); expect(() => parseNpmPackageSpec('@scope/pkg')).toThrow('name@version'); }); diff --git a/.github/scripts/publish-npm-package.ts b/.github/scripts/publish-npm-package.ts index 36bb6fc3f0..6156e0d300 100644 --- a/.github/scripts/publish-npm-package.ts +++ b/.github/scripts/publish-npm-package.ts @@ -59,13 +59,12 @@ export async function publishNpmPackage( const lookupTimeout = setTimeout(() => controller.abort(), NPM_PACKAGE_LOOKUP_TIMEOUT_MS); try { - if ( - await isNpmPackagePublished(options.pkg, { - registry: options.registry, - fetchImpl: options.fetchImpl, - signal: controller.signal, - }) - ) { + const published = await isNpmPackagePublished(options.pkg, { + registry: options.registry, + fetchImpl: options.fetchImpl, + signal: controller.signal, + }); + if (published) { options.log(`${spec} is already published; skipping upload.`); return 'already-published'; } @@ -77,16 +76,20 @@ export async function publishNpmPackage( clearTimeout(lookupTimeout); } - const result = await options.runCommand(options.command, options.args, options.cwd); - if (result.exitCode === 0) { + const { exitCode, output, error } = await options.runCommand( + options.command, + options.args, + options.cwd, + ); + if (exitCode === 0) { return 'published'; } - if (isAlreadyPublishedError(result.output)) { + if (isAlreadyPublishedError(output)) { options.log(`${spec} was accepted by an earlier attempt; skipping upload.`); return 'already-published'; } - const detail = result.error?.message ?? `exit code ${String(result.exitCode)}`; + const detail = error?.message ?? `exit code ${String(exitCode)}`; throw new Error(`Failed to publish ${spec}: ${detail}`); } diff --git a/.github/scripts/wait-for-npm-packages.ts b/.github/scripts/wait-for-npm-packages.ts index 2be6123ccc..e6fdc53773 100644 --- a/.github/scripts/wait-for-npm-packages.ts +++ b/.github/scripts/wait-for-npm-packages.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; /** The registry used by the release workflow. */ @@ -36,10 +37,6 @@ export interface WaitForNpmPackagesOptions { log: (message: string) => void; } -function escapePackageName(name: string): string { - return name.replace('/', '%2f'); -} - type NpmRegistryRequestOptions = Pick & { signal?: AbortSignal; }; @@ -49,7 +46,7 @@ async function fetchNpmPackument( options: NpmRegistryRequestOptions, ): Promise { const registry = options.registry.replace(/\/+$/, ''); - const response = await options.fetchImpl(`${registry}/${escapePackageName(name)}`, { + const response = await options.fetchImpl(`${registry}/${name.replace('/', '%2f')}`, { headers: { accept: ABBREVIATED_PACKUMENT_ACCEPT }, signal: options.signal, }); @@ -130,9 +127,11 @@ export async function waitForNpmPackages( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), remainingMilliseconds); try { - if ( - await isNpmPackageAvailable({ name, version }, { ...options, signal: controller.signal }) - ) { + const available = await isNpmPackageAvailable( + { name, version }, + { ...options, signal: controller.signal }, + ); + if (available) { options.log(` ${name}@${version}: available`); pending.delete(name); } @@ -149,12 +148,11 @@ export async function waitForNpmPackages( if (pending.size === 0) { break; } - if (options.now() >= deadline) { + const remainingMilliseconds = deadline - options.now(); + if (remainingMilliseconds <= 0) { throw propagationTimeoutError(pending, options.timeoutSeconds); } - await options.sleep( - Math.min(options.pollSeconds * 1000, Math.max(0, deadline - options.now())), - ); + await options.sleep(Math.min(options.pollSeconds * 1000, remainingMilliseconds)); } if (options.minSeconds > 0) { @@ -200,7 +198,7 @@ export async function waitForNpmPackagesFromEnv( minSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_MIN_SECONDS', 60), timeoutSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_TIMEOUT_SECONDS', 600), pollSeconds: readNonNegativeInteger('PUBLISH_PROPAGATION_POLL_SECONDS', 5), - sleep: (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)), + sleep, now: Date.now, log: console.log, }); diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index eeefb677ab..e42447e82d 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -6,7 +6,10 @@ import { fileURLToPath } from 'node:url'; import { NapiCli, parseTriple } from '@napi-rs/cli'; import { publishNpmPackageFromEnv } from '../../.github/scripts/publish-npm-package.ts'; -import { waitForNpmPackagesFromEnv } from '../../.github/scripts/wait-for-npm-packages.ts'; +import { + type NpmPackageVersion, + waitForNpmPackagesFromEnv, +} from '../../.github/scripts/wait-for-npm-packages.ts'; import pkg from './package.json' with { type: 'json' }; import { editJsonFile, readJsonFile } from './src/utils/json.ts'; @@ -123,26 +126,19 @@ editJsonFile(join(repoRoot, 'packages', 'core', 'package.json'), (corePkgJson) = ...nativePlatformPins, }, })); -const publishedPlatformPackages = Object.keys(nativePlatformPins).map((name) => ({ +const platformPackages = Object.keys(nativePlatformPins).map((name) => ({ name, version: cliVersion, })); // Publish each NAPI platform package (without vp binary) const npmTag = process.env.NPM_TAG || 'latest'; +const publishArgs = ['publish', '--tag', npmTag, '--access', 'public']; if (!skipNpmPublish) { for (const file of platformDirs) { - const platformDir = join(currentDir, 'npm', file); - const platformPackageJson = readJsonFile(join(platformDir, 'package.json')) as { - name: string; - version: string; - }; - await publishNpmPackageFromEnv( - { name: platformPackageJson.name, version: platformPackageJson.version }, - 'npm', - ['publish', '--tag', npmTag, '--access', 'public'], - platformDir, - ); + const platformDir = join(npmDir, file); + const platformPackage = readJsonFile(join(platformDir, 'package.json')) as NpmPackageVersion; + await publishNpmPackageFromEnv(platformPackage, 'npm', publishArgs, platformDir); } } @@ -201,10 +197,7 @@ for (const napiTarget of pkg.napi.targets) { repository: cliPackageJson.repository, }; writeFileSync(join(platformCliDir, 'package.json'), JSON.stringify(cliPackage, null, 2) + '\n'); - publishedPlatformPackages.push({ - name: cliPackage.name, - version: cliVersion, - }); + platformPackages.push(cliPackage); if (skipNpmPublish) { // eslint-disable-next-line no-console @@ -215,12 +208,7 @@ for (const napiTarget of pkg.napi.targets) { } // Publish CLI package - const result = await publishNpmPackageFromEnv( - { name: cliPackage.name, version: cliVersion }, - 'npm', - ['publish', '--tag', npmTag, '--access', 'public'], - platformCliDir, - ); + const result = await publishNpmPackageFromEnv(cliPackage, 'npm', publishArgs, platformCliDir); if (result === 'published') { // eslint-disable-next-line no-console @@ -228,16 +216,11 @@ for (const napiTarget of pkg.napi.targets) { } } -// `npm publish` returns when npm accepts an upload, before publish-time scanning -// necessarily makes that version installable. Core and the main CLI pin the -// native packages at this exact version, while the installers fetch the CLI -// platform packages directly. Do not continue the release until every -// platform packument and tarball can be fetched. +// npm can accept uploads before scanning makes them installable. Wait for the +// platform packages before publishing core and the CLI, which pin their versions. if (!skipNpmPublish) { - await waitForNpmPackagesFromEnv(publishedPlatformPackages); -} + await waitForNpmPackagesFromEnv(platformPackages); -// Clean up cli-npm directory (skipped when caller still needs the prepared dirs). -if (!skipNpmPublish) { + // Preview releases still need the prepared directories. rmSync(cliNpmDir, { recursive: true, force: true }); } From e5c317ab03eb5aff3fc1efe55e7202b15f0bb404 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Sun, 6 Sep 2026 11:42:02 +0800 Subject: [PATCH 6/6] fix: correct npm release metadata type --- .github/scripts/wait-for-npm-packages.ts | 4 ++-- CONTRIBUTING.md | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/scripts/wait-for-npm-packages.ts b/.github/scripts/wait-for-npm-packages.ts index e6fdc53773..1be99b9578 100644 --- a/.github/scripts/wait-for-npm-packages.ts +++ b/.github/scripts/wait-for-npm-packages.ts @@ -11,10 +11,10 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; */ const ABBREVIATED_PACKUMENT_ACCEPT = 'application/vnd.npm.install-v1+json'; -export interface NpmPackageVersion { +export type NpmPackageVersion = { name: string; version: string; -} +}; interface AbbreviatedPackument { versions?: Record; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57d5c21a83..9a2f66a24e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,6 +196,14 @@ When done, force-push the updated branch history: git push --force-with-lease ``` +## Release and recovery + +The [release workflow](.github/workflows/release.yml) publishes packages in dependency order: platform packages → `@voidzero-dev/vite-plus-core` → `vite-plus`. After each tier, it waits up to 10 minutes for the exact versions and their tarballs to become available. It then waits another 60 seconds for CDN propagation. + +A propagation timeout fails the release job and stops subsequent steps. This does not mean npm rejected the upload; npm may have accepted it and still be scanning the packages. + +Once the packages become available, open the failed workflow run in GitHub Actions and select **Re-run failed jobs**. The workflow skips versions that npm has published and checks availability again before continuing. Keep the same version. + ## Pull upstream dependencies > [!NOTE]