diff --git a/AGENTS.md b/AGENTS.md index 0164f7f1..ff114603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Deployment safety rules: 3. Keep Supabase Auth Site URL and redirect URLs aligned with `https://jyotisha.chat`. 4. After deployment, verify `/login`, logged-out `/api/account` = `401`, internal `/api/health` = `200`, and `swisseph_available = true`. 5. Never expose port `5200`, `SUPABASE_SERVICE_ROLE_KEY`, model keys, user JWTs, passwords, or SSH private keys. -6. GitHub Actions workflows are manual-only. Run the required validation workflows from the Actions page before manually starting production deployment; the production workflow and required secret are documented in `deploy/README.md`. +6. Production GitHub Actions validation, deployment, and migration workflows are manual-only. The explicitly authorized staging `Staging Backend Quality Gate` may run automatically for pull requests and pushes to `staging`, and a successful staging gate may automatically trigger `Deploy staging`; `Migrate Staging Database` remains manual-only. Run the required production validation workflows from the Actions page before manually starting production deployment; the production workflow and required secret are documented in `deploy/README.md`. ## 1. High-Rigor Override diff --git a/deploy/README.md b/deploy/README.md index ce3d7cd5..44c8d44b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -159,14 +159,17 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ``` -After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. The first deployment should be manual: +After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. -1. Confirm `/opt/jyotisha-staging/.env.staging` exists, has mode `0600`, and contains the three selectors above. -2. Open GitHub Actions -> Staging Backend Quality Gate -> Run workflow, using workflow from `main`. -3. Wait for success and copy that run's exact 40-character commit SHA. -4. Open GitHub Actions -> Deploy staging -> Run workflow, using workflow from `main`, and enter the SHA in `deploy_sha`. -5. Confirm `https://staging.jyotisha.chat/api/health` reports that SHA. -6. Only after the manual deployment passes, push a reviewed revision to branch `staging` to validate automatic deployment. +### First-deploy sequence + +1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, configure the staging Environment variables/secrets, and configure the repository staging build variables. +2. Merge the reviewed change, then push the reviewed SHA to `staging`; do not rely on a `main` workflow dispatch to publish images. +3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes the SHA-tagged API/web images for that exact 40-character commit SHA. +4. The automatic `Deploy staging` workflow starts from that successful gate, syncs the exact SHA, and validates both `.env.staging` and `.env.staging.database` before any app change. +5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` from `main` with the same successful SHA in `deploy_sha`; the workflow rechecks a successful staging gate for that exact SHA. +6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA; a successful migration re-dispatches `Deploy staging` with that same SHA. +7. Confirm `https://staging.jyotisha.chat/api/health` reports the exact SHA and private API health. Application rollback uses the same workflow: manually dispatch `Deploy staging` from `main` with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. @@ -253,9 +256,15 @@ The helper invokes `pg_dump --format=custom --no-owner` in the PostgreSQL contai Run a restore drill only against the disposable `jyotisha_restore_check` database. Choose one archive and use a temporary decrypted custom-format dump; the commands below match the backup helper's AES-256-CBC/PBKDF2 and `pg_dump --format=custom` interfaces: ```bash +set -euo pipefail cd /opt/jyotisha-staging export DATABASE_ENV_FILE=../.env.staging.database -BACKUP_FILE=/opt/jyotisha-staging/backups/staging-db/.dump.enc +BACKUP_DIR=/opt/jyotisha-staging/backups/staging-db +BACKUP_FILE="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'jyotisha-staging-*.dump.enc' -print | LC_ALL=C sort | tail -n 1)" +test -n "$BACKUP_FILE" +test -f "$BACKUP_FILE" +test ! -L "$BACKUP_FILE" +test -s "$BACKUP_FILE" RESTORE_DUMP="$(mktemp /tmp/jyotisha-staging-restore.XXXXXX.dump)" chmod 600 "$RESTORE_DUMP" trap 'rm -f -- "$RESTORE_DUMP"' EXIT @@ -277,7 +286,6 @@ docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml \ docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml \ exec -T postgres dropdb -U postgres --if-exists jyotisha_restore_check rm -f -- "$RESTORE_DUMP" -trap - EXIT unset STAGING_BACKUP_ENCRYPTION_KEY ``` diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 23fc937b..d2682e96 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -47,6 +47,43 @@ const deploymentReadmeUrl = new URL( import.meta.url, ); +function markdownSection(readme: string, heading: string): string { + const lines = readme.split("\n"); + const start = lines.indexOf(heading); + assert.notEqual(start, -1, `missing README heading: ${heading}`); + const level = heading.match(/^#+/)?.[0].length ?? 0; + let inFence = false; + let end = -1; + for (let index = start + 1; index < lines.length; index += 1) { + if (lines[index].trim().startsWith("```")) { + inFence = !inFence; + continue; + } + if ( + !inFence && + /^#+\s/.test(lines[index]) && + (lines[index].match(/^#+/)?.[0].length ?? 0) <= level + ) { + end = index; + break; + } + } + return lines.slice(start, end === -1 ? lines.length : end).join("\n"); +} + +function fencedCodeBlocks(section: string, language: string): string[] { + const lines = section.split("\n"); + const blocks: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + if (lines[index] !== `\`\`\`${language}`) continue; + const end = lines.indexOf("```", index + 1); + assert.notEqual(end, -1, `unterminated ${language} code block`); + blocks.push(lines.slice(index + 1, end).join("\n")); + index = end; + } + return blocks; +} + function indentation(line: string): number { return line.match(/^ */)?.[0].length ?? 0; } @@ -206,13 +243,99 @@ function logicalShellLines(script: string): string[] { .filter(Boolean); } +test("first staging deployment is executable and cannot route publish through main", () => { + const readme = readFileSync(deploymentReadmeUrl, "utf8"); + const staging = markdownSection(readme, "## Staging deployment"); + const firstDeploy = markdownSection(staging, "### First-deploy sequence").split( + "\n\nApplication rollback uses", + )[0]; + + assert.doesNotMatch(firstDeploy, /first deployment should be manual/i); + assert.doesNotMatch( + firstDeploy, + /Staging Backend Quality Gate[^\n]*from `main`/i, + ); + for (const phrase of [ + "complete the server and GitHub bootstrap", + "push the reviewed SHA to `staging`", + "publishes the SHA-tagged API/web images", + "automatic `Deploy staging`", + "validates both `.env.staging` and `.env.staging.database`", + "same successful SHA", + "pending migration", + "Migrate Staging Database", + "https://staging.jyotisha.chat/api/health", + ]) { + assert.match(firstDeploy, new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i")); + } + + const orderedPhrases = [ + "complete the server and GitHub bootstrap", + "push the reviewed SHA to `staging`", + "publishes the SHA-tagged API/web images", + "validates both `.env.staging` and `.env.staging.database`", + "same successful SHA", + "pending migration", + "https://staging.jyotisha.chat/api/health", + ]; + const indexes = orderedPhrases.map((phrase) => firstDeploy.indexOf(phrase)); + for (let index = 1; index < indexes.length; index += 1) { + assert.ok( + indexes[index] > indexes[index - 1], + `first-deploy step is out of order: ${orderedPhrases[index - 1]} -> ${orderedPhrases[index]}`, + ); + } +}); + +test("restore drill command block is fail-fast, archive-safe, and narrowly destructive", () => { + const readme = readFileSync(deploymentReadmeUrl, "utf8"); + const operations = markdownSection(readme, "## Staging PostgreSQL operations"); + const restore = markdownSection( + operations, + "### Restore drill into a disposable database", + ); + const blocks = fencedCodeBlocks(restore, "bash"); + assert.equal(blocks.length, 1, "restore drill must have one executable bash block"); + const script = blocks[0]; + + assert.match(script, /^set -euo pipefail\n/); + assert.doesNotMatch(script, //); + assert.match(script, /find "\$BACKUP_DIR"[^\n]*-type f[^\n]*-name/); + assert.match(script, /BACKUP_FILE=.*\$\(.*find/); + assert.match(script, /test -n "\$BACKUP_FILE"/); + assert.match(script, /test -f "\$BACKUP_FILE"/); + assert.match(script, /test ! -L "\$BACKUP_FILE"/); + assert.match(script, /test -s "\$BACKUP_FILE"/); + + const traps = script.match(/^trap .*$/gm) ?? []; + assert.equal(traps.length, 1, "restore drill must have one cleanup trap"); + assert.match(traps[0], /RESTORE_DUMP/); + assert.doesNotMatch(traps[0], /BACKUP_FILE|BACKUP_DIR|jyotisha_restore_check/); + + const decrypt = script.indexOf("openssl enc -d"); + const create = script.indexOf("createdb -U postgres jyotisha_restore_check"); + const restoreDb = script.indexOf("pg_restore"); + const drop = script.indexOf("dropdb -U postgres --if-exists jyotisha_restore_check"); + assert.ok(decrypt >= 0 && decrypt < create, "decrypt must precede database creation"); + assert.ok(create < restoreDb, "database creation must precede pg_restore"); + assert.ok(restoreDb < drop, "dropdb must follow a successful pg_restore"); + assert.match(script, /-pass env:STAGING_BACKUP_ENCRYPTION_KEY/); + assert.match(script, /rm -f -- "\$RESTORE_DUMP"/); + assert.doesNotMatch(script, /dropdb[^\n]*\|\|/); +}); + test("operations runbook documents the staging database boundary and deployment order", () => { const readme = readFileSync(deploymentReadmeUrl, "utf8"); + const staging = markdownSection(readme, "## Staging deployment"); + const operations = markdownSection( + readme, + "## Staging PostgreSQL operations", + ); - assert.match(readme, /Staging PostgreSQL operations/); - assert.match(readme, /\/opt\/jyotisha-staging\/\.env\.staging` \(`?0600`?\)/); - assert.match(readme, /\/opt\/jyotisha-staging\/\.env\.staging\.database` \(`?0600`?\)/); - assert.match(readme, /umask 077[\s\S]*touch \.env\.staging\.database[\s\S]*chmod 600 \.env\.staging\.database/); + assert.match(operations, /Staging PostgreSQL operations/); + assert.match(staging, /\/opt\/jyotisha-staging\/\.env\.staging` \(`?0600`?\)/); + assert.match(staging, /\/opt\/jyotisha-staging\/\.env\.staging\.database` \(`?0600`?\)/); + assert.match(operations, /umask 077[\s\S]*touch \.env\.staging\.database[\s\S]*chmod 600 \.env\.staging\.database/); for (const key of [ "POSTGRES_DB=jyotisha", @@ -227,35 +350,35 @@ test("operations runbook documents the staging database boundary and deployment "STAGING_BACKUP_ENCRYPTION_KEY=", "SCHEMA_DATABASE_URL=postgresql://schema_owner:@postgres:5432/jyotisha", ]) { - assert.match(readme, new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(operations, new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); } - assert.match(readme, /independently generated 32 random bytes/); - assert.match(readme, /openssl rand -base64 32/); - assert.match(readme, /percent-encod(?:e|ed)[^\n]*URL password/i); - assert.match(readme, /SCHEMA_DATABASE_URL[^\n]*must not[^\n]*\.env\.staging/i); + assert.match(operations, /independently generated 32 random bytes/); + assert.match(operations, /openssl rand -base64 32/); + assert.match(operations, /percent-encod(?:e|ed)[^\n]*URL password/i); + assert.match(operations, /SCHEMA_DATABASE_URL[^\n]*must not[^\n]*\.env\.staging/i); assert.match( - readme, + operations, /there is no `?SCHEMA_DATABASE_URL`? in `?\.env\.staging`?/i, ); - assert.match(readme, /private[^\n]*PostgreSQL|PostgreSQL[^\n]*private/i); - assert.match(readme, /no published host port|no host port/i); - assert.match(readme, /127\.0\.0\.1:\$\{POSTGRES_HOST_PORT:-55432\}:5432/); + assert.match(operations, /private[^\n]*PostgreSQL|PostgreSQL[^\n]*private/i); + assert.match(staging, /no published host port|no host port/i); + assert.match(operations, /127\.0\.0\.1:\$\{POSTGRES_HOST_PORT:-55432\}:5432/); for (const workflow of [ "Staging Backend Quality Gate", "Migrate Staging Database", "Deploy staging", ]) { - assert.match(readme, new RegExp(workflow)); + assert.match(staging + operations, new RegExp(workflow)); } - assert.match(readme, /pull_request/); - assert.match(readme, /push[^\n]*staging|staging[^\n]*push/i); - assert.match(readme, /workflow_dispatch/); - assert.match(readme, /deploy_sha/); - assert.match(readme, /exact[^\n]*40-character[^\n]*SHA/i); - assert.match(readme, /re-dispatch|redispatch/i); - assert.match(readme, /same[^\n]*SHA/i); + assert.match(staging, /pull_request/); + assert.match(staging, /push[^\n]*staging|staging[^\n]*push/i); + assert.match(staging, /workflow_dispatch/); + assert.match(staging, /deploy_sha/); + assert.match(staging, /exact[^\n]*40-character[^\n]*SHA/i); + assert.match(operations, /re-dispatch|redispatch/i); + assert.match(operations, /same[^\n]*SHA/i); for (const variable of [ "STAGING_SUPABASE_URL", @@ -267,22 +390,22 @@ test("operations runbook documents the staging database boundary and deployment "STAGING_URL", "STAGING_KNOWN_HOSTS", ]) { - assert.match(readme, new RegExp(variable)); + assert.match(staging, new RegExp(variable)); } - assert.match(readme, /Settings[ ]*[→>-][ ]*Secrets and variables[ ]*[→>-][ ]*Actions[ ]*[→>-][ ]*Variables/); - assert.match(readme, /public build inputs[^\n]*required for publish/i); - assert.match(readme, /never print[^\n]*(?:values|keys)/i); - assert.match(readme, /STAGING_SSH_PRIVATE_KEY/); + assert.match(staging, /Settings[ ]*[→>-][ ]*Secrets and variables[ ]*[→>-][ ]*Actions[ ]*[→>-][ ]*Variables/); + assert.match(staging, /public build inputs[^\n]*required for publish/i); + assert.match(staging, /never print[^\n]*(?:values|keys)/i); + assert.match(staging, /STAGING_SSH_PRIVATE_KEY/); - const mergeIndex = readme.indexOf("Merge to `staging`"); - const gateIndex = readme.indexOf("Staging Backend Quality Gate", mergeIndex); - const migrationIndex = readme.indexOf("Migrate Staging Database", gateIndex); - const redispatchIndex = readme.search(/re-dispatch|redispatch/i); - const healthIndex = readme.indexOf( + const mergeIndex = operations.indexOf("Merge to `staging`"); + const gateIndex = operations.indexOf("Staging Backend Quality Gate", mergeIndex); + const migrationIndex = operations.indexOf("Migrate Staging Database", gateIndex); + const redispatchIndex = operations.search(/re-dispatch|redispatch/i); + const healthIndex = operations.indexOf( "https://staging.jyotisha.chat/api/health", redispatchIndex, ); - const backupIndex = readme.indexOf("backup-staging-postgres.sh", healthIndex); + const backupIndex = operations.indexOf("backup-staging-postgres.sh", healthIndex); assert.ok(mergeIndex >= 0, "runbook must state the staging merge step"); assert.ok(gateIndex > mergeIndex, "quality gate must follow the staging merge"); assert.ok(migrationIndex > gateIndex, "manual migration must follow the gate"); @@ -293,25 +416,29 @@ test("operations runbook documents the staging database boundary and deployment test("operations runbook documents three encrypted local backups and a safe restore drill", () => { const readme = readFileSync(deploymentReadmeUrl, "utf8"); - - assert.match(readme, /newest three|three[^\n]*encrypted local backups/i); - assert.match(readme, /AES-256-CBC/i); - assert.match(readme, /PBKDF2|pbkdf2/); - assert.match(readme, /custom[^\n]*format|format:[ ]*custom/i); - assert.match(readme, /no off[- ]site[^\n]*staging[^\n]*(?:recovery|backup)/i); - assert.match( + const operations = markdownSection( readme, + "## Staging PostgreSQL operations", + ); + + assert.match(operations, /newest three|three[^\n]*encrypted local backups/i); + assert.match(operations, /AES-256-CBC/i); + assert.match(operations, /PBKDF2|pbkdf2/); + assert.match(operations, /custom[^\n]*format|format:[ ]*custom/i); + assert.match(operations, /no off[- ]site[^\n]*staging[^\n]*(?:recovery|backup)/i); + assert.match( + operations, /\.\/deploy\/backup-staging-postgres\.sh[\s\S]*\.env\.staging\.database[\s\S]*\/opt\/jyotisha-staging\/backups\/staging-db/, ); - assert.match(readme, /-pass env:STAGING_BACKUP_ENCRYPTION_KEY/); - assert.match(readme, /passphrase[^\n]*(?:argv|command line|output|history)/i); - assert.match(readme, /jyotisha_restore_check/); - assert.match(readme, /pg_restore/); - assert.match(readme, /dropdb[^\n]*jyotisha_restore_check|DROP DATABASE[^\n]*jyotisha_restore_check/i); - assert.match(readme, /temporary decrypted dump/); - assert.match(readme, /delete[^\n]*(?:only|just)[^\n]*(?:disposable database|jyotisha_restore_check)[^\n]*(?:and|,)[^\n]*temporary decrypted dump/i); - assert.match(readme, /do not[^\n]*(?:docker compose[^\n]*down|down -v)[^\n]*(?:restore|drill|staging)/i); - assert.match(readme, /does not authorize[^\n]*(?:production|cutover)|no production[^\n]*cutover[^\n]*authoriz/i); + assert.match(operations, /-pass env:STAGING_BACKUP_ENCRYPTION_KEY/); + assert.match(operations, /passphrase[^\n]*(?:argv|command line|output|history)/i); + assert.match(operations, /jyotisha_restore_check/); + assert.match(operations, /pg_restore/); + assert.match(operations, /dropdb[^\n]*jyotisha_restore_check|DROP DATABASE[^\n]*jyotisha_restore_check/i); + assert.match(operations, /temporary decrypted dump/); + assert.match(operations, /delete[^\n]*(?:only|just)[^\n]*(?:disposable database|jyotisha_restore_check)[^\n]*(?:and|,)[^\n]*temporary decrypted dump/i); + assert.match(operations, /do not[^\n]*(?:docker compose[^\n]*down|down -v)[^\n]*(?:restore|drill|staging)/i); + assert.match(operations, /does not authorize[^\n]*(?:production|cutover)|no production[^\n]*cutover[^\n]*authoriz/i); }); test("backend quality gate has structured staging triggers and concurrency", () => {