payload run reports success in four distinct failure modes, one of which writes to a remote database.
Verified against payload@3.87.1 and @payloadcms/db-postgres@3.87.1 by reading the published dist. Reporting these together because they share a root cause — the CLI wrapper does not preserve the semantics of the script it runs — and because together they make payload run unsafe as a CI or build gate. We used it as a pre-deploy migration gate, and the gate could never have failed.
1. The exit code is discarded
dist/bin/index.js ends the non-cron branch with an unconditional process.exit(0) after runBinScript returns:
const { payload } = await runBinScript({ args, script })
if (payload) { await payload.destroy() }
process.exit(0)
A script that sets process.exitCode = 1 — the documented, non-throwing way to fail — therefore reports success to the shell. A thrown error is the only failure signal that propagates (via the catch inside runBinScript, which calls process.exit(1)).
Repro: payload run ./fail.ts where fail.ts is process.exitCode = 1; echo $? prints 0.
Suggested fix: propagate a non-zero process.exitCode, or document prominently that only throw fails.
2. A floating promise is abandoned
payload run script.ts awaits module evaluation and then exits, so a trailing void main() never completes. The script prints nothing and exits 0 — indistinguishable from success. Top-level await is the workaround; a docs line would save the discovery.
3. CLI flags never reach the script
process.argv is rebuilt from minimist's positional arguments only:
process.argv = [process.argv[0], process.argv[1], ...args._.slice(2)]
So --pre is consumed by the CLI and process.argv.includes('--pre') inside the script is always false, silently.
Suggested fix: forward unrecognised flags, or document that arguments must be passed via the environment.
4. It performs a dev schema push against whatever DATABASE_URL names
db-postgres/dist/connect.js:110 guards dev push with three conditions:
if (process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_MIGRATING !== 'true' &&
this.push !== false) {
await pushDevSchema(this)
}
Two of those are set for you where it matters — bin/migrate.js sets PAYLOAD_MIGRATING='true', and next build sets NODE_ENV=production. payload run sets neither: bin/index.js sets only DISABLE_PAYLOAD_HMR, and bin/loadEnv.js merely reads NODE_ENV. So for an ad-hoc payload run ./seed.ts with NODE_ENV unset — the default in a plain shell — all three conditions pass and the adapter dev-pushes against whatever DATABASE_URL resolves to, including a production database. It then stamps payload_migrations = (dev, batch -1), and the next deploy's migration gate refuses to run.
Two things make this expensive rather than merely surprising:
migrate:status does not show the (dev, -1) row, so the tool you would reach for to diagnose a refused deploy does not reveal the cause.
- Combined with (1), a guard script written to prevent this cannot fail the build, because
payload run discards its exit code.
Ask: NODE_ENV is a weak proxy for "is this database disposable". Either default push to false for payload run (a one-off script is not a dev server), or refuse a dev push against a non-local host unless explicitly opted in, or at minimum log a warning naming the host before pushing. Today the safe configuration is something each user discovers only after being bitten.
Happy to open a PR for any of these if the direction is agreed — (1) and (3) look mechanical; (4) is a behavioural default you may want to choose differently than we would.
payload runreports success in four distinct failure modes, one of which writes to a remote database.Verified against
payload@3.87.1and@payloadcms/db-postgres@3.87.1by reading the publisheddist. Reporting these together because they share a root cause — the CLI wrapper does not preserve the semantics of the script it runs — and because together they makepayload rununsafe as a CI or build gate. We used it as a pre-deploy migration gate, and the gate could never have failed.1. The exit code is discarded
dist/bin/index.jsends the non-cron branch with an unconditionalprocess.exit(0)afterrunBinScriptreturns:A script that sets
process.exitCode = 1— the documented, non-throwing way to fail — therefore reports success to the shell. A thrown error is the only failure signal that propagates (via thecatchinsiderunBinScript, which callsprocess.exit(1)).Repro:
payload run ./fail.tswherefail.tsisprocess.exitCode = 1;echo $?prints0.Suggested fix: propagate a non-zero
process.exitCode, or document prominently that onlythrowfails.2. A floating promise is abandoned
payload run script.tsawaits module evaluation and then exits, so a trailingvoid main()never completes. The script prints nothing and exits0— indistinguishable from success. Top-levelawaitis the workaround; a docs line would save the discovery.3. CLI flags never reach the script
process.argvis rebuilt from minimist's positional arguments only:So
--preis consumed by the CLI andprocess.argv.includes('--pre')inside the script is alwaysfalse, silently.Suggested fix: forward unrecognised flags, or document that arguments must be passed via the environment.
4. It performs a dev schema push against whatever
DATABASE_URLnamesdb-postgres/dist/connect.js:110guards dev push with three conditions:Two of those are set for you where it matters —
bin/migrate.jssetsPAYLOAD_MIGRATING='true', andnext buildsetsNODE_ENV=production.payload runsets neither:bin/index.jssets onlyDISABLE_PAYLOAD_HMR, andbin/loadEnv.jsmerely readsNODE_ENV. So for an ad-hocpayload run ./seed.tswithNODE_ENVunset — the default in a plain shell — all three conditions pass and the adapter dev-pushes against whateverDATABASE_URLresolves to, including a production database. It then stampspayload_migrations = (dev, batch -1), and the next deploy's migration gate refuses to run.Two things make this expensive rather than merely surprising:
migrate:statusdoes not show the(dev, -1)row, so the tool you would reach for to diagnose a refused deploy does not reveal the cause.payload rundiscards its exit code.Ask:
NODE_ENVis a weak proxy for "is this database disposable". Either defaultpushtofalseforpayload run(a one-off script is not a dev server), or refuse a dev push against a non-local host unless explicitly opted in, or at minimum log a warning naming the host before pushing. Today the safe configuration is something each user discovers only after being bitten.Happy to open a PR for any of these if the direction is agreed — (1) and (3) look mechanical; (4) is a behavioural default you may want to choose differently than we would.