diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 4c0aae99..57262b13 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -259,6 +259,95 @@ SYNC_ENV=0 POSTGRES_DATA_DIR=/srv/hyb-oa/pgdata \ bash deploy/trialrun/deploy.sh ``` +## Scenario: Candidate Build Identity Single Source + +### 1. Scope / Trigger + +- Trigger: changing candidate provenance, `deploy.sh`, production preflight or + Compose build-SHA wiring. +- Purpose: prevent the full commit, its 12-character public prefix and the two + Compose environment keys from drifting between deployment entry points. + +### 2. Signatures + +- Shared function: + `deriveCandidateBuildEnv(commit) -> Object.freeze({ BUILD_SHA, VITE_BUILD_SHA })`. +- Provenance CLI keeps `commit` and `shortCommit` and adds the same `buildEnv` + object. +- Consumers: `deploy/trialrun/deploy.sh` and + `deploy/trialrun/production-preflight.mjs`. + +### 3. Contracts + +- The shared function accepts only a lowercase 40–64 character hexadecimal + commit and returns both environment values in one object; + `VITE_BUILD_SHA` is exactly the first 12 characters of `BUILD_SHA`. +- Consumers traverse and inject the complete object. They must not call + `git rev-parse HEAD`, slice the commit again, define an independent key list + or selectively consume one build key. +- `production-preflight-remote.sh` remains an independent consumption guard: + before `docker compose config` and Prisma status, it requires both keys, + validates both formats and checks the prefix relationship. A single local + source does not replace transport and caller validation. +- Preflight writes the stable redacted summary and report reference before it + classifies a remote failure. A non-zero remote exit takes precedence over a + missing Prisma marker. + +### 4. Validation & Error Matrix + +- Invalid candidate commit -> `CANDIDATE_COMMIT_INVALID` locally; no SSH. +- Missing or malformed remote build value -> `BUILD_SHA_MISSING`, + `PROD_TARGET_BUILD_SHA_INVALID` or `PROD_TARGET_VITE_BUILD_SHA_INVALID`; + no Compose/Prisma call. +- Valid values with a mismatched prefix -> `PROD_TARGET_BUILD_SHA_MISMATCH`; + no Compose/Prisma call. +- Compose config failure -> `PROD_TARGET_COMPOSE_CONFIG_INVALID`; no Prisma + status or migration command. +- Remote non-zero with `code=...` -> propagate that code even without a Prisma + marker or with an unreadable marker body; remote non-zero without a parseable code -> + `PRODUCTION_PREFLIGHT_REMOTE_FAILED`. +- Remote zero without a Prisma marker -> `PRISMA_STATUS_OUTPUT_MISSING`. + +### 5. Good/Base/Bad Cases + +- Good: provenance derives one frozen object; both entry points inject all of + it; the remote helper independently validates what arrived. +- Base: the existing provenance CLI fields remain compatible and equal the two + object values. +- Bad: preflight uses `candidateCommit.slice(0, 12)`, or deployment reads only + one property and reconstructs the other. +- Bad: check the Prisma marker before a non-zero remote status and thereby hide + the remote safety code. + +### 6. Tests Required + +- Unit-test fixed keys, immutability, the 12-character relationship and invalid + commit inputs. +- Execute the real production-preflight Node subprocess behind fake SSH for all + four status/marker quadrants: non-zero with code and no marker, non-zero with + code and marker, non-zero without code, and zero without marker. Assert the + stable summary and report reference exist on every failure. +- Run the remote helper behind an allowlisted fake Docker and prove build-value + and Compose failures occur before any Prisma `exec` or status call. +- Mutation-check both historical defects: reversing status/marker precedence + and removing either shared build key must make the relevant tests fail. + +### 7. Wrong vs Correct + +#### Wrong + +```js +const buildSha = candidateCommit; +const viteBuildSha = candidateCommit.slice(0, 12); +``` + +#### Correct + +```js +const candidateBuildEnv = deriveCandidateBuildEnv(candidateCommit); +const assignments = Object.entries(candidateBuildEnv); +``` + ## Scenario: Public Smoke Navigation Labels ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/check.jsonl b/.trellis/tasks/08-09-deployment-contract-single-source-harness/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/design.md b/.trellis/tasks/08-09-deployment-contract-single-source-harness/design.md new file mode 100644 index 00000000..0e9085a4 --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/design.md @@ -0,0 +1,116 @@ +# 部署合同单一事实源与失败路径覆盖:设计 + +## 1. 根因与设计目标 + +历史缺陷不是 Compose 本身无法校验,而是调用链在不同位置重新定义“候选构建身份”: + +```text +deploy.sh + -> provenance.commit + provenance.shortCommit + -> BUILD_SHA / VITE_BUILD_SHA + +production-preflight.mjs + -> CANDIDATE_COMMIT + -> 自行 slice(0, 12) + -> BUILD_SHA / VITE_BUILD_SHA +``` + +两条路径的键名和派生规则相同,却没有共享可执行来源。新增 Compose 必填值或调整短标识规则时,其中一条可能漏改。设计目标是把该合同收敛到已有 provenance 模块,同时保留远端消费校验,并让 synthetic harness 执行真实 orchestration/remote helper 进程。 + +## 2. 单一合同形态 + +在 `check-source-provenance.mjs` 导出: + +```js +export function deriveCandidateBuildEnv(commit) { + // validate 40..64 lowercase hex + return Object.freeze({ + BUILD_SHA: commit, + VITE_BUILD_SHA: commit.slice(0, 12), + }); +} +``` + +具体实现可在不改变合同的前提下调整错误类或冻结方式,但调用方不得自己派生 `VITE_BUILD_SHA`。 + +`collectSourceProvenance` 先调用该函数,再以同一结果形成: + +```text +commit = buildEnv.BUILD_SHA +shortCommit = buildEnv.VITE_BUILD_SHA # 兼容字段 +buildEnv = { BUILD_SHA, VITE_BUILD_SHA } +``` + +共享 API 只导出一次返回完整对象的派生函数,不另行导出可能与对象漂移的键数组,也不提供 `getBuildSha()` / `getViteBuildSha()` 之类可分别调用的接口。两个调用方必须通过 `Object.entries(buildEnv)` 或等价的完整对象遍历生成整组远端环境赋值;单独选择某个键注入属于合同失败。 + +非法输入在共享函数处失败。provenance 自身使用既有 `SourceProvenanceError`;production preflight 将该失败稳定映射为既有 `CANDIDATE_COMMIT_INVALID`,不把内部错误名暴露为新的操作合同。 + +## 3. 调用链 + +### 3.1 正式部署 + +```text +check-source-provenance CLI + -> buildEnv + -> deploy.sh 读取 BUILD_SHA / VITE_BUILD_SHA + -> 远端 shell env + -> docker compose config/build/runtime +``` + +`deploy.sh` 可继续保留 `SOURCE_COMMIT` / `SOURCE_SHORT_COMMIT` 局部名称用于日志和公网检查,但值必须来自 `buildEnv`,不能从 `commit` 再截取。 + +### 3.2 生产 preflight + +```text +CANDIDATE_COMMIT + -> deriveCandidateBuildEnv + -> remote command env assignments + -> production-preflight-remote.sh + -> 格式 + 前缀 + compose config 消费校验 +``` + +远端 helper 的校验不是第二个来源:它不派生值,只验证接收到的值符合共享合同。这一层用于防止命令组装、环境传输或未来调用方漂移。 + +## 4. Harness 设计 + +继续使用 `deployment-safety-contract.test.js` 的临时目录: + +- 假 `ssh` 捕获命令、按 mode 输出 marker/error 并返回指定状态; +- 假 `docker` 只识别明确 allowlist 的 compose/ps/inspect/exec 调用; +- 所有数据库、origin、migration 和 SHA 均为 synthetic; +- 测试进程不得解析真实 SSH config、调用真实 Docker 或读取仓库外 secret。 + +### 4.1 Orchestrator 失败矩阵 + +| SSH 退出 | Prisma marker | stable code | 预期 | +| --- | --- | --- | --- | +| 非零 | 缺失 | 有 | 传播 stable code;summary/report 先落地 | +| 非零 | 任意 | 无 | `PRODUCTION_PREFLIGHT_REMOTE_FAILED` | +| 零 | 缺失 | 无 | `PRISMA_STATUS_OUTPUT_MISSING` | +| 零 | 存在 | 无 | 进入现有 status/reconciliation 成功路径 | + +纯函数四组合可保留,但四个象限还必须全部通过真实 `node production-preflight.mjs` + fake SSH 子进程验证,防止 main 中的证据顺序或 exit handling 漂移。测试必须对真实缺陷敏感:将远端状态判断与 marker 判断次序调回错误实现时至少一个用例变红;去掉共享对象中的任一远端赋值时,成功/缺失合同用例变红。 + +### 4.2 Remote helper 失败矩阵 + +| 条件 | 预期码 | 必须早于 | +| --- | --- | --- | +| `BUILD_SHA` 缺失 | `BUILD_SHA_MISSING` | Compose / Prisma | +| 完整 SHA 非法 | `PROD_TARGET_BUILD_SHA_INVALID` | Compose / Prisma | +| 12 位 SHA 非法 | `PROD_TARGET_VITE_BUILD_SHA_INVALID` | Compose / Prisma | +| 前缀不一致 | `PROD_TARGET_BUILD_SHA_MISMATCH` | Compose / Prisma | +| Compose config 非零 | `PROD_TARGET_COMPOSE_CONFIG_INVALID` | runtime/Prisma | + +假 Docker 对意外调用返回固定失败码,使测试能证明早停,而不是仅匹配最终 stderr。 + +## 5. 兼容性与停止条件 + +- provenance JSON 保留既有顶层字段;新增 `buildEnv` 是向后兼容扩展。 +- production preflight 的外部 env、报告文件、稳定错误码和远端脚本输入语义不变。 +- 若实现需要让 preflight import 会触发 provenance `main()`、执行 Git/SSH,必须停止并修正模块边界;导入必须无副作用。 +- 若共享 helper 迫使 production preflight 接受 unpublished HEAD、减少远端校验或把 secret 写入报告,停止并回到规划。 +- 本任务不在生产验证。上线收益通过本地合同和 reviewer 证明,下一次获批部署自然消费该改动。 + +## 6. Rollback + +本任务只改本地 tooling。回滚为恢复三个调用点及测试到前一提交;不涉及 schema、生产配置或数据。若共享合同在本地门禁中暴露兼容问题,提交前直接修正,不以生产试跑验证。 diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.jsonl b/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.md b/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.md new file mode 100644 index 00000000..cfddc949 --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/implement.md @@ -0,0 +1,54 @@ +# 部署合同单一事实源与失败路径覆盖:实施计划 + +## Phase A:共享合同 + +- [x] 在 `check-source-provenance.mjs` 添加一次返回完整 `{ BUILD_SHA, VITE_BUILD_SHA }` 对象的派生函数,导入无副作用;不提供两个独立 getter。 +- [x] 让 `collectSourceProvenance` 从共享结果生成 `commit`、`shortCommit` 与兼容的 `buildEnv`。 +- [x] 在 provenance focused tests 中覆盖合法结果、固定键、12 位前缀、非法提交。 + +## Phase B:调用点去重 + +- [x] `deploy.sh` 从 provenance `buildEnv` 读取并完整注入对象中的两值;禁止自行截取候选提交或只消费一个键。 +- [x] `production-preflight.mjs` 导入共享函数,映射非法候选为既有 `CANDIDATE_COMMIT_INVALID`,并从共享完整对象生成两项远端构建 env assignments。 +- [x] 保留 `production-preflight-remote.sh` 的缺失、格式、前缀和 Compose config fail-closed 校验。 +- [x] 更新 `validate-env-contract.js` 的静态合同:要求两个调用点消费共享来源,而不是只搜索任意 `BUILD_SHA=` 文本。 + +## Phase C:Harness + +- [x] 提取/复用 fake SSH 运行 helper,避免每个失败用例重复搭建临时二进制。 +- [x] 通过真实 production-preflight 子进程覆盖完整四象限:非零+code+无 marker、非零+code+有 marker、非零+无 code+无 marker、零退出+无 marker;验证失败前已有稳定 summary/reportRef。 +- [x] 扩展 fake Docker mode 覆盖 Compose config 失败,并证明未继续到 Prisma/status 查询。 +- [x] 补齐构建值缺失、完整/短格式非法、前缀不一致;成功 SSH fixture 对账共享 `buildEnv`。 + +实施证据:focused tests 共 31 项通过。另在两个独立临时 detached worktree 中分别回退错误码判断次序、删除共享对象的 `VITE_BUILD_SHA`;对应回归测试均变红,证明 AC4 用例对两个历史缺陷敏感。临时 worktree 已删除,本轮未连接生产。 + +## Phase D:验证与 Review + +- [x] `node --check deploy/trialrun/check-source-provenance.mjs deploy/trialrun/production-preflight.mjs` +- [x] `bash -n deploy/trialrun/deploy.sh deploy/trialrun/production-preflight-remote.sh` +- [x] `cd server && npm test -- tests/unit/scripts/check-source-provenance.test.js tests/unit/scripts/deployment-safety-contract.test.js` +- [x] `node deploy/trialrun/validate-env-contract.js` +- [x] task/process/governance/sensitive/diff 门禁。 +- [x] 启动只读 reviewer,重点核对共享来源是否真实被两个调用点消费、远端校验是否保留、harness 是否执行真实失败分支且无生产连接。 + +## Phase E:规约与提交 + +- [x] 使用 `trellis-update-spec` 更新现有 `deployment-trialrun.md`,记录候选构建身份的单一来源与远端消费验证职责。 +- [ ] 中文 commit 引用 `08-09-deployment-contract-single-source-harness`。 +- [x] 不部署、不运行生产 preflight;任务证据明确为 local tooling hardening。 + +## 质量检查记录 + +- 语法与 focused tests:`31/31 PASS`。 +- 环境合同:`R26 PASS`。 +- task validation、process baseline、Trellis governance、sensitive scan、docs registry、`git diff --check`:全部退出 0;docs registry 仅保留既有 `unregistered docs files: 22` warning。 +- 边界:所有验证均使用本地 synthetic fixture;未执行 SSH、真实 Docker、生产 preflight、部署或迁移。 +- reviewer 首轮发现“非零远端 + stable code + 不可读 marker”仍可能在证据前被 marker 校验覆盖;修复后增加真实子进程回归,复核 `PASS`、无新 finding。 +- residual:`deploy.sh` 的整对象解析主要由静态合同覆盖,尚无完整 fake deployment 子进程;键白名单、去重与恰好两项均 fail-closed,保留为低风险后续改进,不扩大本任务。 + +## Risky Files / Rollback Points + +- `deploy/trialrun/check-source-provenance.mjs`:发布来源核心;任何 CLI JSON 兼容破坏都必须在 focused tests 阻断。 +- `deploy/trialrun/deploy.sh`:正式部署入口;只改变输入来源,不改变步骤顺序或远端动作。 +- `deploy/trialrun/production-preflight.mjs`:错误码/证据顺序不可回退。 +- `server/tests/unit/scripts/deployment-safety-contract.test.js`:fake 命令必须默认拒绝未知调用,避免宽松 mock 制造假 PASS。 diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/prd.md b/.trellis/tasks/08-09-deployment-contract-single-source-harness/prd.md new file mode 100644 index 00000000..151ac9b8 --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/prd.md @@ -0,0 +1,84 @@ +# 部署合同单一事实源与失败路径覆盖 + +## Goal + +消除正式 `deploy.sh` 与生产 preflight 对候选构建身份的重复定义,使完整候选提交、12 位公开前缀以及 `BUILD_SHA` / `VITE_BUILD_SHA` 映射由同一可执行合同派生;再用完全本地的假 SSH / 假 Docker harness 覆盖历史生产暴露的失败路径,让同类漂移在提交前失败,而不是通过真实 SSH 往返发现。 + +## Confirmed Facts + +- 生产部署刚实际暴露过两类工具缺陷:`production-preflight.mjs` 曾先检查 Prisma marker、覆盖远端真实错误码;preflight 还曾漏注入 Compose 强制要求的 `BUILD_SHA` / `VITE_BUILD_SHA`。 +- 正式 `deploy.sh` 从 `check-source-provenance.mjs` 输出取得 `commit` 与 `shortCommit`;production preflight 又独立校验候选 SHA 并执行 `slice(0, 12)`。候选构建身份的派生逻辑因此存在两个实现点。 +- `production-preflight-remote.sh` 对完整/短 SHA 格式和前缀关系的校验是远端消费端纵深防御,不属于应删除的重复定义。 +- `server/tests/unit/scripts/deployment-safety-contract.test.js` 已有临时目录、假 SSH 和假 Docker 基础,也已有历史缺陷的部分回归断言;缺口是单一合同本身以及更多真实子进程失败分支,而不是再建一套测试框架。 +- 项目生产拓扑只有一套承载公网的 legacy `trialrun` 环境,没有同构预发;因此本任务只运行本地 synthetic harness,不连接生产、不执行部署。 + +## Scope + +- 共享合同:`check-source-provenance.mjs` 中候选完整 SHA、12 位前缀和两个 Compose 构建环境键的可导入派生合同。 +- 消费调用点:`deploy.sh` 与 `production-preflight.mjs`;远端 helper 只保留消费端 fail-closed 校验。 +- 验证范围:provenance focused tests、现有 deployment safety fake SSH/Docker harness、env contract 与 Trellis 本地门禁。 +- 环境边界:全部使用本地 synthetic 数据和临时目录,不运行任何真实 SSH、Docker 部署或生产命令。 + +## Requirements + +### R1 候选构建身份单一事实源 + +- 在现有 `deploy/trialrun/check-source-provenance.mjs` 中提供可导入的候选构建身份函数和固定环境键合同。 +- 输入只接受 40–64 位十六进制完整提交;一次调用必须返回完整 `{ BUILD_SHA, VITE_BUILD_SHA }` 环境对象,固定派生完整 `BUILD_SHA` 与其前 12 位 `VITE_BUILD_SHA`。不得拆成两个可独立调用/修改的 getter,调用方不得自行重新截取、重新定义键名或只注入其中一项。 +- `collectSourceProvenance` 的 CLI 结果保留既有 `commit` / `shortCommit` 兼容字段,同时提供由同一函数产生的构建环境对象;不得破坏既有部署调用与审计输出。 +- `deploy.sh` 必须从 provenance 的构建环境对象读取并注入远端 Compose;`production-preflight.mjs` 必须导入同一函数生成远端命令输入。禁止任一调用方回退到 `git rev-parse HEAD` 或自行 `slice(0, 12)`。 +- `production-preflight-remote.sh` 继续在 `docker compose config` 前校验两值存在、格式正确且前缀一致;这是 fail-closed 消费验证,不得因单一来源而删除。 + +### R2 本地失败路径 harness + +- 扩展既有 `deployment-safety-contract.test.js`,复用临时假 SSH / 假 Docker,不连接网络或真实 Docker daemon。 +- production preflight 的真实子进程路径必须覆盖完整四象限:远端非零且有稳定错误码/无 Prisma marker;远端非零且有稳定错误码/有 marker;远端非零且无可解析错误码/无 marker;远端零退出但无 marker。前三种分别传播原码或返回 `PRODUCTION_PREFLIGHT_REMOTE_FAILED`,最后一种返回 `PRISMA_STATUS_OUTPUT_MISSING`。 +- 失败路径仍须先写稳定脱敏 summary 与相对 report reference,再返回失败;不得为了测试错误码而跳过证据合同。 +- 远端 helper 必须覆盖 Compose config 失败、缺失构建值、完整/短 SHA 格式错误和前缀不一致,并断言每种情况均在 Prisma status / 数据库写入前 fail-closed。 +- provenance 测试必须覆盖完整/短 SHA 与固定环境键的同源性,以及非法提交输入;受控 SSH 成功 fixture 必须断言远端收到的两值等于共享合同输出。 + +### R3 边界与兼容性 + +- 不改变 production target gate、migration reconciliation、rollback window、错误码语义、部署顺序或公网 smoke 行为。 +- 不把备份、WARCH3 timer、自动备份脚本的全部 build-SHA 校验并入本轮;它们是不同运行合同,后续如需统一另行规划。 +- 不执行 SSH、生产 preflight、部署、迁移、配置修改、账号操作或业务写入。 +- 不以删除远端防御、降低 Compose 插值、放宽 SHA 格式或只加静态文本断言换取测试通过。 + +## Acceptance Criteria + +- [x] AC1:候选完整 SHA、12 位前缀及两个 Compose 环境键由一次函数调用返回的完整 `{ BUILD_SHA, VITE_BUILD_SHA }` 对象产生;没有两个独立 getter。`deploy.sh` 与 production preflight 均按整对象消费,不再各自派生短 SHA或只选择性注入一个键。 +- [x] AC2:既有 provenance CLI 字段保持兼容,部署脚本仍从已验证且已发布的候选提交生成远端构建输入;没有本地 HEAD fallback。 +- [x] AC3:远端 helper 保留格式/前缀/Compose fail-closed 校验,且失败发生在 Prisma status、迁移或任何业务写入之前。 +- [x] AC4:本地真实子进程 harness 覆盖远端错误码优先级完整四象限、Compose config 失败及构建身份缺失/非法/不一致;回退错误判断次序或任一调用点的完整对象注入时,对应回归用例必须失败。 +- [x] AC5:focused Vitest、provenance tests、`node --check`、`bash -n`、env contract、task/process/governance/sensitive 与 `git diff --check` 全部通过。 +- [x] AC6:只读 reviewer 核对单一事实源、调用点、fail-closed 顺序与测试有效性;reviewer 不修改代码。 +- [x] AC7:任务结论明确仅为本地部署 tooling hardening;未连接或验证生产,不宣称完成任何新部署。 + +## Out of Scope + +- 生产发布、预发建设、生产邮件、身份引导拓扑或 secret 持久化方式变更。 +- 数据库 schema/migration、产品功能、认证、权限或合同业务状态机修改。 +- 统一所有 backup/timer/monitor 的 build identity 合同,或重写全部部署脚本为单一语言。 +- 处理 8 条会话夹具失败、员工负向验收、合同 UI 样本、管理员手册附件承诺。 + +## Risks and Deferred Items + +- Bash 与 Node 的远端命令格式仍然不同;本轮统一的是候选构建身份的来源、键和派生,不以跨语言模板引擎扩大风险。 +- 远端验证与本地派生看似重复,但承担不同责任:前者防传输/调用漂移,后者建立单一来源。测试必须保留两层。 +- 现有 deployment-safety-contract 测试文件较大;本轮优先复用 helper,避免继续复制 fake SSH/Docker 搭建逻辑。是否拆分测试文件不作为验收条件。 + +## Test Plan + +- `node --check deploy/trialrun/check-source-provenance.mjs deploy/trialrun/production-preflight.mjs` +- `bash -n deploy/trialrun/deploy.sh deploy/trialrun/production-preflight-remote.sh` +- `cd server && npm test -- tests/unit/scripts/check-source-provenance.test.js tests/unit/scripts/deployment-safety-contract.test.js` +- `node deploy/trialrun/validate-env-contract.js` +- `python3 ./.trellis/scripts/task.py validate 08-09-deployment-contract-single-source-harness` +- `node scripts/qa/check-process-contract.mjs --baseline --json` +- `node scripts/qa/check-trellis-governance.mjs --json` +- `node scripts/qa/check-sensitive-content.mjs` +- `git diff --check` + +## Open Questions + +- 无。范围、兼容性和风险边界已由现有代码、生产故障证据及用户确认的实施顺序确定。 diff --git a/.trellis/tasks/08-09-deployment-contract-single-source-harness/task.json b/.trellis/tasks/08-09-deployment-contract-single-source-harness/task.json new file mode 100644 index 00000000..16b7ef71 --- /dev/null +++ b/.trellis/tasks/08-09-deployment-contract-single-source-harness/task.json @@ -0,0 +1,26 @@ +{ + "id": "deployment-contract-single-source-harness", + "name": "deployment-contract-single-source-harness", + "title": "部署合同单一事实源与失败路径覆盖", + "description": "收敛 deploy.sh 与 production preflight 的候选构建身份合同,并以本地 fake SSH/Docker harness 覆盖历史失败路径;不连接或修改生产。", + "status": "in_progress", + "dev_type": null, + "scope": "deploy/trialrun/check-source-provenance.mjs, deploy.sh, production-preflight.mjs/remote helper, validate-env-contract.js and focused deployment safety tests", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-09", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "本地 tooling hardening 已实现并通过 31/31 focused tests、全部门禁与只读 reviewer 复核;未连接生产。低风险 residual:deploy.sh 整对象解析尚无完整 fake deployment 子进程。", + "meta": {} +} diff --git a/deploy/trialrun/check-source-provenance.mjs b/deploy/trialrun/check-source-provenance.mjs index ff0fec41..d65f70da 100644 --- a/deploy/trialrun/check-source-provenance.mjs +++ b/deploy/trialrun/check-source-provenance.mjs @@ -18,6 +18,17 @@ const ALLOWED_UNPUBLISHED_REASONS = new Set([ "controlled-local-recovery", ]); +export function deriveCandidateBuildEnv(commit) { + const normalized = String(commit ?? "").trim(); + if (!/^[0-9a-f]{40,64}$/u.test(normalized)) { + throw new SourceProvenanceError("commit_invalid"); + } + return Object.freeze({ + BUILD_SHA: normalized, + VITE_BUILD_SHA: normalized.slice(0, 12), + }); +} + function runGit(rootDir, args) { return spawnSync("git", args, { cwd: rootDir, @@ -50,6 +61,7 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) { } const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable"); + const buildEnv = deriveCandidateBuildEnv(commit); const branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable") || "detached"; const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null; @@ -64,8 +76,9 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) { return { status: "PASS", - commit, - shortCommit: commit.slice(0, 12), + commit: buildEnv.BUILD_SHA, + shortCommit: buildEnv.VITE_BUILD_SHA, + buildEnv, branch, upstream, published, diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 1ffefb06..f56d89c4 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -127,8 +127,29 @@ fi echo ">>> 0. 校验 Git 来源与发布提交" PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")" -SOURCE_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.commit)' "$PROVENANCE_JSON")" -SOURCE_SHORT_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.shortCommit)' "$PROVENANCE_JSON")" +SOURCE_BUILD_ENV_LINES="$(node -e ' + const p = JSON.parse(process.argv[1]); + for (const [key, value] of Object.entries(p.buildEnv ?? {})) { + process.stdout.write(`${key}=${value}\n`); + } +' "$PROVENANCE_JSON")" +declare -A SOURCE_BUILD_ENV=() +while IFS='=' read -r key value; do + [[ -n "$key" ]] || continue + case "$key" in + BUILD_SHA|VITE_BUILD_SHA) ;; + *) echo "[ERROR] 发布来源包含未知候选构建环境键" >&2; exit 1 ;; + esac + [[ -z "${SOURCE_BUILD_ENV[$key]+x}" ]] \ + || { echo "[ERROR] 发布来源包含重复候选构建环境键" >&2; exit 1; } + SOURCE_BUILD_ENV["$key"]="$value" +done <<< "$SOURCE_BUILD_ENV_LINES" +[[ "${#SOURCE_BUILD_ENV[@]}" -eq 2 \ + && -n "${SOURCE_BUILD_ENV[BUILD_SHA]:-}" \ + && -n "${SOURCE_BUILD_ENV[VITE_BUILD_SHA]:-}" ]] \ + || { echo "[ERROR] 发布来源缺少完整候选构建环境对象" >&2; exit 1; } +SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}" +SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}" SOURCE_BRANCH="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.branch)' "$PROVENANCE_JSON")" SOURCE_PUBLISHED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.published))' "$PROVENANCE_JSON")" SOURCE_REMOTE_VERIFIED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.remoteVerified))' "$PROVENANCE_JSON")" @@ -274,7 +295,7 @@ run_logged_deploy_stage() { echo "[deploy-stage][ERROR] 阶段名称格式无效" >&2 return 2 } - local log_ref="$DEPLOY_RUNTIME_LOG_REF/${stage}-${BUILD_SHA:0:12}.log" + local log_ref="$DEPLOY_RUNTIME_LOG_REF/${stage}-${VITE_BUILD_SHA}.log" local log_file="$REMOTE_DIR/$log_ref" : > "$log_file" chmod 0600 "$log_file" @@ -334,7 +355,7 @@ ready_ok=0 for i in $(seq 1 60); do if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \ - -e EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" server node -e ' + -e EXPECTED_BUILD_SHA="${VITE_BUILD_SHA}" server node -e ' const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); diff --git a/deploy/trialrun/production-preflight.mjs b/deploy/trialrun/production-preflight.mjs index a6821aa4..fa80f74d 100755 --- a/deploy/trialrun/production-preflight.mjs +++ b/deploy/trialrun/production-preflight.mjs @@ -7,6 +7,7 @@ import { isIP } from "node:net"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { deriveCandidateBuildEnv } from "./check-source-provenance.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, "../.."); @@ -275,8 +276,12 @@ function main() { if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING"); const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim(); - if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID"); - const candidateShortCommit = candidateCommit.slice(0, 12); + let candidateBuildEnv; + try { + candidateBuildEnv = deriveCandidateBuildEnv(candidateCommit); + } catch { + fail("CANDIDATE_COMMIT_INVALID"); + } const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit); const candidateSet = new Set(candidateManifest); if (expectedPending.some((migration) => !candidateSet.has(migration))) { @@ -305,8 +310,9 @@ function main() { `STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`, `EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`, `EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`, - `BUILD_SHA=${shellQuote(candidateCommit)}`, - `VITE_BUILD_SHA=${shellQuote(candidateShortCommit)}`, + ...Object.entries(candidateBuildEnv).map( + ([key, value]) => `${key}=${shellQuote(value)}`, + ), `CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`, `EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`, "bash -s", @@ -321,14 +327,6 @@ function main() { }); const stdout = result.stdout || ""; const rawStatus = extractPrismaStatus(stdout); - if (rawStatus != null) { - validatePrismaStatusText(rawStatus); - const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]); - writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, { - encoding: "utf8", - mode: 0o600, - }); - } const stableSummary = stableRemoteSummary(stdout); writeFileSync(path.join(reportDir, "preflight-output.txt"), `${stableSummary}\n`, { encoding: "utf8", @@ -343,6 +341,12 @@ function main() { stderr: result.stderr, rawStatus, }); + validatePrismaStatusText(rawStatus); + const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]); + writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, { + encoding: "utf8", + mode: 0o600, + }); console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过"); } catch (error) { const code = error instanceof ProductionPreflightError diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 0c117994..ef8870f4 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -287,6 +287,9 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath); const remotePreflightScriptContent = readFile(remotePreflightScriptPath); const productionPreflightScriptContent = readFile(productionPreflightScriptPath); const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath); +const sourceProvenanceScriptContent = readFile( + path.join(rootDir, 'deploy/trialrun/check-source-provenance.mjs'), +); const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath); const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath); const dockerfileContent = readFile(dockerfilePath); @@ -453,6 +456,34 @@ for (const requiredFragment of [ errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`); } } + +for (const requiredFragment of [ + 'deriveCandidateBuildEnv', + 'BUILD_SHA: normalized', + 'VITE_BUILD_SHA: normalized.slice(0, 12)', + 'buildEnv,', +]) { + if (!sourceProvenanceScriptContent.includes(requiredFragment)) { + errors.push(`check-source-provenance.mjs 缺少候选构建身份单一合同: ${requiredFragment}`); + } +} +for (const requiredFragment of [ + 'Object.entries(p.buildEnv ?? {})', + 'declare -A SOURCE_BUILD_ENV=()', + '"${#SOURCE_BUILD_ENV[@]}" -eq 2', + 'SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}"', + 'SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}"', +]) { + if (!deployScriptContent.includes(requiredFragment)) { + errors.push(`deploy.sh 未完整消费 provenance buildEnv: ${requiredFragment}`); + } +} +if (deployScriptContent.includes('p.shortCommit') + || deployScriptContent.includes('p.buildEnv.BUILD_SHA') + || deployScriptContent.includes('p.buildEnv.VITE_BUILD_SHA') + || deployScriptContent.includes('${BUILD_SHA:0:12}')) { + errors.push('deploy.sh 不得绕过完整对象遍历单独读取候选构建字段'); +} if (productionIdentityBootstrapScriptContent.indexOf('if (identityOnly === "1")') > productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) { errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前'); @@ -765,8 +796,9 @@ for (const requiredFragment of [ 'collectMigrationManifest', 'EXPECTED_PENDING_MIGRATIONS', 'CANDIDATE_COMMIT_MISSING', - 'BUILD_SHA=', - 'VITE_BUILD_SHA=', + 'deriveCandidateBuildEnv', + 'Object.entries(candidateBuildEnv)', + '([key, value]) => `${key}=${shellQuote(value)}`', 'PRISMA_STATUS_OUTPUT_MISSING', 'PRISMA_STATUS_OUTPUT_UNREADABLE', 'sanitizePrismaStatus', @@ -779,6 +811,16 @@ for (const requiredFragment of [ errors.push(`production-preflight.mjs 缺少生产目标/候选迁移合同: ${requiredFragment}`); } } +if (productionPreflightScriptContent.includes('candidateCommit.slice(0, 12)')) { + errors.push('production-preflight.mjs 不得自行派生候选短 SHA'); +} +if (productionPreflightScriptContent.includes('CANDIDATE_BUILD_ENV_KEYS')) { + errors.push('production-preflight.mjs 不得以独立键数组重新定义候选构建环境对象'); +} +if (productionPreflightScriptContent.lastIndexOf('assertRemotePreflightResult({') + > productionPreflightScriptContent.lastIndexOf('validatePrismaStatusText(rawStatus);')) { + errors.push('production-preflight.mjs 必须先传播远端失败,再校验 Prisma marker 内容'); +} for (const requiredFragment of [ 'EXPECTED_PRODUCTION_ORIGIN_SHA256', diff --git a/server/tests/unit/scripts/check-source-provenance.test.js b/server/tests/unit/scripts/check-source-provenance.test.js index bade8297..7a9d184b 100644 --- a/server/tests/unit/scripts/check-source-provenance.test.js +++ b/server/tests/unit/scripts/check-source-provenance.test.js @@ -25,6 +25,23 @@ describe("deployment source provenance", () => { provenance = await import(pathToFileURL(scriptPath).href); }); + it("derives the complete candidate build environment as one immutable contract", () => { + const commit = "a".repeat(40); + const buildEnv = provenance.deriveCandidateBuildEnv(commit); + expect(Object.keys(buildEnv)).toEqual(["BUILD_SHA", "VITE_BUILD_SHA"]); + expect(buildEnv).toEqual({ + BUILD_SHA: commit, + VITE_BUILD_SHA: commit.slice(0, 12), + }); + expect(Object.isFrozen(buildEnv)).toBe(true); + expect(() => provenance.deriveCandidateBuildEnv("a".repeat(39))).toThrowError( + expect.objectContaining({ code: "commit_invalid" }), + ); + expect(() => provenance.deriveCandidateBuildEnv("A".repeat(40))).toThrowError( + expect.objectContaining({ code: "commit_invalid" }), + ); + }); + beforeEach(async () => { repoDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-test-")); await git(["init", "--initial-branch=main"]); @@ -72,6 +89,10 @@ describe("deployment source provenance", () => { published: true, overrideUsed: false, upstream: "origin/main", + buildEnv: { + BUILD_SHA: expect.stringMatching(/^[0-9a-f]{40,64}$/u), + VITE_BUILD_SHA: expect.stringMatching(/^[0-9a-f]{12}$/u), + }, }); } finally { await rm(remoteDir, { recursive: true, force: true }); diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index 6a72ef66..56ad4883 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 } = require("node:child_process"); +const { execFile, execFileSync } = 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"); @@ -60,6 +60,33 @@ describe("deployment safety contract", () => { }); } + function productionPreflightEnv({ fakeBin, reportDir, overrides = {} }) { + const candidateCommit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: ROOT_DIR, + encoding: "utf8", + }).trim(); + return { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + DEPLOY_SERVER: "deploy.production.invalid", + REMOTE_DIR: remoteDir, + REMOTE_ENV_FILE: envFile, + COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", + COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", + POSTGRES_DATA_DIR: postgresDir, + STORAGE_HOST_DIR: storageDir, + EXPECTED_PRODUCTION_ORIGIN: "https://oa.hyinda.com", + EXPECTED_PRODUCTION_DB_NAME_SHA256: createHash("sha256").update("hyb_oa", "utf8").digest("hex"), + EXPECTED_PENDING_MIGRATIONS: "", + EXPECTED_DEPLOY_SERVER: "deploy.production.invalid", + EXPECTED_REMOTE_DIR: remoteDir, + EXPECTED_COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", + CANDIDATE_COMMIT: candidateCommit, + PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir, + ...overrides, + }; + } + it("accepts a complete existing deployment contract", async () => { await expect(preflight()).resolves.toMatchObject({ stdout: expect.stringContaining("[remote-preflight][PASS]") }); }); @@ -119,6 +146,7 @@ describe("deployment safety contract", () => { it("requires production target identity and pending migration allowlist before deploy.sh", async () => { const wrapper = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8"); + const deploy = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy.sh"), "utf8"); const preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8"); const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8"); const bootstrap = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP, "utf8"); @@ -141,6 +169,19 @@ describe("deployment safety contract", () => { expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); expect(preflight).toContain("CANDIDATE_COMMIT_MISSING"); expect(preflight).not.toContain("rev-parse"); + expect(preflight).toContain("deriveCandidateBuildEnv"); + expect(preflight).toContain("Object.entries(candidateBuildEnv)"); + expect(preflight).not.toContain("CANDIDATE_BUILD_ENV_KEYS"); + expect(preflight).not.toContain("candidateCommit.slice(0, 12)"); + expect(deploy).toContain("Object.entries(p.buildEnv ?? {})"); + expect(deploy).toContain("declare -A SOURCE_BUILD_ENV=()"); + expect(deploy).toContain('"${#SOURCE_BUILD_ENV[@]}" -eq 2'); + expect(deploy).not.toContain("p.buildEnv.BUILD_SHA"); + expect(deploy).not.toContain("p.buildEnv.VITE_BUILD_SHA"); + expect(deploy).not.toContain("p.shortCommit"); + expect(deploy).not.toContain("${BUILD_SHA:0:12}"); + expect(deploy).toContain('${VITE_BUILD_SHA}.log'); + expect(deploy).toContain('-e EXPECTED_BUILD_SHA="${VITE_BUILD_SHA}"'); expect(preflight).toContain("redacted datasource line"); expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH"); expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan( @@ -149,6 +190,9 @@ describe("deployment safety contract", () => { expect(preflight.indexOf("reportRef=${reportRef(reportDir)}")).toBeLessThan( preflight.lastIndexOf("assertRemotePreflightResult({"), ); + expect(preflight.lastIndexOf("assertRemotePreflightResult({")).toBeLessThan( + preflight.lastIndexOf("validatePrismaStatusText(rawStatus);"), + ); expect(remote).toContain("PRISMA_STATUS_BEGIN"); expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); expect(remote).toContain("logged_failure"); @@ -287,6 +331,95 @@ describe("deployment safety contract", () => { ); }); + it.each([ + { + name: "propagates a remote code when the marker is missing", + mode: "code-no-marker", + expectedCode: "PROD_DATABASE_IDENTITY_MISMATCH", + }, + { + name: "propagates a remote code when the marker is present", + mode: "code-with-marker", + expectedCode: "PROD_MIGRATION_STATUS_FAILED", + }, + { + name: "propagates a remote code when the marker body is unreadable", + mode: "code-with-unreadable-marker", + expectedCode: "PROD_MIGRATION_STATUS_FAILED", + }, + { + name: "uses the fallback for a remote failure without a code", + mode: "no-code-no-marker", + expectedCode: "PRODUCTION_PREFLIGHT_REMOTE_FAILED", + }, + { + name: "requires the marker after a successful remote exit", + mode: "success-no-marker", + expectedCode: "PRISMA_STATUS_OUTPUT_MISSING", + }, + ])("runs the real preflight subprocess and $name", async ({ mode, expectedCode }) => { + const fakeBin = path.join(fixtureRoot, `preflight-bin-${mode}`); + const reportDir = path.join(fixtureRoot, `preflight-report-${mode}`); + await mkdir(fakeBin, { recursive: true }); + const fakeSsh = path.join(fakeBin, "ssh"); + await writeFile(fakeSsh, [ + "#!/usr/bin/env node", + "const mode = process.env.FAKE_PREFLIGHT_SSH_MODE;", + "if (mode === 'code-no-marker') {", + " process.stdout.write('[production-preflight][PAUSED] code=PROD_DATABASE_IDENTITY_MISMATCH\\n');", + " process.exit(41);", + "}", + "if (mode === 'code-with-marker') {", + " process.stdout.write('[production-preflight][PRISMA_STATUS_BEGIN]\\n');", + " process.stdout.write('Database schema is up to date!\\n');", + " process.stdout.write('[production-preflight][PRISMA_STATUS_END] exit=0\\n');", + " process.stdout.write('[production-preflight][PAUSED] code=PROD_MIGRATION_STATUS_FAILED\\n');", + " process.exit(42);", + "}", + "if (mode === 'code-with-unreadable-marker') {", + " process.stdout.write('[production-preflight][PRISMA_STATUS_BEGIN]\\n');", + " process.stdout.write('truncated or unknown prisma output\\n');", + " process.stdout.write('[production-preflight][PRISMA_STATUS_END] exit=1\\n');", + " process.stdout.write('[production-preflight][PAUSED] code=PROD_MIGRATION_STATUS_FAILED\\n');", + " process.exit(44);", + "}", + "if (mode === 'no-code-no-marker') {", + " process.stdout.write('remote command failed\\n');", + " process.stderr.write('ssh exited unexpectedly\\n');", + " process.exit(43);", + "}", + "if (mode === 'success-no-marker') {", + " process.stdout.write('[production-preflight][PASS] target_identity\\n');", + " process.exit(0);", + "}", + "process.stderr.write('unexpected fake ssh mode\\n');", + "process.exit(97);", + "", + ].join("\n"), { mode: 0o700 }); + await chmod(fakeSsh, 0o700); + + let failure; + try { + await execFileAsync("node", [PRODUCTION_PREFLIGHT], { + cwd: ROOT_DIR, + env: productionPreflightEnv({ + fakeBin, + reportDir, + overrides: { FAKE_PREFLIGHT_SSH_MODE: mode }, + }), + }); + } catch (error) { + failure = error; + } + expect(failure).toBeDefined(); + expect(failure.code).toBe(1); + expect(failure.stderr).toContain(`[production-preflight][PAUSED] code=${expectedCode}`); + expect(failure.stdout).toContain("[production-preflight] reportRef="); + const stableEvidence = await readFile(path.join(reportDir, "preflight-output.txt"), "utf8"); + expect(stableEvidence.trim()).not.toBe(""); + expect(failure.stdout).toContain(stableEvidence.trim()); + }); + it("captures a redacted pre-status evidence copy from a controlled SSH fixture", async () => { const fakeBin = path.join(fixtureRoot, "bin"); const reportDir = path.join(fixtureRoot, "report"); @@ -312,9 +445,9 @@ describe("deployment safety contract", () => { ); await chmod(fakeSsh, 0o700); - const candidateCommit = require("node:child_process") - .execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" }) - .trim(); + const candidateCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" }).trim(); + const provenance = await import(pathToFileURL(path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs")).href); + const candidateBuildEnv = provenance.deriveCandidateBuildEnv(candidateCommit); const result = await execFileAsync("node", [PRODUCTION_PREFLIGHT], { cwd: ROOT_DIR, env: { @@ -351,8 +484,8 @@ describe("deployment safety contract", () => { expect(sshCommand).not.toContain("hyb_oa"); expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256"); expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256"); - expect(sshCommand).toContain(`BUILD_SHA='${candidateCommit}'`); - expect(sshCommand).toContain(`VITE_BUILD_SHA='${candidateCommit.slice(0, 12)}'`); + expect(sshCommand).toContain(`BUILD_SHA='${candidateBuildEnv.BUILD_SHA}'`); + expect(sshCommand).toContain(`VITE_BUILD_SHA='${candidateBuildEnv.VITE_BUILD_SHA}'`); }); it("bootstraps only an exact historical identity and a unique refresh probe", async () => { @@ -476,13 +609,14 @@ describe("deployment safety contract", () => { "set -euo pipefail", "joined=\"$*\"", "mode=\"${FAKE_REMOTE_MODE:-happy}\"", + "if [[ -n \"${FAKE_DOCKER_TRACE:-}\" ]]; then printf '%s\\n' \"$joined\" >> \"$FAKE_DOCKER_TRACE\"; fi", "if [[ \"$1\" == compose && \"${2:-}\" == version ]]; then exit 0; fi", "if [[ \"$1\" == ps ]]; then", " if [[ \"$joined\" == *service=server* ]]; then printf 'server1\\n'; else printf 'db1\\n'; fi", " exit 0", "fi", "if [[ \"$1\" == inspect ]]; then printf 'NODE_ENV=production\\nCOOKIE_SECURE=true\\nAPP_ORIGIN=https://oa.example.com\\n'; exit 0; fi", - "if [[ \"$joined\" == *' config'* ]]; then exit 0; fi", + "if [[ \"$joined\" == *' config'* ]]; then [[ \"$mode\" == compose-invalid ]] && exit 91; exit 0; fi", "if [[ \"$joined\" == *' exec '* && \"$joined\" == *' sh -lc '* ]]; then", " case \"$mode\" in", " pending) printf 'Following migration have not yet been applied:\\n20260807193001_followup_migration\\n'; exit 1 ;;", @@ -519,12 +653,14 @@ describe("deployment safety contract", () => { const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex"); const buildSha = "a".repeat(40); + const dockerTrace = path.join(fixtureRoot, "fake-docker-calls.log"); const runRemote = (mode, expectedPending = "", buildOverrides = {}) => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], { cwd: remoteDir, env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, FAKE_REMOTE_MODE: mode, + FAKE_DOCKER_TRACE: dockerTrace, REMOTE_DIR: remoteDir, REMOTE_ENV_FILE: envFile, COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", @@ -566,6 +702,19 @@ describe("deployment safety contract", () => { await expect(runRemote("logs")).rejects.toMatchObject({ stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"), }); + await writeFile(dockerTrace, ""); + await expect(runRemote("compose-invalid")).rejects.toMatchObject({ + stderr: expect.stringContaining("PROD_TARGET_COMPOSE_CONFIG_INVALID"), + }); + const composeFailureCalls = await readFile(dockerTrace, "utf8"); + expect(composeFailureCalls).toContain(" config"); + expect(composeFailureCalls).not.toContain("prisma migrate status"); + expect(composeFailureCalls).not.toContain(" exec "); + await writeFile(dockerTrace, ""); + await expect(runRemote("happy", "", { BUILD_SHA: "" })).rejects.toMatchObject({ + stderr: expect.stringContaining("BUILD_SHA_MISSING"), + }); + expect(await readFile(dockerTrace, "utf8")).toBe(""); await expect(runRemote("happy", "", { BUILD_SHA: "a".repeat(39) })).rejects.toMatchObject({ stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_INVALID"), });