完善(08-09-deployment-contract-single-source-harness): 收敛部署合同并补失败路径

这个提交包含在:
2026-08-09 14:40:28 +08:00
父节点 099f58cf3b
当前提交 621934a35e
共修改 13 个文件,包含 648 行新增和 27 行删除
@@ -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
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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、生产配置或数据。若共享合同在本地门禁中暴露兼容问题,提交前直接修正,不以生产试跑验证。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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。
@@ -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
- 无。范围、兼容性和风险边界已由现有代码、生产故障证据及用户确认的实施顺序确定。
@@ -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": {}
}
+15 -2
查看文件
@@ -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,
+25 -4
查看文件
@@ -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);
+16 -12
查看文件
@@ -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
+44 -2
查看文件
@@ -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',
@@ -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 });
@@ -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"),
});