From 179ebc40aa2757fb49ae39b3dfa54c83cd9dbb93 Mon Sep 17 00:00:00 2001 From: Anton Stoychev Date: Tue, 16 Jun 2026 13:35:49 +0300 Subject: [PATCH] fix(run): make non-recursive --no-bail exit non-zero when a script fails (#12263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/no-bail-exit-code.md | 6 +++ exec/commands/src/run.ts | 38 ++++++++++++------ exec/commands/test/index.ts | 71 ++++++++++++++++++++++++++++++--- 3 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 .changeset/no-bail-exit-code.md diff --git a/.changeset/no-bail-exit-code.md b/.changeset/no-bail-exit-code.md new file mode 100644 index 0000000000..1d1fedb334 --- /dev/null +++ b/.changeset/no-bail-exit-code.md @@ -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). diff --git a/exec/commands/src/run.ts b/exec/commands/src/run.ts index 5199b3b6ed..d8eba06943 100644 --- a/exec/commands/src/run.ts +++ b/exec/commands/src/run.ts @@ -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 diff --git a/exec/commands/test/index.ts b/exec/commands/test/index.ts index 96e04fa341..a5ddb94cc7 100644 --- a/exec/commands/test/index.ts +++ b/exec/commands/test/index.ts @@ -1,6 +1,7 @@ /// 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\')'