diff --git a/README.md b/README.md index 1880425..8c31a3b 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ It overrides the derivation, applies to every tarball in the run, and any value ## Nightly releases -uppt can also publish a nightly build of every push to a branch, under a separate package name (`@nuxt/test-utils` → `@nuxt/test-utils-nightly`). Nightlies skip the release PR and staging: they publish straight to npm via OIDC, so add a trusted publisher for each nightly package pointing at the workflow below, with 'Environment name' set to `nightly` (no `npm stage publish` permission needed). +uppt can also publish a nightly build of every push to a branch, under a separate package name (`@nuxt/test-utils` → `@nuxt/test-utils-nightly`). Nightlies skip the release PR and staging: they publish straight to npm via OIDC, so add a trusted publisher for each nightly package pointing at the workflow below, with 'Environment name' set to `nightly` (no `npm stage publish` permission needed). As with stable releases, each nightly package must already exist on npm before you can attach a trusted publisher to it, so publish a stub first (for example with `npx setup-trusted-publishing`). Create a matching `nightly` [GitHub environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) and limit its deployment branches to the branch(es) you publish nightlies from (e.g. `main`). Without approvals or staging, this is what stops a workflow run on any other ref from publishing. @@ -300,7 +300,7 @@ jobs: `uppt/pack` rewrites each package before packing it: - **Name**: `-nightly`. Set `nightly-suffix` to use something else (`-edge`). -- **Version**: `--`, e.g. `3.20.1-2605140905-a1b2c3d`. `` is the `X.Y.Z` part of the version in `package.json`, the timestamp is the HEAD commit's date in UTC, and `` is its 7-character short hash. Newer commits always sort higher. +- **Version**: `--`, e.g. `3.20.1-2605140905-a1b2c3d`. `` is the `X.Y.Z` part of the version in `package.json`, the timestamp is the HEAD commit's date in UTC, and `` is its 7-character short hash. Newer commits always sort higher. Publishing a commit whose nightly is already on npm (for example, a re-run) logs a warning and skips the package rather than failing. - **Workspace dependencies**: in a monorepo (pass the same `packages` input), dependencies between listed packages become `npm:-nightly@`, so each nightly installs the other nightlies from the same commit. - **External nightlies**: `nightly-aliases` points dependencies on packages from other repos at their own nightlies. - **Bins**: every command gains a `-nightly` copy, plus one named after the package, so `npx -nightly` works. diff --git a/scripts/publish.ts b/scripts/publish.ts index 68ad49d..76775b4 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -34,7 +34,7 @@ // `latest` by default) instead of staging import process from 'node:process' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' @@ -47,6 +47,20 @@ function run (cmd: string, args: string[]) { execFileSync(cmd, args, { stdio: 'inherit' }) } +/** `run`, but an E403 (such as a nightly of the same commit already on npm) only warns. */ +function runTolerating403 (cmd: string, args: string[]) { + console.log('$', cmd, ...args) + const result = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'pipe'], encoding: 'utf8' }) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) throw result.error + if (result.status === 0) return + if (/\bE403\b/.test(result.stderr)) { + console.log(`::warning::npm ${args[0]} of ${args[1]} returned E403; skipping it (is this version already published?).`) + return + } + throw new Error(`Command failed: ${cmd} ${args.join(' ')} (exit ${result.status ?? result.signal})`) +} + function parseTarballFiles (raw: string): string[] { let parsed: unknown try { @@ -141,7 +155,8 @@ export function main () { if (!existsSync(tarballPath)) { throw new Error(`Tarball '${tarball}' is not present in ${dir}`) } - run('npm', [...command, tarballPath, '--provenance', '--ignore-scripts', `--access=${access}`, `--tag=${tagFor(tarball, defaultTag)}`]) + const publish = nightly ? runTolerating403 : run + publish('npm', [...command, tarballPath, '--provenance', '--ignore-scripts', `--access=${access}`, `--tag=${tagFor(tarball, defaultTag)}`]) } } diff --git a/test/publish.test.ts b/test/publish.test.ts index ccaccf2..c3678e3 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -5,7 +5,8 @@ import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileSync = vi.hoisted(() => vi.fn()) -vi.mock('node:child_process', () => ({ execFileSync })) +const spawnSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync, spawnSync })) const { main, distTag, versionFromTarballName } = await import('../scripts/publish.ts') @@ -16,7 +17,7 @@ function fixture (files: string[]): string { } function npmArgs () { - return execFileSync.mock.calls.map(([, args]) => args as string[]) + return [...execFileSync.mock.calls, ...spawnSync.mock.calls].map(([, args]) => args as string[]) } let env: NodeJS.ProcessEnv @@ -25,11 +26,13 @@ beforeEach(() => { env = { ...process.env } for (const key of ['NPM_ACCESS', 'NPM_TAG', 'TARBALL_DIR', 'TARBALL_FILES', 'RELEASES', 'NIGHTLY']) delete process.env[key] vi.spyOn(console, 'log').mockImplementation(() => {}) + spawnSync.mockReturnValue({ status: 0, stderr: '' }) }) afterEach(() => { process.env = env execFileSync.mockReset() + spawnSync.mockReset() vi.restoreAllMocks() }) @@ -135,6 +138,40 @@ describe('publish', () => { expect(npmArgs()[0]).toContain('--tag=5x') }) + it('skips a nightly that npm rejects with E403', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz', 'b-nightly-1.0.1-2605140905-0123456.tgz']) + spawnSync.mockReturnValueOnce({ status: 1, stderr: 'npm error code E403\n' }) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + main() + expect(npmArgs()).toHaveLength(2) + expect(stderr).toHaveBeenCalledWith('npm error code E403\n') + expect(log).toHaveBeenCalledWith(expect.stringContaining('::warning::npm publish of')) + }) + + it('throws on other nightly publish failures', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz']) + spawnSync.mockReturnValueOnce({ status: 1, stderr: 'npm error code E404\n' }) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + expect(() => main()).toThrow(/Command failed: npm publish .* \(exit 1\)/) + }) + + it('throws when a nightly publish is killed', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz']) + spawnSync.mockReturnValueOnce({ status: null, signal: 'SIGTERM', stderr: '' }) + expect(() => main()).toThrow(/\(exit SIGTERM\)/) + }) + + it('throws when npm cannot be spawned', () => { + process.env.NIGHTLY = 'true' + process.env.TARBALL_DIR = fixture(['a-nightly-1.0.1-2605140905-0123456.tgz']) + spawnSync.mockReturnValueOnce({ status: null, error: new Error('spawn npm ENOENT') }) + expect(() => main()).toThrow('spawn npm ENOENT') + }) + it('refuses to combine nightlies with RELEASES', () => { process.env.NIGHTLY = 'true' process.env.TARBALL_DIR = fixture([])