From 81102c26cfdab0f0750d9448c45806772685a839 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 9 Sep 2026 12:47:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E7=8A=B6=E6=80=81=E8=AF=AF=E5=88=A4=EF=BC=88?= =?UTF-8?q?09-06-release-gate-integrity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 11 +++ .../09-06-release-gate-integrity/design.md | 7 ++ .../09-06-release-gate-integrity/implement.md | 56 +++++++++++ .../tasks/09-06-release-gate-integrity/prd.md | 9 ++ .../trialrun/production-preflight-remote.sh | 20 +++- .../deployment-safety-contract.test.js | 93 ++++++++++++++++++- 6 files changed, 189 insertions(+), 7 deletions(-) diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index ea50dc43..cc13d0b0 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -2441,6 +2441,12 @@ run_logged_deploy_stage "build-init" "${compose_cmd[@]}" build init stop before Compose config and Prisma status. - Status output missing/unreadable, failed/rolled-back/diverged history or an unknown pending migration -> `PAUSED`; do not reset or resolve in place. +- Status predicates use one `status_matches` helper with direct here-string + input, not `printf | grep -q` under pipefail. An early-exiting consumer can + yield producer=141/consumer=0 and falsely skip unknown-pending rejection. + Matcher exit 0 means match, 1 means no match, any other status immediately + fails with `PROD_MIGRATION_STATUS_UNREADABLE`. Preserve candidate/history + reconciliation; do not infer safety from the absence of parsed pending IDs. - Non-zero remote exit with a safe `code=...`, with or without a Prisma marker -> propagate that code and stop. Non-zero exit without a parseable code -> `PRODUCTION_PREFLIGHT_REMOTE_FAILED`; never continue to marker validation. @@ -2484,6 +2490,11 @@ run_logged_deploy_stage "build-init" "${compose_cmd[@]}" build init - `bash -n deploy/trialrun/production-preflight-remote.sh`. - `node deploy/trialrun/validate-env-contract.js`. - `cd server && npm test -- tests/unit/scripts/deployment-safety-contract.test.js`. +- Inject the observed producer 141 into the actual status parsing/rejection + source; restored old-predicate mutation must demonstrate lost rejection. + Cover safe/allowlisted/unknown/unsafe/unreadable states, 96 KiB trailing output + and matcher failure. The multi-subprocess remote fixture has a bounded 15 s + test budget (not a production timeout change); assertions are unchanged. - Static ordering must prove production preflight precedes `deploy.sh`, and the remote status marker precedes migration-history reconciliation. Fixtures must reject loopback/staging target identities without invoking SSH. diff --git a/.trellis/tasks/09-06-release-gate-integrity/design.md b/.trellis/tasks/09-06-release-gate-integrity/design.md index 01e25f2e..67799b8a 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/design.md +++ b/.trellis/tasks/09-06-release-gate-integrity/design.md @@ -258,3 +258,10 @@ local-candidate 原验证器对其选定集合的严格行为保持;共享解 Wrong:caller 声明 online=true → 未核验的包装启动浏览器 → 无 bootstrap 证据也汇总 207 为待修清单。 Correct:当前候选策略定义环境 → 受管 runner 创建并实测 → 真实 bootstrap 在线才启动业务集合 → 环境、覆盖与来源由验证器共同判定 → 错误环境回执留存但撤销决策资格。 + + +## 迁移状态谓词修复增量 + +按 implement.md 的追加阶段,用 status_matches 集中四处状态判断;grep 直接读取 here-string,不使用 printf 生产者或提前退出模式。退出 0/1 分别代表匹配/不匹配,其余退出码立即停止。保留日志、SQL 查询、候选 allowlist、迁移和回滚判定顺序。 + +回归直接提取现有源码状态解析与未知迁移拒绝分支;注入真实观察到的 producer=141,并恢复旧谓词做负向变异。长输出与四处 matcher 工具错误单列;原多子进程远端 fixture 使用有界 15 秒测试预算,非生产超时修改。产品差异单独保存在 preflight-fix 工作树;与 seed 的组合验证只用一次性本地测试副本,不能称为已集成或新发布候选。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/implement.md b/.trellis/tasks/09-06-release-gate-integrity/implement.md index c3df544a..fbf619aa 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/implement.md +++ b/.trellis/tasks/09-06-release-gate-integrity/implement.md @@ -413,3 +413,59 @@ cd server && npm test -- tests/unit/scripts/deployment-db-lifecycle.test.js test - 实验实际 argv 已显式带 trace,并成功产生真实目标 trace;实验后正式诊断入口的参数传递由上述捕获回归验证。没有声称正式诊断新源码完成过额外整套运行。新策略与实验冻结源码的两处差异单列,后续运行必须重新绑定届时获准候选。 - 普通门禁 retain-on-failure 值得独立审阅,但用户本轮只明确要求诊断 trace;不将“考虑”扩成修改普通门禁策略的授权。任何真实发布或 trace 对外分享仍各自授权。 - 本轮交审包:artifacts/qa-flow/release-gate-integrity/trace-policy-review-20260907-01/。review.diff 为当前完整 21 文件;incremental.diff 相对获准 diagnostic-review-20260906-01 冻结快照仅变 8 文件,13 文件未变,无增减文件。旧快照在独立临时目录由 HEAD 与旧 diff 重建并逐项核对旧 SHA-256,不向真实工作树应用补丁。review-files.json 保存当前摘要、范围及两份 diff 摘要;未跟踪文件另做 whitespace 核对。实验原始摘要及中断回执不覆盖;快速 task/governance/sensitive/syntax/whitespace 复验放在新交审目录,35 项测试沿用未再改动源码对应的原日志。本轮只有主会话验证,不冒充独立复核。 + + +## 追加阶段:迁移状态误判修复(归属确认,尚未实施) + +用户同意将较小修复归入已有合适 task,不为本 finding 新建任务。此阶段承接 seed 分离回归发现的预检问题,原冻结交审包、33 文件门禁诊断包及历史验收保持原状;不将新增阶段倒填为旧包已通过。 + +事实证据入口:09-09-existing-deploy-seed-separation/implement.md 的继续核查记录。原 printf | grep -q 管道在 pipefail 下实际出现 producer=141/consumer=0;合成分支复现未知迁移被 ACCEPTED,直接输入候选正确拒绝。2000 次候选匹配无误判仅为本地实验,不表示生产修复。 + +最小拟修范围:production-preflight-remote.sh 状态解析区四处谓词、原部署安全回归和部署规约。消除提前退出消费者的生产者管道;匹配工具非 0/1 错误失败关闭。验证合法/未知待执行迁移、无待执行、失败状态、长输出和工具错误;保持候选与已应用集合核对及 allowlist,不全库改 grep、不扩成完整门禁重建。 + +- [x] 复用本 task 承接,关联已复现证据,撤回必须新建 task 的建议。 +- [ ] 在本 task 收敛并审阅具体修复增量;新增风险/范围按实际授权确认,不为相同已批准动作反复索权。 +- [ ] 与 seed 产品差异物理隔离,实施最小修复并做确定性回归。 +- [ ] 记录最新候选/回归结果与独立复核缺口,不继承旧冻结回执。 + +本轮仅更新归属与方案记录,未修改该预检脚本或测试;未新建 task、未提交/推送/部署。seed 的发布前置仍未解除。 + +### 后续实施授权与最小方案 + +用户在确认归入本 task 后要求“继续推进主要工作”,本次按上述已明确的小范围方案继续本地修复,不再新建 task 或重复索取相同范围授权。执行副本为 artifacts/isolated-worktrees/20260909-preflight-fix,detached b918530;原 seed 产品工作树不改,主工作树只同步任务状态。提交/推送/部署仍未授权。 + +实现同一 status_matches 谓词:grep 直接读取 here-string,取消 printf 生产者管道;0 为匹配、1 为不匹配、其他码立即 PROD_MIGRATION_STATUS_UNREADABLE。四处谓词共用,候选迁移集合及 DB 对照不变。定向测试提取实际状态解析与未知迁移拒绝分支,注入已观测的 producer=141;恢复旧写法的源码变异必须失去拒绝能力。另验长输出、合法 pending、unsafe 和工具错误,最后跑原完整部署安全回归与 seed 组合验证。独立 reviewer 受 inline 限制时明确留待交审。 + + +### 当前结果:迁移状态修复本地闭合 + +- 实际预检改动仅四处谓词及共享 status_matches:直接输入、完整读取,0/1/其他码分别匹配/不匹配/停止;保留 SQL、候选 allowlist 和应用发布顺序。旧谓词变异在 producer=141 注入下仍会误放行,新逻辑明确拒绝,未删改原未知迁移断言。 +- 最新预检测试 39 项通过,包括四处匹配器错误、96 KiB 输出和源码变异。首次单独运行的旧多子进程 fixture 超过默认 5 秒;仅将该用例预算设为有界 15 秒,未改变生产超时或任何断言。随后完整场景通过,不用重试屏蔽失败。 +- 从 b918530 创建一次性验证副本,只覆盖 seed 的 9 个代码/测试文件及本修复 2 个文件,共 11 个;联合执行 deploy-init、deployment-db-lifecycle、deployment-safety-contract 三文件,109/109 PASS,81.10 秒。覆盖集合未缩减,没有 skip 或重试。覆盖输入 SHA-256:087d5004337ba631d152bd099c6f19bfad34f16cd00de29fc00e14e891b8dfac。 +- 该临时组合仅用于测试,未合入主工作树或任一原执行副本,结束后精确删除验证 worktree。第一次准备因整包补丁参数过长停止于测试前,改为逐文件 apply_patch;第一次与最终临时副本均已清理,不把准备失败计为测试结果。 +- 沿用 seed 已完成的真实合成 PG 数据库测试(该运行时代码未改);本轮不再次操作数据库或 ARM。组合测试不等于镜像构建、完整候选门禁或真人业务验收。 +- Bash syntax、环境契约、task validate、Trellis governance、process contract、git diff --check 通过;docs registry errors=0、warnings=1,22 个未注册文档未扩范围处理。 +- 主会话完成只读差异复核;inline 下未派 reviewer,独立复核待交审。未提交、未推送、未部署。产品增量位于 artifacts/isolated-worktrees/20260909-preflight-fix;seed 产品增量保留在原独立副本。主工作树只同步本 task 状态和证据。 + +本追加修复阶段本地验收通过;原门禁 task 整体、旧冻结包及完整候选发布资格不因此完成或改变。上一阶段 seed 的那一次 93/94 失败保留,当前由确定性复现、根因修复和同基线组合回归闭合。 +### 分包交审(2026-09-09) + +seed 交审前补正 Compose init 参数数组并新增执行测试后,再次临时组合验证:deploy-init、deployment-db-lifecycle、deployment-safety-contract 三文件 110/110 通过(22 + 49 + 39)。共同基线 b918530,11 个输入文件摘要 a3f91e3d1fcdbc522347725659e3b8b5a0438df62a7eea52b03f05fb3ba6d70a;临时 worktree 已删除。本预检运行时没有追加变化,旧 109/109 证据只对应旧组合。 + +交审包:artifacts/qa-flow/20260909-seed-preflight-review-v1/preflight/。只冻结本追加阶段的六文件增量,不覆盖原 33 文件证据包。与 seed 包共享部署 spec 文件,后续集成须合并各自段落,不可互相覆盖。 + +当前追加阶段:本地实施、主会话复核和回归完成,待独立复核与提交授权;未提交、推送、部署。原门禁任务整体仍未完成,不因此取得完整候选发布资格。 +## 2026-09-09 独立复核接受与集成准备 + +用户回传独立审查:seed 通过(3 条非阻断备注),preflight 通过(无 finding)。审查者核验冻结 diff 并阅读合同与代码,另做本地 bash SIGPIPE 合成复现;未重跑项目测试、未审完整历史叙述、未构建镜像或访问生产/ARM,未验证后续集成。不得把该结论扩大为独立执行了 110 项测试或批准部署。 + +集成副本:artifacts/isolated-worktrees/20260909-seed-preflight-integration,基线 b918530,归当前门禁 task 协调整合;两个原 task 归属不变。按共同祖先合并部署 spec,零冲突,不用整文件覆盖。原两份冻结包和执行副本保留未改,主工作树不混入产品实现。 + +备注处理: +- F1:集成副本的既有运维手册明确补充,有意停用全部费用类型、审批规则或报销流程也会挡住下次发布;不得自动补齐或改用 initialize 绕过。 +- F2:保留并强化“迁移先执行,配置检查失败时迁移可能已经落库”,不简化成零写入。 +- F3:deploy.sh 的 sh -lc 与 Compose 的 sh -c 环境加载不同,记录为待环境验收关注项;本轮不改 shell 行为。 +- Compose folded scalar 是基线已有问题,本 task 因承诺两入口真实到达而一并修复,不是本次引入。 +- 预检缺陷定性是“该拦时可能拦不住”,未证明生产曾误放行。独立审查在其环境较大输出触发 141,但约 64KB 不是通用安全阈值;此前已有短输出捕获 producer=141/consumer=0 的记录,调度和管道实现都会影响触发,不采纳“日常绝不会触发”的推断。 + +两笔提交准备顺序:先 preflight(门禁 task 三文档、预检脚本、测试及 spec 对应增量),再 seed(原 17 文件范围,包含 F1/F2 手册补充和 seed task 最新状态;spec 只取 seed 增量)。共享 spec 第二笔以第一笔结果为基线。提交、推送、完整候选门禁与部署均未因此授权;当前只做隔离集成和定向验证。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/prd.md b/.trellis/tasks/09-06-release-gate-integrity/prd.md index 00162c91..3c3250cd 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/prd.md +++ b/.trellis/tasks/09-06-release-gate-integrity/prd.md @@ -137,3 +137,12 @@ full-local 包含本地数据库准备、服务端回归和全量浏览器,耗 最新交审状态:§9 实施及一次完整隔离诊断完成,固定 PARTIAL/exit 2;65 文件/275 用例有对应结果:50 passed、76 failed、131 timedOut、18 skipped,0 重试、0 缺失回执用例。207 项失败不是 207 个已确认产品缺陷;合同/R102 本次先卡登录前置,不能证明下游原断言已执行。18 个 skipped 中 14 个 expectedStatus=skipped、4 个 expectedStatus=passed,不能把条件跳过删出集合或开启 live 环境凑绿。普通 full-local 全绿成本仍未取得,诊断约 197 分 24 秒不能替代它。执行器两项自有问题已在原始运行结束后最小修正,34 项 QA 与原身份/金额 36 项定向单测通过;聚合器净库定向验证保持内部 fail-fast,场景 8 后面的分母仍未补齐。经理盘点 7 条 active 在职记录中 4 条缺直接经理,实际规则和 smoke/真人未消歧,不宣称六路全部锁死。全 task AC 尚未完成;R1、其他阶段 A、阶段 B、提交/push/归档/部署仍未授权,本增量待用户独立复核。 后续状态(2026-09-07):上述 21 文件已获用户独立复核通过。追加只读结果与两次带 trace 的条件对照见 implement.md §13:三张 rejected 的 CEO owner 存在 finance 显式兜底,不能因缺经理否定本人重提;至少五条浏览器登录用例通过。当前构建下代表性合同登录在单条 1 worker 与全套 4 workers 中均失败,不能据此宣称“只有并发才失败”或排除一切竞态。全套在后续 263 条结果后以 143 中断,完整终态未取得,孤立残留已补清理;不重跑、不补造通过。新获批诊断 trace=on 已最小实施,35 项 QA 通过,待本增量独立复核;普通门禁 trace 配置未改。207 仍仅原始失败观测,根因分类/去重后的可执行红项清单尚未取得;R1 与场景 8 等其他边界继续挂起。 + + +## 追加验收:迁移状态误判最小修复 + +用户确认复用本 task 并要求继续主线,授权上述本地修复与验证;不改旧冻结验收、不部署。范围仅迁移状态四处谓词及其定向回归/规约。 + +- [x] 消除 producer=141 导致的匹配假阴性;工具错误失败关闭,候选/已应用迁移核对保持。 +- [x] 确定性回归覆盖未知/合法待执行、无待执行、unsafe、长输出、四处工具错误及旧谓词源码变异。 +- [x] 与 seed 改动的临时组合回归通过,记录本地边界、独立复核缺口及未部署状态。 diff --git a/deploy/trialrun/production-preflight-remote.sh b/deploy/trialrun/production-preflight-remote.sh index 3fd283ae..79516d9b 100755 --- a/deploy/trialrun/production-preflight-remote.sh +++ b/deploy/trialrun/production-preflight-remote.sh @@ -190,20 +190,30 @@ printf '[production-preflight][PRISMA_STATUS_BEGIN]\n' printf '%s\n' "$prisma_status_output" printf '[production-preflight][PRISMA_STATUS_END] exit=%s\n' "$prisma_status_code" -if printf '%s' "$prisma_status_output" | grep -Eiq 'failed migration|migration(s)? have failed|migration failed|rolled back|diverged|drift|could not find migration|database schema is not in sync'; then +status_matches() { + local status + if grep -Ei -- "$1" <<<"$prisma_status_output" >/dev/null; then + return 0 + else + status=$? + fi + [[ "$status" -eq 1 ]] || fail "PROD_MIGRATION_STATUS_UNREADABLE" + return 1 +} + +if status_matches 'failed migration|migration(s)? have failed|migration failed|rolled back|diverged|drift|could not find migration|database schema is not in sync'; then fail "PROD_MIGRATION_STATUS_UNSAFE" fi -if ! printf '%s' "$prisma_status_output" \ - | grep -Eiq 'Database schema is up to date|have not yet been applied|No pending migrations'; then +if ! status_matches 'Database schema is up to date|have not yet been applied|No pending migrations'; then fail "PROD_MIGRATION_STATUS_UNREADABLE" fi if [[ "$prisma_status_code" -ne 0 ]] \ - && ! printf '%s' "$prisma_status_output" | grep -Eiq 'have not yet been applied'; then + && ! status_matches 'have not yet been applied'; then fail "PROD_MIGRATION_STATUS_UNREADABLE" fi declare -a status_pending_ids=() -if printf '%s' "$prisma_status_output" | grep -Eiq 'have not yet been applied'; then +if status_matches 'have not yet been applied'; then status_pending_text="$(printf '%s\n' "$prisma_status_output" | awk ' tolower($0) ~ /have not yet been applied/ { capture=1; next } capture { print } diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index 56ad4883..1638b1bf 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -1,4 +1,4 @@ -const { execFile, execFileSync } = require("node:child_process"); +const { execFile, execFileSync, spawnSync } = require("node:child_process"); const { createHash } = require("node:crypto"); const { chmod, mkdtemp, mkdir, rm, symlink, writeFile, readFile } = require("node:fs/promises"); const os = require("node:os"); @@ -17,6 +17,95 @@ const PRODUCTION_PREFLIGHT_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/product const PRODUCTION_IDENTITY_BOOTSTRAP = path.join(ROOT_DIR, "deploy/trialrun/production-identity-bootstrap.mjs"); const PRODUCTION_IDENTITY_BOOTSTRAP_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-identity-bootstrap-remote.sh"); +describe("migration status predicate fail-closed regression", () => { + const known = "20260807193000_contract_registry_termination_workspace"; + const unknown = "20260807193002_unknown_migration"; + + function runStatus(text, { statusCode = 0, grepFailure = false, oldPredicate = false } = {}) { + const source = require("node:fs").readFileSync(PRODUCTION_PREFLIGHT_REMOTE, "utf8"); + const start = source.indexOf("\nstatus_matches() {"); + const end = source.indexOf("\nmigration_rows=", start); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + let parsing = source.slice(start, end); + if (oldPredicate) { + const target = "if status_matches 'have not yet been applied'; then"; + expect(parsing.split(target)).toHaveLength(2); + parsing = parsing.replace(target, "if printf '%s' \"$prisma_status_output\" | grep -Eiq 'have not yet been applied'; then"); + } + const guardStart = source.indexOf('if [[ "${#status_pending_ids[@]}" -gt 0 ]]; then', end); + const guardEnd = source.indexOf("\npending_ids=()", guardStart); + expect(guardStart).toBeGreaterThan(end); + expect(guardEnd).toBeGreaterThan(guardStart); + const script = [ + "set -euo pipefail", + 'fail() { echo "$1"; exit 1; }', + 'prisma_status_output="$(cat)"', + "prisma_status_code=" + statusCode, + "declare -A candidate_set=([" + known + "]=1)", + 'printf() { builtin printf "$@"; if [[ "$1" == "%s" && "${2:-}" == "$prisma_status_output" ]]; then return 141; fi; }', + "matcher_calls=0", + grepFailure ? 'grep() { matcher_calls=$((matcher_calls + 1)); if [[ "$matcher_calls" -eq ' + grepFailure + ' ]]; then return 2; fi; command grep "$@"; }' : "", + parsing, + source.slice(guardStart, guardEnd), + "echo ACCEPTED", + ].join("\n"); + return spawnSync("bash", ["-c", script], { + input: text, encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, + env: { PATH: process.env.PATH }, + }); + } + + it.each(["Database schema is up to date!", "No pending migrations", "DATABASE SCHEMA IS UP TO DATE!"])("accepts recognized safe status: %s", (text) => { + const result = runStatus(text); + expect(result.status).toBe(0); + expect(result.stdout).toContain("ACCEPTED"); + }); + + it("accepts an allowlisted pending migration with Prisma exit one", () => { + const result = runStatus("Following migration have not yet been applied:\n" + known, { statusCode: 1 }); + expect(result.status).toBe(0); + }); + + it.each([0, 96 * 1024])("rejects unknown pending despite producer 141 with padding %s", (padding) => { + const text = "Following migration have not yet been applied:\n" + unknown + "\n" + "x".repeat(padding); + const result = runStatus(text, { statusCode: 1 }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); + expect(result.stdout).not.toContain("ACCEPTED"); + }); + + it("demonstrates the old predicate mutation loses unknown-pending rejection", () => { + const text = "Following migration have not yet been applied:\n" + unknown; + const result = runStatus(text, { statusCode: 1, oldPredicate: true }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("ACCEPTED"); + expect(runStatus(text, { statusCode: 1 }).stdout).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); + }); + + it.each([ + ["Database schema is up to date!\nmigration failed", "PROD_MIGRATION_STATUS_UNSAFE"], + ["unrecognized status", "PROD_MIGRATION_STATUS_UNREADABLE"], + ["Following migration have not yet been applied:", "PROD_MIGRATION_STATUS_PENDING_IDS_UNREADABLE"], + ])("fails closed on %s", (text, code) => { + const result = runStatus(text); + expect(result.status).toBe(1); + expect(result.stdout).toContain(code); + }); + + it.each([1, 2, 3, 4])("fails closed on matcher error at predicate %s", (grepFailure) => { + const result = runStatus("Following migration have not yet been applied:\n" + unknown, { grepFailure, statusCode: 1 }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("PROD_MIGRATION_STATUS_UNREADABLE"); + }); + + it("rejects a nonzero status without pending migrations", () => { + const result = runStatus("Database schema is up to date!", { statusCode: 1 }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("PROD_MIGRATION_STATUS_UNREADABLE"); + }); +}); + describe("deployment safety contract", () => { let fixtureRoot; let remoteDir; @@ -724,7 +813,7 @@ describe("deployment safety contract", () => { await expect(runRemote("happy", "", { VITE_BUILD_SHA: "b".repeat(12) })).rejects.toMatchObject({ stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_MISMATCH"), }); - }); + }, 15000); it("aggregates preflight checks and keeps the root helper self-contained", async () => { const aggregate = await require("node:fs/promises").readFile(AGGREGATE_PREFLIGHT, "utf8");