Why CI runs the same tests twice
Updated
CI runs the same tests twice because one change reaches it by several routes, and each route starts a run of its own. CI keeps no memory of the code it tested: it sees a new event, and starts from scratch.
Push and pull request
A workflow triggered on both events runs twice for a push to a branch with an open pull request:
on: [push, pull_request]Limit push to the branches that matter:
on:
push:
branches: [main]
pull_request:New pushes while CI is running
A branch that gets another push leaves its earlier run going, testing code nobody will merge. concurrency keeps one run per group, and cancel-in-progress stops the older one:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: trueKeep this for pull requests. On a branch that deploys, cancelling a run can stop a deploy halfway.
The merge
With the configuration above, a merge to main runs everything again, on files the pull request already tested when its branch was up to date. The commit is new, so nothing tells CI the files aren't. This is the repeat most repositories pay for on every change.
Some of that run is worth keeping. If main moved after the pull request's last run, the merged files are new, and they should be tested.
The merge queue
A merge queue tests each pull request against what's ahead of it, so it adds a run on purpose, on files that may not have existed before. Workflows need the merge_group event, or the required check never reports and the merge fails:
on:
pull_request:
merge_group:GitLab's merge trains do the same on its Premium and Ultimate tiers. When a queue holds one pull request, its files often match what already passed, so the run repeats work.
Staging, then production
With a develop branch for staging and main for production, each change is tested and built on develop, then tested and built again on main. The commits differ, but the files are the same. Here are two commits with the same source digest:
$ hushrun digest --rev develop sha256:bad90344…28f8 default $ hushrun digest --rev main sha256:bad90344…28f8 default
To stop rebuilding too, see Build once, deploy many.
Make the repeats cheap
Configuration closes the routes you can name. What's left, such as a merge of what the pull request tested, needs CI to recognize the code. hushrun run does that: it records each pass under the files, the check's name and its command, whichever commit or branch they came from. On the merge, CI finds the pull request's pass:
hushrun: test skipped: this exact code passed in CI 3 min ago (saves 41s)If main moved, the files differ, and the check runs. A pull request from a fork gets no ID token, so it records nothing, and its merge runs in full. A push with the same files still spends the job's checkout and install, but not the check.
Set it up with npx hushrun init, as the Quickstart shows.