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:
Anton Stoychev
2026-06-16 13:35:49 +03:00
committed by GitHub
parent e85aea2cce
commit 179ebc40aa
3 changed files with 98 additions and 17 deletions

View 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).

View File

@@ -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

View File

@@ -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\')'