Skip to content

fix(workflows): init step must not replace init's own error with SystemExit: 1 - #4530

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/init-step-error-masking
Open

fix(workflows): init step must not replace init's own error with SystemExit: 1#4530
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/init-step-error-masking

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

InitStep._run_init appends the runner's exception to stderr:

if result.exit_code != 0 and result.exception is not None:
    detail = f"{type(result.exception).__name__}: {result.exception}"
    stderr = f"{stderr}\n{detail}".strip() if stderr else detail

That branch exists for an unexpected crash. But typer.Exit(n) — how specify init reports every ordinary failure — surfaces through CliRunner as result.exception = SystemExit(n), so it fires on routine errors too.

init prints its diagnostics through Rich to stdout, so result.stderr is empty. The synthesized detail therefore becomes the entire stderr, which preempts execute's fallback two frames later:

error=(
    stderr.strip()
    or stdout.strip()          # <-- never reached
    or f"specify init exited with code {exit_code}."
),

Reproduction on current main (c173bf1)

An ordinary typo in integration: — which InitStep.validate does not value-check:

validate     : []
status       : failed | exit_code: 1
result.error : 'SystemExit: 1'
output.stderr: 'SystemExit: 1'
stdout holds : "... lingma, muse, omp, opencode, pi, qodercli, qwen,
                rovodev, shai, tabnine, trae, vibe, zcode, zed"

So the workflow author is told SystemExit: 1 while the list of valid integrations sits unread in stdout. The same applies to any ordinary init failure — a project: directory that already exists, an unusable script type.

It also leaks into workflow data: steps.<id>.output.stderr is the string "SystemExit: 1", so a downstream step reading it gets the sentinel rather than a diagnosis.

Fix

Exclude only SystemExit, preserving the branch for genuine crashes:

if (
    result.exit_code != 0
    and result.exception is not None
    and not isinstance(result.exception, SystemExit)
):

After the fix the same input reports init's own message, and a RuntimeError escaping the runner still yields RuntimeError: boom inside init.

Verification

  • Fail-before / pass-after: 1 new-vs-baseline failure with the source reverted to upstream/main16 passed with the fix.
  • A second test pins the branch's original purpose — an unexpected RuntimeError still surfaces. It passes both before and after by design: it guards preserved behaviour rather than proving the fix.
  • Scoped regression on tests/test_workflows.py: 20 failed / 959 passed vs a clean-main baseline of 20 failed / 957 passed — no new failures (the 20 are the known Windows os.replace flakiness in that file).
  • uvx ruff@0.15.0 check src tests → clean

Behaviour change, disclosed: error and output.stderr for a failing init step change from "SystemExit: 1" to init's own captured output (which includes its banner, since init writes to stdout). Successful steps are untouched, and no existing test asserted the old sentinel.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

🤖 Generated with Claude Code

…temExit: 1"

`InitStep._run_init` appended the runner's exception to stderr:

    if result.exit_code != 0 and result.exception is not None:
        detail = f"{type(result.exception).__name__}: {result.exception}"
        stderr = f"{stderr}\n{detail}".strip() if stderr else detail

That branch is written for an unexpected crash, but `typer.Exit(n)` -- how
`specify init` reports every ordinary failure -- surfaces through `CliRunner`
as `result.exception = SystemExit(n)`, so it fired on routine errors too.
`init` prints its diagnostics through Rich to stdout, so `result.stderr` is
empty and the synthesized detail became the ENTIRE stderr, preempting
`execute`'s fallback:

    error=(stderr.strip() or stdout.strip() or f"specify init exited ...")

`stdout.strip()` -- which holds the real message -- was never reached.

Reproduced on main with an ordinary typo in `integration:` (which
`InitStep.validate` does not value-check):

    validate     : []
    status       : failed | exit_code: 1
    result.error : 'SystemExit: 1'
    output.stderr: 'SystemExit: 1'
    stdout holds : "... lingma, muse, omp, opencode, pi, qodercli, qwen,
                    rovodev, shai, tabnine, trae, vibe, zcode, zed"

Now excludes only `SystemExit`, so an ordinary non-zero exit falls through to
init's own output while a genuine crash still reports its exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner September 11, 2026 15:59
@mnriem mnriem added the triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants