I tagged a Docker image with ${{ github.sha }}, deployed it to staging, and then went looking for that commit in git log.
It wasn't there. Not on my branch, not on main, not anywhere I could find with a normal git log. My CI had built, tested and shipped a commit that nobody on my team had ever written.
That is how I learned the GitHub Actions pull_request event runs on a merge commit you never pushed. Once you know it, a bunch of weird CI behaviour suddenly makes sense.
TL;DR
- On the
pull_requestevent,github.shais not your branch's latest commit. It is a temporary merge commit GitHub builds from your branch plus the base branch, stored atrefs/pull/<number>/merge. -
actions/checkoutchecks out that merge commit by default, so your tests run against "what main would look like if this merged right now." - The branch tip you actually pushed lives in
github.event.pull_request.head.sha. Use it for image tags, commit statuses and version stamps. - With the default
fetch-depth: 1, the merge commit's parents aren't fetched, soHEAD^2,git describeand diffs against the base fail. - If the PR has a merge conflict, GitHub can't build that commit, and
pull_requestworkflows don't run at all.
What commit does GitHub Actions check out on pull_request?
For the pull_request event, GitHub Actions checks out a synthetic merge commit, not the head of your branch. GitHub sets GITHUB_REF to refs/pull/<number>/merge and GITHUB_SHA to the last merge commit on that ref.
You can see it yourself. Add this step to any PR workflow:
- uses: actions/checkout@v4
- run: |
echo "github.sha: ${{ github.sha }}"
echo "head.sha: ${{ github.event.pull_request.head.sha }}"
git log -1 --format='%H %s'
The output looks like this (shas shortened):
github.sha: 9f3c2a1...
head.sha: 4be70d8...
9f3c2a1... Merge 4be70d8... into 1d0e5c7...
Two different shas. The one your workflow thinks is "the commit" is a merge GitHub made on your behalf, with your branch tip and the base branch tip as its parents.
Why does GitHub test a merge commit instead of my branch?
Because it answers the question you actually care about before merging: "Will main still be green after this lands?" Testing only your branch tip would miss breakage caused by changes that landed on main after you branched.
That part is genuinely good design. The problem is that it's invisible. The workflow UI shows your PR, your branch name and your commit message, so everyone assumes CI ran on your commit. It ran on a cousin of it.
What breaks when github.sha is a merge commit?
Anything that treats github.sha as "the commit the developer pushed" quietly points at the wrong thing. These are the ones that bit me or people I work with:
1. Image and artifact tags. myapp:${{ github.sha }} gives you a tag that maps to no commit on any branch. When someone asks "what code is in this image?", the answer is a ref that GitHub may garbage-collect or recompute on the next push.
2. Commit statuses and checks posted by scripts. If you call the Statuses API with github.sha, the green check lands on the merge commit. Your PR's actual head commit stays grey, and branch protection waits forever for a status that went to the wrong sha.
3. Version stamps. Baking GIT_COMMIT=${{ github.sha }} into a build means your /version endpoint on a preview deploy returns a sha you can't git checkout locally unless you fetch the PR merge ref by hand.
4. Shallow history. actions/checkout defaults to fetch-depth: 1. The merge commit comes down alone, its parents don't. So:
$ git rev-parse HEAD^2
fatal: ambiguous argument 'HEAD^2': unknown revision
$ git describe --tags
fatal: No names found, cannot describe anything.
Tools that compute "changed files in this PR" or derive a version from tags fail, or worse, silently fall back to "everything changed."
5. Merge conflicts mean no CI. If your branch conflicts with the base, GitHub can't create the merge commit. Per GitHub's docs, pull_request workflows don't run when the PR has a merge conflict. You don't get a red X. You get nothing, which reads a lot like "CI is slow today."
How do I get my real branch commit in a pull_request workflow?
Use github.event.pull_request.head.sha. It is the tip of the branch the PR was opened from, the commit you actually pushed.
For tags and statuses, the smallest fix is one expression:
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
The || github.sha fallback keeps the same workflow working on push events, where github.sha really is the pushed commit and pull_request doesn't exist in the payload.
If you want CI to build and test your branch exactly as pushed, not the merge result, tell checkout:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
Be honest with yourself about the trade-off here. You gain reproducibility (the tested commit exists and anyone can check it out). You lose the integration check against the current base. For image builds and preview deploys, I use the head sha. For the test job that gates merging, I keep the default merge commit.
How do I diff a PR against its base in GitHub Actions?
Fetch two levels of history and diff the merge commit against its first parent. With fetch-depth: 2, you get the merge commit plus both parents: HEAD^1 is the base branch tip, HEAD^2 is your branch tip.
- uses: actions/checkout@v4
with:
fetch-depth: 2
- run: git diff --name-only HEAD^1 HEAD
That lists exactly what the PR would change on the base, which is what path-based test selection usually wants. It's much cheaper than fetch-depth: 0 on a big repo. If you need tags for git describe, you do need more history (or fetch-tags: true plus enough depth), because a two-commit window rarely contains a tag.
Does a green pull_request check mean main is safe?
Not necessarily. The merge commit reflects the base branch at the moment the workflow was triggered. Pushes to the base branch don't retrigger pull_request workflows on open PRs, so your green check can be hours or days stale relative to main.
Re-running the workflow won't fix it either. A re-run uses the same GITHUB_SHA and GITHUB_REF as the original run, so you're re-testing the old merge commit, not a fresh one against today's main.
Two real fixes exist:
- Require branches to be up to date before merging in branch protection. Developers have to update the branch, which pushes a commit and retriggers CI against the current base.
-
Use a merge queue. It builds a fresh merge of your PR on top of everything ahead of it in the queue and runs CI on that (via the
merge_groupevent, which you must add to your workflow'son:list, or the queue waits forever).
What about pull_request_target?
pull_request_target flips the behaviour: it runs in the context of the base branch, and actions/checkout checks out the base, not the PR and not the merge commit. People reach for it because it has access to secrets on fork PRs.
If you then set ref: ${{ github.event.pull_request.head.sha }} to test the contributor's code, you're running untrusted code with your secrets available. That is a well-documented way to leak tokens. If you don't need secrets, stay on pull_request.
My cheat sheet
After the staging incident I stuck this at the top of our workflows file:
| I want... | Use |
|---|---|
| The commit the developer pushed | github.event.pull_request.head.sha |
| The branch name | github.head_ref |
| To test "main + this PR" | default checkout on pull_request
|
| Files changed by the PR |
fetch-depth: 2 + git diff HEAD^1 HEAD
|
| A check that's not stale | merge queue or "require up to date" |
It took about fifteen minutes to fix every workflow in our repo. It took me most of an afternoon to understand why the image sha didn't exist, which is the real reason I'm writing this down.
So which commit does GitHub Actions pull_request actually run on?
On the pull_request event, GitHub Actions runs on a temporary merge commit at refs/pull/<number>/merge that combines your branch tip with the base branch as of the trigger time. github.sha points to that merge commit, and actions/checkout checks it out by default with only one commit of history. Your real pushed commit is github.event.pull_request.head.sha. Use the head sha for anything that must map to a commit people can find (image tags, statuses, version stamps), keep the merge commit for the tests that gate merging, and use a merge queue or "require up to date" if you need that green check to reflect the current base.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)