Found while implementing #10953 (which fixes a different divergence in the same file — four advisories being unreachable in the --json payload). Filed unassigned, not routed, not graded. Not fixed there: #10953 is scoped to advisory reachability, while this is about exit-code semantics, and an exit code is a declared contract (CliExitCode in packages/cli/src/utils/format.ts exists precisely to keep that slot honest). Changing it is a decision, not a mechanical repair.
Measured
At main head plus the #10953 branch, on a config that raises four non-blocking warnings (no manifest, no objects, no apps):
$ os validate --strict ; echo "exit=$?"
...
⚠ No objects defined — this stack has no data model
⚠ No apps or plugins defined — this stack may not do much
⚠ Missing manifest.id — required for deployment
⚠ Missing manifest.namespace — required for multi-app hosting
✗ Strict mode: warnings treated as errors
exit=1
$ os validate --json --strict ; echo "exit=$?"
{ "valid": true, ..., "warnings": [ ...four entries... ] }
exit=0
Same config, same flag, opposite exit status.
Why
In packages/cli/src/commands/validate.ts the --json branch emits the payload and returns. The only flags.strict reader sits below that return, inside the text-rendering block:
if(flags.json){awaitemitJson({ ... });return;}// never sees flags.strict
...
if(warnings.length>0){
...
if(flags.strict){printError('Strict mode: warnings treated as errors');this.exit(1);}}So --strict is accepted, documented, and inert whenever --json is also passed. content/docs/deployment/cli.mdx:1712 documents os validate --json --strict as a combination, which is the audience this silently no-ops for: a CI step spelling both flags reads exit 0 and concludes the stack is clean.
Note #10953 reduces the blast radius without closing this: after it, a pipeline can at least gate on warnings.length itself. A pipeline trusting the exit status still cannot.
Open decision, not an obvious fix
Whether --json --strict should exit 1 is a real design call, which is why this is filed rather than patched:
- Exit 1 to match text — consistent, and what the documented flag combination implies. But it flips the exit status of existing green CI runs.
- Keep exit 0 and let the consumer gate on the payload — arguably the
--json contract already: the payload is the product, the exit status reports whether the command ran. Then --strict should arguably be refused alongside --json rather than silently ignored.
Either way, "accepted and silently inert" is the one option that is clearly wrong.
Found while implementing #10953 (which fixes a different divergence in the same file — four advisories being unreachable in the
--jsonpayload). Filed unassigned, not routed, not graded. Not fixed there: #10953 is scoped to advisory reachability, while this is about exit-code semantics, and an exit code is a declared contract (CliExitCodeinpackages/cli/src/utils/format.tsexists precisely to keep that slot honest). Changing it is a decision, not a mechanical repair.Measured
At
mainhead plus the #10953 branch, on a config that raises four non-blocking warnings (nomanifest, no objects, no apps):Same config, same flag, opposite exit status.
Why
In
packages/cli/src/commands/validate.tsthe--jsonbranch emits the payload andreturns. The onlyflags.strictreader sits below that return, inside the text-rendering block:So
--strictis accepted, documented, and inert whenever--jsonis also passed.content/docs/deployment/cli.mdx:1712documentsos validate --json --strictas a combination, which is the audience this silently no-ops for: a CI step spelling both flags reads exit 0 and concludes the stack is clean.Note #10953 reduces the blast radius without closing this: after it, a pipeline can at least gate on
warnings.lengthitself. A pipeline trusting the exit status still cannot.Open decision, not an obvious fix
Whether
--json --strictshould exit 1 is a real design call, which is why this is filed rather than patched:--jsoncontract already: the payload is the product, the exit status reports whether the command ran. Then--strictshould arguably be refused alongside--jsonrather than silently ignored.Either way, "accepted and silently inert" is the one option that is clearly wrong.