diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a0bd9a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +# Per-PR checks. Three independent jobs so one failure does not mask another. +# +# The migrations job is the gate that issue #6 is about: it replays +# prisma/migrations/ into a throwaway Postgres and diffs the result against +# prisma/schema.prisma. A PR that edits the schema without generating a +# migration fails here instead of deploying a Prisma Client whose queries +# reference columns production does not have. +# +# This name is load-bearing twice over. release.yml matches this workflow by it +# (`workflow_run: workflows: [CI]`), so renaming it silently stops every release +# with no error anywhere. And branch protection on main requires three check +# names from here: `Migrations match schema`, `test`, and `lint`. The last two are +# job ids, not names, because those jobs declare no `name:` - so renaming a job, +# or adding a `name:` to either one later, silently un-matches the protection rule +# and removes the gate. +name: CI + +on: + pull_request: + push: + branches: [main] + +# Every job here only reads the checkout. +permissions: + contents: read + +# Successive pushes to a PR otherwise leave superseded runs burning minutes, +# including a Postgres service container each. Only the newest push matters. +concurrency: + group: "ci-${{ github.ref }}" + cancel-in-progress: true + +jobs: + migrations: + name: Migrations match schema + runs-on: ubuntu-latest + + # `migrate diff --from-migrations` replays every migration, so it needs a + # real database to replay them into. A service container is thrown away + # with the job, which is what makes it safe to hand Prisma as a shadow. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: shadow + POSTGRES_DB: shadow + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d shadow" + --health-interval 2s + --health-timeout 3s + --health-retries 15 + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + + # Installs the repo's pinned Prisma 5.22. `npx prisma` below must resolve + # to that, not to prisma@latest, or the diff runs on a different engine + # than the app. + - run: npm ci + + - name: prisma/migrations must reproduce prisma/schema.prisma + id: diff + run: | + npx prisma migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel prisma/schema.prisma \ + --shadow-database-url postgresql://postgres:shadow@localhost:5432/shadow \ + --exit-code + + - name: Explain the failure + if: failure() && steps.diff.outcome == 'failure' + run: | + echo "prisma/schema.prisma and prisma/migrations/ disagree." + echo "The diff above is what production would be missing." + echo + echo "Generate the migration and commit it alongside the schema change:" + echo " npm run db:migrate" + echo + echo "Do not use 'npm run db:push' for a committed change - it alters" + echo "the database without recording a migration, which is how" + echo "Schedule.runUrl and Schedule.runConclusion reached production" + echo "missing. See issue #6." + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm test + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e60bf51 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,169 @@ +# The only path to production that applies migrations - and once vercel.json +# lands (see below), the only one git can trigger at all. +# +# Migrations used to run inside the Vercel build, in a script deleted with this +# change. Ordering was correct there - Vercel promotes only after a successful +# build - but it was incidental: a migration failure looked like a build +# failure, and every retried or concurrent build re-ran it, serialized only by +# Prisma's advisory lock. +# +# Here the ordering is explicit. `migrate deploy` runs first, and because a +# failed step ends the job, the deploy step simply never runs if it fails. +# Vercel keeps serving the previous deployment, which is the safe direction. +# +# A vercel.json disabling Vercel's git trigger for main is NOT in the repo yet. +# Until it is, Vercel also deploys main off the same push, in parallel with this +# workflow, and the two race - the failure mode in issue #6, made intermittent. +# That is deliberate and temporary: it leaves the git-triggered deploy as a +# fallback while `vercel deploy --prod` from this workflow is still unproven, so +# a first release that cannot deploy does not strand the project with no way to +# ship. It must be added promptly, and no schema-changing merge should happen +# before it is. +name: Release + +on: + # Chained off CI rather than `push` so a release cannot start until the + # migration gate, tests, and lint have 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. + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + # Lets the workflow be run by hand — a first run before anything depends on + # it, or retrying a failed deploy. `migrate deploy` is a no-op when nothing + # is pending, so this is safe. + workflow_dispatch: + +# This job needs no GITHUB_TOKEN scope at all, and it is the worst place to have +# one: it pairs the production database credential and a Vercel token with `npx +# vercel@58`, whose ~447 packages are fetched from npm and run at release time. +# If the repository default is read/write, that tree runs beside a token that +# can push to main. +permissions: {} + +# One release at a time. Two quick merges queue instead of overlapping, so +# migrations cannot interleave. cancel-in-progress stays false: cancelling a +# release mid-migration is worse than finishing it. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + # Named because a required-status-check rule matches on the job name. + # Renaming it later silently un-matches the rule and disables the gate with + # no error anywhere. + name: Migrate then deploy + runs-on: ubuntu-latest + environment: production + # `migrate deploy` blocked on Prisma's advisory lock would otherwise hold + # the `release` concurrency group for the 6-hour default - and since + # cancel-in-progress is false, freeze every deploy queued behind it. + timeout-minutes: 15 + # Every condition here is load-bearing. Do not simplify this guard. + # + # 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* - and its completion + # fires this workflow_run, which unlike the PR run itself has full access to + # the secrets below. A fork's default branch is also called `main`, so + # neither `branches: [main]` on the trigger (which filters the triggering + # run's head branch, not its base) nor `head_branch == 'main'` excludes it. + # Unguarded, an outside contributor's commit would be checked out by + # head_sha and this 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'` is what closes that: 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, not a + # fork of it. + # + # `environment: production` cannot substitute for either. Its deployment + # branch policy is evaluated against `github.ref`, which under workflow_run + # is always the default branch, so the policy always passes. That is the same + # reason branch identity has to come from `head_branch` rather than + # `github.ref` here. + # + # A dispatch carries no workflow_run payload, so it is guarded separately: + # run by hand from another branch it would apply that branch's migrations to + # production and deploy that checkout. + if: >- + (github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository + && github.event.workflow_run.head_branch == 'main') + || (github.event_name == 'workflow_dispatch' + && github.ref == 'refs/heads/main') + + steps: + - uses: actions/checkout@v5 + with: + # Under workflow_run this defaults to the default branch's HEAD, not + # the commit CI just validated — which would migrate and deploy a + # different commit than the one that was tested. A dispatch has no + # workflow_run payload and falls back to github.sha. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # Step order is the whole point: schema first, code second. Additive + # changes must be present before the code that reads them ships. + - name: Apply pending migrations + id: migrate + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: npx prisma migrate deploy + + - name: Explain a failed migration + if: failure() && steps.migrate.outcome == 'failure' + run: | + echo "Migrations failed, so nothing was deployed and production is" + echo "still serving the previous release. That is the safe outcome." + echo + echo "An empty or wrong DATABASE_URL secret fails here too - on a" + echo "first release that is likelier than a bad migration." + echo + echo "A migration that failed part-way is recorded in" + echo "_prisma_migrations with finished_at NULL, and every later" + echo "'migrate deploy' aborts with P3009 until that is resolved -" + echo "including releases that touch no schema at all. Once" + echo "vercel.json lands and this is the only deploy path git can" + echo "trigger, one bad migration freezes every deploy. Until then it" + echo "is the worse way round: migrations stall here while Vercel keeps" + echo "deploying main off the same push, so code ships without them -" + echo "issue #6 exactly. Either way, clear this before the next merge." + echo "A cancelled or timed-out run mid-migration leaves the same state." + echo + echo "So check whether the DDL actually landed in the database, then" + echo "tell Prisma which of the two happened:" + echo " prisma migrate resolve --rolled-back # the DDL did not land" + echo " prisma migrate resolve --applied # the DDL did land" + echo + echo "Then write a forward migration and merge again. Migrations are" + echo "forward-only: an applied migration is never edited." + + # Only reached when the migration succeeded. Vercel builds this commit + # with the build command from package.json, which no longer migrates. + # The major version is pinned so a breaking Vercel CLI release can't + # break every release with no repo change to point at. It's pinned + # here rather than as a package.json devDependency because this is the + # only workflow that uses the CLI, and a devDependency would install + # its ~447 packages in ci.yml and schema-drift.yml too, on every run, + # for a tool neither ever invokes. Bumping the major is a deliberate + # one-line edit. + # + # No --token flag: the CLI reads VERCEL_TOKEN from the environment, which + # also keeps the token out of the runner's process arguments. + - name: Deploy to Vercel + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: npx vercel@58 deploy --prod --yes diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml new file mode 100644 index 0000000..bc11b1a --- /dev/null +++ b/.github/workflows/schema-drift.yml @@ -0,0 +1,67 @@ +# Daily check that production's actual schema still matches prisma/schema.prisma. +# +# Nothing used to compare the two, so divergence was invisible until a query +# failed - Schedule.runUrl and Schedule.runConclusion were missing from +# production for five days and were found only when a user reported schedule +# creation broken. A failing scheduled run is the signal that was missing. +# +# `migrate diff --from-url` introspects and writes nothing, so this holds a +# production credential but cannot alter production. +name: Schema drift + +on: + schedule: + # 07:00 UTC daily. Any fixed time works; this is before the working day in + # US Pacific, so a report is waiting rather than arriving mid-change. + - cron: "0 7 * * *" + # So it can be run on demand - which is also the only way to verify it, + # since no development machine should hold the production credential. + workflow_dispatch: + +# Reads the checkout and the database; touches nothing on GitHub. +permissions: + contents: read + +jobs: + drift: + name: Production matches schema + runs-on: ubuntu-latest + # Declared on a workflow that only reads because DATABASE_URL lives in the + # `production` environment, which is what scopes it to main - as a plain + # repository secret it is readable by a workflow_dispatch of any workflow on + # any branch. Without this line, `secrets.DATABASE_URL` below resolves to an + # empty string, `migrate diff --from-url ""` errors, and this check goes red + # every day for the wrong reason: alarm fatigue on the only alarm there is. + environment: production + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Diff production against prisma/schema.prisma + id: drift + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + npx prisma migrate diff \ + --from-url "$DATABASE_URL" \ + --to-schema-datamodel prisma/schema.prisma \ + --exit-code + + - name: Explain the failure + if: failure() && steps.drift.outcome == 'failure' + run: | + echo "Production's schema does not match prisma/schema.prisma." + echo "The diff above is what production is missing or has extra." + echo + echo "A missing column means the deployed app is querying something" + echo "that does not exist. Check whether a release applied its" + echo "migrations:" + echo " gh run list --workflow=release.yml --limit 5" + echo + echo "See issue #6." diff --git a/README.md b/README.md index b5cd2d5..b756ffd 100644 --- a/README.md +++ b/README.md @@ -162,17 +162,56 @@ schema, then: npm run db:migrate ``` -Commit the generated `prisma/migrations/` directory with the schema change. The -production build applies it (see [Deploying schema +Commit the generated `prisma/migrations/` directory with the schema change. +`.github/workflows/release.yml` applies it to production before the new code +ships; the build no longer applies anything (see [Deploying schema changes](#deploying-schema-changes)). > Do not use `npm run db:push` for a change you intend to commit. It alters the -> database to match the schema without recording a migration, so the deployed -> build has nothing to apply and production silently keeps the old columns — +> database to match the schema without recording a migration, so the release has +> nothing to apply and production silently keeps the old columns — > which is exactly how `Schedule.runUrl` and `Schedule.runConclusion` reached > production missing, breaking schedule creation and listing outright and > erroring the run-resolution pass on every cron tick. +#### Expand and contract + +Both old and new code run at the same instant during a deploy, and a code +rollback never rolls back a schema. So the schema must work with *both* +versions at every point, and migrations are forward-only. + +That splits every change into two directions: + +**Adding** — migrate first, ship the code that uses it second. Old code +ignores a column it does not know about, so an additive migration is safe to +apply before its code. The release workflow already enforces this order. + +**Removing** — the reverse, across two releases. Ship code that stops reading +the column first; drop it in a later release. Dropping a column the running +code still selects breaks production the moment the migration lands. + +**Renaming** is never a rename. It is: add the new column, backfill it, stop +reading the old one, then drop it — four steps across at least two releases. A +single `@map` rename in `schema.prisma` generates a destructive migration that +breaks whichever version of the code is not yet deployed. + +New columns are therefore nullable or defaulted. A `NOT NULL` column with no +default fails against a non-empty table, and one added mid-deploy rejects +writes from the old code that does not set it. + +**Reverting** is not an escape hatch either. Never `git revert` a commit that +added a migration: the revert deletes the migration and the schema line together, +so the CI gate is satisfied and nothing complains — while the revert has quietly +become a contract step against a column production still has, with no migration +to drop it. Write the drop as a new migration in a later release instead. + +Nothing lints for this yet. Every migration so far is additive, so a +destructive-change linter ([Squawk](https://squawkhq.com/) or +[Atlas](https://atlasgo.io/)) is deliberately deferred until the first +non-additive change — see +[#6](https://github.com/7174Andy/gitcron/issues/6). Add it then, before the +change that needs it. + ### 6. Run the development server ```bash @@ -235,22 +274,134 @@ npm run build # Production build Tests live in `__tests__` directories beside the code they cover and mock `@/lib/db`, `@/auth`, and `@/lib/crypto`, so none of them need a database or network. A local `npm run build` runs `prisma generate`, which does not connect -to anything, and skips the migration step — that step requires `VERCEL_ENV`, so -building locally never migrates the database you happen to be pointed at. +to anything. + +Three GitHub Actions workflows run outside your machine: + +- **`.github/workflows/ci.yml`** on every pull request and every push to `main`, + as three jobs: `test`, `lint`, and `Migrations match schema`. That last one + replays `prisma/migrations/` into a throwaway Postgres and diffs the result + against `prisma/schema.prisma` — red means the PR edits the schema without a + matching migration, and the fix is to run `npm run db:migrate` and commit what + it generates. +- **`.github/workflows/release.yml`** once CI has passed on `main`, covered in + [Deploying schema changes](#deploying-schema-changes) below. +- **`.github/workflows/schema-drift.yml`** daily at 07:00 UTC, diffing + production's real schema against `prisma/schema.prisma`. It is the alarm that + was missing in [#6](https://github.com/7174Andy/gitcron/issues/6), where + production ran without two columns for five days. + +Note that GitHub disables `schedule` triggers in a repository dormant for 60 +days. If the drift check goes quiet, confirm the schedule is still running rather +than reading silence as no drift: + +```bash +gh run list --workflow=schema-drift.yml +``` + +A red drift check sometimes just means a release is still pending: `ci.yml` +cancels superseded runs, and a cancelled CI run starts no release, so `main`'s +migrations wait for the next push. Production really is behind `main` in that +window, so the alarm is right — but the fix is a release, not a drift hunt, which +is why the workflow's failure output points at `gh run list +--workflow=release.yml`. ### Deploying schema changes -`npm run build` runs `scripts/migrate-deploy.mjs` between `prisma generate` and -`next build`. On a production Vercel build it runs `prisma migrate deploy`; -everywhere else it prints why it is skipping. +`.github/workflows/release.yml` is the only path to production that applies +migrations, and once `vercel.json` lands (below) the only path to production that +git can trigger. It starts when CI completes successfully on `main` — chained +off CI's completion rather than off the merge push, so a release cannot begin +until the migration gate, tests, and lint are green on that exact commit, which +it then checks out by SHA. It runs `prisma migrate deploy` first, and only if that +succeeds does it deploy to Vercel. A failed migration deploys nothing and Vercel +keeps serving the previous release. + +Migrations used to run inside the Vercel build. Ordering was correct there — +Vercel promotes only after a successful build — but a migration failure looked +like a build failure, and every retried or concurrent build re-ran it. +`concurrency: release` now runs one release at a time, so migrations cannot +interleave. + +**`vercel.json` is not in the repo yet.** When it lands it will disable Vercel's +git trigger for `main`, which is what stops Vercel deploying off the same push in +parallel with this workflow. Until then a merge to `main` produces *both* a +Vercel git deploy and this workflow's deploy, and the two race — harmless for a +release with nothing pending, a real race for any release that has a migration. +That is deliberate and temporary: whether `vercel deploy --prod` still works with +the git trigger disabled takes one real production deploy to find out, and +holding `vercel.json` back leaves the git deploy as a fallback if it does not, so +a broken deploy step cannot strand the project with no way to ship at all. It +follows in a one-file pull request as soon as the first release has proven the +CLI path, and no schema-changing merge should happen before it is in. Preview +deployments for other branches are unaffected either way. + +Even with `vercel.json` in place, only *git-triggered* deploys are disabled. Each +of these still ships code whose migrations were never applied, and the daily +drift check cannot detect it — that check compares production's database to +`main`'s schema, not to whatever code is deployed. So don't: + +- **Promote to Production** on a preview deployment, in the dashboard or with + `vercel promote` +- add a **Deploy Hook** for `main` +- use **Instant Rollback** +- run `vercel --prod` from a laptop + +Don't re-run an old run, of either workflow. `gh run rerun ` on an old +release run checks out that run's commit, `migrate deploy` finds nothing pending +and succeeds, and the deploy makes that old commit production — a silent rollback +the branch guard cannot catch, since the branch was `main` both times. Re-running +an old *CI* run does the same thing at one remove: its completion is a fresh +`workflow_run` success for that old commit, which starts a release of it. Release +the fix forward instead. + +The workflow reads four secrets — `DATABASE_URL`, `VERCEL_TOKEN`, +`VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` — from the `production` GitHub +environment, with deployment branches restricted to `main`. That restriction is +what scopes them: as plain repository secrets, a `workflow_dispatch` of any +workflow on any branch could read them. `schema-drift.yml` reads `DATABASE_URL` +from the same environment, which is why it declares `environment: production` +despite only reading. Rotating the database credential means updating Vercel, the +environment secret, and `PROD_DB_USER_SHA256` in `lib/dev-db-guard.mjs`. + +To release without merging anything — retrying a failed deploy, say — run it by +hand from `main`. `migrate deploy` is a no-op when nothing is pending: + +```bash +gh workflow run release.yml +``` + +#### When a migration fails -Preview builds skip it deliberately: a preview pointed at the production -database would apply an unmerged branch's migrations to production. If preview -deployments get their own database, drop that gate so previews migrate too. +Fixing the migration and merging again is not enough on its own. A migration +that failed part-way is recorded in `_prisma_migrations` with `finished_at` NULL, +and **every later `prisma migrate deploy` aborts with `P3009`** until that record +is resolved — including releases that touch no schema at all. Once `vercel.json` +lands and this is the only deploy path git can trigger, one bad migration freezes +every deploy until it is cleared. Until then it is the worse way round: the +migration stalls here while Vercel keeps deploying `main` off the same push, so +code ships without its migrations — issue #6 exactly. Either way, clear the +`P3009` before the next merge. A release cancelled or timed out mid-migration +leaves the same state. -A production build with no `DATABASE_URL` fails rather than skipping. Skipping -would restore the silence this script exists to remove, and failing is the safe -direction — Vercel keeps serving the previous deployment. +So look at the database, decide whether the failed migration's DDL actually +landed, and tell Prisma which of the two happened: + +```bash +prisma migrate resolve --rolled-back # the DDL did not land +prisma migrate resolve --applied # the DDL did land +``` + +`--rolled-back` puts the migration back in the pending set, so it is only correct +if the database really is unchanged — marking a partly-applied migration +`--rolled-back` just fails again on the statement that already succeeded. +`--applied` accepts its DDL as done, so whatever it did *not* finish has to be +written as a new migration. Either way, the next step is a forward migration and +another merge: migrations are forward-only, and an applied migration is never +edited. + +On a first release, an empty or wrong `DATABASE_URL` secret fails at the same +step, and is likelier than a bad migration. ## Development safeguards @@ -344,10 +495,27 @@ change the host side of the port mapping in `docker/docker-compose.yml`. - `CRON_SECRET` - `ENCRYPTION_KEY` 4. Deploy - -`DATABASE_URL` must be exposed to the **Build** step, not only the runtime — the -production build runs `prisma migrate deploy` and fails without it. See -[Deploying schema changes](#deploying-schema-changes). +5. Add the secrets `release.yml` needs, under **Settings → Environments** on the + GitHub repository, in an environment named `production` with deployment + branches restricted to `main`: + - `DATABASE_URL` — the production database, same value as in Vercel + - `VERCEL_TOKEN` — an account token from Vercel + - `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID` — from the Vercel project settings + +Step 4 is the last deploy you trigger from the Vercel side. After that a release +is `.github/workflows/release.yml`: merge to `main`, CI passes, and the workflow +applies migrations and then deploys — or `gh workflow run release.yml` to release +by hand. Watch that workflow rather than the Vercel dashboard, because it is the +thing that applies migrations. Vercel does still deploy `main` off its own git +trigger for now, and will stop once `vercel.json` lands; see [Deploying schema +changes](#deploying-schema-changes) for why it is not in the repo yet and what +races until it is. + +`DATABASE_URL` is needed by the running app, not by the Vercel build — `next +build` never touches the database. It is needed separately by GitHub Actions, in +the `production` environment above, because that is what `release.yml` uses to +run `prisma migrate deploy` before each release, and what `schema-drift.yml` uses +to check production daily. ### Create a production GitHub OAuth App diff --git a/package.json b/package.json index 5cff2cc..bb516e8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "prisma generate && node scripts/migrate-deploy.mjs && next build", + "build": "prisma generate && next build", "start": "next start", "lint": "eslint", "test": "vitest run", diff --git a/scripts/migrate-deploy.mjs b/scripts/migrate-deploy.mjs deleted file mode 100644 index 09c8030..0000000 --- a/scripts/migrate-deploy.mjs +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Applies pending migrations during a production build. - * - * This exists because nothing used to apply schema changes to production at - * all. `prisma generate` builds a client that assumes the schema; it never - * touches the database. So a merged schema change deployed a client whose - * queries referenced columns the production database did not have, and the - * failure only surfaced at runtime -- see the `Schedule.runUrl` / - * `Schedule.runConclusion` outage: creating and listing schedules broke, and - * the run-resolution pass errored on every cron tick for days. - * - * Gated on VERCEL_ENV rather than running everywhere, because a preview build - * pointed at the production database would apply that branch's migrations to - * production before the branch is merged. If preview deployments have their - * own database, drop the gate so previews migrate too. - * - * A missing DATABASE_URL is a hard failure, not a skip. Skipping would restore - * exactly the silence this script exists to remove, and a failed build is the - * safe direction: Vercel keeps serving the previous deployment. - */ -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import path from "node:path"; - -const vercelEnv = process.env.VERCEL_ENV; - -// Local builds (`npm run build`) have no VERCEL_ENV. Migrating a developer's -// database as a side effect of a build would be a surprise, so require Vercel. -if (!vercelEnv) { - console.log("• not a Vercel build - skipping `prisma migrate deploy`"); - process.exit(0); -} - -if (vercelEnv !== "production") { - console.log(`• VERCEL_ENV is "${vercelEnv}" - skipping \`prisma migrate deploy\``); - process.exit(0); -} - -if (!process.env.DATABASE_URL?.trim()) { - console.error( - "DATABASE_URL is not available to this production build, so pending\n" + - "migrations cannot be applied.\n\n" + - "Failing the build on purpose: deploying a Prisma Client whose schema\n" + - "the database does not have produces runtime errors on every query that\n" + - "touches a new column, and Vercel will keep serving the previous\n" + - "deployment until this is fixed.\n\n" + - "Expose DATABASE_URL to the Build step for the Production environment in\n" + - "the Vercel project's Environment Variables settings.\n", - ); - process.exit(1); -} - -// npm puts node_modules/.bin on PATH, but this file may also be run directly. -const local = path.join( - process.cwd(), - "node_modules", - ".bin", - process.platform === "win32" ? "prisma.cmd" : "prisma", -); - -const { status } = spawnSync( - existsSync(local) ? local : "prisma", - ["migrate", "deploy"], - { stdio: "inherit", env: process.env }, -); - -process.exit(status ?? 1);