← All posts

Continuous Deployment, Not Freeze

After a bad deploy reached production, we froze the pipeline. It felt responsible. Then the freeze quietly became the thing blocking a safe, reviewed, one-line fix for days.

  • engineering
  • deployment
  • sre
  • process

I was reviewing the diff in bed on my phone when I caught it. An unreviewed database migration — a change to the shape of stored data — and a semantics tweak had slipped straight through the pipeline and into production. No human had looked at either one.

So I did the thing that felt responsible. I froze deploys. I disabled the pipeline — the automated chain of steps that builds and ships code on every push — and set a watchdog to keep it disabled. The whole flow sat frozen while I built a proper gate.

It felt like control. I enforced the freeze perfectly for three days before I noticed what it was actually doing.

What a freeze actually breaks

At first the freeze did exactly its job. Then it did more than its job.

A teammate had a copy change. Reviewed, visually checked, approved. Ready. But the pipeline’s crude risk-detector matched a word in the file path — consent — and blocked it. A safe, trivial, wanted change sat frozen behind a blanket halt that was never meant for it.

I had optimized for holding the gate correctly and never stepped back to ask whether a durable, blanket gate was the right instrument at all. That’s the trap. A freeze is easy to enforce and it feels safe, so it stops being a stopgap and becomes the status quo.

A freeze with no expiry that blocks safe changes isn’t safety. It’s a defect in a safety vest.

Safety has to live in the flow

The default has to be that changes ship to production continuously. The burden of proof belongs on blocking a deploy — never on allowing one. And the way you make a continuous flow safe is not a halt. It’s checks that run as steps inside the pipeline and stop a deploy only when they find something real.

  • Narrow, automated risk detection. Gate on the things that are actually dangerous: schema migrations that reshape the database, data-semantics changes that alter what stored fields mean, anything touching sensitive data. Let copy, UI, and ordinary code flow. A detector that blocks a text change because the path contains a scary word is the blanket freeze in miniature. False positives on safe changes are the whole problem, just shrunk.
  • Real review of the risky surface, with evidence. For anything user-visible, that means actually opening it. Not “the page returned 200,” but confirming the real behavior. Screenshot. Eyeballs on it.
  • Impact analysis on the diff. Blast radius should be understood before the code ships, not after.

None of those stop the flow by default. They stop one deploy when there’s a real reason.

When a freeze is legitimate

Almost never, and only as an emergency instrument. If you reach for one, it has to (1) name the specific risk class it blocks — not “all deploys,” (2) carry an explicit expiry or lift condition, and (3) be replaced by an in-flow check the moment you can build one. A freeze with no expiry and no named risk gets rejected on sight.

The deeper habit this burned into me: be suspicious of anything that defaults to blocking. When the team — or you — reaches for a blunt halt instead of building safety into the flow, push back. Enforcing the wrong instrument correctly is still wrong.

The goal was never “nothing bad ships.” It was “good things ship continuously and bad things get caught on the way.” Those are different goals. Only one of them is a freeze.

What the in-flow version looks like

Each gate is a step in the pipeline — for me, a GitHub Actions job that conditions on the content of the diff, not a blanket switch. GitHub Actions is just a hosted runner that executes scripts on every push; you define jobs that fire when code changes, and each job can inspect what changed and decide whether to block the deploy or let it through.

how the semantic gate actually works give me the detail

The crude version of a risk gate is a path glob — block anything matching **/migrations/** or *consent*. That’s what produced the false positive here: a copy change in a file named consentForm.copy.ts tripped the gate even though zero data semantics changed.

The better version splits detection into two independent axes:

Axis 1 — structural diff analysis. Parse the raw git diff for SQL ALTER/DROP/ADD COLUMN, Sequelize/Prisma migration files, and ORM model-field removals. This is AST-adjacent work: for TypeScript models, ts-morph lets you diff the public shape of a class between HEAD and base, so you catch a renamed field even if the filename is innocuous.

Axis 2 — semantic field tagging. Maintain a manifest of field names that carry sensitive meaning (PHI, payment, consent). Match only against field names in the diff, not file paths.

A deploy blocks only when both axes agree there’s real risk, or when axis 1 fires alone on a structural migration.

# minimal shell skeleton — run inside a GitHub Actions step
BASE=$(git merge-base HEAD origin/main)
CHANGED=$(git diff --name-only "$BASE" HEAD)

# axis 1: real migration files
if echo "$CHANGED" | grep -qE 'migrations/[0-9]+.*\.(sql|ts)$'; then
  echo "::error::Migration detected — manual review required"; exit 1
fi

# axis 2: semantic field names in the diff body (not file paths)
SENSITIVE_FIELDS='(consentGiven|ssn|dateOfBirth|cardNumber)'
if git diff "$BASE" HEAD -- '*.ts' '*.tsx' | grep -qE "^\+.*$SENSITIVE_FIELDS"; then
  echo "::error::Sensitive field change detected — manual review required"; exit 1
fi

echo "Gate passed — deploying"

For the visual-QA leg, Playwright running in the Actions container takes a screenshot of the deployed preview URL and uploads it as a workflow artifact with actions/upload-artifact; a reviewer eyeballs it before the PR can merge. No CDN round-trip, no “page returned 200” theater.

The detector is narrow on purpose. It matches schema migrations, data-semantics changes, and anything touching sensitive fields — and it pattern-matches on meaning, not a scary word in a path. Copy, UI, and ordinary code never trip it.

This site deploys that way: push to main, Cloudflare Pages builds and ships, and the only thing that can stop a deploy is a check that found a real reason. Cloudflare Pages is just a hosting platform that watches your git repo and rebuilds the site on every push — continuous deployment with no manual steps between you and production.

The whole thing traces back to the DORA research program, which found that elite teams deploy continuously and have lower change-failure rates. The two aren’t in tension. That’s the point.


Built on: GitHub Actions for the pipeline gates · Cloudflare Pages for continuous deploys. The principle is straight out of Accelerate / DORA — elite teams deploy continuously and have lower change-failure rates; the two aren’t in tension, which is the whole point.