mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 02:15:26 -04:00
fix(run): make non-recursive --no-bail exit non-zero when a script fails (#12263)
When running a non-recursive `pnpm run --no-bail` that matches multiple scripts (e.g. via a `/regex/` selector), pnpm always exited with code `0` regardless of whether any script failed. This is inconsistent with recursive runs, which aggregate failures and exit non-zero at the end (via `throwOnCommandFail`). This PR fixes `--no-bail` directly so its exit-code behavior is consistent across recursive and non-recursive runs, as requested in https://github.com/pnpm/pnpm/issues/8013: - `--no-bail` still runs every matched script to completion (it no longer short-circuits on the first failure — execution switched from `Promise.all` to `Promise.allSettled`). - After all scripts settle, the command exits with a non-zero exit code (`ERR_PNPM_RUN_FAILED`) if any of them failed. This is a behavior change: previously a non-recursive `pnpm run --no-bail` with a failing script exited `0`. No new flag is introduced — per the issue discussion, a separate flag "would just add confusion without benefit". Closes https://github.com/pnpm/pnpm/issues/8013
This commit is contained in:
6
.changeset/no-bail-exit-code.md
Normal file
6
.changeset/no-bail-exit-code.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/exec.commands": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
`pnpm run --no-bail` now exits with a non-zero exit code when any of the executed scripts fail, while still running every matched script to completion. This makes the exit-code behavior of `--no-bail` consistent between recursive and non-recursive runs (recursive runs already failed at the end). Previously, a non-recursive `pnpm run --no-bail` always exited with code 0, even when a script failed [#8013](https://github.com/pnpm/pnpm/issues/8013).
|
||||
@@ -137,7 +137,7 @@ For options that may be used with `-r`, see "pnpm help recursive"',
|
||||
shortAlias: '-r',
|
||||
},
|
||||
{
|
||||
description: 'The command will exit with a 0 exit code even if the script fails',
|
||||
description: 'Continue running the remaining scripts even if one of them fails, instead of aborting on the first failure. The command still exits with a non-zero exit code if any script failed',
|
||||
name: '--no-bail',
|
||||
},
|
||||
IF_PRESENT_OPTION_HELP,
|
||||
@@ -292,20 +292,34 @@ so you may run "pnpm -w run ${scriptName}"`,
|
||||
...makeNodeRequireOption(pnpPath),
|
||||
}
|
||||
}
|
||||
try {
|
||||
const limitRun = pLimit(concurrency)
|
||||
const limitRun = pLimit(concurrency)
|
||||
|
||||
const runScriptOptions: RunScriptOptions = {
|
||||
enablePrePostScripts: opts.enablePrePostScripts ?? false,
|
||||
syncInjectedDepsAfterScripts: opts.syncInjectedDepsAfterScripts,
|
||||
workspaceDir: opts.workspaceDir,
|
||||
}
|
||||
const _runScript = runScript.bind(null, { manifest, lifecycleOpts, runScriptOptions, passedThruArgs })
|
||||
const runScriptOptions: RunScriptOptions = {
|
||||
enablePrePostScripts: opts.enablePrePostScripts ?? false,
|
||||
syncInjectedDepsAfterScripts: opts.syncInjectedDepsAfterScripts,
|
||||
workspaceDir: opts.workspaceDir,
|
||||
}
|
||||
const _runScript = runScript.bind(null, { manifest, lifecycleOpts, runScriptOptions, passedThruArgs })
|
||||
|
||||
if (opts.bail !== false) {
|
||||
await Promise.all(specifiedScripts.map(script => limitRun(() => _runScript(script))))
|
||||
} catch (err: unknown) {
|
||||
if (opts.bail !== false) {
|
||||
throw err
|
||||
} else {
|
||||
const results = await Promise.allSettled(
|
||||
specifiedScripts.map(script => limitRun(() => _runScript(script)))
|
||||
)
|
||||
const failures = results
|
||||
.map((result, index) => ({ result, script: specifiedScripts[index] }))
|
||||
.filter((entry): entry is { result: PromiseRejectedResult, script: string } => entry.result.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
throw new PnpmError(
|
||||
'RUN_FAILED',
|
||||
`Some scripts failed: ${failures.length} of ${specifiedScripts.length}`,
|
||||
{
|
||||
hint: failures
|
||||
.map(({ script, result }) => `${script}: ${result.reason?.message ?? String(result.reason)}`)
|
||||
.join('\n'),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference path="../../../__typings__/index.d.ts" />
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import util from 'node:util'
|
||||
|
||||
import { expect, test } from '@jest/globals'
|
||||
import type { PnpmError } from '@pnpm/error'
|
||||
@@ -56,7 +57,7 @@ test('pnpm run: returns correct exit code', async () => {
|
||||
expect(err.errno).toBe(1)
|
||||
})
|
||||
|
||||
test('pnpm run --no-bail never fails', async () => {
|
||||
test('pnpm run --no-bail runs the script to completion but still exits non-zero on failure', async () => {
|
||||
prepare({
|
||||
scripts: {
|
||||
exit1: 'node recordArgs && exit 1',
|
||||
@@ -65,6 +66,69 @@ test('pnpm run --no-bail never fails', async () => {
|
||||
fs.writeFileSync('args.json', '[]', 'utf8')
|
||||
fs.writeFileSync('recordArgs.js', RECORD_ARGS_FILE, 'utf8')
|
||||
|
||||
let err: unknown
|
||||
try {
|
||||
await run.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: 'node_modules/.bin',
|
||||
bail: false,
|
||||
dir: process.cwd(),
|
||||
extraBinPaths: [],
|
||||
extraEnv: {},
|
||||
pnpmHomeDir: '',
|
||||
}, ['exit1'])
|
||||
} catch (_err: unknown) {
|
||||
err = _err
|
||||
}
|
||||
|
||||
expect(util.types.isNativeError(err)).toBe(true)
|
||||
expect((err as PnpmError).code).toBe('ERR_PNPM_RUN_FAILED')
|
||||
|
||||
const { default: args } = await import(path.resolve('args.json'))
|
||||
expect(args).toStrictEqual([[]])
|
||||
})
|
||||
|
||||
test('pnpm run with regex and --no-bail runs every matched script but exits non-zero when one fails', async () => {
|
||||
prepare({
|
||||
scripts: {
|
||||
'lint:a': 'node -e "require(\'fs\').writeFileSync(\'lint-a.txt\', \'a\')"',
|
||||
'lint:b': 'node -e "require(\'fs\').writeFileSync(\'lint-b.txt\', \'b\'); process.exit(1)"',
|
||||
'lint:c': 'node -e "require(\'fs\').writeFileSync(\'lint-c.txt\', \'c\')"',
|
||||
},
|
||||
})
|
||||
|
||||
let err: unknown
|
||||
try {
|
||||
await run.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: 'node_modules/.bin',
|
||||
bail: false,
|
||||
dir: process.cwd(),
|
||||
extraBinPaths: [],
|
||||
extraEnv: {},
|
||||
pnpmHomeDir: '',
|
||||
}, ['/^lint:/'])
|
||||
} catch (_err: unknown) {
|
||||
err = _err
|
||||
}
|
||||
|
||||
expect(util.types.isNativeError(err)).toBe(true)
|
||||
expect((err as PnpmError).code).toBe('ERR_PNPM_RUN_FAILED')
|
||||
|
||||
// Every matched script ran to completion, even though lint:b failed.
|
||||
expect(fs.readFileSync('lint-a.txt', 'utf8')).toBe('a')
|
||||
expect(fs.readFileSync('lint-b.txt', 'utf8')).toBe('b')
|
||||
expect(fs.readFileSync('lint-c.txt', 'utf8')).toBe('c')
|
||||
})
|
||||
|
||||
test('pnpm run with regex and --no-bail exits zero when all matched scripts pass', async () => {
|
||||
prepare({
|
||||
scripts: {
|
||||
'lint:a': 'node -e "process.exit(0)"',
|
||||
'lint:b': 'node -e "process.exit(0)"',
|
||||
},
|
||||
})
|
||||
|
||||
await run.handler({
|
||||
...DEFAULT_OPTS,
|
||||
bin: 'node_modules/.bin',
|
||||
@@ -73,10 +137,7 @@ test('pnpm run --no-bail never fails', async () => {
|
||||
extraBinPaths: [],
|
||||
extraEnv: {},
|
||||
pnpmHomeDir: '',
|
||||
}, ['exit1'])
|
||||
|
||||
const { default: args } = await import(path.resolve('args.json'))
|
||||
expect(args).toStrictEqual([[]])
|
||||
}, ['/^lint:/'])
|
||||
})
|
||||
|
||||
const RECORD_ARGS_FILE = 'require(\'fs\').writeFileSync(\'args.json\', JSON.stringify(require(\'./args.json\').concat([process.argv.slice(2)])), \'utf8\')'
|
||||
|
||||
Reference in New Issue
Block a user