Rules reference
Every static rule gha-doctor checks, why it matters, and how to fix or
silence it. Rules target speed, cost, and reliability — for syntax
correctness use actionlint, for
security use zizmor.
| ID | Name | Severity | --fix |
|---|---|---|---|
| D001 | MissingConcurrencyCancellation | warning | ✅ |
| D002 | NoJobTimeout | warning | ✅ |
| D003 | UncachedSetupAction | warning | ✅ |
| D004 | FullFetchDepth | info | — |
| D005 | HighFrequencyCron | warning | — |
| D006 | ExpensiveRunnerOnEveryPush | info | — |
| D007 | DockerBuildWithoutLayerCache | warning | — |
| D008 | CacheWithoutRestoreKeys | info | ✅ |
| D009 | ContinueOnErrorMasksFailures | info | — |
| D010 | DefaultArtifactRetention | info | — |
| D011 | LargeMatrixOnPRs | warning | — |
| D012 | NpmInstallInCI | info | ✅ |
| D013 | PushAndPullRequestDoubleRun | warning | — |
| D014 | TopOfHourCron | info | ✅ |
| D015 | RetiredActionVersion | warning | ✅ |
| D016 | RetiredRunnerLabel | warning | ✅ (ubuntu) |
| D017 | NoActionsUpdateAutomation | info | — |
| D018 | DeprecatedWorkflowCommand | warning | ✅ |
| D019 | DeprecatedActionRuntime | warning | — |
| D020 | DeprecatingRunnerLabel | warning | ✅ (ubuntu) |
| D021 | UnguardedCron | info | — |
Warnings make gha-doctor exit with code 2 (so you can gate CI on them);
info findings don’t affect the exit code.
Suppressing findings
Every rule is a heuristic; your workflow may be the exception. Three ways to say so:
Inline, per finding — a comment on the flagged line, or on its own line directly above:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gha-doctor: ignore[D004] (semantic-release needs history)
# gha-doctor: ignore[D003,D008]
- uses: actions/setup-node@v4
A bare # gha-doctor: ignore suppresses every rule on that line. Rule IDs
are case-insensitive. --fix respects these directives: a suppressed
finding is never auto-fixed.
Globally, per rule — the --disable flag:
$ gha-doctor --lint-only --disable D004,D009
Repo-wide, as standing policy — a .gha-doctor.yml at the repo root
(or .github/gha-doctor.yml):
disable: [D004, D009]
CLI flags beat the file, --disable adds to its list, and --no-config
ignores it. An applied config is always disclosed (stderr + the config
block in --json), and unknown keys or rule IDs warn loudly — a typo must
never silently disable nothing. With --repo, the target repo’s own config
is fetched and honored.
D001: MissingConcurrencyCancellation
A pull_request workflow has no concurrency group with
cancel-in-progress. When someone pushes a new commit to a PR, the run
for the old commit keeps going — burning billable minutes producing a
result nobody will look at. On an active repo this is routinely 10–30% of
all CI minutes.
# bad: superseded runs keep running
on: pull_request
# good
on: pull_request
concurrency:
group: $-$
cancel-in-progress: true
Also fires (as info) when a concurrency group exists but
cancel-in-progress is absent or false — superseded runs then queue
instead of cancel.
Auto-fix: inserts the concurrency block above jobs:, or adds/flips
cancel-in-progress in an existing group.
The run-history report measures this rule’s real cost: the Superseded PR runs section counts runs a newer push replaced while they were still running, and prices the billable minutes the completed ones burned past that moment.
D002: NoJobTimeout
A job has no timeout-minutes. The default is 360: one wedged step —
a hung network call, a deadlocked test — bills six hours of runner time
before GitHub kills it. Set the timeout a little above the job’s normal
duration so hangs die in minutes, not hours.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # normal run is ~8 min
Jobs that call reusable workflows (uses:) are skipped — the timeout
belongs in the callee.
Auto-fix: inserts timeout-minutes: 30 (deliberately generous — the
point is capping hangs at well under 360, not guessing your build time;
tighten it afterwards).
D003: UncachedSetupAction
actions/setup-node / setup-python / setup-java without the
cache: input. These actions have built-in dependency caching, off by
default. Without it every run re-downloads your whole dependency tree.
Enabling it is one line and typically saves 30s–3min per run.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # or yarn / pnpm
Auto-fix: detects your lockfile (package-lock.json, poetry.lock,
pom.xml, …) and inserts the matching cache: value. Skips when the
ecosystem is ambiguous (two lockfiles) rather than guess wrong.
D004: FullFetchDepth
actions/checkout with fetch-depth: 0 clones the repository’s full
history on every run. On a large repo that can dominate job time. Most
jobs only need the checked-out commit (the default, depth 1).
Legitimate uses exist — changelog generation, semantic-release,
git describe — which is why this is info-level and deliberately not
auto-fixed: whether a job needs history is a semantic question a linter
can’t answer. If yours does:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gha-doctor: ignore[D004] (semantic-release)
D005: HighFrequencyCron
A schedule: cron firing more often than every 15 minutes. That’s
~96+ runs a day of baseline load, and GitHub explicitly deprioritizes
high-frequency schedules — under load they’re delayed or silently
dropped, so you pay the minutes and can’t rely on the timing. Prefer
event-driven triggers (push, workflow_run, webhooks) or a coarser
interval.
D006: ExpensiveRunnerOnEveryPush
A macOS or Windows job triggered on every push or schedule. macOS bills at 10× the Linux rate, Windows at 2×. A matrix that runs all three OSes on every push spends 13× a Linux-only run. Common pattern: run Linux everywhere, and gate macOS/Windows behind a Linux smoke test, path filters, or release tags.
D007: DockerBuildWithoutLayerCache
docker/build-push-action without cache-from. Runners are
ephemeral: with no cache source, every run rebuilds every layer from
scratch — routinely 5–20 minutes for nothing.
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
D008: CacheWithoutRestoreKeys
actions/cache with a key but no restore-keys. The moment your
lockfile changes, the exact key misses and you start from a fully cold
cache — even though yesterday’s cache is 95% right. restore-keys lets a
stale-but-close cache be restored and updated.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-$-$
restore-keys: |
npm-$-
Auto-fix: when the key ends in $, derives the
prefix mechanically (everything before the hash). Other key shapes are
skipped with a note.
D009: ContinueOnErrorMasksFailures
continue-on-error: true at the job level. The job shows green no
matter what happens, so real breakage accumulates unseen — the usual
story is “that job’s been failing for six weeks.” For known-flaky matrix
legs, prefer excluding them from strategy.matrix or surfacing a
separate non-required status check.
D010: DefaultArtifactRetention
actions/upload-artifact without retention-days. Artifacts default
to 90-day retention and count against your storage quota; on a busy repo
debug logs and build outputs quietly pile up into gigabytes you pay for.
Most artifacts are looked at within days:
- uses: actions/upload-artifact@v4
with:
name: test-logs
path: logs/
retention-days: 7
D011: LargeMatrixOnPRs
A static strategy.matrix that expands to 20+ jobs per trigger.
Every push multiplies into that many jobs — queue pressure for everyone
and a big minutes bill. Common pattern: a reduced matrix on PRs, the full
matrix on main/release:
strategy:
matrix:
os: $
(or two workflows: a slim pr.yml and a full main.yml).
D012: NpmInstallInCI
npm install in a run: step, installing the project’s dependencies.
In CI you want npm ci: it installs exactly what the lockfile says (no
drift), deletes node_modules first (reproducible), and is faster.
npm install can modify the lockfile mid-build.
Only dependency installs are flagged — bare npm install or flags-only
forms like npm install --legacy-peer-deps. Installs that name a package,
tarball, or directory (npm install typescript, npm install ./pkg.tgz)
are a different operation that npm ci cannot perform, so they are not
findings. Global installs (-g) are exempt too.
Auto-fix: rewrites bare npm install → npm ci. Flags-only installs
are skipped with a note: not every npm install flag means the same thing
to npm ci, so check the flags and switch by hand.
D013: PushAndPullRequestDoubleRun
on: lists both an unscoped push and pull_request. A commit
pushed to a PR branch in the same repository matches both triggers, so
the whole workflow runs twice — double the minutes, double the queue
pressure, and two status checks racing each other. This is one of the most
common (and most expensive) copy-paste patterns in the wild.
Scope push to the branches that aren’t covered by PRs:
on:
push:
branches: [main] # post-merge runs
pull_request: # PR runs
Not flagged when push is limited to specific branches, uses
branches-ignore, or is tags-only (push: {tags: [...]} never fires for
branch pushes).
D014: TopOfHourCron
A schedule: cron firing at minute 0. Everyone’s crons fire at the
top of the hour, so that’s when GitHub’s scheduler is most overloaded —
runs regularly start many minutes late, and under heavy load scheduled
runs can be dropped entirely.
The fix costs nothing: pick an arbitrary minute.
on:
schedule:
- cron: "23 4 * * *" # not "0 4 * * *"
Auto-fix: rewrites the minute field to a stable value in 1–59, picked by hashing the workflow filename and the expression — so the choice never changes between runs, different workflows scatter across the hour instead of all moving to the same “arbitrary” minute, and the cadence (hourly, daily, weekly…) is untouched. Folded or multi-line cron scalars are skipped with a note.
D015: RetiredActionVersion
A step uses: an action version GitHub has shut down. Unlike every
other rule, this isn’t about waste — these steps hard-fail at runtime,
every run:
actions/upload-artifact/actions/download-artifactv1–v3 — closed down January 30, 2025actions/cache(incl.cache/restore,cache/save) v1–v2 — retired March 1, 2025 when cache storage moved to its new architecture
actions/cache@v3 is not flagged: the floating v3 tag was updated to
a compatible release. Commit-SHA pins are also not flagged — the SHA alone
can’t prove which version it is, and gha-doctor doesn’t report what it
can’t verify (a SHA pin of a retired build will still fail; check it by
hand).
Auto-fix: bumps actions/cache@v1|v2 (and restore/save
subpaths) to @v4 — the inputs (path, key, restore-keys) are
unchanged, so the rewrite is mechanical. The artifact actions are
deliberately not auto-fixed: v4 changed semantics (same-name uploads
across matrix jobs fail; v3/v4 artifacts aren’t cross-compatible), so a
mechanical bump could trade a loud failure for a quiet wrong result. You
get a skip note pointing at the step instead.
D016: RetiredRunnerLabel
A job requests a hosted runner label GitHub has retired. The job cannot run — it fails immediately or sits queued until the timeout. As of this release the retired labels are:
| label | retired |
|---|---|
ubuntu-20.04 |
April 15, 2025 |
windows-2019 |
June 30, 2025 |
macos-13 (+ -large/-xlarge) |
December 4, 2025 |
macos-12, macos-11, macos-10.15 |
Dec 2024 / Jun 2024 / Sep 2022 |
ubuntu-18.04, ubuntu-16.04 |
Apr 2023 / Sep 2021 |
windows-2016 |
June 2022 |
Checked on scalar runs-on:, label lists, and $
indirection (both the axis list and include: entries). Complex
expressions ($) aren’t resolved — no guessing.
Auto-fixed for Ubuntu labels only. --fix bumps retired
ubuntu-* labels to ubuntu-24.04: same architecture, and the only
sensible target now that ubuntu-22.04 has a
scheduled retirement of its own
(see D020). A newer image can still
surface toolchain differences — but the baseline here is a job that
cannot run at all, so any breakage the bump introduces is loud, and you
review the diff (--diff previews it). Everything else gets a skip
note instead of an edit:
- Windows —
windows-2022vswindows-2025is your call. - macOS — newer images change Xcode majors, and coming from
macos-13or older, CPU architecture (Intel → Apple Silicon). - Labels reached through
$— the matrix value’s text may be referenced inif:conditions orinclude:/exclude:combinations the linter can’t see; rewriting it could silently change logic. Update the matrix by hand.
D017: NoActionsUpdateAutomation
Nothing in the repo updates its action pins. Workflow uses: pins
only move when something moves them. Without automation they rot in place
for years — until they hit a version GitHub has shut down (D015) or a
retired runner image (D016) and CI breaks on an otherwise-normal Tuesday.
A GitHub code search finds tens of thousands of workflows still pinned to
actions/upload-artifact@v3, which stopped working in January 2025 —
that’s what “nobody updates action pins by hand” looks like at scale.
The check is satisfied by either:
-
dependabot with the
github-actionspackage ecosystem:# .github/dependabot.yml version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly -
renovate — any config file (
renovate.json,renovate.json5,.renovaterc(.json/.json5), or the.github/variants). Renovate’sgithub-actionsmanager is enabled by default, so presence of a config counts; gha-doctor doesn’t inspect it further.
If a dependabot config exists but lists other ecosystems only, the finding
points at its updates: block instead.
This is a repo-level rule — the evidence lives outside
.github/workflows/ — with a few deliberate differences from the per-file
rules:
- It’s absent from
--sarifoutput: when the finding is that a file is missing, there is no file for code scanning to annotate. --baselinemode omits it: a missing dependabot config wasn’t “introduced since REF” by anyone’s PR (it still counts toward the health score, which grades the whole repo).- It doesn’t run in the browser playground, which lints pasted workflow snippets and can’t see your repo.
- Silence it with
--disable D017, or — when a dependabot config exists — an inline# gha-doctor: ignore[D017]comment above itsupdates:key.
No auto-fix: creating a .github/dependabot.yml decides your update
cadence and PR volume for you; that’s your call. The snippet above is the
whole fix.
D018: DeprecatedWorkflowCommand
A run: step writes a deprecated stdout workflow command. Two
generations of breakage in one rule:
::set-envand::add-pathwere disabled November 16, 2020 after a command-injection vulnerability — a step that emits them errors at runtime, every run.::set-outputand::save-statewere deprecated in October 2022; they still work, but GitHub prints a deprecation-warning annotation on every single run and has announced their removal (the original May 2023 cut-off was postponed, not cancelled).
The replacement is the environment-file syntax, and it’s mechanical:
# before # after
echo "::set-output name=sha::$SHA" echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "::save-state name=pid::$PID" echo "pid=$PID" >> "$GITHUB_STATE"
echo "::set-env name=MODE::release" echo "MODE=release" >> "$GITHUB_ENV"
echo "::add-path::$HOME/.local/bin" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
Detection scans every run: script (comment lines don’t count) and fires
once per command per step, whatever the shell — a pwsh Write-Output
"::set-output …" is just as deprecated.
Auto-fix: rewrites plain single-echo lines to the environment-file
form, preserving your quoting and everything else on the line — but only
when the step provably runs under a bash-compatible shell (explicit
shell: bash/sh, or the runner default on non-Windows runners,
resolved through $ like D016). Everything else is a
loud skip note instead of a guess: Windows/pwsh/cmd steps (>>
"$GITHUB_OUTPUT" means something else there), printf/piped/compound
lines, values using the %0A/%0D/%25 command escapes (environment
files express those with heredocs), and expression-valued runners. The
fix is all-or-nothing per step and command: if one of three ::set-output
lines can’t be rewritten, none are — a half-fixed step would still warn.
D019: DeprecatedActionRuntime
Severity: warning. An action.yml / action.yaml manifest declares
runs.using: node12, node16, or node20.
This is the one rule that lints the actions a repository publishes rather than the workflows it runs. GitHub retires Node runtimes on its runners on a schedule:
- node12 — removed in 2023. node16 — removed in 2024. Actions declaring either are force-migrated onto a newer Node at runtime: the manifest is a lie today, and the runtime actually used is on its own clock (see below).
- node20 — deprecated September 2025; Node 24 became the default
runtime on June 2, 2026, and GitHub has announced Node 20’s removal
from runners in fall 2026. An action still declaring
node20stops working when that lands — for every repository that uses it.
The fix is to declare runs.using: node24 and verify the bundled
dist/ code actually runs on Node 24 (native modules and long-frozen
bundles are the usual casualties). Because that verification is a real
test, not a text edit, --fix deliberately does not rewrite this one.
gha-doctor finds manifests at the conventional places: action.yml at
the repository root, in shallow subdirectories (monorepos like
actions/cache keep restore/action.yml and save/action.yml), and
anywhere under .github/actions/. Dependency and build trees
(node_modules, vendor, dist, …) are never scanned — the vendored
copies of other people’s actions are not yours to fix. Composite-action
steps in these manifests also get the D015 (retired action versions) and
D018 (deprecated workflow commands) checks, driven by the same tables as
their workflow-file counterparts.
D020: DeprecatingRunnerLabel
Severity: warning. A job requests a hosted runner label whose retirement GitHub has announced but not yet completed. The label still works today — but brownouts and longer queue times start on the announced deprecation date, and on the removal date the jobs stop running entirely, exactly like D016. As of this release:
| label | deprecation starts | fully unsupported | move to |
|---|---|---|---|
ubuntu-22.04 |
September 17, 2026 | April 17, 2027 | ubuntu-24.04 |
macos-14 (+ -large/-xlarge) |
July 6, 2026 | November 2, 2026 | macos-15 or macos-26 |
This is D016 on a countdown: the point of flagging it early is that you
migrate on your schedule instead of during a brownout window. Detection
is identical to D016 (scalar runs-on:, label lists, $
axis and include: values; complex expressions not resolved).
Auto-fixed for Ubuntu labels only, under the same policy as D016:
ubuntu-22.04 → ubuntu-24.04 is a same-architecture, mechanical
label swap with an unambiguous target. macOS targets (Xcode majors) and
matrix-resolved values get skip notes — see the D016 section for why.
D021: UnguardedCron
Severity: info. A workflow with an on: schedule trigger has jobs
with no repository guard. Scheduled workflows don’t stay in your repo:
every fork carries a copy, and once a fork owner enables Actions
(commonly to test a CI change on their fork), your crons start running
there too — typically failing on missing secrets, or worse, running
issue/PR automation (stale bots, lock bots, labelers) against the fork.
Honest scope: GitHub disables scheduled workflows by default in fresh forks of public repos and pauses crons in repos with no activity for 60 days, which is why this is info, not a warning — the leak needs a fork owner to turn workflows on. But that’s a single click away, it’s routinely clicked, and the fix costs one line. The standard defense (used by pytorch, transformers, and most large repos):
jobs:
nightly:
if: github.repository == 'your-org/your-repo'
Fork runs then skip cleanly instead of failing or spamming.
A job counts as guarded when its if: mentions github.repository
(slug or owner comparison), github.event.repository.fork, or scopes
by github.event_name (the author has decided when the job runs — a
job gated to run only on schedule slips through; false-positive
avoidance wins). A job that needs: a guarded job is effectively
guarded too: when the guard skips the ancestor, dependents skip with it.
Not auto-fixed, deliberately: the guard needs your repository’s
slug, which the workflow file doesn’t contain, and merging a guard into
an existing if: expression changes its semantics. It’s a one-line
hand edit with the snippet above.
parse: UnparseableWorkflow
Emitted (as a warning) when a workflow file isn’t valid YAML. gha-doctor
won’t guess at broken files; fix the syntax (actionlint gives precise
YAML errors) and re-run.