Catching semantic conflicts in CI Jump to heading
Two pull requests pass CI. Both are approved. Both merge within an hour of each other, without a conflict. Main is now broken, because one renamed a function the other started calling, or one changed a default the other depends on. Neither pull request was wrong; their combination was, and CI never tested the combination. Fixing this is mostly configuration: test the merged result rather than each branch, make sure the tested result is the one that lands, and add a handful of fast static checks for the shapes semantic conflicts usually take. This page sets up all three, within semantic conflicts and merge verification.
When to use this approach Jump to heading
- Main has broken after merging pull requests that were each green.
- Bisect has landed on merge commits whose two sides each pass, as described in bisecting across merges with --first-parent.
- Your CI checks out the pull requestβs head commit rather than its merge with the base branch.
- Several pull requests often merge in quick succession on the same branch.
Step 1 β Check what your CI actually tests Jump to heading
Many pipelines test the pull request head, not the merged result. Find out which yours does by printing the checked-out commitβs parents in a job.
# In a CI job on a pull request
git log -1 --format='commit %h, parents: %p'
# one parent -> testing the branch head (does not see main's newer changes)
# two parents -> testing a merge of the branch into its base On GitHub, actions/checkout in a pull_request workflow checks out the synthetic merge ref by default. Pipelines that pass ref: ${{ github.event.pull_request.head.sha }} β often added to make something else work β lose that. GitLab offers merged-results pipelines for the same purpose.
Step 2 β Close the window between test and merge Jump to heading
The merge ref is computed when the pull request is updated. If another pull request merges afterwards, the tested combination is no longer what will land. Three options close the gap, with different costs.
# Require branches to be up to date before merging (classic protection, strict checks)
gh api -X PATCH "repos/$OWNER/$REPO/branches/main/protection/required_status_checks" -F strict=true For busy branches, a merge queue is the better tool: it batches pull requests, builds the combined result, and lands only what passed. Setting one up is covered in setting up a GitHub merge queue, and GitLabβs equivalent in GitLab merge trains.
Step 3 β Add fast static checks on the merged tree Jump to heading
Some semantic conflicts have recognisable shapes that a type checker or a few lines of script catch in seconds, before the test suite runs. Run them as their own job on the merge result, so they report quickly.
jobs:
merge-sanity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # the merge ref
with: { fetch-depth: 0 }
- name: Type check the merged code
run: mypy src/ || npx tsc --noEmit
- name: Duplicate migration numbers
run: |
d=$(ls db/migrations | sed -n 's/^\([0-9]\{4,\}\)_.*/\1/p' | sort | uniq -d)
[ -z "$d" ] || { echo "duplicate migrations: $d"; exit 1; }
- name: Duplicate route paths
run: |
d=$(grep -rhoE "path\(['\"][^'\"]+" src/routes | sort | uniq -d)
[ -z "$d" ] || { echo "duplicate routes: $d"; exit 1; } A type checker is the single most effective tool here: renamed functions, changed signatures and removed attributes are all type errors in the merged code, even though neither branch had any.
Step 4 β Write tests that exercise interactions Jump to heading
Static checks catch structural breakage. Behavioural semantic conflicts β a changed default, a changed ordering assumption β need tests that exercise one module through another. Look at past incidents for the interactions that broke, and add a test at each.
# tests/integration/test_renewal_billing.py
def test_renewal_charges_through_current_billing_api(account_factory, billing):
account = account_factory(plan="pro")
renew_subscription(account) # subscriptions module
assert billing.charges_for(account) == [account.plan.price] # billing module Tests like this are what fail on the merge result when one side changed the billing API and the other added a new renewal path. They are cheap insurance on the boundaries that change most often.
Step 5 β Recognise and fix a semantic conflict that got through Jump to heading
When main breaks after a clean merge, confirm it is a semantic conflict: each side passes on its own, the merge fails. Then fix forward with a small commit that reconciles the two changes, rather than reverting one side.
m=$(git log --merges -1 --format=%h main)
git checkout -q "$m^1" && make test >/dev/null && echo "main before merge: pass"
git checkout -q "$m^2" && make test >/dev/null && echo "branch alone: pass"
git checkout -q "$m" && make test >/dev/null || echo "merge: FAIL β semantic conflict"
git checkout -q main Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does testing the merge ref slow CI down? Jump to heading
Not by itself β it replaces testing the head. The extra cost comes from re-testing when main moves, which is why busy branches use merge queues that batch several pull requests per run.
Why not just run CI again after every merge to main? Jump to heading
Do that too; it detects breakage quickly. But it detects rather than prevents, so main is broken until someone reverts or fixes forward. Testing the merge before landing keeps main green.
Can a type checker catch semantic conflicts in dynamic languages? Jump to heading
Only as far as the code is annotated. Even partial typing catches most renamed and removed functions. Where there are no types, a smoke test that imports every module and exercises main entry points catches a surprising share.
Related Jump to heading
- Semantic Conflicts & Merge Verification β the parent topic.
- Previewing Merges with git merge-tree β building merge results outside the forge.
- Choosing Required Status Checks That Actually Gate β making the merge-result job required.
- Keeping Trunk Green β the wider practice this supports.