Skip to the content.

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 . 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 installnpm 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/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:

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:

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:

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:

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:

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.04ubuntu-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.