From 236d2a35e426ff084a7449101c6208015e21089a Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 09:13:07 +0300 Subject: [PATCH] ci(release): apply pending migrations before publishing the image Nothing in the pipeline ran migrations. release.yml cut a version and pushed an image; the Dockerfile starts the server directly; db:deploy existed in package.json with no caller. Applying migrations was a manual step somebody had to remember. It was missed for the notification deep-link columns, so production ran code that selects notifications.postId, articleId and commentId against a database without them. GET /notifications answered 500 with a PrismaClientKnownRequestError, and every write that creates a notification was in the same position. A migrate job now runs between the release and the image push, so the schema is in place before the image that expects it can be pulled. It uses deploy, which only applies what has not run yet and takes an advisory lock, and it prints migrate status first so the run log records what each release applied. A missing DATABASE_URL secret fails the job with a message that names the secret rather than a Prisma error. The job deliberately does not set NODE_ENV=production: pnpm would skip devDependencies, and the Prisma CLI it runs is one of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PwzkQ5YGFXSB9jWCZKzX4H --- .github/workflows/release.yml | 65 ++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02727be..ab21fec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,10 +42,73 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + migrate: + name: Apply Database Migrations + runs-on: ubuntu-latest + needs: release + # Only when semantic-release cut a version, and before the image that + # expects the new schema becomes pullable. + if: ${{ needs.release.outputs.new_release_published == 'true' }} + # Gives the run a deployment record, and a place to hang required + # reviewers on if this should ever need approval. + environment: production + env: + # Deliberately no NODE_ENV=production here: pnpm would then skip + # devDependencies, and the Prisma CLI this job runs is one. The + # datasource url comes from the step env either way - prisma.config + # reads process.env.DATABASE_URL, and the .env file it looks for + # does not exist on a runner. + HUSKY: "0" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Verify the database URL is configured + # Without this the job would fail deeper in, with a Prisma error + # that does not say the secret is the thing that is missing. + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + if [ -z "$DATABASE_URL" ]; then + echo "::error::The DATABASE_URL secret is not set. Migrations cannot be applied, so the release would ship code ahead of its schema." + exit 1 + fi + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Show Pending Migrations + # Records in the run log what this release is about to apply. + # Exits non-zero precisely when there is something pending, which + # is the normal case here, so its status is not the job's. + continue-on-error: true + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: pnpm prisma migrate status + + - name: Apply Migrations + # deploy, never dev or reset: it only applies migrations that have + # not run yet, never rewrites or drops anything, and takes an + # advisory lock so overlapping releases queue instead of racing. + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: pnpm db:deploy + docker-push: name: Push Docker Image to GHCR runs-on: ubuntu-latest - needs: release + # Waits on migrate as well: an image whose schema has not been applied + # is exactly how notifications started answering 500 in production. + needs: [release, migrate] # Only runs when semantic-release actually created a new version if: ${{ needs.release.outputs.new_release_published == 'true' }} steps: