ciblame

How to reduce GitHub Actions minutes

Most wasted Actions minutes come from a handful of patterns. Each change below is a few lines of YAML, and none of them reduce what you test before merging.

Cancel runs that a newer push replaced

When someone, or an agent, pushes twice in a row, the first run is already out of date. Cancel it:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This matters most for agents, which often push several times in a few minutes. Don't use it on workflows that deploy, where cancelling half-way can leave things in a bad state.

Don't run full CI on draft pull requests

Agents often open pull requests as drafts while they work. Run the cheap checks on drafts and the full suite when the pull request is marked ready:

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  test:
    if: github.event.pull_request.draft == false

Only run what the change can affect

Documentation changes don't need the integration tests:

on:
  pull_request:
    paths-ignore:
      - "**.md"
      - "docs/**"

If a check is required for merging, skipping it with paths-ignore leaves the pull request waiting. In that case, keep the workflow running and skip the expensive jobs inside it instead.

Set a timeout on every job

A job stuck on a hung test runs until the default limit of 6 hours. Set a realistic limit:

jobs:
  test:
    timeout-minutes: 20

Cache dependencies

Downloading and building dependencies on every run is often the slowest part of a job. Most setup actions can cache for you:

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm

actions/setup-python, actions/setup-go and actions/setup-java have the same option. For anything else, use actions/cache.

Run the full matrix less often

Testing every OS and language version on each push to a pull request multiplies minutes, and macOS runners cost ten times as much as Linux. Run one combination on pull requests and the full matrix on the default branch or in the merge queue:

strategy:
  matrix:
    os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "windows-latest", "macos-latest"]') }}

Batch dependency updates

Dependabot opens one pull request per dependency by default, and each one runs your CI. Group them:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: "/"
    schedule:
      interval: weekly
    groups:
      all-dependencies:
        patterns: ["*"]

Renovate can do the same with groupName, a schedule, and prConcurrentLimit.

Check your scheduled workflows

A nightly job that runs for 40 minutes is 1,200 minutes a month, whether or not anyone reads its results. Look at each schedule: trigger and ask whether it still needs to run that often.

Find out where the minutes go first

These changes help most where most of the minutes are. Find which workflows use the most minutes before you start, and measure again after.

More guides