From 71483c9a0284bad9c20c7b4697f64fea5a29890f Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 3 Sep 2026 10:16:53 +0800 Subject: [PATCH] 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 c85238148f..4c222b4a28 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 @@ -155,11 +160,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).