Compare commits

...

6 Commits

Author SHA1 Message Date
Jesse e718b2c6d9 fix(staging): refresh dual-host edge verification
Staging Backend Quality Gate / validate (pull_request) Successful in 17m22s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-07 01:46:47 +08:00
jesse 28f4207f29 Merge pull request 'Wait for staging public release identity' (#23)
Staging Backend Quality Gate / validate (push) Successful in 11m21s
Staging Backend Quality Gate / publish (push) Successful in 3m33s
Merge reviewed bounded staging public identity convergence fix after successful PR gate 1519.
2026-08-07 01:22:04 +08:00
Jesse 3221b6a9a4 fix(staging): await public release identity
Staging Backend Quality Gate / validate (pull_request) Successful in 13m57s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-07 01:07:08 +08:00
jesse 8dc61e3135 Merge pull request 'Bound staging quality gate frontend build' (#22)
Staging Backend Quality Gate / validate (push) Successful in 11m4s
Staging Backend Quality Gate / publish (push) Successful in 2m7s
Merge reviewed fail-closed staging gate timeout after successful exact-head PR validation run 1511.
2026-08-07 00:32:23 +08:00
Jesse f145f52b01 fix(staging): bound frontend quality build
Staging Backend Quality Gate / validate (pull_request) Successful in 11m6s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-07 00:06:45 +08:00
jesse 6fd2292119 Merge pull request 'Restore staging gate after concurrent admin rollout' (#21) from fix/staging-gate-retry-and-admin-audit into main
Staging Backend Quality Gate / validate (push) Failing after 56m44s
Staging Backend Quality Gate / publish (push) Has been skipped
2026-08-06 22:51:27 +08:00
5 changed files with 141 additions and 28 deletions
+4 -1
View File
@@ -183,7 +183,10 @@ jobs:
python -m build
npm test --prefix frontend
npm run lint --prefix frontend
npm run build --prefix frontend
if ! timeout 600 npm run build --prefix frontend; then
echo "frontend production build exceeded bounded 600-second timeout" >&2
exit 124
fi
publish:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging'
+53 -23
View File
@@ -177,6 +177,7 @@ trap rollback ERR
switched=true
"${compose[@]}" up -d --no-build --remove-orphans
"${compose[@]}" up -d --no-build --force-recreate --no-deps caddy
verify_container_image() {
local service="$1"
@@ -198,31 +199,60 @@ verify_container_image rectification-v4-worker "$WEB_IMAGE"
-e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \
web node --input-type=module <<'NODE'
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let login;
for (let attempt = 0; attempt < 12; attempt += 1) {
let observed = {};
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
login = await fetch(`${process.env.STAGING_URL}/login`);
if (login.ok) break;
} catch {}
await delay(5_000);
}
if (!login?.ok) process.exit(1);
const adminPage = await fetch(`${process.env.STAGING_URL}/admin`, { redirect: "manual" });
if (adminPage.status !== 307 || adminPage.headers.get("location") !== "/login") process.exit(1);
const adminApi = await fetch(`${process.env.STAGING_URL}/api/admin/session`);
if (adminApi.status !== 401) process.exit(1);
const account = await fetch(`${process.env.STAGING_URL}/api/account`);
if (account.status !== 401) process.exit(1);
const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`);
const publicBody = await publicHealth.json();
if (!publicHealth.ok || publicBody.deployment?.gitCommit !== process.env.EXPECTED_SHA) {
process.exit(1);
}
const privateHealth = await fetch("http://api:5200/api/health");
const privateBody = await privateHealth.json();
if (!privateHealth.ok || privateBody.status !== "ok" || privateBody.swisseph_available !== true) {
process.exit(1);
const login = await fetch(`${process.env.STAGING_URL}/login`);
const userAdminPage = await fetch(`${process.env.STAGING_URL}/admin`, { redirect: "manual" });
const userAdminApi = await fetch(`${process.env.STAGING_URL}/api/admin/session`);
const adminPage = await fetch(`${process.env.ADMIN_USER_ORIGIN}/admin`, { redirect: "manual" });
const adminApi = await fetch(`${process.env.ADMIN_USER_ORIGIN}/api/admin/session`);
const account = await fetch(`${process.env.STAGING_URL}/api/account`);
const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`);
const publicBody = await publicHealth.json();
const privateHealth = await fetch("http://api:5200/api/health");
const privateBody = await privateHealth.json();
observed = {
attempt,
login: login.status,
userAdminPage: userAdminPage.status,
userAdminApi: userAdminApi.status,
adminPage: adminPage.status,
adminLocation: adminPage.headers.get("location"),
adminApi: adminApi.status,
account: account.status,
publicHealth: publicHealth.status,
publicSha: publicBody.deployment?.gitCommit ?? "missing",
privateHealth: privateHealth.status,
privateStatus: privateBody.status ?? "missing",
swissephAvailable: privateBody.swisseph_available === true,
};
if (
login.ok
&& userAdminPage.status === 404
&& userAdminApi.status === 404
&& adminPage.status === 307
&& adminPage.headers.get("location") === "/login"
&& adminApi.status === 401
&& account.status === 401
&& publicHealth.ok
&& publicBody.deployment?.gitCommit === process.env.EXPECTED_SHA
&& privateHealth.ok
&& privateBody.status === "ok"
&& privateBody.swisseph_available === true
) {
process.exit(0);
}
} catch (error) {
observed = {
attempt,
error: error instanceof Error ? error.name : "verification_error",
};
}
if (attempt < 12) await delay(5_000);
}
console.error("staging verification predicates did not converge", JSON.stringify(observed));
process.exit(1);
NODE
revision_file="$state_directory/deployed-revision.tmp.$$"
+48
View File
@@ -2336,3 +2336,51 @@
- 相关记录:BUG-128、BUG-134、ERR-093、ERR-098
- 复发自:无
- 修复版本:待 staging service runtime 对齐与 exact-SHA 部署验收
## BUG-136 | staging quality gate frontend build 无界卡住并耗尽 45 分钟 job
- 状态:investigating
- 首次发现:2026-08-06
- 最近更新:2026-08-06
- 影响面:Gitea `Staging Backend Quality Gate` validate job、staging artifact publication;应用代码、staging host 和 production 未被本次失败修改。
- 用户现象:push gate `1507` 对 reviewed SHA `6fd22921197715e065d0d137fbd7ea5a82a188e4` 完成 frontend `1472/1472`、ESLint `0 error`Next.js 输出 `Compiled successfully in 38.2s` 后约 44 分钟无 further output45 分钟 job 超时,publish 被跳过;公网继续运行旧健康 SHA `e59f15d352787f3d05425ba8c459d092e9801a20`
- 触发条件:质量门禁执行 `npm run build --prefix frontend` 没有命令级 bounded timeoutTurbopack 在编译后静态生成/收尾阶段无输出卡住时只能等待 job-level timeout。
- 根因:quality gate 只有 45 分钟 job 上限,缺少针对生产构建步骤的 fail-closed deadline;此前 PR gate `1503/1505` 同一代码完整 build 通过,说明本次是 runner/build hang,不是已观测的业务编译错误。
- 修复:在 Gitea validate 中将 frontend production build 包在 `timeout 600` 内,超时输出明确事实并以非零状态失败;不跳过 build、不降低测试、不发布旧 artifact。新增 workflow contract 锁定该 bounded timeout。
- 验证:待本地 workflow contract、完整 PR gate、同一 reviewed main/staging SHA 的 push gate、immutable manifest 和 deploy 验收;完成前不得标记 resolved。
- 防复发:所有可能长时间静默的编译、镜像构建和外部网络步骤都必须有命令级 deadline,且 deadline 失败必须 fail closed;保留 job-level timeout 作为第二层上限,不把 timeout 当成功。
- 相关记录:BUG-129、BUG-131、ERR-096、ERR-099
- 复发自:无
- 修复版本:待 frontend build bounded timeout 与 staging exact-SHA 验收
## BUG-137 | staging deploy 在公网 upstream 尚未收敛时用旧 SHA 立即判失败
- 状态:investigating
- 首次发现:2026-08-06
- 最近更新:2026-08-06
- 影响面:Gitea `Deploy staging` 最终公网验证、自动回滚;数据库迁移已成功,production 未受影响。
- 用户现象:exact-SHA gate `1514` 与 migration `1516` 成功后,deploy `1517``1518` 均启动目标 web/API image 并达到容器 healthy,却在约 3 秒后的公网 verification 返回非零,随后成功恢复旧 web/worker;公网和 `.state/deployed-revision` 均保持旧 SHA `e59f15d352787f3d05425ba8c459d092e9801a20`
- 触发条件:Compose 切换到目标容器后,旧 Caddy upstream 在短暂收敛窗口仍可让 `/login` 返回 200;脚本只轮询 `/login`,然后对公网 health SHA 和其余 predicate 仅检查一次,读取旧 SHA 时立即触发回滚。
- 根因:发布验证把“login 可达”和“公网已路由到 exact SHA”拆成了不对称检查;容器健康与代理 upstream 收敛不是同一时刻,单次 SHA 检查形成确定性 race。目标 image 隔离 probe 已确认注入的 `GITHUB_SHA` 为目标 SHA。
- 修复:在原 60 秒总预算内,每 5 秒原子重查 login、admin 未登录重定向、admin API/account 401、公网 health exact SHA、私有 API health 和 Swiss Ephemeris;仅当所有 predicate 同轮满足才成功。预算耗尽仍 fail closed 并只输出状态码、observed SHA、health 状态等脱敏摘要,不输出正文、env 或凭据。
- 验证:待合同测试、PR gate、新 reviewed exact-SHA push gate、immutable artifact、migration check 与真实 staging deploy 验收;完成前不得标记 resolved。
- 防复发:发布验证必须等待最终外部路由 identity,而不能把单个 readiness endpoint 当作代理收敛证明;所有重试必须有总上限,失败记录仅含非敏感 predicate 状态并保持自动回滚。
- 相关记录:BUG-129、BUG-136、ERR-099、ERR-100
- 复发自:无
- 修复版本:待 staging verification convergence gate 与 exact-SHA deploy 验收
## BUG-138 | staging Caddy 保留旧单文件 bind inode 且 admin 验证误走用户域名
- 状态:investigating
- 首次发现:2026-08-06
- 最近更新:2026-08-06
- 影响面:staging Caddy 双 host 路由、admin TLS、`Deploy staging` 未登录边界验证;production 未受影响。
- 用户现象:修复公网 SHA 收敛后,deploy `1522` 明确观测目标 SHA、私有 API 和 Swiss Ephemeris 均正常,但在用户域名上得到 `/admin -> 307 /``/api/admin/session -> 403`;同时 `admin.staging.jyotisha.chat` TLS 握手失败。workflow 在 60 秒后自动恢复旧应用,未写入新 deployed-revision。
- 触发条件:controller 通过原子目录同步替换 `deploy/Caddyfile.staging`,但长期运行的 Caddy 容器仍持有旧单文件 bind mount inode;随后 checker 又把 admin 页面/API 请求错误发送到 `STAGING_URL` 而不是 `ADMIN_USER_ORIGIN`
- 根因:host 文件与 Caddy 容器 mount inode 漂移。只读现场证据显示 host Caddyfile 含 admin host、运行容器内文件不含,inode/size/mtime 均不同;staging VPS 从两台权威 nameserver 查询 admin A 记录均为 `118.26.111.127`,排除 DNS 缺失。用户域名按新 Caddy 合同本应对 admin paths 404,因此旧 checker 的 307/401 期待也违反双 host 边界。
- 修复:应用 Compose 切换后显式 `--force-recreate --no-deps caddy`,使其重新挂载 gate-attested Caddyfile;完整 convergence 同轮要求用户域名 admin page/API 均 404、admin origin page 307 到 `/login`、admin API 401,并继续要求 exact public SHA、account 401、私有 API/Swiss 健康。失败仍 bounded、fail closed 并自动恢复旧应用。
- 验证:待 shell/workflow 合同、PR gate、新 exact-SHA push artifact、真实 Caddy admin 证书/路由和 deploy 验收;完成前不得标记 resolved。
- 防复发:原子替换单文件 bind mount 后必须 recreate/reload 长期运行服务;部署 smoke 必须分别使用各自主机 origin,不能在 user host 上测试 admin host 合同。保留 authority DNS、mount inode 和 TLS 检查作为脱敏现场证据。
- 相关记录:BUG-134、BUG-137、ERR-097、ERR-100、ERR-101
- 复发自:无
- 修复版本:待 Caddy mount refresh 与双 host exact-SHA staging 验收
+18
View File
@@ -169,6 +169,24 @@ After the admin-origin selector was repaired, the full staging validators expose
Prevention: `/docker-entrypoint-initdb.d` is not a compatibility mechanism for an existing PostgreSQL volume. Every newly required runtime role must have a reviewed non-destructive repair path plus pre-deploy role/presence probes. Keep `admin_runtime` outside `service_role`; only `service_runtime` may assume the BYPASSRLS role through the dedicated service URL. Never display role passwords, pass them in argv, or edit an already-ledgered migration checksum to retrofit host bootstrap behavior.
## ERR-099 | Gitea frontend production build hung after successful compilation until job timeout | investigating 2026-08-06
Staging push gate `1507` completed all frontend tests (`1472/1472`), lint with zero errors, and Turbopack compilation in 38.2 seconds, then emitted no further build output for roughly 44 minutes. The 45-minute validate job expired, publish was skipped, no artifact or deployment was produced, and public staging remained on the previous healthy SHA. The same code had completed production builds in PR gates `1503` and `1505`, so the observed failure is a runner/build-finalization hang rather than a reported compile error. A manually dispatched diagnostic run does not satisfy the push-only publication contract and must not be treated as an immutable release artifact.
Prevention: wrap the Gitea frontend production build in a command-level 600-second timeout with an explicit nonzero failure; retain the 45-minute job timeout as a second boundary. Never skip the build, reuse an old artifact, or treat a manual validation-only run as a successful staging push gate. Only a successful exact-SHA push gate may publish images and trigger deployment.
## ERR-100 | Staging public verification checked exact SHA only once before proxy convergence | investigating 2026-08-06
Exact-SHA gate `1514` and migration `1516` succeeded, but deploy runs `1517` and `1518` each reached healthy target web/API containers and then failed public verification roughly three seconds later. Both runs restored the prior application image and retained the old deployed-revision state. The verifier retried only `/login`; once that endpoint returned 200 through the existing Caddy route, it performed exactly one check of public health SHA and the remaining authorization/private-health predicates. This permits an old upstream response to trigger immediate rollback during container/proxy convergence even though the new container itself carries the expected SHA. No application release completed and production was not involved.
Prevention: within the existing bounded 60-second budget, retry the complete predicate set together: login, admin redirect, logged-out admin/account responses, public exact SHA, private API status, and Swiss Ephemeris availability. Success requires every predicate in the same attempt. On exhaustion, fail closed and emit only redacted status codes, observed SHA, and health booleans; never log response bodies, env, credentials, or error stacks.
## ERR-101 | Atomic Caddyfile replacement left the running Caddy bind-mounted to the old inode | investigating 2026-08-06
The bounded verifier in deploy `1522` proved that public health had converged to the target exact SHA and private API/Swiss checks passed, but admin checks on the user host returned the expected application fail-closed statuses rather than admin-host statuses. Read-only host inspection then found the reviewed host `deploy/Caddyfile.staging` contained the admin virtual host while `/etc/caddy/Caddyfile` inside the long-running Caddy container did not; their inode, size, and mtime differed even though Docker reported the expected bind source. The tree sync atomically replaced the source file, leaving the existing bind mount attached to the old inode. Both authoritative Spaceship nameservers returned the staging VPS address for the admin host when queried from the VPS, so this was not an absent DNS record. The failed deploy restored the prior application and did not advance deployed-revision.
Prevention: after syncing a gate-attested single-file bind mount, force-recreate Caddy under the shared host lock before public verification. Verify user and admin origins separately: user-host admin paths must be 404; admin-host anonymous page/API must be 307-to-login and 401. Preserve bounded convergence, exact public SHA, private health, automatic rollback, and redacted diagnostics. Do not weaken identity host routing or modify DNS based on intercepted local resolver results.
## Fragment Sweep Command Set
## ERR-086 | Steve Jobs jyotishganit artifacts used non-San-Francisco coordinates | mitigated 2026-07-21
@@ -210,6 +210,8 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
assert.equal((workflow.match(/npm test --prefix frontend/g) ?? []).length, 1);
assert.equal((workflow.match(/npm run lint --prefix frontend/g) ?? []).length, 1);
assert.equal((workflow.match(/npm run build --prefix frontend/g) ?? []).length, 1);
assert.match(workflow, /timeout 600 npm run build --prefix frontend/);
assert.match(workflow, /frontend production build exceeded bounded 600-second timeout/);
assert.match(workflow, /needs: validate/);
assert.match(workflow, /IMAGE_REPOSITORY: crpi-[^\n]+\/copse\/jyotisha/);
assert.match(workflow, /\$IMAGE_REPOSITORY:api-\$GITEA_SHA/);
@@ -642,7 +644,14 @@ test("remote deployment verifies running image IDs, RepoDigests, and application
assert.match(runner, /"\$\{docker_command\[@\]\}" image inspect --format '\{\{\.Id\}\}' "\$expected_ref"/);
assert.match(runner, /RepoDigests/);
assert.match(runner, /grep -Fqx "\$expected_ref"/);
assert.match(runner, /publicBody\.deployment\?\.gitCommit !== process\.env\.EXPECTED_SHA/);
assert.match(runner, /for \(let attempt = 1; attempt <= 12; attempt \+= 1\)/);
assert.match(runner, /publicBody\.deployment\?\.gitCommit === process\.env\.EXPECTED_SHA/);
assert.match(runner, /staging verification predicates did not converge/);
assert.match(runner, /publicSha: publicBody\.deployment\?\.gitCommit \?\? "missing"/);
assert.match(runner, /up -d --no-build --force-recreate --no-deps caddy/);
assert.match(runner, /userAdminPage\.status === 404/);
assert.match(runner, /userAdminApi\.status === 404/);
assert.match(runner, /fetch\(`\$\{process\.env\.ADMIN_USER_ORIGIN\}\/admin`/);
assert.match(runner, /mv -f "\$revision_file" "\$state_directory\/deployed-revision"/);
assert.match(runner, /restoring prior application images/);
assert.match(
@@ -769,9 +778,14 @@ test("normal deployment checks migrations but never applies them", () => {
assert.doesNotMatch(runner, /npm\s+run\s+db:migrate(?!:check)/);
assert.doesNotMatch(runner, /pull api web postgres/);
assert.match(runner, /verify_container_image rectification-v4-worker \"\$WEB_IMAGE\"/);
assert.match(runner, /adminPage\.status !== 307/);
assert.match(runner, /adminPage\.headers\.get\("location"\) !== "\/login"/);
assert.match(runner, /adminApi\.status !== 401/);
assertOrder(runner, [
"up -d --no-build --remove-orphans",
"up -d --no-build --force-recreate --no-deps caddy",
"verify_container_image api",
]);
assert.match(runner, /adminPage\.status === 307/);
assert.match(runner, /adminPage\.headers\.get\("location"\) === "\/login"/);
assert.match(runner, /adminApi\.status === 401/);
});
test("staging runs the rectification V4 worker from the immutable web image", () => {