A Jenkins pipeline's test stage reports success and the deploy stage runs, even though the test command failed. How does a step failure normally become the build result, and what breaks that chain?
answer
- failure travels as a thrown exception
- sh throws on a non-zero exit
- returnStatus hands you the code instead
- catchError and warnError downgrade on purpose
- UNSTABLE does not stop later stages
basics
~20 sIn Jenkins a step signals failure by throwing: sh throws on a non-zero exit, the stage becomes FAILURE and the build FAILED, and later stages are skipped. Anything that swallows the exception — returnStatus, catchError, a bare try/catch — leaves the build green.
solid answer
~50 sThe normal chain is exception-driven. `sh` runs the script and throws when it exits non-zero; nothing catches it, so the stage ends FAILURE, the build result becomes FAILED, and Declarative skips the remaining stages. A green build with a failed command means something intercepted that chain, and there are only a few candidates. `sh(script: ..., returnStatus: true)` returns the exit code instead of throwing, so the pipeline continues unless you check the value. The shell itself can hide it: `cmd || true`, a pipeline like `cmd | tee log` whose status comes from `tee`, or a custom shebang that drops the default `-e`. A `catchError` or `warnError` wrapper downgrades deliberately, and a plain `try/catch` in a `script` block without a rethrow swallows it entirely. Finally, UNSTABLE is not FAILURE: later stages still run unless the pipeline sets `skipStagesAfterUnstable()`.
code
groovy · 5 lines// Swallows the failure: the exit code is returned, nothing throws
def rc = sh(script: 'pytest -q', returnStatus: true)
if (rc != 0) {
error("tests failed with exit code ${rc}") // restore the chain explicitly
}go deeper
Know that a failing sh command throws and that this is what turns the stage and the build red, and that later stages are then skipped.
Explain the interception points: returnStatus returns instead of throwing, shell-level tricks like || true and unchecked pipes hide the status, and catchError downgrades on purpose.
Demonstrate the diagnosis on a real build — read the stage result rather than the console text, separate a swallowed failure from an UNSTABLE run that never stopped, and know that skipStagesAfterUnstable exists.
Own the guarantee rather than the symptom: define which results are allowed to reach a deploy stage, keep that gate in shared pipeline code instead of per-team Jenkinsfiles, and treat any silent downgrade as a delivery-safety defect.
## How failure normally travels Jenkins Pipeline has no return-code model at the stage level. Failure is an exception. A step that fails throws; if nothing on the stack catches it, the stage that contained the step is marked FAILURE, the run's result becomes FAILED, and in Declarative Pipeline the subsequent stages are skipped rather than run. That single sentence is what an interviewer is checking you can say. The most common thrower is `sh`. By default Jenkins runs the script with the shell's `-e` behaviour, so the first failing command ends the script with its status, and a non-zero status makes the step throw. `error('message')` throws explicitly. Most publisher steps throw on genuinely broken input. ## Break one: you asked for the exit code ```groovy def rc = sh(script: 'pytest', returnStatus: true) // the pipeline continues here even when rc == 1 ``` `returnStatus: true` changes the contract: the step returns the exit code and never throws. This is a legitimate feature — sometimes you want to branch on an exit code — but it moves the responsibility for failing the build onto you. The fix is explicit: ```groovy if (rc != 0) { error("tests failed with exit code ${rc}") } ``` A related trap is `returnStdout: true`, which captures output; people reach for one and remember the semantics of the other. ## Break two: the shell hid it Even with a normal `sh`, the *script* can mask the failure: - `make test || true` — someone silenced a noisy step months ago and never removed it. - `pytest | tee test.log` — a shell pipeline's status is the status of the **last** command, and `tee` almost always succeeds, so the step exits 0. `set -o pipefail` before it, or `${PIPESTATUS[0]}` in bash, restores the real status. - A script beginning with its own shebang line (`#!/usr/bin/env bash`) does not get Jenkins' default `-e`, so only the last command's status matters. Add `set -e` yourself. - A trailing command that always succeeds — `run-tests.sh; echo done` — for the same reason. These are the ones that survive code review, because the Jenkinsfile looks correct and the damage is inside the shell script. ## Break three: something downgraded it on purpose ```groovy catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { sh './run-integration-tests.sh' } ``` `catchError` catches the exception and sets the results you name — here the stage shows FAILURE while the build is only UNSTABLE, and the pipeline continues. `warnError('integration tests failed')` is the shorthand for the unstable/unstable case. Both are deliberate tools, and both are exactly how a deploy stage ends up running after a failing test stage when someone tuned the wrapper without thinking about what comes next. A bare `try { } catch (e) { echo e.toString() }` inside a `script` block is the same failure with none of the reporting. ## Break four: UNSTABLE is not FAILURE A publisher such as `junit` typically records test failures by marking the run UNSTABLE rather than FAILED, and the `unstable('reason')` step does the same. UNSTABLE does **not** stop a Declarative pipeline: the remaining stages run normally. If your intent is "never deploy a build with failing tests", set the option explicitly: ```groovy pipeline { options { skipStagesAfterUnstable() } ... } ``` That one line converts a very common silent-deploy into a stopped pipeline. ## Reading the result from inside the pipeline `currentBuild.result` is `null` until something sets it — a fresh, so-far-successful run has no result yet, so `currentBuild.result == 'SUCCESS'` is false in the middle of a green build. `currentBuild.currentResult` gives you `SUCCESS` in that case, which is what conditional logic should read. Getting this backwards produces gates that never fire. ## How to diagnose the reported symptom Open the failing run and look at the *stage* result rather than the console text — the stage view shows whether the test stage was SUCCESS, UNSTABLE or FAILURE, which immediately separates "the failure was swallowed" from "the failure became UNSTABLE and did not stop anything". Then read the actual `sh` invocation for `returnStatus`, and read the shell script for `|| true`, a pipe, or a shebang. One of those four is always the answer.
- Why can sh 'pytest | tee test.log' report success when pytest fails?A shell pipeline's exit status is the status of its last command, and `tee` succeeds even when `pytest` did not. Jenkins sees exit 0 and the step does not throw. Fix it inside the script with `set -o pipefail` before the pipeline, or read `${PIPESTATUS[0]}` in bash, so the real status reaches Jenkins.
- What is the difference between currentBuild.result and currentBuild.currentResult?`result` is null until something explicitly sets it, so a run that is fine so far has no result at all and comparisons against 'SUCCESS' are false. `currentResult` returns the result if set and 'SUCCESS' otherwise, which is what conditional logic in mid-pipeline should read. Mixing them up produces gates that silently never fire.
- When is catchError the right tool rather than a mistake?When you genuinely want the run to continue while recording that something went wrong — collecting all of several independent checks in one build, or letting an optional publisher fail without killing a release. Name both results explicitly with `buildResult` and `stageResult` so the stage still shows red, and make sure a later stage that must not run on a bad build is guarded.
- What does the Declarative option skipStagesAfterUnstable() change?Without it, an UNSTABLE run — the usual result of recorded test failures — keeps executing the remaining stages, so a deploy stage happily ships a build with failing tests. With it, once the run becomes UNSTABLE the subsequent stages are skipped, which is almost always the intent when tests and deployment live in the same Jenkinsfile.
saying these in an interview costs you the question
- Believing a non-zero exit always fails the build regardless
- Using returnStatus and never checking the returned code
- Assuming UNSTABLE stops the pipeline like FAILURE does
- Catching an exception in a script block without rethrowing
- Testing currentBuild.result == 'SUCCESS' mid-run