← keel

A squash merge silently reverted our release — and CI stayed green

17 August 2026 · a real incident, with the detection that eventually caught it

We published version 1.15.0. A day later it was gone from main — not reverted by anyone, not rolled back, just quietly absent. The git tag still pointed at 1.15.0. PyPI still served 1.15.0. Every test passed the entire time.

This is a write-up of what happened, why the usual detection does not work, and the check that does.

What actually happened

An accessibility pull request — adding ARIA labels to copy buttons — was branched before the release and merged after it. It touched sixty-odd files, because the tooling that generated its surfaces regenerated everything, including the version markers.

When it squash-merged, it wrote its whole view of those files onto main. Its view predated the release. So:

pyproject.toml       1.15.0 → 1.14.2
src/keel/__init__.py 1.15.0 → 1.14.2
CHANGELOG.md         the 1.15.0 section, deleted
git tag v1.15.0      unchanged
PyPI                 unchanged

The repository now disagreed with the artifact it had published, and nothing said so.

Why every check passed

This is the part worth internalising. We had a guard for exactly this: a test asserting that the version strings across the site and the code agree.

It passed — because the revert moved both sides together. The reverted state was internally consistent. A consistency check compares two things inside the repository, and a wholesale overwrite keeps them consistent with each other while making both wrong.

The general form: a check that compares your repository to itself cannot see a change that moved the whole repository. To catch that, something has to be compared against a fact outside it — a published artifact, a git tag, a registry.

Why git branch --merged does not help

The obvious instinct is to ask git which branches contain which commits. With squash merges, that question has no useful answer.

A squash merge creates a new commit whose content is the branch's tree. The branch's own commits never become ancestors of main. So git branch --merged reports the branch as unmerged, ancestry queries find nothing, and the tooling that relies on them is blind by construction.

The check that also does not work

Our first attempt compared the pull request's file list against the merge commit's file list, looking for files the merge touched that the PR did not claim.

It reported the real incident as clean.

The reason is subtle and worth knowing if you build this yourself: when you press Update branch, GitHub merges main into the branch before computing the diff you review. The reverting content is therefore inside the diff a reviewer reads and inside the PR's declared file list. The two lists agree. Nothing looks wrong.

What does work: compare the timing, not the content

The question that has a reliable answer is about ordering:

Did this merge write to a file that another pull request changed after this one branched?

That is the only shape a stale squash can take, and it is answerable from metadata alone — no ancestry, no diff heuristics:

for each merged PR A:
    for each merged PR B:
        if A.created < B.merged < A.merged and files(A) ∩ files(B):
            A may have overwritten B's work in those files

We ran this over 118 merged pull requests across two repositories. It produced six candidates. Five were benign on inspection — Dependabot rebases its branches, so its work survived — and the sixth was the incident, found by name.

Six candidates from 118 is a signal you can actually act on. A check that flags everything gets switched off.

The cheaper check that would have caught it first

The timing sweep is the general answer. There is a narrower one that costs almost nothing and would have caught this specific case on the very next push:

assert current_version >= highest_published_version

Compare the version in the repository against the highest release tag, or against what the package registry already serves. Being ahead of the last release is the normal state between releases; being behind one is never normal.

It is one comparison, it needs no history walking, and it anchors against something outside the repository — which is the property that makes it work.

One trap when you implement it. actions/checkout defaults to fetch-depth: 1 and fetch-tags: false, so git tag -l returns nothing on a runner. We shipped this check and it silently did nothing for every CI run — while printing a success line claiming it had compared against the latest tag. Set fetch-depth: 0, and make "no tags found" a failure rather than a pass. Cannot check and checked and fine must never print the same thing.

Takeaways

Running it yourself

keel ships this as keel verify-merge, which runs after a merge and reports drift by name:

pipx install keel-workflow
keel verify-merge .keel/project.yaml --root . --pr 780

It is open source and the detection logic is a few dozen lines of pure comparison — worth reading and reimplementing if you would rather not add a dependency. The point is the question it asks, not the tool that asks it.

← back to keel