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

SectionPhase
resolve_secretsprepare
prepare_executorprepare
prepare_scriptprepare
get_sourcesfetch
restore_cachefetch
download_artifactsfetch
step_scriptscript
after_scriptpost
archive_cachepost
archive_cache_on_failurepost
upload_artifacts_on_successpost
upload_artifacts_on_failurepost
cleanup_file_variablespost

GitHub Actions section names

SectionPhase
setup_jobprepare
setupprepare
checkoutfetch
runscript
postpost
complete_jobpost

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_idFires whenBlamesConfidence
empty_log_no_runnerThe log is empty or absent — the job never really ran.provisionhigh
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 reasonhigh / medium
script_failure_is_scriptThe 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.scripthigh
unclosed_sectionNo reason given, and a section never saw its end marker. Execution died inside it.that section'smedium
oom_137The trailer reports exit code 137 — SIGKILL, so the OOM killer. Infrastructure, not a script bug.scripthigh
trailer_exit_codeThe trailer reports any other non-zero exit code.scripthigh
trailer_system_failureThe trailer says `system failure` or names the prepare-environment step.preparehigh
trailer_timeoutThe trailer says execution took longer than the limit.provisionhigh
last_error_sectionThe last section containing a genuinely fatal line. A section whose only bad news is `WARNING:` can never be picked here.that section'slow
no_signalNothing above fired. Said plainly rather than guessed.unknownlow

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.

Disagree with a verdict? That is a bug with a failing test, not a prompt to tune. Run 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 .