Skip to content

fix(cli): exit non-zero when a command fails - #771

Merged
CybotTM merged 3 commits into
mainfrom
fix/cli-exit-codes
Aug 2, 2026
Merged

fix(cli): exit non-zero when a command fails#771
CybotTM merged 3 commits into
mainfrom
fix/cli-exit-codes

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 2, 2026

Copy link
Copy Markdown
Member

ofelia validate could not fail

Every failing invocation exited 0:

command before after
ofelia validate --config=/does/not/exist 0 1
ofelia validate on a malformed INI 0 1
ofelia bogus-command 0 1
ofelia version / --version / --help 0 0

So ofelia validate --config=… || exit 1 never fired. Every pipeline, Makefile target and deploy gate that ran validate to stop a broken config let it through, and the only sign was a log line nothing reads.

It was not a design decision, despite the comment saying so. The line came in with 242cf6d — a linting cleanup ("comprehensive linting resolution") — and "Exit gracefully instead of os.Exit(1)" reads as a rationalisation of that fix. Nothing enforces it now either: os.Exit(1) in that position produces zero findings under the current .golangci.yml, because revive's deep-exit rule is not enabled. Verified by putting it there and running the linter.

And it had been documented into permanence. e2e/config_validation_test.go discarded the status with _ = err under a note calling exit 0 intentional — a test that recorded the defect instead of catching it.

main() now does nothing but exit with what run() returns, so the status is a value rather than a process-ending side effect, which is also what makes it assertable.

Tests

The exit status is the only part of a CLI that a shell can act on, and nothing asserted it — which is exactly how this survived: the tests read the output, which was correct all along.

New e2e coverage at the process boundary, where the contract actually lives: unknown command fails; version, --version and --help succeed; strict validation rejects a bad config; and validate used the way a deploy gate uses it — run over a good and a bad config, branch on the status — must tell the two apart. That last test would have caught the original defect on its own.

The unit tests move from main() to run() so they can assert the code too. Two of them were fixed rather than kept: they passed --log-level/--config before the subcommand and only passed because everything returned 0.

Three findings this turned up, deliberately not fixed here

Each is a separate contract change and belongs in its own PR:

  1. Global flags are rejected before the subcommand. --log-level and --config are pre-parsed from anywhere in argv, but the top-level parser declares neither, so ofelia --config=x validate fails with unknown flag `config' while ofelia validate --config=x works.

  2. A bad schedule passes validation and the daemon starts anyway. With enable-strict-validation off (the default), schedule = not-a-schedule validates clean; the daemon then logs WARN Failed to register job "broken" and starts with jobCount=1, leaving a job that never fires and nothing that stops a deployment.

  3. Strict validation demands web-auth fields regardless. Turning it on for a config with no web UI fails on web-password-hash and web-secret-key being "required".

Behaviour change to be aware of

Pipelines that today run past a broken config will start failing. That is the point, but it will surface.

Test plan

  • go test ./... — green, coverage 90.09%
  • go test -race -tags=e2e ./e2e/... — green
  • golangci-lint run incl. --build-tags="e2e unix" — 0 issues
  • lefthook run pre-push — exit 0
  • Exit codes verified against the built binary, before and after

CybotTM added 2 commits August 2, 2026 12:18
Every failing invocation exited 0:

    ofelia validate --config=/does/not/exist   ->  0
    ofelia validate <malformed ini>            ->  0
    ofelia bogus-command                       ->  0

So `ofelia validate --config=… || exit 1` never fired. Any pipeline,
Makefile target or deploy gate that ran validate to stop a broken config
let it through, and the only sign was a log line nothing was reading.

The cause is not a design decision, despite the comment saying so. The
line came in with 242cf6d, a linting cleanup, and "Exit gracefully
instead of os.Exit(1)" reads as a rationalisation of that fix. Nothing
enforces it now: os.Exit(1) in that position produces no findings under
the current .golangci.yml, since revive's deep-exit rule is not enabled.

main() now does nothing but exit with what run() returns, so the status
is a value rather than a process-ending side effect — which is also what
makes it assertable. Help and --version stay 0; asking for information
and receiving it is not a failure. Everything else is 1, and the error
now travels with the log line instead of being dropped.

The e2e test for the malformed-INI case discarded the exit status with
`_ = err` and a note calling exit 0 intentional, so it documented the
defect rather than catching it. It now asserts the status, as do the
missing-file and happy-path cases.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
The exit status is the only part of a CLI a shell, a Makefile or a CI
step can act on, and nothing asserted it. That is how a validate command
that could not fail survived: the tests read its output, which was
correct, and never looked at what it handed back.

e2e covers the process boundary, which is where the contract lives — a
unit test can inspect an error value, but only the binary can be wrong
about the status it returns. Added: unknown command fails; version, the
--version flag and --help succeed; strict validation rejects a bad
config; and validate used the way a deploy gate uses it — run it over a
good and a bad config and branch on the status — must tell the two
apart. That last one would have caught the original defect on its own.

The unit tests move from main() to run() so they can assert the code
too, and two of them were fixed rather than kept: they invoked
`--log-level`/`--config` before the subcommand, where the top-level
parser rejects them as unknown flags, and only passed because everything
returned 0. They now use the position that works.

That placement asymmetry is a separate defect and is deliberately not
pinned here: both flags are pre-parsed from anywhere in argv, but the
top-level parser declares neither, so `ofelia --config=x validate` fails
while `ofelia validate --config=x` works.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Copilot AI review requested due to automatic review settings August 2, 2026 10:20
@github-actions github-actions Bot added the tests label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

github-actions[bot]
github-actions Bot previously approved these changes Aug 2, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ Mutation Testing Results

Mutation Score: 0.00% (threshold: 60%)

⚠️ Score is below threshold. Consider improving test coverage or test quality.

What is mutation testing?

Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness.

  • Killed mutants: Tests caught the mutation (good!)
  • Survived mutants: Tests missed the mutation (needs improvement)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Ofelia’s CLI contract so failures (invalid config, malformed INI, unknown commands, command execution errors) return a non-zero process exit code, making ofelia validate … usable as a deployment/CI gate as intended.

Changes:

  • Refactor main() into run(args []string) int and have main() exit with the returned status code.
  • Ensure help/version paths exit 0, while all other parse/execute errors exit 1 (and log the underlying error).
  • Add/adjust unit + e2e tests to assert exit-status behavior at the process boundary.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ofelia.go Introduces run() returning exit codes and makes main() exit with that code; returns non-zero on command failures.
ofelia_main_test.go Updates unit tests to call run() and assert exit codes for version/help/unknown/no-args/validate paths.
e2e/helpers_test.go Adds assertExitCode helper to reliably assert subprocess exit status in e2e tests.
e2e/config_validation_test.go Stops discarding subprocess errors; asserts that invalid configs exit non-zero and valid configs exit zero.
e2e/cli_exit_codes_test.go New e2e coverage for exit-code behavior across unknown commands, help/version, strict validation failures, and validate-as-gate scenarios.
Suppressed comments (5)

ofelia_main_test.go:85

  • This paralleltest suppression comment still mentions os.Args, but these tests now call run(argv) and only redirect os.Stdout. Please update the justification to reflect the actual shared global state.
//nolint:paralleltest // mutates os.Args and os.Stdout, which are process-global

ofelia_main_test.go:107

  • This paralleltest suppression comment still mentions os.Args, but these tests now call run(argv) and only redirect os.Stdout. Please update the justification to reflect the actual shared global state.
//nolint:paralleltest // mutates os.Args and os.Stdout, which are process-global

ofelia_main_test.go:122

  • This paralleltest suppression comment still mentions os.Args, but these tests now call run(argv) and only redirect os.Stdout. Please update the justification to reflect the actual shared global state.
//nolint:paralleltest // mutates os.Args and os.Stdout, which are process-global

ofelia_main_test.go:143

  • This paralleltest suppression comment still mentions os.Args, but these tests now call run(argv) and only redirect os.Stdout. Please update the justification to reflect the actual shared global state.
//nolint:paralleltest // mutates os.Args and os.Stdout, which are process-global

ofelia_main_test.go:161

  • This paralleltest suppression comment still mentions os.Args, but these tests now call run(argv) and only redirect os.Stdout. Please update the justification to reflect the actual shared global state.
//nolint:paralleltest // mutates os.Args and os.Stdout, which are process-global

Comment thread ofelia_main_test.go Outdated
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 89.03%. Comparing base (48b4515) to head (698d1b9).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
ofelia.go 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #771      +/-   ##
==========================================
- Coverage   89.06%   89.03%   -0.03%     
==========================================
  Files          90       90              
  Lines       12059    12061       +2     
==========================================
- Hits        10740    10739       -1     
- Misses       1022     1025       +3     
  Partials      297      297              
Flag Coverage Δ
integration 89.03% <87.50%> (-0.03%) ⬇️
unittests 88.48% <87.50%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Switching these tests from main() to run(argv) removed the os.Args
mutation, but the suppression comments still named it. They are
suppressed because they replace os.Stdout, which is process-global on
its own; the reason now says only that.

Review finding from Copilot on PR #771.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

sonarqubecloud Bot commented Aug 2, 2026

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM
CybotTM added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 6b5f157 Aug 2, 2026
37 checks passed
@CybotTM
CybotTM deleted the fix/cli-exit-codes branch August 2, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants