Repository navigation
Deploy safety: D1 migration gate, /api/health, alert watchdog, runbooks - #203
Merged
Merged
Conversation
- migration-check.yml: on PRs touching drizzle/ or src/db/, apply every migration to a fresh local D1, fail on unapproved destructive statements (`migration-destructive: approved`) or edits to applied migrations, run drizzle-kit check, and diff the schema drizzle generates against the migrated database. - GET /api/health: D1 SELECT 1, KV read, worker version and the cron's last run (KV heartbeat written by the scheduled handler, 24 h TTL). 503 with per-check details when D1 or KV fails. - production-alerts.yml: every 15 minutes, opens/closes deduplicated `production-alert` issues for /api/health failing, D1 above 8 GB, the cron not running cleanly, and Worker error rate above 5%. Cloudflare Notifications has no type for any of these. - Runbooks: docs/runbooks/deploy-rollback.md and d1-full.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # apps/dashboard/src/entry-worker.ts
Contributor
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 19 minutes. View limit details
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. |
- Watchdog: a check that cannot be evaluated keeps an open firing issue instead of closing it; network failures and timeouts become `unknown` rather than crashing the run; one failed reconcile no longer skips the rest; error rate counts 5xx responses (the Worker catches errors and answers 500, which `sum.errors` misses). - Migration scan: diff against the merge commit's first parent so a stale base SHA can't blame main's migrations on the PR; flag ALTER ... RENAME and UPDATE without WHERE; comments strip to a space; drift compares partial, DESC and expression index columns. - Health checks time out after 5 s instead of 2 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
stylessh
force-pushed
the
diffkit/deploy-hardening
branch
from
November 8, 2026 06:15
c7f8662 to
d1874a8
Compare
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner
Author
|
@coderabbitai review |
Contributor
Action not completed
|
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.
First of two PRs that make deploys safe and observable after the 2026-11-08 D1-full incident. This one adds the migration gate,
/api/health, the alert watchdog and runbooks. The deploy workflow changes (bookmark, health gate, auto-rollback) follow in a second PR, because the gate depends on/api/healthbeing live.Migration gate:
.github/workflows/migration-check.ymlRuns on PRs that touch
apps/dashboard/drizzle/**,src/db/**ordrizzle.config.ts, and re-runs when the PR body is edited.scripts/check-d1-migrations.mjsruns four checks:DROP,ALTER … DROP COLUMN,ALTER … RENAME,TRUNCATE, or aDELETE/UPDATEwithoutWHEREin the PR's migration files (diffed against the merge commit's first parent), unless the PR body hasmigration-destructive: approved. Also fails when a PR edits or deletes an already-applied migration, since production never re-runs those (override:migration-edit: approved).wrangler d1 migrations apply --localinto a throwaway Miniflare state.drizzle-kit check.drizzle-kit generatefromsrc/db/schema.tsinto a temp dir, applied to an in-memory SQLite. Its tables, columns, indexes and foreign keys are compared with the database from step 2. The journal only covers 0000–0001 and later migrations are hand-written, so ageneratedry run against the journal would always report drift; this compares actual results instead. The four legacy cache tables are allowlisted until Drop the legacy cache tables after the production purge #198 drops them, and the check says when the allowlist can go.Locally against main, the gate passes in about 2 s: 7 migrations applied in 1.4 s. I verified each failure mode with throwaway commits:
0007_drop_legacy_cache_tables.sql, no markerDROP TABLEstatementsmigration-destructive: approvedin the bodyactorcolumn added togithub_changein the schema, no migrationgithub_change: schema has `actor text notnull=0 default=null pk=0`, migrations do not0005_user_forbidden_org.sql#198 will need
migration-destructive: approvedin its body. I'll leave a comment there.GET /api/healthNew route,
src/routes/api/health.ts, with the logic insrc/lib/health.server.ts. Each dependency check is a discriminated result (ok|failed, with latency and error). The top-levelstatusis derived in one place (overallStatus), and the HTTP code comes from it: 200 or 503.{"status":"ok","checkedAt":"…","version":{"status":"known","id":"285c121f-…","tag":"","uploadedAt":"…"}, "checks":{"d1":{"status":"ok","latencyMs":1,"sizeBytes":8192},"kv":{"status":"ok","latencyMs":0}}, "maintenance":{"status":"never_recorded"}}That is real output from a local production build (
wrangler devondist/server/wrangler.json).SELECT 1, plusmeta.size_after.GETof the maintenance heartbeat key.recent|stale|never_recorded|unreadable. The cron handler inentry-worker.tswrites a heartbeat to KV after each run ({ranAt, result}, 24 h TTL, 288 writes a day). This is informational and never causes a 503, because a missed cron shouldn't roll back a deploy. The watchdog alerts on it instead.CF_VERSION_METADATAbinding, which PR B adds. Until then it reports{"status":"unknown"}.Each check times out after 5 s. The response contains no secrets, only statuses, latencies, DB size and error messages. Test: one vitest covering the degrade-to-503 path (KV throws → 503,
kv.failedwith the error,d1still ok,maintenance: unreadable).The only edit to an existing file is the
scheduledhandler inentry-worker.ts, merged with #200's error-logging version, plus a KV stub in #200'sentry-worker.test.tsfixture.Alerts:
.github/workflows/production-alerts.ymlNo Cloudflare Notifications were created. I tried with the owner's wrangler OAuth token: every
/alerting/v3/*endpoint returns10000 Authentication error, because wrangler's OAuth scopes don't include notifications. Workers Observability queries returnForbidden. Separately, Cloudflare's available notifications list has no type for Worker error rate, cron failure or D1 storage.So this PR implements the design doc's fallback. Every 15 minutes a watchdog evaluates four checks. Each check is
firing|clear|unknown, and is reconciled against open issues labelledproduction-alert, deduplicated by title:https://diff-kit.com/api/health, 3 tries 20 s apartokfile_size(works when the Worker is down)/api/health→maintenancefiringopens an issue, or leaves the open one alone.clearcomments "Recovered" and closes it.unknownopens a "(check unavailable)" issue, so a broken watchdog check is never silent. If the check already has a firing issue open, it stays open; a blip doesn't close it.unknown. A failed GitHub write on one check doesn't skip the others; it fails the run.It uses
GITHUB_TOKEN(issues: write) and the existing Cloudflare secrets, with no new credentials. A local dry run against production (issue writes stubbed out) gave: health firing (route not deployed yet), D1 clear at 7.99 GB of 10 GB (79.9%), cron unknown, error rate clear with 0 errors in 5,360 requests. After merge I'll run it viaworkflow_dispatchand report what it opens.Runbooks
docs/runbooks/deploy-rollback.md: deploy phases, manual rollback, deploying with the gate skipped, migration timeouts, restoring D1 to the pre-deploy bookmark.docs/runbooks/d1-full.md: symptoms (inserts fail, updates work), measuring, a throttled purge loop (20k rows, 4 s pause, back off on 7429), dropping tables safely, Time Travel restore.docs/data-fetching.md→ "Monitoring and alerting" now describes what exists.Review fixes
An independent review found these, now fixed in d1874a8:
sum.errorsmissing caught 500s;base.shablaming main's migrations on the PR;RENAMEandUPDATEwithoutWHEREnot flagged;DROP/**/TABLEevading the scan;Checks run
pnpm lint,pnpm check-types,pnpm testpass (dashboard: 41 files, 345 tests) after merging origin/main (Harden server routes: graceful storage failures, auth/redirect fixes, +150 tests #200).actionlint1.7.12 passes on all workflows.🤖 Generated with Claude Code