Stop a schema change from reaching production without its migration - #9
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
This reverts commit fe711df.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #6.
What was missing
#5 stopped the silent failure by putting
prisma migrate deployin the build. It left four gaps, and the first one is the bug that actually caused the outage: a PR that editsschema.prismawithout 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. Replaysprisma/migrations/into a throwawaypostgres:16-alpineservice container and diffs the result againstschema.prisma. A schema change without its migration fails here instead of at runtime. Plustestandlintjobs — this is the repo's first CI of any kind.One correction to the command in #6:
migrate diff --from-migrationsrequires--shadow-database-url. Without it Prisma exits withYou 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 givesNo difference detected.and exit0; a drifted schema prints the diff and exits2.schema-drift.yml— the missing signal. Dailymigrate diff --from-urlagainst production, so divergence surfaces on its own rather than waiting for a failing query.--from-urlintrospects and writes nothing, so it holds a production credential but cannot alter production.release.yml— migrations as a release step. On a merge tomain: 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: releasenow runs one release at a time. Supersedesscripts/migrate-deploy.mjs, which is deleted along with itsVERCEL_ENVgate — 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.jsonis not in this PR. It would takemainoff Vercel's git trigger, makingrelease.ymlthe only git-triggered path to production. Whethernpx vercel deploy --prodstill 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.jsonlands, a merge tomainproduces 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 beforevercel.jsonis in.release.ymlis chained off CI, notpush. A red gate does not block a merge on its own, and if someone merged past it,migrate deploywould 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; theworkflow_runchain 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.ymlruns onpull_requestwith no branch filter and a fork's default branch is alsomain, a fork PR's CI completion would have reached a job holding production credentials and checked out the contributor's commit. The guard now requiresworkflow_run.event == 'push'andhead_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 viadevDependencies. The manifest route was tried and reverted: it added 447 packages / 18 MB installing on everynpm ciin all three workflows, for a tool onlyrelease.ymlinvokes.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.
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.mainis an unguarded path to production. And if the Build Command is overridden in the dashboard and still referencesnode scripts/migrate-deploy.mjs, every build breaks — this PR deletes that file.productionenvironment, set Deployment branches tomainonly, 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 aworkflow_dispatchof any workflow on any branch. Bothrelease.ymlandschema-drift.ymldeclareenvironment: production— the drift workflow declares it only so itsDATABASE_URLdoes not silently resolve to empty once the secrets move.Migrations match schemafails 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.main: requireMigrations 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.No pending migrations to apply.thenDeploy to Vercel. You will also see a Vercel git deploy; that is expected untilvercel.jsonlands. Then rungh workflow run schema-drift.yml— expectNo difference detected.A failure there is a real finding about production, not a broken workflow.vercel.jsononce step 5 confirms the CLI deploy works.Notes
prisma migrate deployis 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 withfinished_atNULL and every latermigrate deployaborts untilprisma migrate resolve --rolled-backor--appliedclears it. Worth knowing before it happens rather than during.🤖 Generated with Claude Code