Files
Jyotisha/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md
T
2026-07-21 07:45:14 +08:00

1045 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PostgreSQL and Backend Quality-Gate Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the self-hosted PostgreSQL staging foundation, least-privilege roles, reviewed SQL migration runner, automatic backend quality gate, immutable GHCR images, and exact-SHA staging deploy path without moving authentication or business traffic off Supabase.
**Architecture:** PostgreSQL 17 runs on the private Compose network of the Hong Kong staging VPS. A separate deployment-user-owned database env file supplies bootstrap and schema credentials only to PostgreSQL and an opt-in migrator; normal web/API containers never receive them. Reviewed plain SQL is the schema source of truth, while `pg` and Drizzle provide the future runtime seam. PRs and `staging` pushes run database/backend/frontend/configuration tests; successful `staging` pushes publish web/API images and a run-bound SHA-to-digest manifest, and staging deploys only those exact digests. Migrations remain a separate manual workflow.
**Tech Stack:** PostgreSQL 17 Alpine, Docker Compose, Node.js 22, Next.js 16, TypeScript, `pg`, Drizzle ORM, Python 3.12, GitHub Actions, GHCR, Bash, OpenSSL.
## Global Constraints
- Scope is only Milestone 1 of `docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md`.
- Prerequisite: merge `codex/staging-deployment-automation` commit `801666a6c71b8efc220afa4248f42c5c776ba9e6` into `main`, then create the implementation worktree from that updated `main`.
- Before starting, these prerequisite files must exist: `.github/workflows/deploy-staging.yml`, `deploy/Caddyfile.staging`, and `deploy/validate-staging-env.sh`.
- Supabase remains source of truth. Do not add Better Auth, identity cutover, admin UI, dual writes, or business-table migration in this milestone.
- Staging PostgreSQL has no published port. Only the CI overlay may bind a loopback port.
- `.env.staging.database` is server-side only, mode `0600`, and excluded from Git/rsync. It contains bootstrap and migration credentials. `.env.staging` must not contain them.
- Normal web/API containers never receive `SCHEMA_DATABASE_URL`.
- App deployment never runs schema migration. Migration is manual and separately serialized.
- Before changing app containers, staging deploy runs the exact SHA image in read-only migration-check mode. No pending migration means automatic continuation. Pending or checksum-drifted migration stops before app changes; a successful manual migration dispatches staging deploy again for the same full SHA.
- Production defaults remain manual-only and unchanged.
- Staging publication uses full Git SHA tags for discovery, but deployment is
authorized and pinned by the build outputs' `sha256` manifest digests. Never
deploy a mutable tag such as `latest`, or treat a tag alone as image identity.
- The `main` workflow revision is the trusted deployment controller. Target and
rollback SHAs must already be ancestors of reviewed `main`; their code is
represented by the digest-pinned images, but their validators and remote
orchestration scripts are never executed with staging Environment privileges.
- Finish each task with the focused commit shown.
## Planned Files
```text
.github/workflows/backend-quality-gate.yml
.github/workflows/deploy-staging.yml
.github/workflows/migrate-staging-database.yml
deploy/backup-staging-postgres.sh
deploy/docker-compose.postgres-ci.yml
deploy/docker-compose.postgres.yml
deploy/docker-compose.server.yml
deploy/postgres/001-bootstrap-roles.sh
deploy/validate-staging-database-env.sh
frontend/db/migrations/20260720000100_backend_foundation.sql
frontend/scripts/db-migrate.mjs
frontend/src/lib/db/client.ts
frontend/src/lib/db/config.ts
frontend/tests/database-backup.test.ts
frontend/tests/database-foundation.test.ts
frontend/tests/database-topology.test.ts
frontend/tests/helpers/postgres-fixture.ts
frontend/tests/staging-backend-workflows.test.ts
```
---
### Task 1: Private PostgreSQL topology and roles
**Files:**
- Create: `deploy/docker-compose.postgres.yml`
- Create: `deploy/docker-compose.postgres-ci.yml`
- Create: `deploy/postgres/001-bootstrap-roles.sh`
- Create: `deploy/validate-staging-database-env.sh`
- Create: `frontend/tests/helpers/postgres-fixture.ts`
- Create: `frontend/tests/database-topology.test.ts`
- Modify: `frontend/package.json`
- [ ] **Step 1: Verify prerequisite**
```bash
git merge-base --is-ancestor 801666a6c71b8efc220afa4248f42c5c776ba9e6 HEAD
test -f .github/workflows/deploy-staging.yml
test -f deploy/Caddyfile.staging
test -x deploy/validate-staging-env.sh
```
Expected: all exit `0`. Otherwise stop; do not duplicate the prerequisite branch.
- [ ] **Step 2: Write the failing topology test**
Add `frontend/tests/helpers/postgres-fixture.ts` exporting:
```ts
export type PostgresFixture = {
projectName: string;
databaseEnvFile: string;
hostPort: number;
connectionUrl(role: string, password: string): string;
psql(sql: string): string;
stop(): void;
};
export function startPostgresFixture(): PostgresFixture;
```
It creates a mode-`0600` temp env, chooses an unused port from `55432..55531`, starts Compose with the two files below and `--wait postgres`, and always runs `down -v --remove-orphans` in `stop()`. Use only these deterministic test values:
```text
POSTGRES_DB=jyotisha
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres-test-password
SCHEMA_OWNER_PASSWORD=schema-owner-test-password
IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password
APP_RUNTIME_PASSWORD=app-runtime-test-password
ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password
MIGRATION_RUNNER_PASSWORD=migration-runner-test-password
BACKUP_READER_PASSWORD=backup-reader-test-password
STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password
SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha
```
Add `frontend/tests/database-topology.test.ts`:
```ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture";
test("staging postgres is private and CI binds loopback only", () => {
const staging = readFileSync("../deploy/docker-compose.postgres.yml", "utf8");
const ci = readFileSync("../deploy/docker-compose.postgres-ci.yml", "utf8");
assert.match(staging, /image:\s*postgres:17-alpine/);
assert.doesNotMatch(staging, /^\s+ports:/m);
assert.match(ci, /127\.0\.0\.1:\$\{POSTGRES_HOST_PORT:-55432\}:5432/);
});
test("database roles have no cluster privileges", () => {
const fixture = startPostgresFixture();
try {
assert.equal(
fixture.psql(`
select rolname || ':' || rolsuper || ':' || rolcreatedb || ':' ||
rolcreaterole || ':' || rolbypassrls
from pg_roles
where rolname in ('schema_owner','identity_runtime','app_runtime',
'admin_runtime','migration_runner','backup_reader')
order by rolname
`),
[
"admin_runtime:f:f:f:f",
"app_runtime:f:f:f:f",
"backup_reader:f:f:f:f",
"identity_runtime:f:f:f:f",
"migration_runner:f:f:f:f",
"schema_owner:f:f:f:f",
].join("\n"),
);
} finally {
fixture.stop();
}
});
```
Add:
```json
"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts"
```
- [ ] **Step 3: Confirm red**
```bash
cd frontend && npm run test:db
```
Expected: FAIL because the Compose topology does not exist.
- [ ] **Step 4: Add staging and CI Compose files**
Create `deploy/docker-compose.postgres.yml`:
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
shm_size: 128mb
env_file:
- ${DATABASE_ENV_FILE:-../.env.staging.database}
command:
- postgres
- -c
- max_connections=30
- -c
- shared_buffers=256MB
- -c
- effective_cache_size=1GB
- -c
- work_mem=4MB
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/001-bootstrap-roles.sh:/docker-entrypoint-initdb.d/001-bootstrap-roles.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks: [app]
migrator:
image: ${WEB_IMAGE:-jyotisha-web:local}
profiles: ["migration"]
restart: "no"
env_file:
- ${DATABASE_ENV_FILE:-../.env.staging.database}
working_dir: /app/frontend
command: ["npm", "run", "db:migrate"]
depends_on:
postgres:
condition: service_healthy
networks: [app]
migration-checker:
image: ${WEB_IMAGE:-jyotisha-web:local}
profiles: ["migration-check"]
restart: "no"
env_file:
- ${DATABASE_ENV_FILE:-../.env.staging.database}
working_dir: /app/frontend
command: ["npm", "run", "db:migrate:check"]
depends_on:
postgres:
condition: service_healthy
networks: [app]
volumes:
postgres_data:
networks:
app:
```
Create `deploy/docker-compose.postgres-ci.yml`:
```yaml
services:
postgres:
ports:
- "127.0.0.1:${POSTGRES_HOST_PORT:-55432}:5432"
```
- [ ] **Step 5: Implement idempotent role bootstrap**
Create executable `deploy/postgres/001-bootstrap-roles.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
set +x
required=(
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD
SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD
ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD
)
for key in "${required[@]}"; do
if [ -z "${!key:-}" ]; then
echo "required database bootstrap variable is missing: $key" >&2
exit 1
fi
done
psql --set ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
--set database_name="$POSTGRES_DB" \
--set schema_owner_password="$SCHEMA_OWNER_PASSWORD" \
--set identity_runtime_password="$IDENTITY_RUNTIME_PASSWORD" \
--set app_runtime_password="$APP_RUNTIME_PASSWORD" \
--set admin_runtime_password="$ADMIN_RUNTIME_PASSWORD" \
--set migration_runner_password="$MIGRATION_RUNNER_PASSWORD" \
--set backup_reader_password="$BACKUP_READER_PASSWORD" <<'SQL'
SELECT format(
'CREATE ROLE schema_owner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'schema_owner_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'schema_owner'
) \gexec
SELECT format(
'CREATE ROLE identity_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'identity_runtime_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'identity_runtime'
) \gexec
SELECT format(
'CREATE ROLE app_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'app_runtime_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'app_runtime'
) \gexec
SELECT format(
'CREATE ROLE admin_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'admin_runtime_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'admin_runtime'
) \gexec
SELECT format(
'CREATE ROLE migration_runner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'migration_runner_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'migration_runner'
) \gexec
SELECT format(
'CREATE ROLE backup_reader WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L',
:'backup_reader_password'
) WHERE NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader'
) \gexec
SELECT format(
'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner',
:'database_name'
) \gexec
SELECT format(
'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader',
:'database_name'
) \gexec
SQL
```
Run `chmod +x deploy/postgres/001-bootstrap-roles.sh`. Do not grant role membership, `BYPASSRLS`, database ownership, or public-schema creation.
- [ ] **Step 6: Add database-env validator**
Create executable `deploy/validate-staging-database-env.sh`. Reuse the safe parser pattern in `validate-staging-env.sh`, never `source`/`eval`. Require exactly once and non-empty:
```text
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD
SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD
ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD
STAGING_BACKUP_ENCRYPTION_KEY SCHEMA_DATABASE_URL
```
Reject missing files, symlinks, foreign ownership, or mode other than `600`. Require `POSTGRES_DB=jyotisha`, `POSTGRES_USER=postgres`, and a schema URL shaped as `postgresql://schema_owner:<encoded>@postgres:5432/jyotisha`. Print values never; success output is exactly `staging database environment validated`.
- [ ] **Step 7: Verify**
```bash
chmod +x deploy/postgres/001-bootstrap-roles.sh \
deploy/validate-staging-database-env.sh
cd frontend && npm run test:db
```
Expected: PASS and fixture volumes removed.
- [ ] **Step 8: Commit**
```bash
git add deploy/docker-compose.postgres.yml deploy/docker-compose.postgres-ci.yml \
deploy/postgres/001-bootstrap-roles.sh deploy/validate-staging-database-env.sh \
frontend/tests/helpers/postgres-fixture.ts frontend/tests/database-topology.test.ts \
frontend/package.json
git commit -m "feat: add private staging postgres topology"
```
---
### Task 2: Reviewed SQL migrations and runtime DB seam
**Files:**
- Create: `frontend/scripts/db-migrate.mjs`
- Create: `frontend/db/migrations/20260720000100_backend_foundation.sql`
- Create: `frontend/src/lib/db/config.ts`
- Create: `frontend/src/lib/db/client.ts`
- Create: `frontend/tests/database-foundation.test.ts`
- Modify: `frontend/package.json`
- Modify: `frontend/package-lock.json`
- Modify: `deploy/railway-web.Dockerfile`
- [ ] **Step 1: Write failing tests**
`frontend/tests/database-foundation.test.ts` must test:
1. `readDatabaseUrl({}, "APP_DATABASE_URL")` throws `APP_DATABASE_URL is required`.
2. First `node scripts/db-migrate.mjs` applies one file and records a 64-character checksum.
3. Second run is a no-op with the same ledger row.
4. Applying a copied migration directory, changing one byte, then rerunning exits non-zero with `migration checksum mismatch: <filename>`.
5. `app_runtime` cannot `CREATE SCHEMA` or select `migration.schema_migrations`.
6. Test stderr/output never includes any fixture password.
Spawn the runner with only `SCHEMA_DATABASE_URL` and optional `MIGRATIONS_DIRECTORY`.
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:db
```
Expected: FAIL on missing runner/config/migration.
- [ ] **Step 3: Install runtime packages**
```bash
cd frontend
npm install pg drizzle-orm
npm install --save-dev @types/pg
```
Add `"db:migrate": "node scripts/db-migrate.mjs"` and
`"db:migrate:check": "node scripts/db-migrate.mjs --check"`.
- [ ] **Step 4: Add typed URL config and lazy client**
Create `frontend/src/lib/db/config.ts`:
```ts
export type DatabaseUrlKey =
| "IDENTITY_DATABASE_URL"
| "APP_DATABASE_URL"
| "ADMIN_DATABASE_URL";
export function readDatabaseUrl(
env: NodeJS.ProcessEnv,
key: DatabaseUrlKey,
): string {
const value = env[key]?.trim();
if (!value) throw new Error(`${key} is required`);
if (!value.startsWith("postgresql://")) {
throw new Error(`${key} must be a PostgreSQL URL`);
}
return value;
}
```
Create `frontend/src/lib/db/client.ts`:
```ts
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
export type DomainDatabase = { pool: Pool; db: NodePgDatabase };
export function createDomainDatabase(
connectionString: string,
maxConnections = 5,
): DomainDatabase {
const pool = new Pool({
connectionString,
max: maxConnections,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
application_name: "jyotisha-web",
});
return { pool, db: drizzle(pool) };
}
```
Do not instantiate a global pool yet.
- [ ] **Step 5: Implement `db-migrate.mjs`**
Export:
```js
export async function runMigrations({
connectionString,
migrationsDirectory,
logger = console,
}) {}
```
Required behavior:
- Accept only sorted `/^\d{14}_[a-z0-9_]+\.sql$/` files.
- SHA-256 exact file bytes.
- One `pg.Client`.
- Acquire `select pg_advisory_lock(hashtext('jyotisha_schema_migrations'))`.
- Create `migration` owned by `schema_owner`, revoke public access, and create:
```sql
create table if not exists migration.schema_migrations (
filename text primary key,
checksum text not null check (length(checksum) = 64),
applied_at timestamptz not null default now()
);
```
- Matching row: log `already applied <filename>`.
- Changed checksum: throw `migration checksum mismatch: <filename>`.
- New file: `BEGIN`, execute file, insert ledger row, `COMMIT`; rollback on error.
- With `--check`, perform no DDL/DML: compare exact files with the existing ledger, print pending filenames only, exit `0` when current, exit `3` when any file is pending, and exit `1` on checksum drift or unsafe failure. A missing ledger means every file is pending.
- Release lock and close in `finally`.
- Never log URL, SQL, env, or driver config.
- Direct invocation defaults to `frontend/db/migrations`, requires `SCHEMA_DATABASE_URL`, prints safe filename-only errors, and exits `1`.
- [ ] **Step 6: Add foundation migration**
Create `frontend/db/migrations/20260720000100_backend_foundation.sql`:
```sql
create schema if not exists identity authorization schema_owner;
create schema if not exists audit authorization schema_owner;
revoke all on schema public from public;
revoke all on schema identity from public;
revoke all on schema audit from public;
grant usage on schema identity to identity_runtime, admin_runtime;
grant usage on schema public to app_runtime, admin_runtime;
grant usage on schema audit to admin_runtime;
alter default privileges for role schema_owner in schema identity
revoke all on tables from public;
alter default privileges for role schema_owner in schema public
revoke all on tables from public;
alter default privileges for role schema_owner in schema audit
revoke all on tables from public;
```
Do not create business or auth tables. The foundation grants schema discovery only;
later reviewed migrations grant access to named tables and narrow functions. Never
grant runtime roles broad default DML on future tables.
- [ ] **Step 7: Put runner in final web image**
Before the existing `RUN npm run build && npm prune --omit=dev` line in the single-stage `deploy/railway-web.Dockerfile`, add:
```dockerfile
COPY frontend/scripts ./scripts
COPY frontend/db ./db
```
Keep `pg` in production dependencies.
- [ ] **Step 8: Verify**
```bash
cd frontend && npm run test:db
cd ..
docker build -f deploy/railway-web.Dockerfile \
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder \
-t jyotisha-web:migration-foundation .
docker run --rm --entrypoint node jyotisha-web:migration-foundation \
scripts/db-migrate.mjs
```
Expected: tests and build PASS; last command exits `1` with only `SCHEMA_DATABASE_URL is required`.
- [ ] **Step 9: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/scripts/db-migrate.mjs \
frontend/db/migrations/20260720000100_backend_foundation.sql \
frontend/src/lib/db/config.ts frontend/src/lib/db/client.ts \
frontend/tests/database-foundation.test.ts deploy/railway-web.Dockerfile
git commit -m "feat: add reviewed postgres migration foundation"
```
---
### Task 3: Encrypted local staging backups
**Files:**
- Create: `deploy/backup-staging-postgres.sh`
- Create: `frontend/tests/database-backup.test.ts`
- [ ] **Step 1: Write failing integration test**
Start the Postgres fixture, run the backup script four times with deterministic `BACKUP_TIMESTAMP` values, then assert:
- Each invocation exits `0`.
- Completed names match `jyotisha-staging-YYYYMMDDTHHMMSSZ.dump.enc`.
- No `.partial` remains and only the newest three encrypted files remain.
- Decryption with `openssl enc -d -aes-256-cbc -pbkdf2` produces a dump accepted by `pg_restore --list`.
- Output contains no fixture passwords.
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:db
```
Expected: FAIL because the script is absent.
- [ ] **Step 3: Implement backup script**
Interface:
```text
backup-staging-postgres.sh DATABASE_ENV_FILE BACKUP_DIRECTORY
```
Use `set -euo pipefail`, `set +x`, validate the env first, refuse disk usage `>=70%`, create directory `0700`, output file `0600`, and run:
```bash
DATABASE_ENV_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
export DATABASE_ENV_FILE
docker compose -p "${COMPOSE_PROJECT_NAME:-jyotisha-staging}" \
-f deploy/docker-compose.postgres.yml exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner |
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass env:STAGING_BACKUP_ENCRYPTION_KEY > "$PARTIAL_FILE"
```
Atomically rename after success. Delete only older matching dumps inside the explicit backup directory, retaining three. Print path/count only.
- [ ] **Step 4: Verify and commit**
```bash
chmod +x deploy/backup-staging-postgres.sh
cd frontend && npm run test:db
cd ..
git add deploy/backup-staging-postgres.sh frontend/tests/database-backup.test.ts
git commit -m "feat: add encrypted staging database backups"
```
---
### Task 4: Automatic backend gate and GHCR publishing
**Files:**
- Create: `.github/workflows/backend-quality-gate.yml`
- Create: `frontend/tests/staging-backend-workflows.test.ts`
- Modify: `frontend/package.json`
- [ ] **Step 1: Write failing workflow contracts**
Assert the new workflow:
- Is named `Staging Backend Quality Gate`.
- Runs on PR, push to `staging`, and manual dispatch.
- Cancels superseded same-ref runs.
- Runs `npm run test:db`, frontend tests/lint/build, and the exact passing Python quick gate from `.github/workflows/ci.yml`.
- Publishes only after validation and only on `staging` push.
- Gives `packages: write` only to publish.
- Pushes web/API tags with `${{ github.sha }}` and no `latest`.
Add:
```json
"test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts"
```
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:deployment
```
- [ ] **Step 3: Create workflow**
Use this job structure:
```yaml
name: Staging Backend Quality Gate
on:
pull_request:
push:
branches: [staging]
workflow_dispatch:
concurrency:
group: backend-quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 30
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/staging'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
```
Validation checks out, sets Python `3.12` and Node `22`, installs with `python -m pip install -r requirements.txt -r requirements-dev.txt` and `npm ci --prefix frontend`, then runs the exact existing Python gate:
```bash
ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \
tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \
tests/test_yoga_rules_integrity.py
python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
mkdir -p artifacts
python scripts/run_quality_gate.py \
--profile quick --skip-yoga-logic --skip-frontend-runtime \
2>&1 | tee artifacts/quick-quality-gate.log
python -m build --no-isolation
```
It then runs `npm run test:db`, `npm test`, `npm run lint`, and `npm run build` with non-production Supabase placeholders. Upload `artifacts/quick-quality-gate.log` with `if: always()`.
Publish logs into GHCR using `GITHUB_TOKEN`, then use `docker/build-push-action@v6`. The API build uses repository context `.` with `file: deploy/railway-api.Dockerfile`:
```yaml
tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }}
```
The web build uses repository context `.` with `file: deploy/railway-web.Dockerfile`:
```yaml
tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder
```
- [ ] **Step 4: Verify and commit**
```bash
cd frontend
npm run test:deployment
npm run test:db
npm test
npm run lint
NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \
NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder npm run build
cd ..
ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \
tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \
tests/test_yoga_rules_integrity.py
python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
python scripts/run_quality_gate.py \
--profile quick --skip-yoga-logic --skip-frontend-runtime
python -m build --no-isolation
git add .github/workflows/backend-quality-gate.yml \
frontend/tests/staging-backend-workflows.test.ts frontend/package.json
git commit -m "ci: add automatic backend quality gate"
```
Expected: all commands PASS.
---
### Task 5: Exact-image staging deployment
**Files:**
- Modify: `deploy/docker-compose.server.yml`
- Modify: `.github/workflows/deploy-staging.yml`
- Modify: `frontend/tests/health-deployment.test.ts`
- Modify: `frontend/tests/staging-backend-workflows.test.ts`
- [ ] **Step 1: Add failing contracts**
Assert:
- Base Compose has `image: ${API_IMAGE:-jyotisha-api:local}` and `image: ${WEB_IMAGE:-jyotisha-web:local}`, while retaining both `build:` blocks.
- Staging listens to successful `Staging Backend Quality Gate`; manual validation queries `backend-quality-gate.yml` for exact SHA.
- Every staging Compose invocation uses server and Postgres files plus explicit app/database env, Caddyfile, hostname, API image, and web image.
- Workflow logs into GHCR, pulls, and runs `up -d --no-build`.
- It never invokes the applying `db:migrate` command, `migrator` service, or `--profile migration`.
- It runs the exact web image through `migration-checker`/`db:migrate:check` before changing any app container.
- Pending migrations stop before `api`, `web`, or `caddy` changes and print the manual workflow name plus exact SHA.
- Rollback uses recorded prior digest references, image IDs, and SHA.
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:deployment
```
- [ ] **Step 3: Add image indirection**
Keep build definitions and add:
```yaml
services:
api:
image: ${API_IMAGE:-jyotisha-api:local}
web:
image: ${WEB_IMAGE:-jyotisha-web:local}
```
No-selector production invocations must still build locally.
- [ ] **Step 4: Update workflow**
- Listen to `["Staging Backend Quality Gate"]`.
- Manual API lookup uses `/actions/workflows/backend-quality-gate.yml/runs`.
- Add `packages: read`.
- Pin every remote Compose call with:
```text
APP_ENV_FILE=../.env.staging
DATABASE_ENV_FILE=../.env.staging.database
CADDYFILE_PATH=./Caddyfile.staging
SITE_ADDRESS=staging.jyotisha.chat
API_IMAGE=ghcr.io/jesse-ux/jyotisha-api@sha256:<manifest-digest>
WEB_IMAGE=ghcr.io/jesse-ux/jyotisha-web@sha256:<manifest-digest>
```
- Validate both env files and Compose config.
- Send GHCR token through `docker login --password-stdin`; never save it in either env file.
- Pull `api web postgres`, start/wait for PostgreSQL, then run the exact web image through `--profile migration-check run --rm migration-checker`.
- Continue to `up -d --no-build --remove-orphans` only after check exit `0`; treat exit `3` as a safe stop with no application changes.
- Download and validate the successful gate run's SHA-to-digest manifest. Record
prior container digest references, image IDs, and SHA before switching. Roll
back with those exact digest references and `--no-build`.
- Log out in an always-running cleanup step.
- Never run migrations.
- [ ] **Step 5: Verify production/staging compatibility**
```bash
cd frontend && npm run test:deployment
cd ..
docker compose --env-file "$APP_ENV_FIXTURE" \
-f deploy/docker-compose.server.yml config --quiet
DATABASE_ENV_FILE="$DATABASE_ENV_FIXTURE" \
docker compose --env-file "$APP_ENV_FIXTURE" \
-f deploy/docker-compose.server.yml \
-f deploy/docker-compose.postgres.yml config --quiet
```
Expected: contracts PASS and both configs validate.
- [ ] **Step 6: Commit**
```bash
git add deploy/docker-compose.server.yml .github/workflows/deploy-staging.yml \
frontend/tests/health-deployment.test.ts \
frontend/tests/staging-backend-workflows.test.ts
git commit -m "ci: deploy immutable staging images"
```
---
### Task 6: Separate manual staging migration workflow
**Files:**
- Create: `.github/workflows/migrate-staging-database.yml`
- Modify: `frontend/tests/staging-backend-workflows.test.ts`
- [ ] **Step 1: Add failing contracts**
Assert manual-only dispatch, full 40-character SHA, `staging` environment, successful exact-SHA backend gate, pinned web image, both env validators, Postgres-only start, `--profile migration run --rm migrator`, filename-only ledger output, and no web/API/Caddy restart. Also assert that success dispatches `deploy-staging.yml` with the same full SHA.
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:deployment
```
- [ ] **Step 3: Create workflow**
Header:
```yaml
name: Migrate Staging Database
on:
workflow_dispatch:
inputs:
deploy_sha:
description: Full tested commit SHA to migrate
required: true
type: string
concurrency:
group: staging-mutation
cancel-in-progress: false
permissions:
contents: read
actions: write
packages: read
jobs:
migrate:
environment: staging
runs-on: ubuntu-latest
timeout-minutes: 20
```
Use the same pinned host/user/path/known-host logic as staging deploy. Reject non-`^[0-9a-f]{40}$`, require a successful backend gate for that SHA on `staging`, check it out, and rsync without `.env*`.
Remote sequence:
```bash
deploy/validate-staging-env.sh \
.env.staging staging.jyotisha.chat deploy/Caddyfile.staging
deploy/validate-staging-database-env.sh .env.staging.database
DATABASE_ENV_FILE=../.env.staging.database \
docker compose -p jyotisha-staging \
-f deploy/docker-compose.postgres.yml up -d --wait postgres
DATABASE_ENV_FILE=../.env.staging.database \
WEB_IMAGE="ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_SHA" \
docker compose -p jyotisha-staging \
-f deploy/docker-compose.postgres.yml \
--profile migration run --rm migrator
docker compose -p jyotisha-staging \
-f deploy/docker-compose.postgres.yml exec -T postgres \
psql -U postgres -d jyotisha -Atc \
'select filename from migration.schema_migrations order by filename'
```
Authenticate GHCR through stdin using run-local Docker state and remove it in
cleanup. Do not start/restart app services. Deployment and migration share the
`staging-mutation` concurrency group and the host mutation lock. After migration
and ledger reporting succeed, recheck that `staging` still points at the validated
SHA, then call the GitHub workflow-dispatch API for `deploy-staging.yml` with the
`main` controller ref, `inputs.deploy_sha` equal to that full SHA, and
`inputs.allow_rollback` set to `false`.
- [ ] **Step 4: Verify and commit**
```bash
cd frontend && npm run test:deployment
cd ..
git add .github/workflows/migrate-staging-database.yml \
frontend/tests/staging-backend-workflows.test.ts
git commit -m "ci: add manual staging database migrations"
```
---
### Task 7: Operations runbook
**Files:**
- Modify: `deploy/README.md`
- Modify: `frontend/tests/staging-backend-workflows.test.ts`
- [ ] **Step 1: Add failing documentation contracts**
Require the runbook to cover two mode-`0600` env files, exact database keys, private Postgres, manual migration order, automatic PR/`staging` gate, three encrypted local backups, no offsite staging recovery, and no production cutover authorization.
- [ ] **Step 2: Confirm red**
```bash
cd frontend && npm run test:deployment
```
- [ ] **Step 3: Document server bootstrap**
Include:
```bash
cd /opt/jyotisha-staging
umask 077
touch .env.staging.database
chmod 600 .env.staging.database
```
Document exact keys:
```text
POSTGRES_DB=jyotisha
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<generated>
SCHEMA_OWNER_PASSWORD=<generated>
IDENTITY_RUNTIME_PASSWORD=<generated>
APP_RUNTIME_PASSWORD=<generated>
ADMIN_RUNTIME_PASSWORD=<generated>
MIGRATION_RUNNER_PASSWORD=<generated>
BACKUP_READER_PASSWORD=<generated>
STAGING_BACKUP_ENCRYPTION_KEY=<generated>
SCHEMA_DATABASE_URL=postgresql://schema_owner:<percent-encoded-password>@postgres:5432/jyotisha
```
Each secret uses independently generated 32 random bytes. URL password is percent-encoded. State explicitly: no schema URL in `.env.staging`.
Document order:
1. Merge the reviewed revision to `main`, then fast-forward/push that exact SHA to `staging`.
2. Wait for backend quality gate and its exact-SHA image digest manifest.
3. If automatic deploy reports pending migrations, manually run `Migrate Staging Database` with the reported full SHA.
4. The successful migration workflow re-dispatches exact-SHA staging deploy automatically.
5. Check `https://staging.jyotisha.chat/api/health`.
6. Run:
```bash
./deploy/backup-staging-postgres.sh \
.env.staging.database \
/opt/jyotisha-staging/backups/staging-db
```
Also document a restore drill into a disposable `jyotisha_restore_check` database and deletion of only that database and temporary decrypted dump.
- [ ] **Step 4: Verify and commit**
```bash
cd frontend && npm run test:deployment
cd ..
git add deploy/README.md frontend/tests/staging-backend-workflows.test.ts
git commit -m "docs: add staging postgres operations runbook"
```
---
### Task 8: Milestone verification
**Files:** Verify Tasks 17 only; add no feature code.
- [ ] **Step 1: Full local gate**
```bash
cd frontend
npm ci
npm run test:db
npm run test:deployment
npm test
npm run lint
NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \
NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder npm run build
cd ..
python -m pip install -r requirements.txt -r requirements-dev.txt
ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \
tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \
tests/test_yoga_rules_integrity.py
python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
python scripts/run_quality_gate.py \
--profile quick --skip-yoga-logic --skip-frontend-runtime
python -m build --no-isolation
```
Expected: all PASS.
- [ ] **Step 2: Boundary and secret scans**
```bash
rg -n 'db:migrate([^:]|$)|migrator|profile migration' \
.github/workflows/deploy-staging.yml
rg -n 'up -d.*--build|docker compose build' \
.github/workflows/deploy-staging.yml
rg -n 'db:migrate|--profile migration' \
.github/workflows/migrate-staging-database.yml
rg -n 'sb_secret_|sb_publishable_|postgresql://[^:<[:space:]]+:[^<[:space:]]+@' \
.github deploy frontend/db frontend/scripts frontend/src/lib/db frontend/tests
```
Expected: first two commands have no applying-migration/build matches (the read-only `db:migrate:check` is allowed); third matches manual migration; fourth finds no real credential (inspect and allow only explicit test fixtures or documentation placeholders).
- [ ] **Step 3: Inspect final state**
```bash
git status --short
git diff --check
git log --oneline --decorate -8
git diff --stat "$(git merge-base HEAD main)"..HEAD
```
Expected: clean worktree, no whitespace errors, seven focused commits, Milestone 1 files only.
- [ ] **Step 4: Open implementation PR**
Target updated `main`. Record prerequisite commit, exact test evidence, Supabase-still-source-of-truth status, normal-deploy/no-migration guarantee, server-only database env guarantee, production unchanged, and rollback rule (prior SHA image; database forward-fix unless a reviewed reverse migration exists). Do not merge until the PRs new backend quality gate succeeds.