I've been building a GitHub Actions linter and didn't want to trust my own heuristics, so I ran it on real projects first: the most-starred JavaScript and TypeScript repos with 1,000–20,000 stars that were active since September 2026. That gave 149 repos, and 139 of them had workflows.
I only checked three things, all boring and all cheap to fix:
- Dependency cache: do CI workflows re-download every package on every run?
- Concurrency on pull requests: when you push again to a PR, does the outdated run get cancelled?
-
Job timeouts: does each job have
timeout-minutes, or does it fall back to GitHub's default of 6 hours?
The results
| Check | Result |
|---|---|
| Repos with PR workflows that have no concurrency group | 97 of 129 (75%) |
CI jobs with no timeout-minutes
|
1,329 of 2,181 (61%) |
| Repos that install deps with no cache in at least one CI workflow | 41 of 121 (34%) |
| Repos that passed all three | 1 of 139 |
These are popular, well-maintained projects run by experienced people. That isn't a criticism of them. It shows how easy these defaults are to miss.
Fix 1: cancel outdated PR runs (2 lines)
Without a concurrency group, every push to a PR starts a full new run while the previous one keeps going. Add this at the top level of the workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
For deploy workflows, use cancel-in-progress: false. You never want to cancel a deploy halfway.
Fix 2: put a timeout on every job (1 line)
A test waiting on a port that never opens, or a --watch flag left on in CI, can hold a runner for 6 hours.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # about 2x the job's normal duration
Fix 3: cache dependencies (1 line)
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm # or yarn / pnpm
Since setup-node v5, npm projects get this automatically if package.json has a packageManager field set to npm. My scanner accounts for that.
What I deliberately didn't count
I wanted the numbers to be conservative, not scary:
- Local composite actions and reusable workflows are skipped for the cache check. I can't see inside them, and many of them do cache.
- Release, publish and deploy workflows are skipped. They often avoid caches on purpose, as hardening against cache poisoning.
-
Explicitly disabled caches (
package-manager-cache: false) are treated as a deliberate choice. - I checked 15 flagged repos by hand. After the first round I tightened a rule; in the second round, 6 of 7 were correct. The remaining one tests clean installs on purpose.
Bias warning: this is popular open source. Company repos may look better or worse.
Check your own repo
I turned the checks into a free web tool. Paste a public repo and get a 0–100 score with the exact file and line to change. No signup, and nothing is stored:
👉 devrescue.vercel.app
The method and the per-repo raw data are public: devrescue.vercel.app/research
If you think a heuristic is wrong, tell me in the comments. That's the most useful feedback I can get.
Top comments (1)
The conservative counting is what makes these numbers believable — hand-checking 15 flagged repos and skipping composite actions you can't see inside is the part most linter writeups skip. The bias warning too.
One edge worth flagging on Fix 1: the two-line snippet cancels in-progress runs for every trigger, not just PR pushes. Plenty of repos run CI and deploy jobs in one workflow (build → deploy behind an environment gate), and a second push to main would cancel the first run mid-deploy — the exact scenario your "deploy workflows use false" note warns about. Would you scope the linter's concurrency expectation to pull_request-triggered workflows, or recommend gating cancel-in-progress so mixed workflows don't need a whole-file false?
Smaller question on the cache check: a repo where the fast lint workflow intentionally skips cache (few deps, sub-minute install) but the test workflow caches gets flagged as a repo-level miss. Deliberate, or is per-workflow severity a future knob?