fix: validate staging runtime before deploy
This commit is contained in:
@@ -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
@@ -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`.
|
||||
|
||||
Executable
+38
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user