Jenkins Incident Case Study: The Green Pipeline That Shipped Failing Tests

The Incident

At 14:08 on a Tuesday, the Atlas team deployed version 4.18.0 of its pricing service. Jenkins showed a green Pipeline: Checkout, Test, Package, and Deploy Production all succeeded. Six minutes later, checkout requests with promotional codes began returning HTTP 500.

The rollback completed at 14:23. The first surprise came from a developer running the release commit locally: 17 integration tests failed. The question was no longer why the application failed. It was why Jenkins had certified it.

---

Step 1: Establish What Jenkins Actually Ran

The console for build 1842 contained failure output:

14:04:31  FAIL test/promotion.integration.test.js
14:04:31    expected 200, received 500
14:04:32  Test Suites: 2 failed, 41 passed, 43 total
14:04:32  Tests:       17 failed, 386 passed, 403 total
14:04:32  writing reports/junit.xml
14:04:32  integration test command completed
14:04:33  [Pipeline] archiveArtifacts
14:04:33  Archiving artifacts
14:04:34  [Pipeline] stage (Package)

The evidence was visible, but Jenkins proceeded. The Test stage had no Tests result link, only an archived XML file. That meant nobody had called the junit Pipeline step.

---

Step 2: Read the Wrapper, Not Just the Jenkinsfile

The Jenkinsfile looked reasonable:

stage('Test') {
  steps {
    sh './ci/run-tests.sh'
    archiveArtifacts artifacts: 'reports/**', allowEmptyArchive: true
  }
}

The defect was in ci/run-tests.sh, changed three days earlier while adding report upload:

#!/usr/bin/env bash
set +e
npm run test:integration -- --reporter=junit
curl -sS -X POST -F report=@reports/junit.xml "$REPORTS_URL"
echo "integration test command completed"

set +e allowed execution after npm returned 1. The script's exit status became the status of the final echo, which was zero. Jenkins' sh step only knows the process exit code; it does not interpret Jest's prose.

The report upload had another flaw. curl lacked --fail, so an HTTP 500 from the report service could also look successful.

---

Step 3: Reproduce the False Green Result

On an isolated agent the team ran:

./ci/run-tests.sh
echo $?

Output:

Test Suites: 2 failed, 41 passed, 43 total
integration test command completed
0

That final zero explained the green stage. Jenkins behaved correctly according to the contract it received.

Git history showed why the change passed review:

git show 7db81ca -- ci/run-tests.sh Jenkinsfile

The author had disabled fail-fast so the upload would still execute after failed tests. The intent was good—retain diagnostics—but failure preservation and evidence publication had been conflated.

---

Step 4: Check the Deployment Gate

Production deployment used only the branch name:

stage('Deploy Production') {
  when { branch 'main' }
  steps { sh './ci/deploy.sh production' }
}

There was no separate quality-gate state. Once Test returned zero, deployment was inevitable. The archived JUnit XML was inert: archiving a file does not parse tests or change build health.

---

The Fix

The wrapper now captures and restores the test exit code while allowing evidence upload:

#!/usr/bin/env bash
set -uo pipefail

test_status=0
npm run test:integration -- --reporter=junit || test_status=$?

if [[ -f reports/junit.xml ]]; then
  curl --fail --silent --show-error     -X POST -F report=@reports/junit.xml "$REPORTS_URL" || true
fi

exit "$test_status"

The Jenkinsfile publishes results in a post condition, so publication still runs after the shell step fails:

stage('Test') {
  steps {
    sh './ci/run-tests.sh'
  }
  post {
    always {
      junit testResults: 'reports/junit.xml', allowEmptyResults: false
      archiveArtifacts artifacts: 'reports/**', allowEmptyArchive: true
    }
  }
}

The optional external upload may warn, but it cannot erase the test command's status. Missing JUnit output is now itself an error rather than silently accepted.

---

Prevention Added After the Incident

The outage lasted 15 minutes; the semantic defect had existed for three days. Green means only that every enforced gate passed. The engineering task is making sure the right gates are enforced.

Recreate the relevant mechanics in Publish Test Results, Enforce a Quality Gate, and Contain a Non-Critical Failure.