Post Conditions and Result Semantics
Scenario: Cleanup placed after a failing step never runs and leaves a shared target locked.
"It didn't fail" is not the same as "it succeeded"
A very common beginner surprise: a Jenkins build can finish with a shell command returning exit code 0 (meaning "no error" to the shell) and still be marked UNSTABLE rather than SUCCESS by Jenkins itself. This isn't a bug - it's Jenkins deliberately distinguishing between "the commands ran without crashing" and "the actual quality bar we care about was met."
Analogy: Imagine a school report card with more than just "pass" or "fail." A student can attend every class and turn in every assignment (nothing technically "failed") but still get a "needs improvement" mark because their test scores were below the expected bar. UNSTABLE in Jenkins is that "needs improvement" mark - the pipeline ran to completion, but something it measured (usually test results) didn't meet the bar, even though no step technically crashed.
Jenkins actually has five possible results, not two: SUCCESS, UNSTABLE, FAILURE, ABORTED, and NOT_BUILT - and knowing which is which is essential for deciding what should happen next (like: should this be allowed to deploy to production?).
Pipeline example
pipeline { agent any; stages { stage('Test') { steps { sh './test.sh' } } }; post { always { junit 'reports/*.xml' }; cleanup { deleteDir() } } }
Results carry genuinely distinct meanings
Jenkins records success, unstable, failure, aborted, or not-built as a build's overall result. A test-reporting publisher (like junit) may mark a run unstable even when the shell command that ran the tests itself returned exit code 0 - because "the test runner didn't crash" and "all tests passed" are different facts, and junit is checking the second one. An abandoned manual input approval is recorded as aborted, not as a compilation failure - a very different signal for anyone reading the history later. Declarative post conditions include always, changed, fixed, regression, aborted, failure, success, unstable, unsuccessful, and cleanup - which of these blocks actually run depends on the accumulated result so far and the order conditions are evaluated in.
A concrete example of the distinction
[Pipeline] { (Test)
[Pipeline] sh
+ npm test
Tests: 116 passed, 2 failed, 118 total
+ exit 0
[Pipeline] junit
Recording test results
118 tests, 2 failures
[Pipeline] }
Finished: UNSTABLE
The test runner was configured to always exit 0 so the shell step itself "succeeds" - but the junit step then reads the actual XML report, sees 2 real failures, and downgrades the overall build to UNSTABLE. This is exactly why publishing real, structured test evidence (covered in a later module) matters so much more than trusting a shell exit code alone.
Publish evidence in post-always, and mean it
Publish JUnit results and other diagnostic evidence inside post { always { ... } }, specifically because a failing test command in an earlier stage would otherwise skip that publisher entirely if it were placed only after a successful path - which would hide exactly the evidence you need most when something goes wrong. Require the report-matching glob to actually match real files, and set allowEmptyResults: false (the safer default) so "no tests ran at all" can't quietly masquerade as a clean green result. cleanup runs as best-effort final work after other post behavior, but deleting a workspace is not a reliable way to "unlock" some external resource - external locks and leases need their own explicit, idempotent release logic that runs regardless of how the build ended.
Don't let cleanup hide the real problem
A notification step or a cleanup script that itself throws an error can obscure the original exception that actually caused the failure - make these secondary steps defensive (wrap them so they can't crash the reporting of the real failure), preserve the primary build result, and report any secondary failure separately and clearly. catchError can deliberately assign a different result to just one stage or the whole build for a consciously-tolerated scanner warning or known test outcome - but using catchError around everything paints every failure green indiscriminately and destroys the very distinctions this whole system exists to preserve.
A pitfall: "the badge is green" as the entire acceptance criteria
Exercise every result path deliberately and check what actually happens to the final badge, stage states, reports, notifications, and cleanup in each case - not just the happy path. Any downstream consumer of a build's result (an automated promotion job, a human approver, a dashboard) needs to pick an explicit threshold and stick to it: promotion to production should almost always require a real SUCCESS, while a diagnostic or informational collection step might reasonably accept UNSTABLE or even ABORTED. A genuinely trustworthy Pipeline is one that makes the difference between "rejected on quality grounds," "broke because of infrastructure," and "a human intentionally cancelled it" visible at a glance, to both automation and the people reading the dashboard.
Goal: Practice the concrete behavior in jenkins-artifact-promotion. Learn sections have no Jenkins terminal; the Jenkins lab opens the real shared service in a new full-screen tab inside your private folder.