Sections & attribution
Three ideas, in the order they happen: a log becomes sections, sections are labelled with a phase, and one phase gets the blame. All of it deterministic, all of it before a model is involved.
Section
A named region of a job log, holding its own lines, any nested sections, and aclosed flag. GitLab emits section_start:…:step_script markers; GitHub emits ##[group] headers whose names embed action versions (Run actions/checkout@v4), so its segmenter canonicalises them to stable tokens. Either way the result is the same tree, and that is the last point where the two formats differ.
Two synthetic sections always exist: __preamble__ for output before the first marker, and __trailer__ for the runner's closing verdict after the last one. Neither is ever blamed directly — the classifier reads the trailer, it does not attribute to it.
closed is the load-bearing field. A section with no end marker means execution died inside it, which is the strongest structural signal available when the provider reported no reason at all.
Phase
What a section was for, independent of what the CI system calls it:provision (getting a runner at all), prepare (the image and environment), fetch (source, cache, artifacts), script (your commands), post (cleanup, artifact upload). The one distinction that matters is script — your bug — versus everything else, which is infrastructure and not.
The phases config is what maps section names onto them. This is the shipped map; add your own custom sections to it and they deep-merge on top. A section name that is not in the map inherits the nearest enclosing known phase, falling back toscript, because an unrecognised block is far more often your command than the runner's.
GitLab section names
| Section | Phase |
|---|---|
resolve_secrets | prepare |
prepare_executor | prepare |
prepare_script | prepare |
get_sources | fetch |
restore_cache | fetch |
download_artifacts | fetch |
step_script | script |
after_script | post |
archive_cache | post |
archive_cache_on_failure | post |
upload_artifacts_on_success | post |
upload_artifacts_on_failure | post |
cleanup_file_variables | post |
GitHub Actions section names
| Section | Phase |
|---|---|
setup_job | prepare |
setup | prepare |
checkout | fetch |
run | script |
post | post |
complete_job | post |
Attribution
Choosing which phase actually failed. A first-match-wins ladder: the provider's own reason is trusted before any log parsing, structure is only consulted when it said nothing, and the weakest rules sit at the bottom where they belong. It is a pure function with no config and no model, so its verdict is reproducible and every rung has a fixture behind it.
Every report carries the rule_id that produced it, so "why did it blame that?" is always answerable without re-reading the log:
rule_id | Fires when | Blames | Confidence |
|---|---|---|---|
empty_log_no_runner | The log is empty or absent — the job never really ran. | provision | high |
reason_* | The provider itself reported a reason: no runner, runner system failure, missing dependency, unmet prerequisites, cancelled, timeout, API failure. Authoritative, so it is consulted before any log parsing. | from the reason | high / medium |
script_failure_is_script | The provider said `script_failure`. This is the rule that kills the "blamed the cache" bug — the noisy fetch and prepare warnings are never even looked at. | script | high |
unclosed_section | No reason given, and a section never saw its end marker. Execution died inside it. | that section's | medium |
oom_137 | The trailer reports exit code 137 — SIGKILL, so the OOM killer. Infrastructure, not a script bug. | script | high |
trailer_exit_code | The trailer reports any other non-zero exit code. | script | high |
trailer_system_failure | The trailer says `system failure` or names the prepare-environment step. | prepare | high |
trailer_timeout | The trailer says execution took longer than the limit. | provision | high |
last_error_section | The last section containing a genuinely fatal line. A section whose only bad news is `WARNING:` can never be picked here. | that section's | low |
no_signal | Nothing above fired. Said plainly rather than guessed. | unknown | low |
Underneath it all sits one rule about words. A runner's WARNING: (or##[warning]) is non-fatal by definition — a missing cache, a missing artifact and a failed cache extract all warn and the job carries on. A tool printing the word from inside a step is still fatal, because that is the step's own output. Getting this backwards is exactly the "blamed the cache" bug.
ci-doctor analyze -v on the saved log, read therule= field, and find that rung in the table above.Once a phase is blamed, only its section is looked at again — everything from there is evidence selection .