diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 62658856..bc884a5d 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -2,17 +2,19 @@ name: Deploy staging on: workflow_run: - workflows: ["Jyotish Skill CI"] + workflows: ["Staging Backend Quality Gate"] types: [completed] workflow_dispatch: inputs: - git_sha: - description: Exact 40-character commit SHA from a successful CI run + deploy_sha: + description: Exact 40-character commit SHA from a successful backend quality gate required: true + type: string permissions: contents: read actions: read + packages: read concurrency: group: staging @@ -20,11 +22,7 @@ concurrency: jobs: deploy: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'staging') + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'staging') runs-on: ubuntu-latest timeout-minutes: 30 environment: @@ -42,12 +40,12 @@ jobs: - name: Validate tested revision id: revision env: - REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.git_sha }} + REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.deploy_sha }} GH_TOKEN: ${{ github.token }} run: | test "${#REQUESTED_SHA}" -eq 40 case "$REQUESTED_SHA" in - *[!0-9a-fA-F]*) echo "git_sha must be a full hexadecimal commit SHA" >&2; exit 1 ;; + *[!0-9a-fA-F]*) echo "deploy_sha must be a full hexadecimal commit SHA" >&2; exit 1 ;; esac DEPLOY_GIT_SHA="$(printf '%s' "$REQUESTED_SHA" | tr '[:upper:]' '[:lower:]')" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then @@ -55,9 +53,11 @@ jobs: --header "Authorization: Bearer $GH_TOKEN" \ --header "Accept: application/vnd.github+json" \ --header "X-GitHub-Api-Version: 2022-11-28" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$DEPLOY_GIT_SHA&status=success&per_page=1")" - test "$(printf '%s' "$TESTED_RUNS" | jq -r '.total_count')" -ge 1 || { - echo "No successful Jyotish Skill CI run found for $DEPLOY_GIT_SHA" >&2 + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$DEPLOY_GIT_SHA&branch=staging&event=push&status=success&per_page=100")" + MATCHING_RUNS="$(printf '%s' "$TESTED_RUNS" | jq --arg sha "$DEPLOY_GIT_SHA" \ + '[.workflow_runs[] | select(.head_sha == $sha and .head_branch == "staging" and .event == "push" and .conclusion == "success")] | length')" + test "$MATCHING_RUNS" -ge 1 || { + echo "No successful Staging Backend Quality Gate run found for exact SHA $DEPLOY_GIT_SHA" >&2 exit 1 } fi @@ -93,29 +93,52 @@ jobs: printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts - - name: Record previous staging state + - name: Record previous staging images + id: previous env: DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} run: | SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" - PREVIOUS_SHA="$(curl --fail --silent --show-error --max-time 10 "$STAGING_URL/api/health" 2>/dev/null | jq -r '.deployment.gitCommit // empty' || true)" - test -n "$PREVIOUS_SHA" || PREVIOUS_SHA="not-deployed" - PREVIOUS_IMAGES="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "if [ -f '$DEPLOY_PATH/.env.staging' ] && [ -f '$DEPLOY_PATH/deploy/docker-compose.server.yml' ]; then cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml images --quiet; else echo not-deployed; fi")" - test -n "$PREVIOUS_IMAGES" || PREVIOUS_IMAGES="not-deployed" + PREVIOUS_HEALTH_SHA="$(curl --fail --silent --show-error --max-time 10 "$STAGING_URL/api/health" 2>/dev/null | jq -r '.deployment.gitCommit // empty' || true)" + PREVIOUS_API_IMAGE="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=api' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{.Config.Image}}' \"\$container_id\"; fi")" + PREVIOUS_WEB_IMAGE="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{.Config.Image}}' \"\$container_id\"; fi")" + PREVIOUS_SHA="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$container_id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; fi")" + if [ -z "$PREVIOUS_SHA" ] && [[ "$PREVIOUS_HEALTH_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + PREVIOUS_SHA="$PREVIOUS_HEALTH_SHA" + fi + if [ -z "$PREVIOUS_SHA" ] && [[ "$PREVIOUS_WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web:([0-9a-f]{40})$ ]]; then + PREVIOUS_SHA="${BASH_REMATCH[1]}" + fi + if [ -n "$PREVIOUS_SHA" ]; then + test "${#PREVIOUS_SHA}" -eq 40 + case "$PREVIOUS_SHA" in + *[!0-9a-fA-F]*) echo "Previous staging SHA is unsafe" >&2; exit 1 ;; + esac + PREVIOUS_SHA="$(printf '%s' "$PREVIOUS_SHA" | tr '[:upper:]' '[:lower:]')" + fi + for image in "$PREVIOUS_API_IMAGE" "$PREVIOUS_WEB_IMAGE"; do + case "$image" in + "") ;; + *[!A-Za-z0-9._/@:-]*) echo "Previous staging image reference is unsafe" >&2; exit 1 ;; + esac + done + { + echo "api_image=$PREVIOUS_API_IMAGE" + echo "web_image=$PREVIOUS_WEB_IMAGE" + echo "previous_sha=$PREVIOUS_SHA" + } >> "$GITHUB_OUTPUT" { echo "### Staging deployment state" - echo "- Previous verified SHA: \`$PREVIOUS_SHA\`" + echo "- Previous verified SHA: \`${PREVIOUS_SHA:-not-deployed}\`" echo "- Target SHA: \`$DEPLOY_GIT_SHA\`" - echo "- Previous image IDs:" - echo '```text' - printf '%s\n' "$PREVIOUS_IMAGES" - echo '```' + echo "- Previous API image: \`${PREVIOUS_API_IMAGE:-not-deployed}\`" + echo "- Previous web image: \`${PREVIOUS_WEB_IMAGE:-not-deployed}\`" } >> "$GITHUB_STEP_SUMMARY" - - name: Sync and rebuild staging - env: - DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + - name: Sync tested staging sources run: | SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" RSYNC_SSH="ssh $SSH_OPTIONS" @@ -127,8 +150,65 @@ jobs: --exclude='frontend/.next/' \ -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" + + - name: Validate staging configuration + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && bash deploy/validate-staging-env.sh .env.staging && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans" + "cd '$DEPLOY_PATH' && bash deploy/validate-staging-env.sh .env.staging && bash deploy/validate-staging-database-env.sh .env.staging.database && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml config --quiet" + + - name: Log in to GHCR + env: + GHCR_TOKEN: ${{ github.token }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin" + + - name: Pull exact staging images + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml pull api web postgres" + + - name: Start and wait for staging PostgreSQL + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --wait postgres" + + - name: Check staging migrations + id: migration_check + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + set +e + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml --profile migration-check run --rm migration-checker" + CHECK_STATUS=$? + set -e + if [ "$CHECK_STATUS" -eq 3 ]; then + echo "Run the Migrate Staging Database workflow manually with exact SHA $DEPLOY_GIT_SHA; no API, web, or Caddy container was changed." >&2 + exit 3 + fi + if [ "$CHECK_STATUS" -ne 0 ]; then + echo "Staging migration check failed safely for exact SHA $DEPLOY_GIT_SHA" >&2 + exit "$CHECK_STATUS" + fi + + - name: Deploy exact staging images + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --remove-orphans" - name: Verify staging env: @@ -137,8 +217,25 @@ jobs: curl --fail --silent --show-error --retry 12 --retry-delay 5 "$STAGING_URL/login" >/dev/null test "$(curl --silent --output /dev/null --write-out '%{http_code}' "$STAGING_URL/api/account")" = "401" test "$(curl --fail --silent --show-error "$STAGING_URL/api/health" | jq -r '.deployment.gitCommit')" = "$DEPLOY_GIT_SHA" - ssh -i ~/.ssh/jyotisha-staging -p "$DEPLOY_PORT" \ - -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ - "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r => { const body = await r.json(); if (!r.ok || body.status !== \"ok\" || body.swisseph_available !== true) process.exit(1); console.log(JSON.stringify(body)); })'" + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r => { const body = await r.json(); if (!r.ok || body.status !== \"ok\" || body.swisseph_available !== true) process.exit(1); console.log(JSON.stringify(body)); })'" echo "- Verified deployed SHA: \`$DEPLOY_GIT_SHA\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Roll back staging images + if: failure() && steps.migration_check.outcome == 'success' && steps.previous.outputs.api_image != '' && steps.previous.outputs.web_image != '' && steps.previous.outputs.previous_sha != '' + env: + PREVIOUS_API_IMAGE: ${{ steps.previous.outputs.api_image }} + PREVIOUS_WEB_IMAGE: ${{ steps.previous.outputs.web_image }} + PREVIOUS_SHA: ${{ steps.previous.outputs.previous_sha }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='$PREVIOUS_API_IMAGE' WEB_IMAGE='$PREVIOUS_WEB_IMAGE' GITHUB_SHA='$PREVIOUS_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --remove-orphans api web caddy" + + - name: Log out of GHCR + if: always() + continue-on-error: true + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker logout ghcr.io >/dev/null 2>&1 || true" diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 42998526..9e660a16 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -1,5 +1,6 @@ services: api: + image: ${API_IMAGE:-jyotisha-api:local} build: context: .. dockerfile: deploy/railway-api.Dockerfile @@ -18,6 +19,7 @@ services: retries: 5 web: + image: ${WEB_IMAGE:-jyotisha-web:local} build: context: .. dockerfile: deploy/railway-web.Dockerfile diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index f4f15bf4..7f99772e 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -68,6 +68,52 @@ test("server compose accepts staging paths while preserving production defaults" ); }); +test("server compose defaults to local images without removing either build", () => { + const composeFile = fileURLToPath( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + ); + const compose = readFileSync(composeFile, "utf8"); + + assert.match(compose, /^\s+image: \$\{API_IMAGE:-jyotisha-api:local\}$/m); + assert.match(compose, /^\s+image: \$\{WEB_IMAGE:-jyotisha-web:local\}$/m); + + const root = mkdtempSync(join(tmpdir(), "jyotisha-server-compose-")); + const appEnvFile = join(root, ".env.production"); + writeFileSync(appEnvFile, "RUNTIME_FIXTURE=1\n"); + chmodSync(appEnvFile, 0o600); + + const env = { + ...process.env, + APP_ENV_FILE: appEnvFile, + CADDYFILE_PATH: fileURLToPath( + new URL("../../deploy/Caddyfile", import.meta.url), + ), + GITHUB_SHA: "0000000000000000000000000000000000000000", + NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder", + }; + delete env.API_IMAGE; + delete env.WEB_IMAGE; + + try { + const result = spawnSync( + "docker", + ["compose", "-f", composeFile, "config", "--format", "json"], + { encoding: "utf8", env }, + ); + assert.equal(result.status, 0, result.stderr); + const rendered = JSON.parse(result.stdout) as { + services: Record; + }; + assert.equal(rendered.services.api.image, "jyotisha-api:local"); + assert.equal(rendered.services.web.image, "jyotisha-web:local"); + assert.ok(rendered.services.api.build, "api build definition was removed"); + assert.ok(rendered.services.web.build, "web build definition was removed"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("staging Caddy configuration serves only the configured staging address", () => { const caddy = readFileSync( new URL("../../deploy/Caddyfile.staging", import.meta.url), @@ -80,8 +126,8 @@ test("staging Caddy configuration serves only the configured staging address", ( }); test("staging deploy consumes only the isolated staging environment and tested revision", () => { - const ci = readFileSync( - new URL("../../.github/workflows/ci.yml", import.meta.url), + const qualityGate = readFileSync( + new URL("../../.github/workflows/backend-quality-gate.yml", import.meta.url), "utf8", ); const workflow = readFileSync( @@ -89,18 +135,22 @@ test("staging deploy consumes only the isolated staging environment and tested r "utf8", ); - assert.match(ci, /push:\s*\n\s*branches: \[staging\]/); - assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/); + assert.match(qualityGate, /push:\s*\n\s*branches: \[staging\]/); + assert.match(workflow, /workflows: \["Staging Backend Quality Gate"\]/); assert.match( workflow, /github\.event\.workflow_run\.head_branch == 'staging'/, ); assert.match(workflow, /actions: read/); + assert.match(workflow, /packages: read/); assert.match(workflow, /environment:\s*\n\s*name: staging/); - assert.match(workflow, /git_sha:/); + assert.match(workflow, /deploy_sha:/); assert.doesNotMatch(workflow, /default: staging/); assert.match(workflow, /test "\$\{#REQUESTED_SHA\}" -eq 40/); - assert.match(workflow, /actions\/workflows\/ci\.yml\/runs\?head_sha=/); + assert.match( + workflow, + /actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=/, + ); assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/); assert.match(workflow, /vars\.STAGING_HOST/); assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/); @@ -108,29 +158,23 @@ test("staging deploy consumes only the isolated staging environment and tested r assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/); assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/); assert.match(workflow, /--exclude='\.env\*'/); - assert.match(workflow, /docker compose --env-file \.env\.staging/); + assert.match( + workflow, + /docker compose -p jyotisha-staging --env-file \.env\.staging/, + ); assert.match( workflow, /bash deploy\/validate-staging-env\.sh \.env\.staging/, ); assert.match( workflow, - /docker compose --env-file \.env\.staging -f deploy\/docker-compose\.server\.yml config --quiet/, + /bash deploy\/validate-staging-database-env\.sh \.env\.staging\.database/, ); + assert.match(workflow, /-f deploy\/docker-compose\.server\.yml/); + assert.match(workflow, /-f deploy\/docker-compose\.postgres\.yml/); assert.match(workflow, /deployment\.gitCommit/); assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); assert.doesNotMatch(workflow, /103\.117\.123\.53/); - - const composeLines = workflow - .split("\n") - .filter((line) => line.includes("docker compose")); - assert.equal(workflow.match(/docker compose/g)?.length, 4); - assert.equal(composeLines.length, 3); - for (const line of composeLines) { - assert.match(line, /APP_ENV_FILE='\.\.\/\.env\.staging'/); - assert.match(line, /CADDYFILE_PATH='\.\/Caddyfile\.staging'/); - assert.match(line, /SITE_ADDRESS='https:\/\/staging\.jyotisha\.chat'/); - } }); test("staging rsync preserves every destination env variant during delete", () => { diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 024e4a9d..b39c28ae 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -1,5 +1,15 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; import test from "node:test"; type YamlNode = { @@ -20,10 +30,14 @@ type WorkflowStep = { name: string; }; -const workflowUrl = new URL( +const backendWorkflowUrl = new URL( "../../.github/workflows/backend-quality-gate.yml", import.meta.url, ); +const deploymentWorkflowUrl = new URL( + "../../.github/workflows/deploy-staging.yml", + import.meta.url, +); function indentation(line: string): number { return line.match(/^ */)?.[0].length ?? 0; @@ -60,8 +74,8 @@ function mappingNodes( return nodes; } -function parseWorkflow(): WorkflowDocument { - const lines = readFileSync(workflowUrl, "utf8").split("\n"); +function parseWorkflow(url = backendWorkflowUrl): WorkflowDocument { + const lines = readFileSync(url, "utf8").split("\n"); return { lines, root: mappingNodes(lines, 0) }; } @@ -104,10 +118,7 @@ function blockScalar(document: WorkflowDocument, node: YamlNode): string { .join("\n"); } -function job( - document: WorkflowDocument, - name: "validate" | "publish", -): YamlNode { +function job(document: WorkflowDocument, name: string): YamlNode { return child(document, requiredNode(document.root, "jobs"), name); } @@ -174,6 +185,19 @@ function stepField( ); } +function stepRun(document: WorkflowDocument, step: WorkflowStep): string { + const run = stepField(document, step, "run"); + return run.value === "|" ? blockScalar(document, run) : run.value; +} + +function logicalShellLines(script: string): string[] { + return script + .replace(/\\\n\s*/g, " ") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + test("backend quality gate has structured staging triggers and concurrency", () => { const document = parseWorkflow(); @@ -414,3 +438,213 @@ test("deployment test script covers health and backend workflow contracts", () = "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts", ); }); + +test("staging deploy structurally follows the backend gate and validates an exact manual SHA", () => { + const document = parseWorkflow(deploymentWorkflowUrl); + + assert.equal(requiredNode(document.root, "name").value, "Deploy staging"); + const triggers = requiredNode(document.root, "on"); + const workflowRun = child(document, triggers, "workflow_run"); + assert.equal( + child(document, workflowRun, "workflows").value, + '["Staging Backend Quality Gate"]', + ); + assert.equal(child(document, workflowRun, "types").value, "[completed]"); + const dispatch = child(document, triggers, "workflow_dispatch"); + const deploySha = child(document, child(document, dispatch, "inputs"), "deploy_sha"); + assert.equal(child(document, deploySha, "required").value, "true"); + assert.equal(child(document, deploySha, "type").value, "string"); + + assert.deepEqual( + children(document, requiredNode(document.root, "permissions")).map( + ({ key, value }) => [key, value], + ), + [ + ["contents", "read"], + ["actions", "read"], + ["packages", "read"], + ], + ); + const deploy = job(document, "deploy"); + assert.match(child(document, deploy, "if").value, /conclusion == 'success'/); + const revision = requiredStep(document, deploy, "Validate tested revision"); + assert.equal( + child(document, stepField(document, revision, "env"), "REQUESTED_SHA").value, + "${{ github.event.workflow_run.head_sha || inputs.deploy_sha }}", + ); + const validation = stepRun(document, revision); + assert.match(validation, /test "\$\{#REQUESTED_SHA\}" -eq 40/); + assert.match( + validation, + /actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=\$DEPLOY_GIT_SHA/, + ); + assert.match(validation, /head_branch == "staging"/); + assert.match(validation, /conclusion == "success"/); +}); + +test("staging deploy pins every Compose call and gates app changes on the read-only checker", () => { + const document = parseWorkflow(deploymentWorkflowUrl); + const deploy = job(document, "deploy"); + const deploySteps = steps(document, deploy); + const names = deploySteps.map(({ name }) => name); + const index = (name: string) => { + const found = names.indexOf(name); + assert.notEqual(found, -1, `missing deployment step: ${name}`); + return found; + }; + + assert.ok(index("Pull exact staging images") < index("Start and wait for staging PostgreSQL")); + assert.ok(index("Start and wait for staging PostgreSQL") < index("Check staging migrations")); + assert.ok(index("Check staging migrations") < index("Deploy exact staging images")); + assert.ok(index("Deploy exact staging images") < index("Verify staging")); + assert.ok(index("Verify staging") < index("Roll back staging images")); + assert.ok(index("Roll back staging images") < index("Log out of GHCR")); + + const scripts = deploySteps + .map((step) => { + const fields = mappingNodes( + document.lines, + step.node.indent + 2, + step.node.start + 1, + step.node.end, + ); + const run = fields.find(({ key }) => key === "run"); + return run ? (run.value === "|" ? blockScalar(document, run) : run.value) : ""; + }) + .filter(Boolean); + const composeCommands = scripts + .flatMap(logicalShellLines) + .filter((line) => line.includes("docker compose")); + assert.equal(composeCommands.length, 7); + for (const command of composeCommands) { + assert.match(command, /ssh /); + assert.match(command, /APP_ENV_FILE='\.\.\/\.env\.staging'/); + assert.match(command, /DATABASE_ENV_FILE='\.\.\/\.env\.staging\.database'/); + assert.match(command, /CADDYFILE_PATH='\.\/Caddyfile\.staging'/); + assert.match(command, /SITE_ADDRESS='https:\/\/staging\.jyotisha\.chat'/); + assert.match(command, /API_IMAGE=/); + assert.match(command, /WEB_IMAGE=/); + assert.match(command, /--env-file \.env\.staging/); + assert.match(command, /-f deploy\/docker-compose\.server\.yml/); + assert.match(command, /-f deploy\/docker-compose\.postgres\.yml/); + } + + const login = stepRun(document, requiredStep(document, deploy, "Log in to GHCR")); + assert.match(login, /printf '%s' "\$GHCR_TOKEN" \| ssh /); + assert.match(login, /docker login ghcr\.io .*--password-stdin/); + assert.doesNotMatch(login, /--password(?:\s|=)/); + + assert.match( + stepRun(document, requiredStep(document, deploy, "Pull exact staging images")), + /pull api web postgres/, + ); + assert.match( + stepRun( + document, + requiredStep(document, deploy, "Start and wait for staging PostgreSQL"), + ), + /up -d --no-build --wait postgres/, + ); + + const check = requiredStep(document, deploy, "Check staging migrations"); + assert.equal(stepField(document, check, "id").value, "migration_check"); + const checkScript = stepRun(document, check); + assert.match(checkScript, /--profile migration-check run --rm migration-checker/); + assert.match(checkScript, /"\$CHECK_STATUS" -eq 3/); + assert.match(checkScript, /Migrate Staging Database/); + assert.match(checkScript, /\$DEPLOY_GIT_SHA/); + assert.match(checkScript, /exit 3/); + assert.match( + readFileSync( + new URL("../../deploy/docker-compose.postgres.yml", import.meta.url), + "utf8", + ), + /command: \["npm", "run", "db:migrate:check"\]/, + ); + + const workflow = readFileSync(deploymentWorkflowUrl, "utf8"); + assert.doesNotMatch(workflow, /npm\s+run\s+db:migrate(?!:check)/); + assert.doesNotMatch(workflow, /run\s+--rm\s+migrator/); + assert.doesNotMatch(workflow, /--profile\s+migration(?:\s|["'])/); + assert.doesNotMatch(workflow, /(?:up -d[^\n]*--build|docker compose build)/); + + const applicationDeploy = stepRun( + document, + requiredStep(document, deploy, "Deploy exact staging images"), + ); + assert.match(applicationDeploy, /up -d --no-build --remove-orphans/); + const previous = requiredStep(document, deploy, "Record previous staging images"); + assert.equal(stepField(document, previous, "id").value, "previous"); + assert.match(stepRun(document, previous), /docker inspect --format '\{\{\.Config\.Image\}\}'/); + assert.match(stepRun(document, previous), /api_image=\$PREVIOUS_API_IMAGE/); + assert.match(stepRun(document, previous), /web_image=\$PREVIOUS_WEB_IMAGE/); + assert.match(stepRun(document, previous), /previous_sha=\$PREVIOUS_SHA/); + + const rollback = requiredStep(document, deploy, "Roll back staging images"); + assert.match(stepField(document, rollback, "if").value, /steps\.migration_check\.outcome == 'success'/); + const rollbackEnv = stepField(document, rollback, "env"); + assert.equal( + child(document, rollbackEnv, "PREVIOUS_API_IMAGE").value, + "${{ steps.previous.outputs.api_image }}", + ); + assert.equal( + child(document, rollbackEnv, "PREVIOUS_WEB_IMAGE").value, + "${{ steps.previous.outputs.web_image }}", + ); + assert.equal( + child(document, rollbackEnv, "PREVIOUS_SHA").value, + "${{ steps.previous.outputs.previous_sha }}", + ); + assert.match(stepRun(document, rollback), /API_IMAGE='\$PREVIOUS_API_IMAGE'/); + assert.match(stepRun(document, rollback), /WEB_IMAGE='\$PREVIOUS_WEB_IMAGE'/); + assert.match(stepRun(document, rollback), /GITHUB_SHA='\$PREVIOUS_SHA'/); + assert.match(stepRun(document, rollback), /up -d --no-build/); + + const logout = requiredStep(document, deploy, "Log out of GHCR"); + assert.equal(stepField(document, logout, "if").value, "always()"); + assert.equal(stepField(document, logout, "continue-on-error").value, "true"); + assert.match(stepRun(document, logout), /docker logout ghcr\.io/); +}); + +test("pending migration status exits 3 with the manual workflow and exact SHA", () => { + const document = parseWorkflow(deploymentWorkflowUrl); + const check = requiredStep( + document, + job(document, "deploy"), + "Check staging migrations", + ); + const script = stepRun(document, check); + const root = mkdtempSync(join(tmpdir(), "jyotisha-migration-check-")); + const fakeBin = join(root, "bin"); + const fakeSsh = join(fakeBin, "ssh"); + const exactSha = "0123456789abcdef0123456789abcdef01234567"; + mkdirSync(fakeBin); + writeFileSync(fakeSsh, '#!/usr/bin/env bash\nexit "${FAKE_SSH_STATUS:?}"\n'); + chmodSync(fakeSsh, 0o700); + + const run = (status: number) => + spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + DEPLOY_GIT_SHA: exactSha, + DEPLOY_HOST: "staging.example.invalid", + DEPLOY_PORT: "22", + DEPLOY_USER: "deploy", + DEPLOY_PATH: "/opt/jyotisha-staging", + FAKE_SSH_STATUS: String(status), + HOME: root, + PATH: `${fakeBin}:${process.env.PATH}`, + }, + }); + + try { + const pending = run(3); + assert.equal(pending.status, 3, pending.stderr); + assert.match(`${pending.stdout}\n${pending.stderr}`, /Migrate Staging Database/); + assert.match(`${pending.stdout}\n${pending.stderr}`, new RegExp(exactSha)); + assert.equal(run(0).status, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});