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 new file mode 100644 index 0000000000..5d31116960 --- /dev/null +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -0,0 +1,121 @@ +/// + +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'; +import { response, stalledFetch } from './npm-registry.ts'; + +const pkg = { name: '@scope/pkg', version: '1.2.3' }; + +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('publishes after a stalled preflight read times out', async ({ onTestFinished }) => { + vi.useFakeTimers(); + 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); + }); +}); + +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..e57896bf47 --- /dev/null +++ b/.github/scripts/__tests__/wait-for-npm-packages.spec.ts @@ -0,0 +1,152 @@ +/// + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + type FetchLike, + type WaitForNpmPackagesOptions, + isNpmPackageAvailable, + parseNpmPackageSpec, + waitForNpmPackages, +} from '../wait-for-npm-packages.ts'; +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 { + 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, + }; +} + +describe('isNpmPackageAvailable', () => { + test('checks the abbreviated packument and its tarball', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(200, packument)) + .mockResolvedValueOnce(response(200)); + + await expect( + isNpmPackageAvailable( + { ...pkg, name: '@scope/pkg' }, + { 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' }, + signal: undefined, + }); + 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(pkg, options(missingVersion))).resolves.toBe(false); + + const missingTarball = vi + .fn() + .mockResolvedValueOnce(response(200, packument)) + .mockResolvedValueOnce(response(404)); + 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 () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(404)) + .mockResolvedValueOnce(response(200, packument)) + .mockResolvedValueOnce(response(200)); + const waitOptions = options(fetchImpl, { minSeconds: 60, timeoutSeconds: 600, pollSeconds: 5 }); + const result = waitForNpmPackages([pkg], waitOptions); + + await vi.advanceTimersByTimeAsync(65_000); + await result; + + 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 () => { + 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 vi.advanceTimersByTimeAsync(5_000); + await result; + + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(waitOptions.sleep).toHaveBeenLastCalledWith(1_000); + expect(vi.getTimerCount()).toBe(0); + }); + + test('checks the deadline before each package', async () => { + const fetchImpl = vi.fn(async () => { + vi.setSystemTime(Date.now() + 5_000); + return response(404); + }); + + 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 () => { + 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); + }); +}); + +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(pkg); + 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..6156e0d300 --- /dev/null +++ b/.github/scripts/publish-npm-package.ts @@ -0,0 +1,164 @@ +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'; + +const NPM_PACKAGE_LOOKUP_TIMEOUT_MS = 10_000; + +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}`; + const controller = new AbortController(); + const lookupTimeout = setTimeout(() => controller.abort(), NPM_PACKAGE_LOOKUP_TIMEOUT_MS); + + try { + 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'; + } + } 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)})`); + } finally { + clearTimeout(lookupTimeout); + } + + const { exitCode, output, error } = await options.runCommand( + options.command, + options.args, + options.cwd, + ); + if (exitCode === 0) { + return 'published'; + } + if (isAlreadyPublishedError(output)) { + options.log(`${spec} was accepted by an earlier attempt; skipping upload.`); + return 'already-published'; + } + + const detail = error?.message ?? `exit code ${String(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..1be99b9578 --- /dev/null +++ b/.github/scripts/wait-for-npm-packages.ts @@ -0,0 +1,221 @@ +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. */ +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 type NpmPackageVersion = { + name: string; + version: string; +}; + +interface AbbreviatedPackument { + versions?: Record; +} + +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => 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; +} + +type NpmRegistryRequestOptions = Pick & { + signal?: AbortSignal; +}; + +async function fetchNpmPackument( + name: string, + options: NpmRegistryRequestOptions, +): Promise { + const registry = options.registry.replace(/\/+$/, ''); + const response = await options.fetchImpl(`${registry}/${name.replace('/', '%2f')}`, { + headers: { accept: ABBREVIATED_PACKUMENT_ACCEPT }, + signal: options.signal, + }); + 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: NpmRegistryRequestOptions, +): 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: NpmRegistryRequestOptions, +): 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', + 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. + */ +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) { + const remainingMilliseconds = deadline - options.now(); + if (remainingMilliseconds <= 0) { + throw propagationTimeoutError(pending, options.timeoutSeconds); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), remainingMilliseconds); + try { + const available = await isNpmPackageAvailable( + { name, version }, + { ...options, signal: controller.signal }, + ); + if (available) { + 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); + } + } + + if (pending.size === 0) { + break; + } + const remainingMilliseconds = deadline - options.now(); + if (remainingMilliseconds <= 0) { + throw propagationTimeoutError(pending, options.timeoutSeconds); + } + await options.sleep(Math.min(options.pollSeconds * 1000, remainingMilliseconds)); + } + + 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, + 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/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] diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index b4eb1abad1..e42447e82d 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,11 @@ import { fileURLToPath } from 'node:url'; import { NapiCli, parseTriple } from '@napi-rs/cli'; +import { publishNpmPackageFromEnv } from '../../.github/scripts/publish-npm-package.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'; @@ -93,6 +97,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 +126,22 @@ editJsonFile(join(repoRoot, 'packages', 'core', 'package.json'), (corePkgJson) = ...nativePlatformPins, }, })); +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) { - 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(npmDir, file); + const platformPackage = readJsonFile(join(platformDir, 'package.json')) as NpmPackageVersion; + await publishNpmPackageFromEnv(platformPackage, 'npm', publishArgs, 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 +197,7 @@ for (const napiTarget of pkg.napi.targets) { repository: cliPackageJson.repository, }; writeFileSync(join(platformCliDir, 'package.json'), JSON.stringify(cliPackage, null, 2) + '\n'); + platformPackages.push(cliPackage); if (skipNpmPublish) { // eslint-disable-next-line no-console @@ -216,17 +208,19 @@ for (const napiTarget of pkg.napi.targets) { } // Publish CLI package - execSync(`npm publish --tag ${npmTag} --access public`, { - cwd: platformCliDir, - env: process.env, - stdio: 'inherit', - }); - - // eslint-disable-next-line no-console - console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`); + const result = await publishNpmPackageFromEnv(cliPackage, 'npm', publishArgs, platformCliDir); + + if (result === 'published') { + // eslint-disable-next-line no-console + console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`); + } } -// Clean up cli-npm directory (skipped when caller still needs the prepared dirs). +// 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(platformPackages); + + // Preview releases still need the prepared directories. rmSync(cliNpmDir, { recursive: true, force: true }); }