CI automation usually does not fail because there is no log. It fails because the useful evidence is scattered across a runner, a test inbox, a webhook provider, and three unrelated dashboards.
When a job turns red, the first question is rarely just “what command exited with code 1?” We want to know which test data was created, which message or event was expected, what actually arrived, and whether a retry changed the result. Without that chain, debugging becomes archaeology.
This article describes a small pattern I use for making automation failures easier to explain: give every run an evidence ledger and publish one compact receipt when the run ends. It works for email tests, browser checks, API workflows, and other developer tools.
The failure is not the log line
A normal CI log is a timeline of commands. That is useful, but it is not always a timeline of causes.
For example, a browser test may report that a verification link was missing. The real cause could be any of these:
- The test created a fixture for the wrong run.
- The application sent the event, but the worker retried it later.
- The inbox query used a broad subject and selected an older message.
- A cleanup step removed the message before the assertion ran.
- The test passed locally because the local clock was a little different.
The log may contain clues for each case, but a developer has to manually join them. That is the expensive part. A receipt should make the joins explicit, even when the final status is failed.
Model each CI run as an evidence ledger
Start with a run identifier that is unique outside the CI provider too. A short commit SHA plus the CI attempt number works well, for example a81f2c7-attempt-2.
Every resource created for the test should carry that identifier:
- test account or email fixture
- outbound event ID
- worker job ID
- inbox message ID
- assertion result
- cleanup result
The ledger does not need a database on day one. A JSON file stored as a CI artifact is often enough. The important part is ownership: each record says which run created it and what happened next. This also keeps a test from accidentally reading another run's data, which is a common source of flaky results.
If your UI flow has several asynchronous states, define them before writing assertions. A test email state machine for React forms is a useful mental model: “created”, “sent”, “received”, “opened”, and “expired” are different facts, not one big “email worked” fact.
A small receipt format
Keep the receipt boring. Boring is good when somebody is staring at it during an incident. Here is a minimal shape:
{
"run_id": "a81f2c7-attempt-2",
"status": "failed",
"started_at": "2026-11-12T05:00:14Z",
"finished_at": "2026-11-12T05:01:03Z",
"fixture": {
"id": "fixture-a81f2c7",
"owner": "checkout-verification"
},
"events": [
{"name": "created", "id": "evt_91", "at": "2026-11-12T05:00:21Z"},
{"name": "received", "id": "msg_44", "at": "2026-11-12T05:00:37Z"}
],
"assertions": [
{"name": "verification_link_present", "ok": false, "reason": "wrong host"}
],
"cleanup": {"ok": true, "deleted": ["fixture-a81f2c7"]}
}
There is no need to make this format fancy at first. Use stable names, UTC timestamps, and IDs that can be searched in the other systems. If a field is unknown, write null or an explicit reason instead of silently leaving a gap. A missing value is itself useful evidence.
One small rule saves time: record the expected value beside the observed value. “Wrong host” is better than “link assertion failed,” but expected_host and observed_host is better still. It save a second trip through the application configuration.
Turn the receipt into a developer tool
An evidence ledger becomes valuable when the team can find it without learning a new ritual. Add three simple outputs to the CI job:
- Print a one-line summary with the run ID and the first failed assertion.
- Upload the full receipt as an artifact with a predictable filename.
- Add links from the job summary to the relevant event, message, and cleanup logs.
For pull requests, a small Markdown summary is enough:
Email verification: FAILED
Run: a81f2c7-attempt-2
First bad state: verification_link_present
Expected host: staging.example.test
Observed host: example.test
Receipt: artifacts/a81f2c7-attempt-2.json
This pattern also helps with privacy. The receipt can store opaque IDs and hashes instead of full message bodies or personal addresses. For teams handling test email, an audit trail for privacy-safe test email shows why retention and access rules belong in the design, not as a cleanup afterthought.
Do not over-collect. A receipt that copies every payload becomes a new security problem and is harder to read. Keep the minimum fields needed to reproduce the decision, then link to protected logs when more detail is required.
What to do about retries
Retries are where many automation reports become confusing. Treat every attempt as a child of one logical run, and include the attempt number in each resource key. The parent receipt can then say whether the second attempt reused data or created a clean fixture.
If a retry changes the result, report both outcomes. “Passed after retry” is not the same signal as “passed.” The distinction matters because a retry may hide a race, a delayed worker, or a flaky dependency. The failure ledger should make that visible, not smooth it away.
Also make cleanup idempotent. If the first attempt already deleted a fixture, the second cleanup should report “already absent” as an acceptable outcome. Otherwise the cleanup error can distract from the original failure.
Checklist before you ship the workflow
- Does every created test resource include a run and attempt ID?
- Can the receipt identify the first bad state, not only the last failed command?
- Are expected and observed values recorded together?
- Are retry outcomes kept distinct?
- Can a developer open the artifact from the CI job summary?
- Does cleanup run on success, failure, and cancellation?
- Are private payloads excluded or protected?
- Does the report remain useful when an external service is unavailable?
The goal is not another observability platform. It is a small shared contract between the test, the automation runner, and the person who has to fix the failure. Once that contract exists, noisy CI output starts to read like a story: what the run owned, what changed, where the first unexpected state appeared, and what was cleaned up. That is usually enough to get unstuck faster.
Top comments (1)
A commit SHA plus attempt number can collide across separate CI runs of the same commit: two first attempts can both be
a81f2c7-attempt-1. On GitHub Actions, I'd userepository + GITHUB_RUN_ID + GITHUB_RUN_ATTEMPTfor the receipt identity and keep the SHA as metadata. Add a job/matrix identifier to resource keys when parallel jobs own separate fixtures. The variable scopes are documented here.