Two days ago GitHub emailed me to say one of my workflows had failed. The next day it emailed me again. I saw it, told myself I'd fix it tomorrow, and promptly forgot. It was my nightly database backup, quietly broken the whole time, and I only caught it because a failure-rate number nudged up.
A failed run at least gets you an email. A slow run gets you nothing. GitHub never pings you when CI quietly takes twice as long, runs the whole suite twice per PR, or rebuilds dependencies from scratch every time. That waste compounds where no one looks. Here are the usual culprits, each with the exact fix.
When I scanned 35 popular open-source repos, not one had a fully clean config. 32 of 35 had no concurrency control, 33 of 35 had no job timeouts, and 22 of 35 ran the full suite twice on every PR. If projects this polished leave minutes on the table, the rest of us definitely do.
Your suite runs twice on every PR (push + pull_request)
Trigger a workflow on both push and pull_request and, for a branch in the same repo, opening a PR fires both. You just paid for two identical runs. This one is pure waste and it can roughly halve your PR-related minutes. Trigger on pull_request, and keep push for your default branch:
on:
push:
branches: [main]
pull_request:
Old runs don't cancel when you push again (no concurrency group)
Push a fix 30 seconds after the first push and, with no concurrency group, both runs go to completion. The first is dead weight, and it is holding a slot in your queue while it finishes. This hides even when you do have a group: a concurrency block without cancel-in-progress queues the new run behind the old one instead of cancelling it, so the superseded run still finishes. Add a group keyed on the branch, with cancel-in-progress, so a new push supersedes the old run:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Every run reinstalls dependencies from scratch (no cache)
No cache means every run re-downloads and rebuilds your dependencies. On a typical Node or Python project that is roughly 30 to 90 seconds per run, every run, forever. The setup-* actions cache for free, you just have to ask:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
Your matrix is bigger than it needs to be
Every OS times every version multiplies your minutes. Most regressions show up on a single combination, so run a slim matrix on PRs and save the full grid for your default branch or a nightly run:
strategy:
matrix:
os: [ubuntu-latest] # add macos/windows only on main or nightly
node: [20]
A README typo runs your whole test suite (no paths filter)
Without a paths filter, any change triggers full CI, docs-only commits included. Scope the trigger to the files that actually affect the build:
on:
pull_request:
paths:
- 'src/**'
- 'package.json'
A hung job can run for six hours (no timeout-minutes)
With no timeout-minutes, a stuck step runs until GitHub's 6-hour ceiling. One wedged run can quietly eat a day of minutes. Cap every job:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
How to find which ones you have
Curious how the big projects do it? The showcase has real 30-day Actions scorecards for popular repos. Then point GitSpider at your own.
I build GitSpider, which scans a repo's GitHub Actions and flags exactly which of these apply, with the fix for each. Free, no install for public repos: gitspider.com/scan.
Top comments (0)