A deep dive into how GitHub Actions checks out your pull request code — and why it matters.
The Frustrating Scenario
You've been working on a feature branch for a few days. You run your tests locally:
pytest --cov
All green. You push your branch, open a pull request, and... CI fails. The same tests. The same code. But a different result.
You scratch your head, run the tests again locally — still green. What's going on?
The Root Cause: CI Doesn't Test What You Think It Tests
Here's the key insight most developers miss:
GitHub Actions doesn't test your branch in isolation. It tests what would happen if your branch were merged into
mainright now.
What you see locally
When you check out your branch and run tests, you're running against your branch's code — and only your branch's code. Your working tree looks exactly like your latest commit.
main: A --- B --- C --- D --- E (main has moved ahead)
\
your-branch: F --- G --- H (your work)
Locally, you're sitting on commit H. Your tests run against the snapshot at H. If your branch is internally consistent — your service code matches your models, your tests mock the right fields — everything passes.
What CI sees
When you open a PR, GitHub Actions runs actions/checkout@v4. This action doesn't simply clone your branch. For pull request events, it checks out a merge commit — a temporary commit that combines your branch with the latest main:
main: A --- B --- C --- D --- E
\ \
your-branch: F --- G --- H --------- M (merge commit)
Commit M is a blend of your code (H) and the current state of main (E). GitHub creates this merge ref automatically at refs/pull/<number>/merge.
This means CI is answering a different question than your local test run:
| Environment | Question being answered |
|---|---|
| Local | "Does my branch work?" |
| CI | "Will my branch work after it's merged into main?" |
A Real-World Example
Let's make this concrete. Say you're working on adding an action_type field to an API response.
On your branch, you:
- Added
action_typeto the Pydantic model:
class OaRResponse(BaseModel):
oar_title: str
action_type: str | None = None # new field
- Updated the service to populate it:
return OaRResponse(
oar_title=oar.oar_title,
action_type=oar.action_type, # new line
)
- Updated test mocks:
mock.action_type = "review"
Everything is consistent. Tests pass.
Meanwhile, on main, a teammate:
- Removed
action_typefrom the model and addedgeneration_contextandscoresinstead:
class OaRResponse(BaseModel):
oar_title: str
generation_context: dict | None = None # replaced action_type
scores: dict | None = None # new field
- Updated the service accordingly
- Added new tests that assert on
generation_context
When CI creates the merge commit:
Git tries to combine both sets of changes. Depending on which files conflict and how they merge, you might end up with:
- A service that sets
action_typeon a model that no longer has that field - Test mocks missing
generation_contextandscoresthat the service now requires - New tests from
mainthat assert on fields your service doesn't produce
The result: a Frankenstein codebase that neither you nor your teammate intended.
Under the Hood: How actions/checkout Works
Here's what actions/checkout@v4 does for pull requests, step by step:
- uses: actions/checkout@v4 # This one line hides a lot of complexity
- Clones the repository to the runner
- Detects the event type — for
pull_requestevents, it usesgithub.refwhich points torefs/pull/<number>/merge - Checks out the merge ref — this is a read-only ref that GitHub maintains, representing the result of merging your PR branch into the base branch
- The runner now has a working tree that represents the merged state
You can verify this by adding a debug step to your workflow:
- name: Debug ref
run: |
echo "github.ref: ${{ github.ref }}"
echo "github.sha: ${{ github.sha }}"
git log --oneline -5
For a PR, you'll see something like:
github.ref: refs/pull/42/merge
github.sha: abc123def456
abc123d Merge <sha> into <sha> ← the merge commit
1234567 Your latest commit
89abcde Main's latest commit
Why GitHub Does This
This design is intentional and actually protects you. Consider the alternative — if CI only tested your branch in isolation:
- Your branch passes CI ✅
- Your teammate's branch passes CI ✅
- You both merge to
main mainis now broken 💥
Nobody did anything wrong individually, but the combination of changes is broken. By testing the merge result, GitHub catches this before the merge happens.
How to Fix It (and Prevent It)
Immediate fix: Rebase onto main
git fetch origin
git rebase origin/main
# Resolve any conflicts
git push --force-with-lease
After rebasing, your branch starts from the latest main. The merge commit in CI becomes trivial (no divergence to reconcile), and your local environment matches what CI will test.
Prevention strategies
1. Rebase frequently
If your branch lives for more than a day or two, rebase onto main regularly:
git fetch origin && git rebase origin/main
2. Enable branch protection rules
In your repo settings, enable "Require branches to be up to date before merging". This forces contributors to update their branch before the merge button becomes available.
3. Watch for high-traffic files
If you're modifying files that change frequently (models, shared utilities, API schemas), expect merge issues. Communicate with your team about who's touching what.
4. Keep branches short-lived
The longer your branch lives, the more main diverges, and the higher the chance of this problem. Aim for small, focused PRs that merge within a day or two.
Key Takeaways
| Concept | Detail |
|---|---|
| Local tests | Run against your branch in isolation |
| CI tests | Run against a merge of your branch + latest main |
| The merge ref | GitHub maintains refs/pull/<N>/merge automatically |
| Why it exists | To catch integration issues before they reach main |
| The fix | Rebase your branch onto main to eliminate divergence |
The next time your tests pass locally but fail in CI, don't assume it's a flaky test or an environment issue. Check how far your branch has diverged from main:
git log HEAD..origin/main --oneline | wc -l
If that number is anything other than zero, you likely have a merge integration problem — and a rebase is your friend.
Understanding this CI behavior turns a frustrating debugging session into a 30-second diagnosis. The merge commit isn't a bug — it's a feature that keeps your main branch healthy.