fix: validate staging runtime before deploy

This commit is contained in:
Jesse_Chen
2026-07-20 16:30:51 +08:00
parent aa9a0fe830
commit 965b0d5888
6 changed files with 202 additions and 23 deletions
+22 -1
View File
@@ -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"
+1 -1
View File
@@ -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`.
+38
View File
@@ -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"
@@ -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`:
@@ -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
+125 -17
View File
@@ -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 });
}
});