Journal
5 min read

Green means proven green

My first merge guardrail let a PR through with no tests at all. How a one-line rule became a gate in Python, and what it refuses today.

"We only merge when CI is green."

Everyone has that rule. I had it too, written in plain words in my agents' instructions. And for weeks, nothing in my code checked it.

When I built Foundry, that was the first thing I wanted to fix: take the rule out of the text and put it in the code. A gate, in Python, that every merge has to go through.

The first version of the gate was wrong. I knew it the same day.

Zero tests, so zero failures

The starting rule: the merge is refused unless all checks have succeeded.

Sounds solid. Now take a PR where CI hasn't started yet. How many failed checks? Zero. So all checks have succeeded. So it's green.

An empty list contains no failures. It contains no proof either.

Review turned up a second one, nastier. On a PR that only touches documentation, my workflows are filtered by path: every job is skipped. Nothing red, nothing pending. Green, again.

And a third. GitHub has two ways of reporting a CI result: the recent API, and an older one that Jenkins or CircleCI still use. My gate only read the recent one. A red build on the old one looked exactly like "no CI".

Three holes in a one-line guardrail.

Too strict, too

The reflex is to tighten: require that everything explicitly succeeded.

Except that breaks healthy CI. A conditional job that doesn't run is marked "skipped", and that's it working normally. Requiring success everywhere means blocking legitimate merges every day. And a guardrail that blocks for no reason is one you end up working around.

The rule, the real one

I ended up writing it down as an architecture decision, so I'd never come back to it:

  • at least one check that really succeeded, across all sources;
  • nothing pending — we wait;
  • nothing failing — we stop;
  • skipped checks are tolerated alongside a real success, but never prove anything on their own;
  • both APIs are read and merged.

In code, the core fits in two lines:

proven = any(c.status == "completed" and c.conclusion == "success" for c in checks)
...
"passed": (proven or waived) and not pending and not failing,

Last lock: the merge is pinned to the exact commit the gate verified. If someone pushes between the check and the merge, GitHub refuses. You can't get one commit approved and merge another.

The waiver that chased its own tail

That leaves the repository with no CI at all. There, you do need a way out: an explicit option that leaves a trace.

But "no CI" and "CI hasn't started yet" look very much alike in the seconds after a push. So the waiver is only granted if nothing suggests active CI on that commit.

And here, a second wrong version. Some of my repositories have scheduled workflows, which permanently leave an empty "queued" suite behind. The gate read that as CI about to start. Forever. So the waiver was refused for life on those repositories.

The cherry on top: the refusal message recommended using the waiver. The one it had just refused.

Fixed the next day: a queued suite only counts if it is less than fifteen minutes old.

The hook that says no

A gate is useless if you can walk around it. In a repository steered by Foundry, a hook intercepts commands before they run. A directly typed gh pr merge is refused. So is gh pr create. Same for a push to the main branch.

The agent gets the refusal along with the path to take instead. It doesn't insist: it goes through the gate.

I don't kid myself about what that's worth. It's written in the hook's own code: a discipline tool, not a sandbox. An agent that really wanted to get around it would manage. But agents don't cheat out of malice. They take the shortest path. The shortest path just has to be the right one.

And before the gate, Maigret

CI only says one thing: the tests pass. It doesn't say the code does what the ticket asked for.

That's Maigret's job. He reviews the diff knowing nothing of the conversation that produced it, in two passes: first each acceptance criterion of the ticket, one by one — covered, not covered, contradicted. Only then, quality.

In August, I followed ten tickets closely. Eight were blocked on the first review. One of them was sent back four times.

Eight out of ten. Code that the agent who wrote it considered done, green tests to prove it.

What it changes

The gate is slow, sometimes. It makes me wait for a CI I know is green. It refuses merges I would have done with my eyes closed.

That's exactly the point. Eyes closed is how I used to merge.

What I ship today is clearly better than in the spring. Not because I write better. Because what isn't proven no longer gets through.

— Pato