diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index fff34ad7..38717937 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -93,6 +93,26 @@ jobs: printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + - name: Record previous staging state + 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' && 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" + { + echo "### Staging deployment state" + echo "- Previous verified SHA: \`$PREVIOUS_SHA\`" + echo "- Target SHA: \`$DEPLOY_GIT_SHA\`" + echo "- Previous image IDs:" + echo '```text' + printf '%s\n' "$PREVIOUS_IMAGES" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Sync and rebuild staging env: DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} @@ -108,7 +128,7 @@ jobs: -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && test -f .env.staging && 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 && docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - name: Verify staging env: @@ -121,3 +141,4 @@ jobs: -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ "$DEPLOY_USER@$DEPLOY_HOST" \ "cd '$DEPLOY_PATH' && 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)); })'" + echo "- Verified deployed SHA: \`$DEPLOY_GIT_SHA\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/deploy/README.md b/deploy/README.md index 73c69bf7..fa4da0d2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -155,7 +155,7 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ``` -Before deploying, run `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet` on the server. The first deployment should be manual: +After source sync and before `up`, the workflow validates `.env.staging` mode/selectors 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: 1. Confirm `/opt/jyotisha-staging/.env.staging` exists, has mode `0600`, and contains the three selectors above. 2. Open GitHub Actions -> Jyotish Skill CI -> Run workflow, using workflow from `main`. diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh new file mode 100755 index 00000000..ddee7473 --- /dev/null +++ b/deploy/validate-staging-env.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env.staging}" + +if [ ! -f "$ENV_FILE" ]; then + echo "staging environment file is missing: $ENV_FILE" >&2 + exit 1 +fi + +if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then + : +else + MODE="$(stat -f '%Lp' "$ENV_FILE")" +fi + +if [ "$MODE" != "600" ]; then + echo "staging environment file must have mode 0600" >&2 + exit 1 +fi + +require_selector() { + local key="$1" + local expected="$2" + local count + + count="$(grep -c "^${key}=" "$ENV_FILE" || true)" + if [ "$count" -ne 1 ] || ! grep -Fqx "${key}=${expected}" "$ENV_FILE"; then + echo "invalid staging selector: $key" >&2 + exit 1 + fi +} + +require_selector APP_ENV_FILE ../.env.staging +require_selector CADDYFILE_PATH ./Caddyfile.staging +require_selector SITE_ADDRESS https://staging.jyotisha.chat + +echo "staging environment selectors: valid" diff --git a/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md index 045c009c..877fdf05 100644 --- a/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md +++ b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md @@ -26,6 +26,7 @@ - Modify `deploy/docker-compose.server.yml`: environment-specific env file and Caddyfile selection while retaining production defaults. - Create `deploy/Caddyfile.staging`: staging-only public reverse proxy with no production `www` redirect. +- Create `deploy/validate-staging-env.sh`: fail closed unless the staging env is mode `0600` and contains exactly the three fixed staging selectors. - Modify `frontend/tests/health-deployment.test.ts`: Compose, Caddy, CI-trigger, and staging-workflow contracts. - Modify `.github/workflows/ci.yml`: run the existing CI on pushes to `staging`; do not add a `main` push trigger in this task. - Create `.github/workflows/deploy-staging.yml`: tested-revision staging deployment and smoke checks. @@ -194,6 +195,8 @@ test("staging deploy consumes only the isolated staging environment and tested r assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/); assert.match(workflow, /--exclude='\.env\*'/); assert.match(workflow, /docker compose --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/); assert.match(workflow, /deployment\.gitCommit/); assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); assert.doesNotMatch(workflow, /103\.117\.123\.53/); @@ -322,6 +325,13 @@ jobs: printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + - name: Record previous staging state + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + # Query the current public deployment SHA and current Compose image IDs. + # Append both, plus DEPLOY_GIT_SHA, to GITHUB_STEP_SUMMARY before rebuilding. + - name: Sync and rebuild staging env: DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} @@ -337,7 +347,7 @@ jobs: -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && test -f .env.staging && 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 && docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - name: Verify staging env: @@ -408,7 +418,7 @@ Staging is isolated from production: The GitHub Environment contains `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment policy allows the `main` controller branch; the workflow separately requires an upstream successful CI push from branch `staging`. The staging key, database, Supabase keys, and model-provider keys must not be shared with production. -A push to branch `staging` runs `Jyotish Skill CI`. A successful push run triggers `.github/workflows/deploy-staging.yml`, which deploys the tested SHA and verifies the login route, logged-out account response, deployment SHA, and private Python health endpoint. +A push to branch `staging` runs `Jyotish Skill CI`. A successful push run triggers `.github/workflows/deploy-staging.yml`, which records the previous SHA/images, validates the env selectors and Compose configuration, deploys the tested SHA, and verifies the login route, logged-out account response, deployment SHA, and private Python health endpoint. The first deployment should be manual, after `.env.staging` is verified to contain `APP_ENV_FILE=../.env.staging`, `CADDYFILE_PATH=./Caddyfile.staging`, and `SITE_ADDRESS=https://staging.jyotisha.chat`: diff --git a/docs/superpowers/specs/2026-07-20-staging-server-design.md b/docs/superpowers/specs/2026-07-20-staging-server-design.md index bfb65ae1..89d13b65 100644 --- a/docs/superpowers/specs/2026-07-20-staging-server-design.md +++ b/docs/superpowers/specs/2026-07-20-staging-server-design.md @@ -60,7 +60,7 @@ staging 配置包含: - Secret:`STAGING_SSH_PRIVATE_KEY`; - Variable:`STAGING_HOST=118.26.111.127`、SSH port/user/path、staging URL; -- 只允许 `staging` 分支使用; +- GitHub Environment 只允许控制器分支 `main` 使用;`workflow_run` 另外强制上游成功运行来自 `staging`,并部署其 `head_sha`; - staging 部署使用独立 concurrency group,不能阻塞或取消 production。 部署流: @@ -69,7 +69,9 @@ staging 配置包含: push staging -> Jyotish Skill CI -> checkout 已测试 SHA - -> rsync 到 /opt/jyotisha-staging(排除 .env.staging) + -> 记录旧 SHA 和镜像 ID + -> rsync 到 /opt/jyotisha-staging(排除所有 .env*) + -> 校验 .env.staging 权限、固定选择器和 Compose 配置 -> docker compose build/up -> login、401 account、Python health smoke tests -> 记录部署 SHA diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 4e9dc5c1..4ecf9a0c 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -1,12 +1,24 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + 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"; +import { fileURLToPath } from "node:url"; test("health endpoint exposes deployment identity for production verification", () => { - const source = readFileSync(new URL("../src/app/api/health/route.ts", import.meta.url), "utf8"); + const source = readFileSync( + new URL("../src/app/api/health/route.ts", import.meta.url), + "utf8", + ); assert.match(source, /deployment:/); assert.match(source, /GITHUB_SHA/); @@ -15,8 +27,14 @@ test("health endpoint exposes deployment identity for production verification", }); test("manual production deployment passes the selected revision into the web runtime", () => { - const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8"); - const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8"); + const compose = readFileSync( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + "utf8", + ); + const workflow = readFileSync( + new URL("../../.github/workflows/deploy-production.yml", import.meta.url), + "utf8", + ); assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/); assert.match(workflow, /workflow_dispatch:/); @@ -31,15 +49,30 @@ test("manual production deployment passes the selected revision into the web run }); test("server compose accepts staging paths while preserving production defaults", () => { - const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8"); + const compose = readFileSync( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + "utf8", + ); - assert.match(compose, /env_file:\s*\n\s*- \$\{APP_ENV_FILE:-\.\.\/\.env\.production\}/); - assert.match(compose, /\$\{CADDYFILE_PATH:-\.\/Caddyfile\}:\/etc\/caddy\/Caddyfile:ro/); - assert.match(compose, /SITE_ADDRESS: \$\{SITE_ADDRESS:-https:\/\/jyotisha\.chat\}/); + assert.match( + compose, + /env_file:\s*\n\s*- \$\{APP_ENV_FILE:-\.\.\/\.env\.production\}/, + ); + assert.match( + compose, + /\$\{CADDYFILE_PATH:-\.\/Caddyfile\}:\/etc\/caddy\/Caddyfile:ro/, + ); + assert.match( + compose, + /SITE_ADDRESS: \$\{SITE_ADDRESS:-https:\/\/jyotisha\.chat\}/, + ); }); test("staging Caddy configuration serves only the configured staging address", () => { - const caddy = readFileSync(new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8"); + const caddy = readFileSync( + new URL("../../deploy/Caddyfile.staging", import.meta.url), + "utf8", + ); assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/staging\.jyotisha\.chat\}/); assert.match(caddy, /reverse_proxy web:3000/); @@ -47,12 +80,21 @@ 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), "utf8"); - const workflow = readFileSync(new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), "utf8"); + const ci = readFileSync( + new URL("../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ); + const workflow = readFileSync( + new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), + "utf8", + ); assert.match(ci, /push:\s*\n\s*branches: \[staging\]/); assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/); - assert.match(workflow, /github\.event\.workflow_run\.head_branch == 'staging'/); + assert.match( + workflow, + /github\.event\.workflow_run\.head_branch == 'staging'/, + ); assert.match(workflow, /actions: read/); assert.match(workflow, /environment:\s*\n\s*name: staging/); assert.match(workflow, /git_sha:/); @@ -67,13 +109,24 @@ test("staging deploy consumes only the isolated staging environment and tested r 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, + /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/, + ); assert.match(workflow, /deployment\.gitCommit/); assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); assert.doesNotMatch(workflow, /103\.117\.123\.53/); }); test("staging rsync preserves every destination env variant during delete", () => { - const workflow = readFileSync(new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), "utf8"); + const workflow = readFileSync( + new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), + "utf8", + ); const envExclusion = workflow.match(/--exclude='([^']*\.env[^']*)'/)?.[1]; assert.equal(envExclusion, ".env*"); @@ -84,21 +137,76 @@ test("staging rsync preserves every destination env variant during delete", () = mkdirSync(source); mkdirSync(destination); writeFileSync(join(source, "app.txt"), "new revision\n"); - for (const name of [".env", ".env.local", ".env.staging", ".env.staging.backup"]) { + for (const name of [ + ".env", + ".env.local", + ".env.staging", + ".env.staging.backup", + ]) { writeFileSync(join(destination, name), "preserve\n"); } try { const result = spawnSync( "rsync", - ["-a", "--delete", `--exclude=${envExclusion}`, `${source}/`, `${destination}/`], + [ + "-a", + "--delete", + `--exclude=${envExclusion}`, + `${source}/`, + `${destination}/`, + ], { encoding: "utf8" }, ); assert.equal(result.status, 0, result.stderr); - for (const name of [".env", ".env.local", ".env.staging", ".env.staging.backup"]) { - assert.equal(existsSync(join(destination, name)), true, `${name} was deleted`); + for (const name of [ + ".env", + ".env.local", + ".env.staging", + ".env.staging.backup", + ]) { + assert.equal( + existsSync(join(destination, name)), + true, + `${name} was deleted`, + ); } } finally { rmSync(root, { recursive: true, force: true }); } }); + +test("staging env validator rejects selector drift, duplicates, and unsafe permissions", () => { + const validator = fileURLToPath( + new URL("../../deploy/validate-staging-env.sh", import.meta.url), + ); + const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-env-")); + const envFile = join(root, ".env.staging"); + const validSelectors = [ + "APP_ENV_FILE=../.env.staging", + "CADDYFILE_PATH=./Caddyfile.staging", + "SITE_ADDRESS=https://staging.jyotisha.chat", + ]; + const run = () => + spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + const writeEnv = (lines: string[], mode = 0o600) => { + writeFileSync(envFile, `${lines.join("\n")}\n`); + chmodSync(envFile, mode); + }; + + try { + writeEnv(validSelectors); + assert.equal(run().status, 0); + + writeEnv(["APP_ENV_FILE=../.env.production", ...validSelectors.slice(1)]); + assert.notEqual(run().status, 0); + + writeEnv([...validSelectors, "SITE_ADDRESS=https://example.invalid"]); + assert.notEqual(run().status, 0); + + writeEnv(validSelectors, 0o644); + assert.notEqual(run().status, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});