← All posts

Each Repo's CI Is Ground Truth

I told my boss four PRs were held for sign-off. They'd been live in production for minutes. Here's why I never infer a deploy model from a sibling repo again.

  • ci-cd
  • github-actions
  • deployment
  • incident
  • devops

I told my boss four pull requests were “held for a prod tag, waiting on your sign-off.” They’d been live on production for several minutes by the time I said it.

Here’s the plain version for anyone who doesn’t ship software: when you finish a change, there’s a question of when it goes live. Some setups make you push a second button — merge your work into the main line, but nothing reaches real users until someone tags a release or clicks approve. Others go live the instant you merge, no second button. I had two projects, one of each kind, and I told everyone the second one worked like the first. It did not.

The two repos I run have opposite promotion models. One is a merge-to-main-hits-dev, tag-a-version-promotes-to-prod setup — merging is safe, a human still has to bless the release. The other is a live app of mine, where merging to main is the release. Merge closes the loop; production updates on its own.

I’d been living in the first repo all week. So when I merged those four PRs into that app, my hands did the safe-repo thing and my mouth reported the safe-repo status. “Held for sign-off.” I wasn’t lying — I genuinely believed there was a gate. There wasn’t. The production environment had an empty protection-rules list, which is GitHub’s polite way of saying nobody has to approve anything, ship it.

The tell I ignored: I assumed a convention carried across two repos because they were mine and sat next to each other. But CI config doesn’t inherit from the repo next door. It’s written per repo, in a file, and that file is the only thing that decides what happens on merge. My mental model was a sibling’s model. The repo in front of me had never agreed to it.

So now, before I ever say the words “it’s not deployed yet,” I open the actual workflow file and read the literal trigger — on: push: branches: [main] means live on merge, full stop — and I check whether the production environment has any protection rules at all. If the list is empty, there is no gate, no matter what the repo across the hall does.

Auth-free prod verification give me the detail

Don’t trust the dashboard’s word — check the running host. This confirms production is actually serving the SHA you think it is:

# 1. HEAD on the deploy host matches origin/main
ssh deploy-host 'cd /srv/app && git rev-parse HEAD'
git rev-parse origin/main   # compare — must be equal

# 2. last Deploy run for that SHA succeeded
gh run list --workflow=deploy.yml --branch=main \
  --json headSha,conclusion -q '.[0]'

# 3. it answers, and the served bundle carries your change
curl -so /dev/null -w '%{http_code}\n' https://your-app.example.com
curl -s https://your-app.example.com/assets/*.js | grep -c 'YOUR_CHANGE_MARKER'

And read the truth before you speak it:

# .github/workflows/deploy.yml
on:
  push:
    branches: [main]   # live the moment you merge

Empty protection_rules on the production environment = no approval gate exists.

The mechanism is boring and that’s the point: a deploy trigger is a fact written in one file, not a habit shared across your projects. Assuming shared conventions is exactly how you announce something is safe when it already shipped. Read the on: trigger for the repo in your hands. It’s the only thing that gets a vote.