Skip to the content.

How gha-doctor stays honest

A diagnostics tool is only useful if you can trust its numbers. Most of gha-doctor’s analysis is built on samples — recent runs, recent artifacts, the largest caches — and samples can lie when they’re too small, too short, or too bursty. So the tool carries explicit honesty gates: rules about when a number is allowed to be reported, extrapolated, or turned into a dollar figure. When a gate fires, the output says so instead of printing a confident guess.

This page lists every gate, so you know exactly what a gha-doctor number means — and what it refuses to mean.

Projections need a real window

Monthly projections are only made when the sampled runs span at least 3 days. A repo that did 300 runs in 12 minutes of a release stampede would “project” to a six-figure monthly burn — that’s noise, not signal.

Small samples don’t get graded

Only decisive runs count

Skipped, cancelled, and action_required runs are excluded from success rates and duration percentiles. Counting them as failures made svelte look like 19% success when its decisive rate was 50%; counting them as fast runs made p50s absurd (0.1 minutes). The output reports how many runs were decisive.

The same principle drives --run verdicts: a run that failed, was cancelled, or skipped is never praised for being fast — stopping early is not speed. Failed runs lead with the failing job and step instead.

Dollar figures are floors, and say what they include

A zombie cron has to be both long-dead and provably dead

A scheduled workflow is only called out as a failing-on-repeat “zombie cron” when its newest sampled scheduled runs are an unbroken failure streak of 5+ consecutive failures spanning 3+ days:

Supersession has to be provable

A run only counts as superseded when a different commit’s run of the same workflow, from the same head repo and branch, was created before the first run’s last job finished:

PR feedback time only counts full verdicts

The “push → last check finishes” percentiles need at least 5 qualifying pushes, and a push only qualifies when the wait it describes actually happened, end to end:

Sampling is labeled

Diffs that survive line drift

--baseline REF reports only findings introduced since a git ref. The diff is a multiset over (rule, file basename, message) — not line numbers — so reformatting or an unrelated edit above a finding doesn’t produce a false “new issue”. Fixed and hidden findings are counted and reported.

Failure modes are loud, not silent

D017 (“nothing updates your action pins”) only fires after actually looking: the repo root and .github/ are checked for every config location renovate documents, plus dependabot’s two. If the lookup fails (rate limit, network), the check is skipped — a failed search is not evidence of absence. An unparseable dependabot.yml gets the benefit of the doubt: D017 is about missing automation, not YAML syntax. And any renovate config counts as covered — its github-actions manager is on by default, and gha-doctor won’t accuse a config it didn’t fully parse.

Flakiness needs proof

A job is only called flaky when it both failed and passed on the same commit SHA — flaky by construction, not by vibes. No heuristic guessing from failure rates.

The sample is provably current

The run sample is always taken from GitHub’s unfiltered run listing, and completed runs are selected client-side. That’s deliberate: the API’s status= filtered listings are served from a separate index whose replicas can lag by weeks — observed live (2026-07-31) on apache/superset, where 7 of 8 identical status=completed requests returned a page whose newest run was 38 days old, while unfiltered requests were fresh 8/8. A stale window would silently shift every downstream number (fail rate, cost, “last run” age) to a different era of the repo, which is worse than any single wrong stat. Versions before v0.23.1 could be bitten by this on very busy repos.

Charts don’t decorate thin data

The --html report’s charts follow the same rules as the numbers. The run-duration scatter only draws with 10+ decisive runs (the same minRunsToGrade bar the health score uses) — a trend through three dots is decoration, not information. A workflow only gets a p50→p95 range bar with 5+ decisive runs of that workflow, because percentiles of two runs are noise. Skipped/cancelled runs are excluded from both, exactly as they are from success rates and percentiles, and the scatter says so in its caption along with the sample size.

A trend needs both runs and time

The duration-trend section compares the p50 of a workflow’s successful runs in the older half of the sample against the newer half — failures stop early or get retried, so mixing them in would make any “trend” an artifact of the failure mix. A workflow is only measured with 12+ successful runs spanning 24+ hours (halves of a two-hour burst are noise, not a trend), and a measured change is only reported past both a 20% and a 1-minute p50 shift — percent alone would flag 0.1m → 0.2m, minutes alone would flag 60m → 61.5m. The report states the window and the per-half run counts on every line; measured-but-stable workflows are counted out loud (“no significant change across N measured workflows”) instead of disappearing. The “investigate the slowdown” to-do slot holds a higher bar still (30%+ slower), and carries no dollar figure: the extra minutes are already inside the cost totals, and pricing them twice would inflate the ledger.

A scoped sample never wears whole-repo labels

--workflow restricts the run sample to one workflow, and everything that would be wrong at that scope steps aside rather than quietly reporting a different number. The health score is not computed (its hygiene and success components grade the whole repo; a one-workflow score would be an unlabeled different metric, and --badge/--score-history refuse the combination outright). PR feedback time is skipped — it measures the wait until the last check across all workflows, so a scoped sample would understate every wait. Cache, artifact and storage figures have no per-workflow API view; they stay repo-wide and the report header says so on the spot.

An absent series is not a zero

The --prom export follows the same rule as --json’s omitted fields: a section the run did not measure — no history sample, cache API unavailable, too few qualifying PR pushes — emits no series at all, so the dashboard shows a gap instead of a fabricated flat line at zero. A measured zero (zero flaky jobs across a sampled window) IS emitted: “we looked, and it’s zero” is information. A success ratio or duration percentile for a workflow with no decisive runs is undefined, so that series is absent rather than 0 or 1. Every value is a gauge describing the sampled window; gha_doctor_sample_since_timestamp_seconds states how far back it reaches, and gha_doctor_runs_missing_job_data says loudly when job-derived gauges understate.

Config is never silent

A .gha-doctor.yml changes what the doctor reports, so applying one is always disclosed: a stderr note naming the file and every setting it contributed, plus a config block in --json. Unknown keys and unknown rule IDs in the file warn loudly instead of being skipped quietly — a typo in disable: must never end up disabling nothing while the author believes otherwise. --no-config shows what the report would say unconfigured, and the scoreboard/state-of-actions numbers are collected with it so no repo can grade itself.


If you catch a number that’s more confident than its data, that’s a bug — open an issue.