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..02a1216fee
--- /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) {
+ 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..959981952e
--- /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) {
+ 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).