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 a closed 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 to script, 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_idFires whenBlamesConfidence
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.

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 the rule= 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.