Skip to content

Stop a schema change from reaching production without its migration - #9

Merged
7174Andy merged 16 commits into
mainfrom
migration-safety
Aug 1, 2026
Merged

Stop a schema change from reaching production without its migration#9
7174Andy merged 16 commits into
mainfrom
migration-safety

Conversation

@7174Andy

Copy link
Copy Markdown
Owner

Closes #6.

What was missing

#5 stopped the silent failure by putting prisma migrate deploy in the build. It left four gaps, and the first one is the bug that actually caused the outage: a PR that edits schema.prisma without a migration still merged cleanly. Nothing checked, nothing reported, and the failure surfaced only when a user hit the query at runtime — five days later.

What this adds

ci.yml — the pre-merge gate. Replays prisma/migrations/ into a throwaway postgres:16-alpine service container and diffs the result against schema.prisma. A schema change without its migration fails here instead of at runtime. Plus test and lint jobs — this is the repo's first CI of any kind.

One correction to the command in #6: migrate diff --from-migrations requires --shadow-database-url. Without it Prisma exits with You must pass the --shadow-database-url if you want to diff a migrations directory. The issue's snippet does not run, which is why there is a service container here. Verified against Postgres 16 — in sync gives No difference detected. and exit 0; a drifted schema prints the diff and exits 2.

schema-drift.yml — the missing signal. Daily migrate diff --from-url against production, so divergence surfaces on its own rather than waiting for a failing query. --from-url introspects and writes nothing, so it holds a production credential but cannot alter production.

release.yml — migrations as a release step. On a merge to main: apply migrations, and only if that succeeds, deploy. Ordering was already correct in the build — Vercel promotes only after a successful build — but incidentally: a migration failure looked like a build failure, and every retried or concurrent build re-ran it, serialized only by Prisma's advisory lock. concurrency: release now runs one release at a time. Supersedes scripts/migrate-deploy.mjs, which is deleted along with its VERCEL_ENV gate — the gate existed only because migrations ran inside a build that previews also run, and removing it is what unblocks per-preview databases later.

Expand and contract, documented. Both old and new code run at the same instant during a deploy, and a code rollback never rolls back a schema. So migrations are forward-only, and backward compatibility is what makes rollback survivable. Additive migrations land before their code; removals ship the code that stops reading the column first. Renaming is never a rename.

Deliberate choices

vercel.json is not in this PR. It would take main off Vercel's git trigger, making release.yml the only git-triggered path to production. Whether npx vercel deploy --prod still works with that setting off is unverified and needs a real production deploy to find out. If it fails after a merge, migrations are applied, the deploy step fails, and there is no remaining way to deploy anything — including an unrelated hotfix. So it lands in a follow-up once this PR's first release proves the CLI path, with the git deploy still available as a fallback.

The interim state is a race, and it is temporary. Until vercel.json lands, a merge to main produces both a Vercel git deploy and this workflow's CLI deploy, with nothing ordering them. Harmless for this merge, which has no pending migrations — that is the point. No schema-changing merge should happen before vercel.json is in.

release.yml is chained off CI, not push. A red gate does not block a merge on its own, and if someone merged past it, migrate deploy would find nothing pending, succeed, and ship a client selecting a column production does not have — the original outage, unchanged. Branch protection is the primary fix; the workflow_run chain is the half that lives in the repo, where it cannot be edited away in a settings page.

That rewiring initially opened a hole worth naming: because ci.yml runs on pull_request with no branch filter and a fork's default branch is also main, a fork PR's CI completion would have reached a job holding production credentials and checked out the contributor's commit. The guard now requires workflow_run.event == 'push' and head_repository.full_name == github.repository. event == 'push' is an allowlist of one event name rather than a blocklist, so every fork-reachable trigger is excluded by construction, and a missing or misspelled payload field fails closed — a mistake stops releases rather than admitting fork PRs.

The Vercel CLI is pinned inline as npx vercel@58, not via devDependencies. The manifest route was tried and reverted: it added 447 packages / 18 MB installing on every npm ci in all three workflows, for a tool only release.yml invokes.

Destructive-change linting is deferred, per #6's own scoping — every migration so far is additive. The trigger for adding Squawk or Atlas is recorded in the README's new section.

⚠️ Check before merging

These are ordered, and the first three must happen before the merge, because merging is the first execution of release.yml. None of the workflows has ever run.

  1. Two Vercel settings the workflows cannot see. Any Deploy Hook for main is an unguarded path to production. And if the Build Command is overridden in the dashboard and still references node scripts/migrate-deploy.mjs, every build breaks — this PR deletes that file.
  2. Create the production environment, set Deployment branches to main only, and put all four secrets in it: DATABASE_URL, VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID. Environment scoping is what actually restricts them; as plain repository secrets they are readable by a workflow_dispatch of any workflow on any branch. Both release.yml and schema-drift.yml declare environment: production — the drift workflow declares it only so its DATABASE_URL does not silently resolve to empty once the secrets move.
  3. Probe the gate on this PR. Push a schema-only column, confirm Migrations match schema fails with [+] Added column …, then revert and confirm it passes. Everything so far is verified locally against Docker; this is the only proof that the workflow itself works.
  4. Then configure the ruleset on main: require Migrations match schema, test, lint; require branches up to date before merging; block direct and force pushes. After the check names exist and are green once, so they resolve — and note that renaming a job later silently un-matches the rule.
  5. After merging, watch the release: No pending migrations to apply. then Deploy to Vercel. You will also see a Vercel git deploy; that is expected until vercel.json lands. Then run gh workflow run schema-drift.yml — expect No difference detected. A failure there is a real finding about production, not a broken workflow.
  6. Follow-up PR: add vercel.json once step 5 confirms the CLI deploy works.

Notes

prisma migrate deploy is a verified no-op for this PR — it adds no migrations, so the first release exercises the pipeline without touching the schema.

The P3009 case is now documented in both release.yml's failure output and the README: a partially-applied migration is recorded with finished_at NULL and every later migrate deploy aborts until prisma migrate resolve --rolled-back or --applied clears it. Worth knowing before it happens rather than during.

🤖 Generated with Claude Code

7174Andy added 13 commits July 30, 2026 16:19
A PR that edits prisma/schema.prisma without generating a migration
merged cleanly, deploying a Prisma Client whose queries referenced
columns production did not have. Replaying prisma/migrations/ into a
throwaway Postgres and diffing against the schema catches that before
merge rather than at runtime.

Refs #6
Nothing compared production's schema to prisma/schema.prisma, so
divergence stayed invisible until a query failed - which took five days
last time. A failing scheduled run is that missing signal.

Refs #6
A build can be cached, retried, or promoted, and concurrent builds hit
the database together - serialized only by Prisma's advisory lock. A
release should apply migrations exactly once, with its own logs and its
own failure surface.

The release workflow applies migrations and deploys only if that
succeeded. vercel.json takes main off Vercel's git trigger so this
workflow is the only path to production; without it the two would race
off the same push, which is the failure in #6 made intermittent.

Removes scripts/migrate-deploy.mjs and its VERCEL_ENV gate, which
existed only because migrations ran inside a build that previews also
run. That unblocks per-preview databases later.

Refs #6
Pin the Vercel CLI as a devDependency and call `npx vercel` instead of
`npx vercel@latest`, so the only path to production doesn't fetch
whatever the CLI happens to be that day - the same pinning Prisma
already gets. Guard the release job to main, since workflow_dispatch
had no branch restriction of its own and could otherwise apply another
branch's migrations to production and deploy that checkout.

Corrects two README passages left over from the old build-step
migration: the "Checks" section no longer describes a VERCEL_ENV gate
that left with scripts/migrate-deploy.mjs, and the Vercel deploy
walkthrough no longer claims DATABASE_URL is a Build-step requirement
- it's needed at runtime and separately as the GitHub Actions secret
release.yml uses.

Refs #6
npm install --save-dev vercel added 447 packages, and cache: npm caches
the npm cache rather than node_modules, so every npm ci in all three
workflows installed them - including ci.yml and schema-drift.yml,
which never touch the Vercel CLI. Prisma earns its place in the
manifest because the app imports it at runtime; the Vercel CLI is one
line in one workflow.

Revert package.json/package-lock.json to the pre-devDependency state
and pin the major directly in the deploy command instead:
npx vercel@58. Still not @latest - a breaking CLI release can't break
every release with no repo change to point at - but the pin costs one
version pointer instead of a subtree only one job uses.

Refs #6
Both old and new code run during a deploy and a code rollback does not
roll back a schema, so migrations are forward-only and backward
compatibility is what makes rollback survivable. Records the discipline
the release ordering depends on, and the trigger for adding a
destructive-change linter.

Closes #6
A red `Migrations match schema` check does not, on its own, block a merge.
Triggered from `push`, the release then starts anyway: `migrate deploy` finds
nothing pending and succeeds, and the deploy ships a Prisma Client selecting a
column production does not have — issue #6 through the front door.

Chaining off `workflow_run` makes the release structurally unable to start
before CI has passed on this commit. Branch protection on `main` is the primary
enforcement; this is the half that lives in the repo, where it cannot be edited
away in a settings page.

Two `workflow_run` footguns come with it, both closed here:

- `actions/checkout` defaults to the default branch's HEAD, not the commit
  whose CI run just passed, so a release could migrate and deploy a different
  commit than the one that was tested. The checkout `ref:` pins it to
  `head_sha`, falling back to `github.sha` for a dispatch.
- `github.ref` is the default branch regardless of what triggered CI, so the
  old `if: github.ref == 'refs/heads/main'` guard would have been vacuously
  true and guarded nothing. Branch identity now comes from `head_branch`, with
  the dispatch arm guarded separately.

Refs #6
Whether `npx vercel@58 deploy --prod` works with `git.deploymentEnabled.main`
set to false is this plan's one unverified assumption, and only a real
production deploy can test it. If it fails once vercel.json is on main,
migrations are applied, the deploy step fails, and nothing — not even an
unrelated hotfix — can reach production until someone reverts.

Splitting it into a one-file follow-up PR keeps Vercel's git trigger as a
fallback for the first release. The cost is honest and recorded in the header
comment: until vercel.json lands, a merge produces both a Vercel git deploy and
this workflow's CLI deploy, harmless for a release with no pending migrations
and a genuine race for any later one. It must land before any schema-changing
merge.

Also stop naming scripts/migrate-deploy.mjs, deleted earlier on this branch, and
drop the claim that this workflow is the *only* path to production: vercel.json
disables git-triggered deploys, not promote, deploy hooks, rollback, or a local
`vercel --prod`.

Refs #6
No workflow declared `permissions:`, so all three ran with the repository
default. The release job is the worst place for that: it pairs a production
database credential and a Vercel token with `npx vercel@58`, ~447 packages
fetched from npm and executed at release time, and it needs no GITHUB_TOKEN
scope at all. It gets `{}`; the two read-only workflows get `contents: read`.

schema-drift.yml gains `environment: production`. The four secrets are moving
into a `production` environment with deployment branches restricted to main,
which is what actually scopes them — as repository secrets a workflow_dispatch
of any workflow on any branch can read them. Once they move, an undeclared
environment makes `secrets.DATABASE_URL` an empty string, `migrate diff
--from-url ""` errors, and the branch's only alarm goes red daily for the wrong
reason. No behaviour change until they move.

The failed-migration hint said to fix the migration and merge again, which does
not work: a part-applied migration is recorded with finished_at NULL and every
later `migrate deploy` aborts with P3009 — and since this workflow is now the
only deploy path git can trigger, that freezes all deploys, schema-touching or
not. It now covers `migrate resolve --rolled-back` / `--applied`, how to choose
between them, and that an empty or wrong DATABASE_URL is the likelier cause on a
first release.

Smaller fixes:

- `name: Migrate then deploy` on the release job. Added now rather than after
  branch protection is configured, since a required-status-check rule matches on
  the job name and renaming it later un-matches the rule silently.
- `timeout-minutes: 15`, so a `migrate deploy` stuck on Prisma's advisory lock
  cannot hold the `release` group for the 6-hour default with
  cancel-in-progress false.
- Drop the redundant `--token=` flag; the CLI reads VERCEL_TOKEN from the
  environment, which also keeps it out of the runner's process arguments.
- `concurrency` on ci.yml, so successive pushes to a PR stop burning minutes on
  superseded runs.

Refs #6
Four passages were false or missing, and "Changing the schema" is the first
place a developer changing the schema lands:

- It said the production build applies the migration. This branch deleted that
  behaviour; it is the release workflow now.
- "release.yml is the only path to production" was never true. vercel.json
  disables git-triggered deploys only — Promote to Production, a deploy hook for
  main, Instant Rollback and a local `vercel --prod` all still ship code whose
  migrations were never applied, and the drift check cannot see it because it
  compares production's database to main's schema, not to the deployed code.
  Those are now named as things not to do. Re-running an old release run is
  listed with them: it checks out the old commit, `migrate deploy` no-ops, and
  the deploy silently rolls production back past the branch guard.
- vercel.json is not in the repo yet, so until it lands Vercel also deploys main
  off the same push and the two deploys race. Recorded as deliberate and
  temporary, with what has to happen before a schema-changing merge.
- The secrets are not repository secrets. They live in a `production`
  environment with deployment branches restricted to main, which is what scopes
  them, and both release.yml and schema-drift.yml read from it.

Added, because none of it was written down anywhere in-tree:

- What ci.yml, release.yml and schema-drift.yml each gate, so a contributor can
  tell what a red `Migrations match schema` means.
- That GitHub disables `schedule` triggers in a repository dormant for 60 days.
  An alarm that can be switched off silently needs its check (`gh run list
  --workflow=schema-drift.yml`) documented, or a quiet drift check reads as no
  drift.
- The P3009 remediation — `migrate resolve --rolled-back` / `--applied`, how to
  choose, and that an applied migration is never edited — since "fix it and
  merge again" does not clear a part-applied migration.
- That `git revert` on a commit that added a migration passes the CI gate while
  becoming a contract step against a column production still has.
- A step 5 in the Vercel walkthrough, which otherwise ended at "Deploy" and
  never mentioned the Actions secrets or how a release is actually triggered.

Refs #6
The workflow_run chain, as added in ebe466f, would let an outside contributor
run this job — the one holding the production database credential and the Vercel
token — against their own commit.

This repository is public and ci.yml runs on `pull_request` with no branch
filter, so a pull request from a fork runs CI here. Its completion fires
workflow_run, which unlike the pull_request run itself gets full secret access.
A fork's default branch is also named `main`, so neither `branches: [main]` on
the trigger nor `head_branch == 'main'` in the guard excludes it: that filter
tests the triggering run's head branch, not its base. `ref:
workflow_run.head_sha` would then check out the fork's commit and the job would
run their package.json through `npm ci`, their prisma/migrations/ against the
production database, and `vercel deploy --prod` with the production token.

`workflow_run.event == 'push'` closes it, because a fork PR's triggering run
carries `pull_request`. `head_repository.full_name == github.repository` is the
second lock: the commit must have come from this repository.

`environment: production` never protected this. Its deployment branch policy is
evaluated against `github.ref`, which under workflow_run is always the default
branch, so it always passes — the same reason branch identity has to come from
head_branch here.

Not exploitable today only because none of the four secrets exist yet; it would
open the moment they are created. `branches: [main]` stays as a cheap pre-filter,
but it is not a security boundary and the comment above the guard now says so,
along with what each condition is for, because the guard reads like something to
simplify later.

Refs #6
The P3009 text said one bad migration freezes every deploy, which is true only
once vercel.json lands. Until then it is the worse way round: the migration
stalls in this workflow while Vercel keeps deploying main off the same push, so
code ships without its migrations — issue #6 exactly. Both the workflow's failure
output and the README now say which state they mean, matching the qualification
already used where the interim races are described.

Also:

- The `gh run rerun` warning covered only old release runs. Re-running an old CI
  run now does the same thing at one remove: its completion is a fresh
  workflow_run success for that old commit, so it releases it.
- ci.yml records that its `name: CI` is what release.yml matches on, and that
  the three check names branch protection needs are `Migrations match schema`,
  `test`, and `lint` — the last two being job ids, since those jobs declare no
  `name:`, so adding one later silently un-matches the rule.
- A red drift check can also mean a release is merely pending, because a
  cancelled CI run starts no release. True positive, wrong remedy, so the README
  says how to tell it from real drift.

Refs #6
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gitcron Ready Ready Preview Aug 1, 2026 4:22am

Every job annotated: the v4 actions target Node.js 20, which GitHub has
deprecated on Actions runners, so they were being forced onto Node 24 via
a compatibility shim. A warning today, a failure whenever the shim goes.

This is the actions' own runtime, not the app's. node-version stays 20
across all three workflows, matching the local toolchain and
@types/node ^20.

Refs #6
Adds a column to schema.prisma with no accompanying migration, which is
exactly the mistake that put Schedule.runUrl and runConclusion into
production missing. The gate should reject this.

Reverted immediately after.
@7174Andy
7174Andy merged commit b195da1 into main Aug 1, 2026
5 checks passed
@7174Andy
7174Andy deleted the migration-safety branch August 1, 2026 04:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema changes can reach production without their migration

1 participant