A pipeline that deploys on merge, and the four gates before it

The deploy has been a single command since 2023 and has been run by a person, roughly an hour after merge, by whoever noticed. Making it run on merge is a one-line change to a workflow file, and it took two years because the change is not the automation.

The symptom

eight weeks, measured:

  merges                       64
  deploys                      41
  median merge-to-deploy       1h 40m
  p90                          6h 20m
  merges batched into one
    deploy                     23

and what the batching costs: a rollback reverts four
changes, three of which were fine.

Batching is the real cost rather than the latency. A deploy containing four merges is a deploy whose failure cannot be attributed, and the response to a problem is to revert all of it — which is why the p90 matters more than the median.

Why it happens

A deploy that requires a decision gets deferred until somebody is confident enough to make it, and confidence comes from the tests, the rollback and the monitoring rather than from the automation. Automating the trigger before those exist moves the risk rather than removing it.

The fix

The four gates, each of which predates this change

  1  a test suite at 90 seconds (2024-09)
     fast enough that it runs before every push, so a
     merge has already been verified locally and in CI.

  2  an automatic rollback on a health check (2023-05)
     a deploy that produces a failing health check
     reverts within 40 seconds, unattended.

  3  the expand-and-contract migration rule (2023-05)
     a migration must be safe against both the old and
     the new code, checked by a script.

  4  per-consumer error rate alerting (2024-06)
     a change that breaks one integrator is visible
     within five minutes rather than at the next
     support ticket.

none of these were built for this. all four are the
reason it is now possible.

Continuous deployment is a consequence of four unrelated pieces of work rather than a project of its own, which is why it took two years and one line. Anybody attempting the one line first would have automated the deployment of unverified changes with no rollback.

The migration safety check

// runs against the pending migrations, in CI
private const array UNSAFE = [
    '/DROPs+COLUMN/i'             => 'dropping a column',
    '/RENAMEs+COLUMN/i'           => 'renaming a column',
    '/MODIFYs+COLUMN.*NOT NULL/i' => 'adding NOT NULL',
    '/DROPs+TABLE/i'              => 'dropping a table',
];

// anything matching requires an explicit marker in the
// migration saying the previous release stopped using it
the marker, which is the whole mechanism:

  /**
   * @contract-safe The `legacy_ref` column stopped
   * being written in release 5.4.0 and stopped being
   * read in 5.4.2. Both are deployed.
   */

without it, the check fails and the merge is blocked.
with it, a human has asserted something specific that
a reviewer can verify.

A pattern match on SQL is crude and it is the only thing that scales to every migration without a human reading each one. The marker turns the check from an obstacle into a prompt — the author has to state which release stopped using the column, which is a claim somebody can check against the deployment history.

The deploy window, which is a business constraint

  deploy:
    needs: [test, analyse, migration-safety]
    if: |
      github.ref == 'refs/heads/main' &&
      github.event_name == 'push'
    steps:
      - name: within the deploy window
        run: |
          h=$(TZ=Europe/London date +%-H)
          d=$(TZ=Europe/London date +%u)

          [ "$d" -le 4 ] && [ "$h" -ge 8 ] && [ "$h" -lt 16 ] || {
            echo 'outside the deploy window; queued'
            exit 78
          }

Monday to Thursday, eight until four, is a constraint about who is available rather than about the software — a deploy at five on a Friday is fine technically and is a deploy nobody will notice failing. Exit code 78 marks the job as neutral rather than failed, so a merge outside the window is not a red build.

What stayed manual

  a migration marked @contract-safe by its author
    → deploys automatically. the marker is the review.

  a migration the check flags with no marker
    → the merge is blocked. not the deploy.

  anything touching the schema in a way the pattern
  match cannot classify — a data backfill, a
  partition operation, an ALTER on a table over 10M
  rows
    → labelled `manual-deploy`, which skips the
      automatic job entirely.

11 of 64 merges in the first eight weeks carried the
label. all eleven were correct to.

The rollback, which was already there

switch_release "$new" && systemctl reload php-fpm

for i in $(seq 1 20); do
  body=$(curl -sf localhost/health/deep) && 
    [ "$(jq -r .commit <<<"$body")" = "$SHA" ] && ok=1 && break
  sleep 2
done

[ -n "${ok:-}" ] || {
  switch_release "$previous" && systemctl reload php-fpm
  ./bin/notify '#ops' "rolled back $SHA"
  exit 1
}

Comparing the deployed commit against the one being deployed is what makes the health check assert the right thing — a check served by a stale process passes otherwise, which is the failure this exists to catch. Forty seconds of retrying and a rollback that is the same function called with the previous release.

Verifying it worked

# three weeks after
  merges                      52
  automatic deploys           41
  manual (labelled)           11
  median merge-to-deploy      6m 20s   # was 1h 40m
  batched merges               0
  rollbacks                    1

# the rollback: a health check failure caused by a
# config cache that was not rebuilt. reverted in 38
# seconds, unattended, at 14:02 on a Wednesday.

$ ./bin/incident-log --since=2025-04 --tag=deploy | wc -l
0

Zero batched merges is the outcome, and the single rollback is the mechanism working — a failure attributable to one change, reverted before anybody looked at it. Six minutes from merge to production is a consequence of the ninety-second suite rather than of anything in this change.

What this costs

A merge that is now irreversible in a way a merge was not. Reviewing a pull request is now reviewing a deployment, and the pressure that used to sit on the person running the deploy has moved to the person clicking merge — which is the right place for it and is a change in what approval means.

The migration check is also a pattern match on SQL, which is a weak tool. It will pass something dangerous eventually, and the marker convention depends on an author making an accurate claim about which release stopped reading a column — a claim nobody verifies except by reading the deployment history, which nobody does.