发布可以跟上只多了文档的测试环境头

四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。

自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 10:24:40 +08:00
co-authored by Grok (agent loop)
parent f4c53e7f9f
commit 00e6efdbfa
23 changed files with 1470 additions and 174 deletions
+18 -7
View File
@@ -12,6 +12,9 @@ import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
import {
databaseHealthFromFilenames,
RECTIFICATION_CONTRACT_VERSION,
@@ -218,7 +221,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
try {
writeFileSync(envFile, "APP_ENV_FILE=../.env.staging\nOPENAI_API_KEY=legacy\nLLM_MODEL=legacy\n");
chmodSync(envFile, 0o600);
const first = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const first = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(first.status, 0, first.stderr);
const prepared = readFileSync(envFile, "utf8");
assert.match(prepared, /^APP_ENV_FILE=\.\.\/\.env\.staging$/m);
@@ -227,7 +230,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
assert.ok(key);
assert.equal(statSync(envFile).mode & 0o777, 0o600);
const second = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const second = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(second.status, 0, second.stderr);
const secondKey = readFileSync(envFile, "utf8").match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1];
assert.equal(secondKey, key);
@@ -267,7 +270,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
"PERSONAL_REPORT_DAILY_LIMIT=5",
];
const run = () =>
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
spawnSync(bash, [validator, envFile], { encoding: "utf8" });
const writeEnv = (lines: string[], mode = 0o600) => {
writeFileSync(envFile, `${lines.join("\n")}\n`);
chmodSync(envFile, mode);
@@ -515,20 +518,28 @@ test("production env validators accept only self-hosted production selectors and
try {
writeEnv(appEnv, appLines);
writeEnv(databaseEnv, databaseLines);
assert.equal(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
for (const forbidden of ["IDENTITY_TEST_OTP=123456", "IDENTITY_TEST_OTP="]) {
writeEnv(appEnv, [...appLines, forbidden]);
const rejected = spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" });
assert.notEqual(rejected.status, 0, forbidden);
assert.match(rejected.stderr, /IDENTITY_TEST_OTP is forbidden in production/);
}
writeEnv(appEnv, appLines);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
writeEnv(appEnv, appLines.map((line) =>
line.startsWith("ADMIN_USER_ORIGIN=")
? "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat"
: line,
));
assert.notEqual(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
writeEnv(databaseEnv, databaseLines.filter((line) =>
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
));
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
+20
View File
@@ -77,3 +77,23 @@ test("identity test OTP channel is opt-in and strictly six digits", () => {
/must be a 6-digit/,
);
});
test("production origin refuses a configured test OTP and staging still accepts one", () => {
assert.throws(
() => readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
IDENTITY_TEST_OTP: "123456",
}),
/forbidden when AUTH_USER_ORIGIN is the production site/,
);
const unset = readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
});
assert.equal(unset.provider === "self-hosted" ? unset.testOtp : "unexpected", null);
const staging = readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "123456" });
assert.equal(staging.provider === "self-hosted" ? staging.testOtp : null, "123456");
});
@@ -6,6 +6,9 @@ import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
const giteaQualityWorkflow = new URL(
"../../.gitea/workflows/backend-quality-gate.yml",
import.meta.url,
@@ -999,7 +1002,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
chmodSync(join(mockBin, "flock"), 0o755);
try {
const result = spawnSync("bash", [fileURLToPath(deployScript)], {
const result = spawnSync(bash, [fileURLToPath(deployScript)], {
encoding: "utf8",
env: {
...process.env,
@@ -1117,6 +1120,9 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
assert.match(workflow, /NODE_TOOL_IMAGE: node:22-bookworm-slim/);
assert.match(workflow, /node --version \| grep -Eq '\^v22\\\.'/);
assert.match(workflow, /docker compose version --short \| grep -Eq '\^v\?2\\\.'/);
assert.match(workflow, /RESOLVE_MODE: discover/);
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /H relative to R is docs-only/);
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
for (const dependency of [
@@ -1139,7 +1145,13 @@ test("production deploy is manual-only and consumes the accepted staging artifac
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
assert.match(production, /public staging has not accepted the requested SHA/);
assert.match(production, /current_main[\s\S]*DEPLOY_SHA[\s\S]*current_staging[\s\S]*DEPLOY_SHA/);
// 原值: current_main 与 current_staging 都必须等于 DEPLOY_SHA
// 新值: 两者必须相等,并且用门禁产物里的 is-docs-only-range.sh 确认相对 DEPLOY_SHA 仍是纯文档
// 原因: 产品 2026-10-14 允许 staging head 在最近一次门禁提交之后只前进文档
assert.match(production, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(production, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(production, /bash deploy\/resolve-release-revision\.sh/);
assert.doesNotMatch(production, /stale production revision refused/);
assert.match(production, /verification_mode:/);
assert.match(production, /PRODUCTION_HOST/);
assert.match(production, /118\.194\.235\.34/);
@@ -1162,10 +1174,12 @@ test("deployment scripts pass shell syntax validation", () => {
productionSyncScript,
productionEnvValidator,
productionDatabaseEnvValidator,
new URL("../../deploy/resolve-release-revision.sh", import.meta.url),
new URL("../../deploy/rehearse-production-migrations.sh", import.meta.url),
]) {
const path = fileURLToPath(script);
chmodSync(path, 0o755);
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });
const result = spawnSync(bash, ["-n", path], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
}
});
@@ -1246,8 +1260,13 @@ test("production recovery workflow creates a verified encrypted off-site artifac
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /permissions:\n\s+contents: read\n\s+actions: write/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
// 原值: main_head 与 staging_head 都必须等于 DEPLOY_SHA,且工作流正文里用 jq 找 release 门
// 新值: 调用 resolve-release-revision.sh,dispatch 的 GITEA_SHA 必须等于脚本给出的 head_sha
// 原因: 恢复点跟四个生产工作流共用同一条「最近门禁提交 + 其后纯文档」规则
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /main_head == "\$DEPLOY_SHA" && "\$staging_head" == "\$DEPLOY_SHA"/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /git checkout --detach --force "\$DEPLOY_SHA"/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
@@ -1291,15 +1310,21 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
// 原值: main_head、staging_head、GITEA_SHA 都必须等于 DEPLOY_SHA,门禁 jq 写在工作流里
// 新值: 工作流调用 resolve-release-revision.sh;GITEA_SHA 对齐 head_sha,DEPLOY_SHA 对齐 release_sha
// 原因: 生产迁移与部署接受同一发布修订,不再要求被 dispatch 的 main 提交自己有 push 门禁
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /GITEA_SHA" == "\$DEPLOY_SHA"/);
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /backend-quality-gate\.yml/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /release-quality-gate\.yml/);
assert.match(workflow, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(workflow, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
@@ -1582,7 +1607,7 @@ test("is-docs-only-range.sh decides from local history and refuses non-ancestor
git("commit", "-q", "-m", message);
return git("rev-parse", "HEAD");
};
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
const run = (...args: string[]) => spawnSync(bash, [script, ...args], { cwd: root, env, encoding: "utf8" });
try {
git("init", "-q", "-b", "staging");
@@ -1700,7 +1725,7 @@ esac
] as const) {
const cleanupLog = join(root, "cleanup.log");
writeFileSync(cleanupLog, "");
const result = spawnSync("bash", ["-c", script], {
const result = spawnSync(bash, ["-c", script], {
cwd: root,
env: { ...process.env, PATH: `${root}:${process.env.PATH}`, TMPDIR: root,
NODE_TOOL_SOURCE_IMAGE: "test-image", NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com",