From 801666a6c71b8efc220afa4248f42c5c776ba9e6 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 16:43:08 +0800 Subject: [PATCH] fix: pin staging compose selectors --- .github/workflows/deploy-staging.yml | 6 ++-- deploy/README.md | 2 +- ...026-07-20-staging-deployment-automation.md | 2 +- .../specs/2026-07-20-staging-server-design.md | 2 +- frontend/tests/health-deployment.test.ts | 33 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 38717937..62658856 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -101,7 +101,7 @@ jobs: 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")" + "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" { echo "### Staging deployment state" @@ -128,7 +128,7 @@ jobs: -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "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" + "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" - name: Verify staging env: @@ -140,5 +140,5 @@ jobs: ssh -i ~/.ssh/jyotisha-staging -p "$DEPLOY_PORT" \ -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)); })'" + "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)); })'" echo "- Verified deployed SHA: \`$DEPLOY_GIT_SHA\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/deploy/README.md b/deploy/README.md index fa4da0d2..3d729fc5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -155,7 +155,7 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ``` -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: +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: 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/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md index 877fdf05..6ee361b8 100644 --- a/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md +++ b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md @@ -347,7 +347,7 @@ jobs: -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "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" + "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" - name: Verify staging env: 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 d2de9298..31da798d 100644 --- a/docs/superpowers/specs/2026-07-20-staging-server-design.md +++ b/docs/superpowers/specs/2026-07-20-staging-server-design.md @@ -71,7 +71,7 @@ push staging -> checkout 已测试 SHA -> 记录旧 SHA 和镜像 ID -> rsync 到 /opt/jyotisha-staging(排除所有 .env*) - -> 校验 .env.staging 权限、固定选择器和 Compose 配置 + -> 校验 .env.staging 权限、固定选择器和 Compose 配置,并在 Compose 进程上显式钉死 staging 选择器 -> 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 311eb4df..f4f15bf4 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -120,6 +120,17 @@ test("staging deploy consumes only the isolated staging environment and tested r 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", () => { @@ -209,6 +220,28 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi ); writeEnv(validSelectors); assert.equal(run().status, 0); + const shellOverride = spawnSync( + "docker", + [ + "compose", + "--env-file", + envFile, + "-f", + composeFile, + "config", + "--format", + "json", + ], + { + encoding: "utf8", + env: { ...process.env, APP_ENV_FILE: "../.env.production" }, + }, + ); + assert.equal(shellOverride.status, 0, shellOverride.stderr); + assert.equal( + JSON.parse(shellOverride.stdout).services.probe.environment.SELECTED, + "../.env.production", + ); writeEnv(["APP_ENV_FILE=../.env.production", ...validSelectors.slice(1)]); assert.notEqual(run().status, 0);