Chapter 4 of 11. Prerequisite: Chapter 3 gave you branches worth protecting. This chapter is about the gate every one of those branches has to pass through before its content is allowed near
main: the pull request.
I work alone on most of what ships to captainrandom.co.uk. There is no second reviewer waiting to approve anything. And every change still goes through a PR. Not because a rule says so, because the PR is the only place three things happen at once: CI runs against exactly the diff that's about to land, the diff gets a stable identity (a number, a URL, a set of check runs) that outlives the branch, and the merge itself becomes a single, loggable event instead of a git push that vanishes into main's history indistinguishably from every other push. Solo doesn't remove the need for a unit of shipping. It just means you're the only reviewer, so the machine-checked parts of the gate have to do more work.
This chapter is about what that gate actually does under the bonnet: what a squash merge does to your commits, why waiting for green is a real discipline and not a platitude, and a gotcha in concurrent Actions runs that cost me two same-day surprises before I understood it properly.
Squash merge: one commit, and what disappears
captainrandom.co.uk, like most of my solo repos, merges every PR with squash. GitHub's own definition is exact: squash merge means "the pull request's commits are squashed into a single commit" on the base branch, and the documented tradeoff is stated just as plainly: "intermediate commits from the pull request are not preserved as separate commits on the base branch" (About pull request merges).
Read that twice, because it's easy to nod past. It doesn't say the commits are hidden, or collapsed-but-recoverable, or folded into a merge commit you can expand later. It says they are not preserved on the base branch. Whatever sequence you committed on your feature branch, whatever "wip", "fix typo", "actually fix it" trail you left while iterating, none of that survives the squash. main gets one commit, with one message (usually the PR title), with one SHA. The five commits you made getting there still exist on the feature branch itself, until you delete it.
That's the operative word: until. Which is where the illusion starts.
The ahead-1 illusion on your local main
Here's the sequence that catches people, including me, more than once. You open a PR from a feature branch, it gets squash-merged on GitHub, and you go back to your terminal. git fetch && git log --oneline main..my-feature-branch shows your branch is still "ahead" of main by five commits. git branch --merged main doesn't list it. Every graph-based signal you'd normally use to ask "is this branch's work already on main" says no.
It's a trick of the graph, not a trick of the content. The squash commit on main has a brand new SHA; it shares no ancestry with your five feature-branch commits, so Git's merge-tracking (which walks commit ancestry) can't see the relationship. But the content of those five commits, the actual file changes, is sitting on main right now, inside that one squash commit. The branch looks unmerged. The work is merged. Those are different facts and the graph only tells you the first one.
I found this the expensive way on a Saturday branch cleanup: six local branches showed commits no longer reachable from main by the graph, and my first instinct was that six PRs' worth of work had gone missing. It hadn't. Every one of those branches' content was already on main, squashed into six single commits, one per branch. Deleting a branch after a squash merge is safe, but only once you've verified it by content (does main already contain this branch's actual changes, checked via git diff or just trusting that the PR you opened is the PR that shows merged on GitHub) rather than by the local branch-graph, which will lie to you about ancestry every single time.
Wait for green, never merge on red
Squashing is a mechanical fact about history. The discipline that actually protects you is upstream of it: don't merge a PR whose checks haven't passed. GitHub's protected-branch model makes this enforceable rather than a habit you have to remember: with required status checks turned on, "all required status checks must pass before collaborators can merge changes into the protected branch," and a passing state means the check reported "successful," "skipped," or "neutral" (About protected branches). Red doesn't merge. Not "red merges with a note to fix it later." It cannot merge, full stop, if the branch protection rule is configured that way.
Solo, it's tempting to treat this as theatre, since you're both the author and the only approver. It isn't theatre. The check is running the actual build, the actual typecheck, against the actual diff that's about to become main. Merging on red means you now have a broken main, and on a static-export site fed by a single FTP deploy job, a broken main is a broken production site, because the very next successful deploy run is what actually ships. There's no staging environment absorbing the mistake. The PR gate is the only thing standing between a red build and a live one.
The same page notes a detail worth knowing before you rely on it: required checks can be strict or loose. Strict checks require your branch to be up to date with the base branch before merge; loose checks don't: "status checks may fail after you merge your branch if there are incompatible changes with the base branch." If you're merging PRs in quick succession, a loose check can tell you a PR is green when it was only tested against a main that's already a commit or two stale. Strict is the safer default for a single-maintainer repo, because you're not paying a meaningful review-latency cost for the extra rebase-and-recheck.
Cancelled is not failed
Somewhere in the middle of "wait for green" is a distinction most people never have to learn, because it rarely bites: a check that shows as cancelled on a PR is not the same claim as a check that failed. Failed means the code ran and something was wrong. Cancelled means the run stopped before it finished, for a reason that has nothing to do with correctness.
I learned this on a PR that touched nothing but component code. Its CI failed, but the failure was at the workflow-file level, on a workflow the PR had never touched. That's a strong signal by itself: a PR that touches component code failing on a workflow-definition step is much more likely to be transient infrastructure than a real regression in the diff. I reran the job. It passed clean, on the same commit, with no changes. That confirmed it: the original run was a flake, not a verdict on the code.
The part worth carrying forward is what it did to my tooling, not just my judgement. The merge-watcher I'd been using polled a pr-checks endpoint to decide when a PR was safe to merge. During the incident, that endpoint returned empty, and the watcher had no way to distinguish "no data yet" from "checks are still running" from "checks are done." It wedged. Afterward I rewrote it to poll the run directly rather than trust an aggregate endpoint that can go quiet without telling you why. If your automation's definition of "green" comes from a single summary field, ask what that field does when the underlying run is cancelled rather than failed. Silence and failure look identical to a watcher that only checks one flag.
The concurrency gotcha: merging PR B cancels PR A's deploy
The deploy workflow on this site carries a concurrency block that looks small and reads as an optimisation:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
GitHub's own docs are direct about what that second line does: cancel-in-progress: true will "cancel any currently running job or workflow in the same concurrency group" (Using concurrency), and that setting cannot be combined with a queued-max strategy, it's one or the other. The intent behind the setting is sensible: if you push twice to main in quick succession, you don't need the first, now-stale deploy to keep running, you want the newest one.
The trap is in what the group key actually is. ${{ github.ref }} for a push to main resolves to refs/heads/main for every single deploy run, regardless of which PR triggered it. That means the concurrency group isn't scoped per-PR, it's scoped to the branch. Merge PR A, its deploy starts running against refs/heads/main. Merge PR B thirty seconds later, before A's deploy has finished, and B's push lands in exactly the same group. cancel-in-progress does what it says: A's still-running deploy gets cancelled outright, not queued, not finished-then-superseded. The next deploy run that actually completes ships both PRs' changes at once, because by the time anything finishes, main already contains both.
Functionally this is usually fine, since the end state (the newest main, fully deployed) is what you wanted anyway. Where it stops being fine is the audit trail: if you're watching for "did PR A's deploy succeed" as your confirmation that A shipped, you'll see a cancelled run and, per the previous section, a cancelled run is not a failed one, but it's also not evidence that A's specific deploy ever completed in isolation. It completed bundled with B. That's a fact worth knowing before you go looking for a deploy log that proves A shipped alone, because it doesn't exist.
Two cancellations in one day, and what the merge taught
I hit this twice in the same day, both times from the same cause: merging a second PR while the first PR's deploy job was still mid-flight. Both times the visible symptom was identical, an Actions run for the earlier PR showing cancelled rather than a green tick, in a UI that at a glance looks like something went wrong.
The second time is the one that mattered, because I nearly acted on the wrong read of it. I saw the cancelled run, assumed it meant the deploy hadn't gone out, and queued up a manual merge action against what I thought was a stuck PR. It wasn't stuck. It had been superseded by the concurrency cancellation, and the very next run on main, the one triggered by the PR I'd just merged, already carried both changesets to the host. The manual action I'd been about to take would have been redundant at best. What stopped me was applying the same check from the cancelled-vs-failed section above: look at the run's actual conclusion, not the colour of the badge, and check whether a later successful run on the same ref already covers the content in question. It did. Nothing needed redoing.
The lesson generalises past this one repo. If your deploy pipeline uses cancel-in-progress keyed to a branch ref rather than a PR number, expect cancellations whenever merges land close together, treat a cancelled run as "ask what happened next on this ref" rather than "this needs re-running," and never issue a manual recovery action against a run before confirming with gh run view or the Actions UI whether it's cancelled, failed, or already superseded by something newer that succeeded. Confirm before you act. The five minutes it takes to check gh run list --branch main --limit 5 is cheaper than an accidental duplicate deploy.
The transferable rule
None of this changes if a human never opens the PR. On a fully autonomous editorial pipeline that drafts and opens PRs with no human author, PR #97 was still blocked by a duplicate-fingerprint gate (G-L3) before it could merge, exactly the same kind of gate a human-authored PR goes through. The gate doesn't care who or what proposed the change. It cares whether the diff, as it actually stands right now, passes the checks that are required to pass. That's the whole point of making the PR the unit of shipping rather than the commit, or the branch, or the author's judgement: it's the one place where "this specific diff, checked, is what's about to become production" is a fact you can verify, not an assumption you're trusting.
Trace one of your own squash merges by content, not by graph
Pick a feature branch in one of your repos that you already squash-merged and haven't deleted yet. Prove to yourself, using content rather than the local branch graph, that its work is genuinely on main before you delete it.
Expected behaviour
- `git log --oneline main..<branch>` still shows the branch as ahead, demonstrating the ahead-1 illusion for yourself
- `gh pr view <branch> --json state,mergedAt` confirms GitHub's record of the merge independent of your local graph
- A `git diff main <branch>` (or manual inspection of the changed files) shows no remaining delta, confirming the content is already on main
- The branch is deleted only after step 3 confirms zero content delta, not after step 1 or 2 alone
PROVE IT gh pr view <branch> --json state,mergedAt,number after deleting the branch, showing MERGED true against a branch that no longer exists locally.
After a squash merge, `git log main..my-branch` still shows five commits ahead. What does that actually tell you?
What is the exact GitHub Docs wording for what happens to intermediate commits under a squash merge?
You merge PR B thirty seconds after merging PR A. PR A's deploy run, still in progress, shows as cancelled rather than green. Walk through what you should check before taking any recovery action, and why.
Show answer
First confirm the run's actual conclusion is cancelled, not failed, since those mean different things: failed means something was wrong with the code, cancelled means the run was pre-empted, usually by a concurrency group with cancel-in-progress set. Then check whether the deploy workflow's concurrency group is scoped to the branch ref (like refs/heads/main) rather than per-PR, which explains why merging B pre-empted A's run in the same group. Finally check whether a later run on main has already succeeded, since that run will contain both A's and B's changes bundled together, meaning A's work has already shipped even though its own deploy run never finished. Only after confirming all three should you decide no recovery action is needed.