Skip to content

Deploy safety: staging tier, real smoke test, destructive-migration gate, failing health checks - #2125

Open
2witstudios wants to merge 4 commits into
masterfrom
pu/staging-deploy
Open

Deploy safety: staging tier, real smoke test, destructive-migration gate, failing health checks#2125
2witstudios wants to merge 4 commits into
masterfrom
pu/staging-deploy

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Implements section 6 of the deploy-safety audit (tasks/solo-tells-audit-2026-07-17.md):

"No staging env at all... no post-deploy smoke test... prod tracks mutable :latest...
Health checks can't fail... laptop deploy script silently skips migrations."

Four independent pieces, each committed separately:

  1. /api/health returns 503 on sustained DB outage (fix(health) commit) — it
    previously always returned HTTP 200 even when the DB check failed, so Fly's rolling
    deploy and any uptime monitor could never detect a degraded instance. Requires 3
    consecutive failures before flipping to 503, so one transient blip doesn't fail a
    healthy rolling deploy.

  2. Destructive-migration CI gate (feat(ci): destructive-migration commit) — a new
    migration-safety job fails a PR when a newly-added packages/db/drizzle*/*.sql
    migration contains a destructive statement (DROP TABLE/COLUMN/TYPE, TRUNCATE, a
    column type change, an enum-swap rename, or NOT NULL added without a DEFAULT)
    without a leading -- destructive-migration-ack: <reason> comment. Per-file,
    newly-added-files-only — never retroactive. Covers both packages/db/drizzle and
    the drizzle-admin trust-plane migrations.

  3. Staging tier + real post-deploy smoke test + sha-pinned deploys
    (feat(ci): staging tier commit) — docker-images.yml is now
    build-and-push -> deploy-staging -> smoke-test -> deploy-fly (prod). Every deploy
    pulls an immutable sha-<short-sha> tag instead of :latest, so the exact artifact
    smoke-tested in staging is the one promoted to prod. smoke-test polls
    /api/health until it reports status: healthy AND checks.database: connected
    if it doesn't, prod is never touched. The ~200 lines of duplicated
    migration-polling/deploy bash were extracted into
    scripts/deploy/{run-fly-migration,deploy-fly-service,smoke-test}.sh so staging and
    prod share one implementation.

  4. Fixed the silent migration-skip footgun — companion PR in PageSpace-Deploy:
    2witstudios/PageSpace-Deploy#19. deploy-fly.sh's run_migrations() used to scrape
    DATABASE_URL out of flyctl secrets list (which never prints values) and silently
    skip migrations on an empty scrape. Also adds fly.web.staging.toml for the new
    staging app.

Review round 1 (Codex) — fixed

  • P1: check-destructive-migrations.ts treated SQL comments as live code, so a
    comment mentioning "DEFAULT" or "SERIAL" could suppress a real destructive match.
    Fixed with stripSqlComments() applied per-statement before the exemption checks;
    added regression tests.
  • P2: the gate only scanned packages/db/drizzle, missing the drizzle-admin
    trust-plane migrations. Now scans both.
  • P2: docker-images.yml's push-path filter didn't include scripts/deploy/**,
    so a fix to those helper scripts alone wouldn't trigger the pipeline. Added.

All three replied to inline and fixed in 15203ac80 — see thread replies for detail.

Owner action required (this PR's CI will not pass end-to-end until these are done)

  • Merge companion PR 2witstudios/PageSpace-Deploy#19 first
  • flyctl apps create pagespace-web-staging
  • Create an isolated pagespace_staging database + role on the existing
    pagespace-db machine (exact commands in PageSpace-Deploy/fly/FLY.md
    "Staging Environment")
  • flyctl secrets set --app pagespace-web-staging DATABASE_URL='...' CRON_SECRET='...'
  • First manual deploy so the app has a running release before CI takes over
    (mirror-then-deploy steps in PageSpace-Deploy/fly/FLY.md)
  • Add a fly-staging GitHub environment on this repo with a FLY_API_TOKEN
    secret (can reuse the fly-production token, or scope a tighter one to just
    pagespace-web-staging)

Scope notes / deliberate non-goals

  • Staging only covers pagespace-web (+ its own DB). No staging realtime / processor
    / admin / cron / marketing / proxy — those aren't on the migration hot path
    this exists to protect; extending parity is a follow-up, not assumed here.
  • Staging reuses the exact prod-built image (same sha tag, no separate "staging build"),
    so NEXT_PUBLIC_* values still point at pagespace.ai. This validates backend/DB/
    migration behavior, not full user-facing UI staging.
  • No automatic rollback on a failed prod deploy — out of scope for this PR.

Test plan

  • scripts/check-destructive-migrations.ts — 24 unit tests, all passing (incl.
    comment-stripping and drizzle-admin regression cases added in review round 1)
  • apps/web/src/app/api/health/__tests__/route.test.ts — 13 unit tests, all passing
    (including the new debounce/recovery cases)
  • scripts/deploy/smoke-test.sh manually exercised against local fake HTTP servers:
    healthy response (pass), degraded response (fail + retries), connection-refused
    (fail + retries)
  • .github/workflows/docker-images.yml validated as well-formed YAML with the
    expected job dependency graph (ci, build-and-pushdeploy-staging
    smoke-testdeploy-fly)
  • All CI checks green (Security Test Suite, Unit Tests, Lint & TypeScript, Destructive
    Migration Check, CodeQL, Secret Scanning, Dependency Audit, Static Security Analysis)
  • CodeRabbit + Codex automated review passes clean on the latest commit
  • End-to-end: needs the owner setup above before a real push to master can
    exercise deploy-staging/smoke-test/deploy-fly for real

🤖 Generated with Claude Code

https://claude.ai/code/session_01RK2U2tc6buCvpJWCs49mtN

Adds a migration-safety CI job that fails a PR when a newly added
packages/db/drizzle/*.sql migration contains a destructive statement
(DROP TABLE/COLUMN/TYPE, TRUNCATE, a column type change, an enum-swap
rename, or a NOT NULL column added without a DEFAULT) and doesn't carry
a leading `-- destructive-migration-ack: <reason>` comment.

Scope is per-file, newly-added-files-only — existing migrations are
never retroactively flagged.

Part of the deploy-safety audit (tasks/solo-tells-audit-2026-07-17.md
section 6): forward-only destructive migrations currently run before
old app code retires with no expand/contract discipline enforced.
/api/health previously always returned HTTP 200 even when the database
check failed — Fly's rolling-deploy health check and any external
uptime monitor could never detect a degraded instance.

Require 3 consecutive DB failures before flipping the HTTP status to
503, so one transient blip (e.g. a slow reconnect right as the grace
period elapses) doesn't fail a healthy rolling deploy. Monitoring
misconfiguration stays a 200-with-warning — it's a config issue, not a
traffic-serving failure, and shouldn't cycle machines.

Part of the deploy-safety audit (tasks/solo-tells-audit-2026-07-17.md
section 6): "Health checks can't fail."
Replaces the direct build -> deploy-prod pipeline with:
  build-and-push -> deploy-staging -> smoke-test -> deploy-fly (prod)

- Every deploy (staging and prod) now pulls an immutable sha-<short-sha>
  tag instead of the mutable :latest — the artifact smoke-tested in
  staging is bit-for-bit the one promoted to prod.
- deploy-staging deploys pagespace-web to a new pagespace-web-staging
  Fly app (config in PageSpace-Deploy, fly.web.staging.toml) and runs
  migrations against an isolated staging database first.
- smoke-test polls the deployed instance's /api/health until it
  reports status=healthy AND checks.database=connected (not just "the
  process answers HTTP") before prod is touched at all.
- Extracted the ~200 lines of inline migration-polling and
  pull/tag/push/deploy bash that were duplicated per-service into
  scripts/deploy/{run-fly-migration,deploy-fly-service,smoke-test}.sh,
  parameterized by app/image/tag, so staging and prod share one
  implementation instead of drifting.

Part of the deploy-safety audit (tasks/solo-tells-audit-2026-07-17.md
section 6): "no staging env at all... no post-deploy smoke test...
prod tracks mutable :latest."

Requires manual owner setup before this can pass — see the PR
description checklist (new Fly app, staging DB, fly-staging GitHub
environment).
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8e1893dc-9510-4bd3-ac87-c23e0b914c23

📥 Commits

Reviewing files that changed from the base of the PR and between 3e9e0a7 and 15203ac.

📒 Files selected for processing (10)
  • .github/workflows/ci.yml
  • .github/workflows/docker-images.yml
  • apps/web/src/app/api/health/__tests__/route.test.ts
  • apps/web/src/app/api/health/route.ts
  • scripts/__tests__/check-destructive-migrations.test.ts
  • scripts/check-destructive-migrations.ts
  • scripts/deploy/deploy-fly-service.sh
  • scripts/deploy/run-fly-migration.sh
  • scripts/deploy/smoke-test.sh
  • scripts/vitest.config.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/staging-deploy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 903c28e073

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +39 to +40
!/\bDEFAULT\b/i.test(s) &&
!/\b(BIG)?SERIAL\b/i.test(s), // serial/bigserial self-populate via an implicit sequence default

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip SQL comments before checking for DEFAULT

When a destructive migration includes an explanatory comment containing DEFAULT—for example, -- table is empty, so no DEFAULT is needed before ADD COLUMN ... NOT NULL—the check treats that comment as part of the statement, DEFAULT matches here, and the migration passes without an acknowledgement. Because the same problem applies to SERIAL, comments and ideally string literals must be removed before applying these exemption patterns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15203ac: added stripSqlComments() (strips -- and /* */ comments) and now run it on each statement before testing the DEFAULT/SERIAL exemption regexes — a comment mentioning DEFAULT/SERIAL can no longer suppress a real destructive match. Added regression tests for both cases plus a comment-only-DROP-TABLE false-positive case.

Comment thread scripts/check-destructive-migrations.ts Outdated
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const MIGRATIONS_DIR = 'packages/db/drizzle';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include admin migrations in the destructive gate

The production workflow conditionally runs db:migrate:admin, whose migrations come from packages/db/drizzle-admin, but this gate searches only packages/db/drizzle. Once ADMIN_DB_MIGRATIONS_ENABLED=true, a newly added destructive admin migration can therefore reach the production trust-plane database without the acknowledgement required for equivalent main-database changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15203ac: MIGRATIONS_DIRS now includes packages/db/drizzle-admin alongside packages/db/drizzle, and addedMigrationFiles() scans both. Verified against bun scripts/check-destructive-migrations.ts master locally.

docker pull "$FULL_GHCR_IMAGE"
docker tag "$FULL_GHCR_IMAGE" "$FLY_IMAGE"
docker push "$FLY_IMAGE"
flyctl deploy --app "$FLY_APP" --image "$FLY_IMAGE" --wait-timeout "$WAIT_TIMEOUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger deployments when deploy helpers change

These new helper scripts are executed directly from the checked-out commit, but .github/workflows/docker-images.yml limits push triggers to apps/**, packages/**, docker/**, bun.lock, and the workflow itself. Consequently, merging a fix that changes only scripts/deploy/** will not run the deployment pipeline, leaving the fix unapplied until an unrelated matching change is merged.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 15203ac: added scripts/deploy/** to the docker-images.yml push paths filter.

- P1: strip SQL comments before testing for a DEFAULT/SERIAL exemption.
  A migration whose explanatory comment happens to contain the word
  "DEFAULT" or "SERIAL" (e.g. "-- table is empty, no DEFAULT needed")
  was satisfying the regex and letting a genuinely destructive
  ADD COLUMN ... NOT NULL through unacknowledged. Added
  stripSqlComments() and apply it per-statement before running
  DESTRUCTIVE_CHECKS (comments still count for ACK_PATTERN, which
  needs the real ack comment).
- P2: the gate only scanned packages/db/drizzle, missing the
  drizzle-admin trust-plane migrations that run via db:migrate:admin
  once ADMIN_DB_MIGRATIONS_ENABLED=true. Now scans both directories.
- P2 (docker-images.yml): added scripts/deploy/** to the push path
  filter — those scripts run directly from the checked-out commit, not
  baked into any image, so a fix there previously wouldn't trigger the
  deploy pipeline until an unrelated apps/packages change also landed.
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

1 participant