Running GitHub Actions Efficiently

I recently went through the GitHub Actions configuration in every repository I own, which is somewhere north of seventy, and tuned them all in one pass. Doing the same job that many times in a row is clarifying. You stop debating and start noticing which changes actually move the number.
This is the checklist that came out of it, in the order I’d apply it.
Start with the runner OS, because nothing else comes close
GitHub bills runner minutes with a multiplier per operating system. Linux is 1x, Windows is 2x, and macOS is 10x. That last one is the single most important fact about Actions pricing, and it dominates everything below it: one minute on a macOS runner costs the same as ten minutes on Linux.
So before you optimize anything, go find the jobs running on macOS that don’t need to be. In my case the offenders were all the same shape: a Swift or iOS project where the build genuinely requires macOS, and then a linter, a JSON validation step, and a markdown check riding along on the same runner because that’s where the workflow already was. Every one of those moved to Linux for a tenth of the cost and no loss of coverage.
The rule I’d write down: a job belongs on macOS only if it invokes a tool that exists nowhere else. Xcode, yes. ruff, no.
Stop double-firing
The default workflow trigger many of us copy looks like this:
on:
push:
pull_request:
On a pull request from a branch in the same repository, that runs the entire workflow twice, once for the push to the branch and once for the pull request. You are paying double for every commit on every open PR and getting one extra red or green checkmark for it.
The fix is to scope push to the branches where you actually want post-merge validation:
on:
push:
branches: [main]
pull_request:
Now a PR commit runs the suite once, and the merge to main runs it once more. Add paths-ignore for documentation-only changes if your suite is slow and your docs are chatty.
Cancel superseded runs
If you push three times in ten minutes, the first two runs are worthless the moment the third exists, and they keep billing until they finish. One block fixes it:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
The important part is where not to put this. Do not add it to a publish or release workflow, and do not add it to a Pages deploy. Cancelling a half-finished publish is how you get a partial release or a broken site, and the minutes you save are the cheapest minutes you spend all month. Test, lint, and docs-build workflows: yes. Anything that writes to the outside world: no.
Move the expensive scans off the PR path
In most of my repositories the single most expensive per-PR job was a full CodeQL analysis. It’s genuinely valuable and it does not need to run on every commit of every branch.
Match scan frequency to how fast the risk changes. Code scanning belongs on pushes to your default branch plus a weekly schedule. Dependency review, which is fast and answers a question specific to the diff, belongs on the pull request. The distinction I use: if the scan’s answer depends on the diff, run it per PR. If it depends on the codebase as a whole, run it per merge or on a schedule.
Cache, keyed on the lockfile
The setup-* actions have caching built in, and it’s usually one line rather than a cache block you configure yourself:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
The key detail regardless of ecosystem is that the cache key must be derived from your lockfile, so it invalidates exactly when dependencies change and not otherwise. That applies equally to uv, npm, Go modules, Cargo, SwiftPM, and Docker layers. If you’re on macOS anyway, HOMEBREW_NO_AUTO_UPDATE=1 is free money, because a Homebrew auto-update on a 10x runner is a genuinely expensive way to do nothing.
Matrix discipline
The full support matrix is a merge-time and nightly question, not a per-commit one. On a pull request, run the oldest and newest supported interpreter. On main and on a nightly schedule, run everything. You keep the coverage that tells you about a version-specific break, and you stop paying for five interpreters to disagree about a typo.
Watch the schedule, not just the push
Two things I found repeatedly that had nothing to do with pull requests at all:
Crons bill around the clock whether or not anybody is working. A five-minute uptime check every fifteen minutes is roughly 14,000 minutes a month. Uptime monitoring does not belong on Actions; use something built for it, most of which is free at this scale.
Cron schedules are in UTC, so they drift against your local clock twice a year. If you have a job at what you believe is 8am local, it will be 7am or 9am for half the year, and a job specified in both a morning and evening slot can fire twice in one local day around the transition. Pick UTC deliberately and note it in a comment.
Failures and hangs still bill
A job that fails at minute 40 costs the same as one that succeeds at minute 40, so order your steps cheapest-first: lint before test, unit before integration, and fail the workflow the moment the cheap check says no. Then set timeout-minutes on every job, because the default is six hours, and a hung process will happily bill all of it.
How to actually measure
Do not guess at any of this. Two places give you real numbers:
- The billing page breaks minutes down by repository and by runner OS, which is where you find the surprise macOS job.
- The API endpoint
/repos/{owner}/{repo}/actions/runs/{run_id}/timinggives you per-job billable milliseconds for a specific run, which is how you confirm a change did what you thought.
The order that matters
If you only do part of this, do it in this order:
- Find your top three workflows by minutes.
- Move everything that doesn’t need macOS off macOS.
- Stop the push and pull_request double-fire.
- Add
cancel-in-progresseverywhere except publish, release, and deploy. - Then cache.
Caching is the change everybody reaches for first and it’s fourth on the list, because the multiplier and the double-fire are multiplicative wins and the cache is an additive one.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.