Debugging Pipeline Failures

Debugging a pipeline failure turns "the build is red" into an explained failure: which job, step and test failed, what the first error was, whether it happens again on the same commit, and which change caused it. The lesson on automation reporting and debugging applied the ISTQB debugging cycle (reproduce the failure, diagnose its cause, fix it) to one failed test; at pipeline level the commit, the runner and the run history become part of the evidence. The tester often drives the reproduction and the first diagnosis, then hands the developer the evidence.

Example: a nightly end-to-end run fails in one of four shards. The log names the test, the trace shows the data the page received, the test fails on your laptop once the browser uses the runner’s time zone, and a bisect names the commit that broke the date format.

Why it matters

A red pipeline blocks every merge and release behind it, and a wrong classification costs more than slow debugging. Calling a product defect "flaky" lets it ship; calling an environment problem a product bug sends developers hunting for a defect that is not in the code. Re-running until green does both: it hides the evidence and teaches the team that red means nothing.

For a tester, this is where trust is earned. A report that names the failing test, the first error, the artifacts, a reproduction command and the first bad commit gets fixed quickly; "the pipeline is red, can someone look?" does not.

Start with the log and the artifacts

As in the lesson on automation reporting and debugging, start from the first error and its evidence; in a pipeline, that means first finding the first failed job and the first failed step inside it. Search the log for the test name or the error marker (##[error] in a raw GitHub Actions log) instead of scrolling, and remember that runner timestamps are usually in UTC.

Then download the job’s artifacts before their retention period ends, such as the JUnit XML report, the Playwright trace and the application logs, and read them as that lesson showed. Compare them with the job’s last green run: a new runner image, a changed dependency or fewer tests executed is a clue before you read any code.

# GitHub CLI: logs of the failed steps only, then the artifacts of one job
gh run view 9876543 --log-failed
gh run download 9876543 -n e2e-results-3

# open the trace recorded for the failing test
npx playwright show-trace test-results/invoice-order-date/trace.zip

Try it: XML Formatter Try it: Text Diff

Reproduce the failure locally

A failure you can reproduce on your own machine can be studied with a debugger, extra logging and as many runs as you need. Reproduction is a matter of parity: match the CI job in everything that can change the result, then change one thing at a time. If the test still passes locally, the remaining difference is your best clue.

  • Commit: the exact SHA from the job log. A pull-request run on GitHub Actions tests a temporary merge into the target branch, not your branch head; that commit is on no branch, so fetch it with git fetch origin pull/<number>/merge and check that the SHA matches the log.
  • Image and versions: the job’s container image if it has one, dependencies from the lockfile (npm ci, not npm install) and the browser builds your Playwright version pins.
  • Environment variables: the non-secret values the job sets, including CI=true, which many test configurations read; your own test credentials, never production secrets.
  • Time zone, locale and seed: runners often use UTC and an English locale, and tests that generate random data should log their seed so the data can be replayed.
  • Subset and order: the same shard and worker count, then the failing test alone and repeated, to tell "fails alone" from "fails only next to other tests".
git checkout 9f8e7d6                     # the SHA the job tested
npm ci && npx playwright install --with-deps chromium
export CI=true TEST_SEED=48151           # non-secret values copied from the job
npx playwright test --shard=3/4 --workers=2
npx playwright test invoice.spec.ts --repeat-each=10

Timeouts, fail-fast and re-runs

A timeout means something the test waited for never happened: an element, a response, a service. Raising the limit rarely fixes it; the call log or the trace shows what was awaited. Layer the limits: a test timeout (30 seconds by default in Playwright) fails one named test and the report is still written, while a job timeout (timeout-minutes on GitHub Actions, 360 minutes if unset) cancels the whole job and may leave no report. Keep test timeouts well below the job timeout.

Fail-fast stops at the first failure (in Playwright, -x, short for --max-failures=1); run-all collects every failure. Fail fast in cheap early stages such as linting and unit tests. In expensive stages and nightly runs, run everything: one failing test and forty failing tests tell different stories, and forty usually point to a shared cause such as the environment. A GitHub Actions matrix cancels its other jobs when one fails unless you set fail-fast: false.

Re-run as narrowly as you can: only the failed jobs (gh run rerun <run-id> --failed, where the run ID is the long number in the run’s URL, not its # number) or only the failing test. A GitHub Actions re-run uses the same commit, so a pass proves non-determinism, not a fix; record it as evidence.

Retries and quarantine, in brief: an automatic retry turns a run green when a flaky test passes on a later attempt, and quarantine moves a known-flaky test into a non-blocking job where it keeps running. Both are containment, not a cure: allow at most one retry, report "passed on retry" as flaky, and give every quarantined test an owner, a ticket and a deadline. The flaky tests guide covers the policy, and the CI/CD testing guide’s triage flow shows where a re-run fits.

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30      # job limit: cuts off a hang
    strategy:
      fail-fast: false       # one failing shard does not cancel the others
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # checkout and install steps omitted
      - run: npx playwright test --shard=${{ matrix.shard }}/4

Try it: YAML Formatter

Bisecting a regression across pipeline runs

When a scheduled run on main turns red after many merges, the pipeline history gives you the last green and the first red run, each with its commit. First check the test’s history in the CI test reports: a test that has failed now and then for weeks is a flaky candidate, not a new regression. If it fails every time from one run on, binary search finds the change: test the middle commit, keep the half where pass turns into fail, and repeat. Sixteen candidate commits need at most four test runs.

git bisect run automates the search: its command exits with 0 for a good commit, 125 to skip a commit that cannot be tested, and another code from 1 to 127 for a bad one. Trust the result only if the test fails every time on a bad commit, so repeat it at each step, and if the environment stays fixed. If the last green commit now fails too, the cause is outside the code, such as a runner image, an external service or expired test data.

# last green nightly: a1b2c3d     first red nightly: 9f8e7d6
git bisect start 9f8e7d6 a1b2c3d
# TZ=UTC gives the browser the runner's time zone (Linux and macOS)
git bisect run sh -c 'npm ci || exit 125
  TZ=UTC npx playwright test -g "order date" --repeat-each=3'
# git prints "<sha> is the first bad commit"
git bisect reset

Worked example: a red nightly run

Nightly run 412 on main is red; run 411 was green, with nine commits in between. Fail-fast is off, so all four shards finished, and shard 3 has one failure in invoice.spec.ts, "invoice shows the order date": expected 1 Oct 2026, received 30 Sep 2026. It failed on its first attempt and on its retry, so the report lists it as failed, not flaky. The trace shows that the API returned createdAt as 2026-09-30T22:40:00Z, which is 01:40 on 1 October in Riyadh, the store’s time zone: the data is right and the display is wrong.

On the tester’s laptop in Riyadh the test passes. With the job’s commit, lockfile, browser build and seed, one difference remains: the runner’s browser uses UTC. With Playwright’s timezoneId set to UTC, the test fails 10 times out of 10. With TZ=UTC in the bisect command, so that every commit runs in the runner’s time zone, git bisect run names c41d9a0, "Format dates with Intl.DateTimeFormat", which dropped the store’s time zone. The tester files a product defect with the run link, the test, the trace, the reproduction setting and the first bad commit, and proposes pinning timezoneId in the test configuration so that local runs behave like CI.

Nightly run 412, shard 3, beside two local runs of the invoice-date test: only the browser time zone differed, and setting it to UTC made the test fail locally as it did in CI.
SettingCI: nightly run 412, shard 3Laptop: first runLaptop: time zone matched
Commit tested9f8e7d69f8e7d69f8e7d6
Dependencieslockfile, npm cilockfile, npm cilockfile, npm ci
Browser buildChromium pinned by PlaywrightChromium pinned by PlaywrightChromium pinned by Playwright
Test data seed481514815148151
Browser time zoneUTCAsia/Riyadh (UTC+3)UTCset with timezoneId
Invoice-date testshows 30 Sep 2026Failshows 1 Oct 2026Passshows 30 Sep 2026 in 10 of 10 runsFail
  • Differs from the CI job: the clue

Try it: Timezone Converter

Common mistakes

  • Letting the job’s artifacts expire before downloading them, so the trace and the reports of a failure you could not reproduce are gone.
  • Re-running the whole workflow instead of only the failed jobs, which costs runner time and repeats jobs that already passed.
  • Reproducing on a different commit, such as your branch head instead of the merge commit CI tested, or with npm install instead of the lockfile.
  • Passing the # run number to gh run rerun instead of the run ID from the run’s URL, so the command does not re-run the run you meant.
  • Leaving fail-fast on in an expensive matrix, so one failure cancels the shards whose results you needed for triage.
  • Bisecting a flaky test or a changed environment: the search then points at an innocent commit.

The QA perspective

Agree with the team who looks at a red pipeline and how fast; some teams rotate the role, sometimes pairing a tester with a developer. Start from the run link and write down what you find as you go: job, step, first error, artifacts, reproduction command, re-run result and classification.

Ask for what makes the next failure cheaper: artifacts uploaded even when tests fail, seeds and versions printed in the log, test timeouts below the job timeout. Keep the failure log the CI/CD testing guide recommends; within a few weeks it shows whether product defects, flaky tests or environments dominate, which is the evidence you need to get the pipeline fixed.

Practice exercise

Triage a job that timed out

Your task

A pull request’s pipeline is red. Read the run summary and the log below, then classify the failure, name the missing evidence, plan the reproduction and say what you would change in the pipeline so that the next failure leaves better evidence.

Material
Pull request #214, workflow "ci", run #7731 (run ID 11407731 in its URL)
Commit 5be20d7 (merge of feature/refunds into main)

unit-tests    passed     3m 10s
api-tests     failed    20m 00s
e2e (1/2)     skipped    needs: api-tests
e2e (2/2)     skipped    needs: api-tests

api-tests log, last lines (UTC):
12:04:31  PASS  orders.create > returns 201 for a valid order (0.8s)
12:04:32  RUN   refunds.create > refunds a captured payment
12:23:58  ##[error]The job running on runner ci-runner-07 has exceeded the
          maximum execution time of 20 minutes.
12:23:58  ##[error]The operation was canceled.

Artifacts: unit-reports (api-reports missing)
api-tests on main, last two runs: passed, passed
Steps
  1. Identify the failed job, the step and the last test that started.
  2. Classify the failure as far as the evidence allows, and say what the skipped jobs tell you.
  3. Explain why there is no API test report and how to get one next time.
  4. Write how you would reproduce the failure and how you would re-run it.
Show the model answer
Model answer

The api-tests test step hung: refunds.create > refunds a captured payment started at 12:04:32 and never reported a result; about 19 minutes later the 20-minute job timeout cancelled the job. It is a hang, not an assertion failure. The e2e jobs were skipped because they need api-tests, so they say nothing about the product.

Still open: a product defect (the refund call never returns), an environment problem (the payment stub, a database lock) or a flaky hang. The job is green on main and the pull request changes refunds, so the change is the first suspect, but only reproduction can decide.

The job was cancelled before the test runner wrote its report. Set a per-test timeout well below the job timeout (for example 60 seconds) and client timeouts on the tests’ HTTP calls, so that a hang fails one named test with a report; upload reports in a step that runs even when tests fail; and log request ids and timestamps to match them with the server logs.

Re-run only the failed job once (gh run rerun 11407731 --failed, with the run ID, not #7731): a pass makes it a flaky candidate but does not prove the change safe. Locally, fetch the merge commit (git fetch origin pull/214/merge), check out 5be20d7, run npm ci, start the job’s services, run refunds.create alone with the short timeout and read the application and stub logs while it hangs. No bisect is needed: the only candidate change is this pull request.

Frequently asked questions

Should I re-run a failed job before investigating?

Read the first error and download the artifacts first, then re-run once on the same commit. The re-run tells you whether the failure is deterministic; it is evidence for triage, not a fix, and a pass does not prove the change safe.

What if I cannot reproduce the failure locally?

Close the remaining differences one at a time: container image, browser build, environment variables, time zone, seed, shard and worker count. If the test still passes, collect more evidence in CI instead: traces on every attempt, more logging, a re-run with debug logging, and repetitions there to measure how often it fails.

When is bisecting worth it?

When the failure is deterministic and several commits lie between the last green and the first red run, which is typical for scheduled runs on the main branch. In a single pull request the change itself is the suspect, and with a flaky test or a changed environment bisecting points at the wrong commit.

References

  • ISTQB Certified Tester Foundation Level (CTFL) v4.0ISTQB · 2023The foundation syllabus of software testing: test levels and types, test design techniques, test management and tools.
  • GitHub Actions documentationGitHubHow workflows, jobs, runners and artifacts are defined in GitHub Actions — where automated tests run on each push or pull request.
  • Reference — GitGit projectThe official reference for every Git command and its options, grouped by task — setup, branching, sharing, patching, inspection and debugging.
  • Playwright DocumentationMicrosoft (Playwright)Official documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.

Practise with Mutqan tools

Compare two texts line by line or word by word and highlight differences.

Formatting

Pretty-print XML with configurable indentation, and minify it back.

Formatting

Convert a date and time between timezones (Riyadh, Amman, Delhi, UTC, New York, London…).

Utilities

Format and validate YAML with consistent indentation and quoting.

Formatting

Go deeper

Your progress on this topic

Part of the QA Roadmap