ci-doctor rules reference

Every rule that ci-doctor runs against your .github/workflows/*.yml files. 14 rules, three severities, three categories (cost, security, reliability). The four marked with --fix can be auto-applied. Run npx ci-doctor in any repo to see your own findings.

Contents

  1. missing-concurrency
  2. missing-timeout
  3. missing-cache
  4. missing-permissions
  5. pinned-action-sha
  6. deprecated-action
  7. expensive-runner
  8. matrix-overcommit
  9. stale-cache-key
  10. fail-fast-true
  11. always-run-on-pr
  12. artifact-no-retention
  13. fetch-depth-zero
  14. wide-trigger

missing-concurrencywarn

Workflows triggered on push or pull_request should declare a concurrency group with cancel-in-progress: true. Auto-fixable with --fix.

on:
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
on:
  pull_request:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
Why: Without this, every push to a PR branch starts a new run while the previous one keeps running to completion. On a busy PR with 5 force-pushes you pay for 5 full CI runs instead of 1.

missing-timeoutwarn

Jobs without timeout-minutes default to 360 (6 hours). A runaway job burns minutes you pay for. Auto-fixable with --fix.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - run: npm test
Why: A test that hangs because of a flaky network call or an infinite loop will eat all 360 minutes. On private repos that's billable. Set this to 2-3x your expected job time.

missing-cachewarn

setup-* actions without a cache option re-download dependencies on every run.

- uses: actions/setup-node@v4
  with:
    node-version: 20
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
Why: Re-downloading node_modules from npm registry costs ~30-60 seconds per job. With cache: npm the second run pulls from GitHub's cache in ~3 seconds. setup-python, setup-go, setup-java all support the same flag.

missing-permissionswarn

Without a top-level permissions block, GITHUB_TOKEN gets the repository default - usually write-all.

on:
  pull_request:
jobs:
  test: ...
on:
  pull_request:
permissions:
  contents: read
jobs:
  test: ...
Why: A compromised dependency in any third-party action can use GITHUB_TOKEN to push to main, create releases, or delete branches if permissions are wide. Set the workflow's permissions to the minimum it needs.

pinned-action-shawarn

Third-party actions should be pinned to a full commit SHA, not a tag or branch. Use npx pin-actions to bulk-fix.

- uses: tj-actions/changed-files@v45
- uses: tj-actions/changed-files@a284dc1814e3fc6e30d9f170b3d17d61b4a7156c # v45.0.4
Why: The Jan 2024 tj-actions/changed-files compromise replaced the published tag with a payload that exfiltrated secrets. Repos that pinned to a SHA were unaffected. actions/* from GitHub itself is the only safe-to-tag namespace.

deprecated-actionerror

Pinned to a deprecated major version (Node12/16, set-output, save-state, ::set-env). GitHub will start failing these.

- uses: actions/checkout@v2
- run: echo "::set-output name=x::y"
- uses: actions/checkout@v4
- run: echo "x=y" >> "$GITHUB_OUTPUT"
Why: GitHub has already removed ::set-output from new runners and Node12-based actions stopped working in 2023. Node16 sunset is in progress. Pinning to a v2 of an official action is a ticking time bomb.

expensive-runnerwarn

macos-* runners cost 10x and windows-* costs 2x ubuntu. Use them only when platform-specific commands are present.

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test
Why: A 10-minute job on macos-latest costs $0.80, the same job on ubuntu-latest costs $0.08. If your code does not call xcodebuild, codesign, or use macOS-only APIs, drop the macOS runner.

matrix-overcommitwarn

A matrix that crosses many OS or version axes can multiply CI minutes silently.

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [16, 18, 20, 22]
    pg:   [13, 14, 15, 16]
strategy:
  matrix:
    os: [ubuntu-latest]
    node: [18, 20, 22]
  include:
    - os: macos-latest
      node: 20
    - os: windows-latest
      node: 20
Why: 3 x 4 x 4 = 48 jobs per run. Most teams want full coverage on Linux + LTS, plus a smoke test on the others. Use include: to add specific cells without multiplying everything.

stale-cache-keywarn

actions/cache step has a key that does not include a lockfile hash, so the cache never invalidates when deps change.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-cache
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
Why: A static cache key means GitHub keeps serving the same cache forever, even after you update package-lock.json. Builds get stale dependencies and you are paying for cache storage that never gets refreshed.

fail-fast-trueinfo

Matrix job uses default fail-fast: true, which kills sibling jobs on first failure - wasting their already-billed minutes and hiding parallel failures.

strategy:
  matrix:
    node: [18, 20, 22]
  # fail-fast defaults to true
strategy:
  fail-fast: false
  matrix:
    node: [18, 20, 22]
Why: When the Node 18 job fails 30 seconds in, the 20 and 22 jobs get cancelled mid-run. You still pay for those 30 seconds and you lose the signal of "does it also fail on 20 and 22?". For matrices < 6 cells, fail-fast: false is almost always the right call.

always-run-on-prinfo

A heavy step (docker build, e2e, codeql) runs on every PR with no paths: filter, no label gate, and no condition. It runs whether or not the PR touched anything that matters to it.

on:
  pull_request:
jobs:
  e2e:
    steps:
      - uses: cypress-io/github-action@v6
        with:
          spec: cypress/e2e/**/*.cy.ts
on:
  pull_request:
    paths:
      - 'src/**'
      - 'cypress/**'
      - 'package*.json'
jobs:
  e2e:
    steps:
      - uses: cypress-io/github-action@v6
        with:
          spec: cypress/e2e/**/*.cy.ts
Why: A docs-only PR should not trigger a 12-minute E2E run. Use paths: filters or if: contains(github.event.pull_request.labels.*.name, 'needs-e2e') to gate expensive steps.

artifact-no-retentioninfo

upload-artifact without retention-days uses the repo default (often 90 days) and bills storage for the full window.

- uses: actions/upload-artifact@v4
  with:
    name: build
    path: dist/
- uses: actions/upload-artifact@v4
  with:
    name: build
    path: dist/
    retention-days: 7
Why: CI build artifacts are almost always disposable - you rebuild them on every PR. Holding 90 days of every build at $0.25/GB-month adds up fast on private repos.

fetch-depth-zeroinfo

actions/checkout with fetch-depth: 0 pulls full history. Slow and rarely needed.

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- uses: actions/checkout@v4
  # default fetch-depth: 1 (just HEAD)
Why: fetch-depth: 0 downloads every commit ever. Tools like semantic-release, git diff main...HEAD, and changelog generators need it - but most jobs do not. If you only need the diff against the base branch, use fetch-depth: 2 or fetch the base ref selectively.

wide-triggerinfo

on: push without a branches filter runs the workflow on every branch push.

on:
  push:
on:
  push:
    branches: [main]
  pull_request:
Why: Without a branches filter, every feature branch push runs the workflow once on push and again on the PR open. Restrict push to your protected branch and let pull_request handle the rest.

Run all 14 rules in 6 ms

npx ci-doctor in any repo. Zero config. Or pipe to GitHub Code Scanning with npx ci-doctor --sarif > results.sarif.

View on npm   Source on GitHub

Want the full pattern set?

The Cut Your CI Bill cookbook is 30 paste-ready GitHub Actions patterns plus 5 hardened workflow templates - the long-form versions of these rules with edge cases handled. $19, one-time, MIT-licensed templates.

Get the cookbook   Free preview (5 patterns)

See also: paste-and-audit your YAML · cost estimator · how 20 popular OSS repos score · per-repo deep dives