发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。 自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
f4c53e7f9f
commit
00e6efdbfa
@@ -98,6 +98,9 @@ function readTestOtp(env: IdentityEnvironment): string | null {
|
||||
if (!/^\d{6}$/.test(value)) {
|
||||
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
|
||||
}
|
||||
if (env.AUTH_USER_ORIGIN?.trim() === "https://jyotisha.chat") {
|
||||
throw new Error("IDENTITY_TEST_OTP is forbidden when AUTH_USER_ORIGIN is the production site");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user