完善(08-09-deployment-contract-single-source-harness): 收敛部署合同并补失败路径
这个提交包含在:
@@ -259,6 +259,95 @@ SYNC_ENV=0 POSTGRES_DATA_DIR=/srv/hyb-oa/pgdata \
|
|||||||
bash deploy/trialrun/deploy.sh
|
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
|
## Scenario: Public Smoke Navigation Labels
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 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": {}
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ const ALLOWED_UNPUBLISHED_REASONS = new Set([
|
|||||||
"controlled-local-recovery",
|
"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) {
|
function runGit(rootDir, args) {
|
||||||
return spawnSync("git", args, {
|
return spawnSync("git", args, {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
@@ -50,6 +61,7 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commit = gitText(rootDir, ["rev-parse", "--verify", "HEAD"], "commit_unavailable");
|
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 branch = gitText(rootDir, ["branch", "--show-current"], "branch_unavailable") || "detached";
|
||||||
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
const upstreamResult = runGit(rootDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||||
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
const upstream = upstreamResult.status === 0 ? upstreamResult.stdout.trim() : null;
|
||||||
@@ -64,8 +76,9 @@ export function collectSourceProvenance({ rootDir, allowUnpublished = false }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: "PASS",
|
status: "PASS",
|
||||||
commit,
|
commit: buildEnv.BUILD_SHA,
|
||||||
shortCommit: commit.slice(0, 12),
|
shortCommit: buildEnv.VITE_BUILD_SHA,
|
||||||
|
buildEnv,
|
||||||
branch,
|
branch,
|
||||||
upstream,
|
upstream,
|
||||||
published,
|
published,
|
||||||
|
|||||||
+25
-4
@@ -127,8 +127,29 @@ fi
|
|||||||
|
|
||||||
echo ">>> 0. 校验 Git 来源与发布提交"
|
echo ">>> 0. 校验 Git 来源与发布提交"
|
||||||
PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")"
|
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_BUILD_ENV_LINES="$(node -e '
|
||||||
SOURCE_SHORT_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.shortCommit)' "$PROVENANCE_JSON")"
|
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_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_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")"
|
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
|
echo "[deploy-stage][ERROR] 阶段名称格式无效" >&2
|
||||||
return 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"
|
local log_file="$REMOTE_DIR/$log_ref"
|
||||||
: > "$log_file"
|
: > "$log_file"
|
||||||
chmod 0600 "$log_file"
|
chmod 0600 "$log_file"
|
||||||
@@ -334,7 +355,7 @@ ready_ok=0
|
|||||||
for i in $(seq 1 60); do
|
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 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 \
|
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 fs = require("node:fs");
|
||||||
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
||||||
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
|
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isIP } from "node:net";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { deriveCandidateBuildEnv } from "./check-source-provenance.mjs";
|
||||||
|
|
||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "../..");
|
const ROOT_DIR = path.resolve(SCRIPT_DIR, "../..");
|
||||||
@@ -275,8 +276,12 @@ function main() {
|
|||||||
|
|
||||||
if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING");
|
if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING");
|
||||||
const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim();
|
const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim();
|
||||||
if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID");
|
let candidateBuildEnv;
|
||||||
const candidateShortCommit = candidateCommit.slice(0, 12);
|
try {
|
||||||
|
candidateBuildEnv = deriveCandidateBuildEnv(candidateCommit);
|
||||||
|
} catch {
|
||||||
|
fail("CANDIDATE_COMMIT_INVALID");
|
||||||
|
}
|
||||||
const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit);
|
const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit);
|
||||||
const candidateSet = new Set(candidateManifest);
|
const candidateSet = new Set(candidateManifest);
|
||||||
if (expectedPending.some((migration) => !candidateSet.has(migration))) {
|
if (expectedPending.some((migration) => !candidateSet.has(migration))) {
|
||||||
@@ -305,8 +310,9 @@ function main() {
|
|||||||
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
|
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
|
||||||
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
|
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
|
||||||
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
|
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
|
||||||
`BUILD_SHA=${shellQuote(candidateCommit)}`,
|
...Object.entries(candidateBuildEnv).map(
|
||||||
`VITE_BUILD_SHA=${shellQuote(candidateShortCommit)}`,
|
([key, value]) => `${key}=${shellQuote(value)}`,
|
||||||
|
),
|
||||||
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
|
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
|
||||||
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
|
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
|
||||||
"bash -s",
|
"bash -s",
|
||||||
@@ -321,14 +327,6 @@ function main() {
|
|||||||
});
|
});
|
||||||
const stdout = result.stdout || "";
|
const stdout = result.stdout || "";
|
||||||
const rawStatus = extractPrismaStatus(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);
|
const stableSummary = stableRemoteSummary(stdout);
|
||||||
writeFileSync(path.join(reportDir, "preflight-output.txt"), `${stableSummary}\n`, {
|
writeFileSync(path.join(reportDir, "preflight-output.txt"), `${stableSummary}\n`, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
@@ -343,6 +341,12 @@ function main() {
|
|||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
rawStatus,
|
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] 生产目标与迁移只读门禁通过");
|
console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error instanceof ProductionPreflightError
|
const code = error instanceof ProductionPreflightError
|
||||||
|
|||||||
@@ -287,6 +287,9 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
|
|||||||
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
||||||
const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
|
const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
|
||||||
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
|
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
|
||||||
|
const sourceProvenanceScriptContent = readFile(
|
||||||
|
path.join(rootDir, 'deploy/trialrun/check-source-provenance.mjs'),
|
||||||
|
);
|
||||||
const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath);
|
const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath);
|
||||||
const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath);
|
const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath);
|
||||||
const dockerfileContent = readFile(dockerfilePath);
|
const dockerfileContent = readFile(dockerfilePath);
|
||||||
@@ -453,6 +456,34 @@ for (const requiredFragment of [
|
|||||||
errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`);
|
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")')
|
if (productionIdentityBootstrapScriptContent.indexOf('if (identityOnly === "1")')
|
||||||
> productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) {
|
> productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) {
|
||||||
errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前');
|
errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前');
|
||||||
@@ -765,8 +796,9 @@ for (const requiredFragment of [
|
|||||||
'collectMigrationManifest',
|
'collectMigrationManifest',
|
||||||
'EXPECTED_PENDING_MIGRATIONS',
|
'EXPECTED_PENDING_MIGRATIONS',
|
||||||
'CANDIDATE_COMMIT_MISSING',
|
'CANDIDATE_COMMIT_MISSING',
|
||||||
'BUILD_SHA=',
|
'deriveCandidateBuildEnv',
|
||||||
'VITE_BUILD_SHA=',
|
'Object.entries(candidateBuildEnv)',
|
||||||
|
'([key, value]) => `${key}=${shellQuote(value)}`',
|
||||||
'PRISMA_STATUS_OUTPUT_MISSING',
|
'PRISMA_STATUS_OUTPUT_MISSING',
|
||||||
'PRISMA_STATUS_OUTPUT_UNREADABLE',
|
'PRISMA_STATUS_OUTPUT_UNREADABLE',
|
||||||
'sanitizePrismaStatus',
|
'sanitizePrismaStatus',
|
||||||
@@ -779,6 +811,16 @@ for (const requiredFragment of [
|
|||||||
errors.push(`production-preflight.mjs 缺少生产目标/候选迁移合同: ${requiredFragment}`);
|
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 [
|
for (const requiredFragment of [
|
||||||
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
|
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ describe("deployment source provenance", () => {
|
|||||||
provenance = await import(pathToFileURL(scriptPath).href);
|
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 () => {
|
beforeEach(async () => {
|
||||||
repoDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-test-"));
|
repoDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-provenance-test-"));
|
||||||
await git(["init", "--initial-branch=main"]);
|
await git(["init", "--initial-branch=main"]);
|
||||||
@@ -72,6 +89,10 @@ describe("deployment source provenance", () => {
|
|||||||
published: true,
|
published: true,
|
||||||
overrideUsed: false,
|
overrideUsed: false,
|
||||||
upstream: "origin/main",
|
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 {
|
} finally {
|
||||||
await rm(remoteDir, { recursive: true, force: true });
|
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 { createHash } = require("node:crypto");
|
||||||
const { chmod, mkdtemp, mkdir, rm, symlink, writeFile, readFile } = require("node:fs/promises");
|
const { chmod, mkdtemp, mkdir, rm, symlink, writeFile, readFile } = require("node:fs/promises");
|
||||||
const os = require("node:os");
|
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 () => {
|
it("accepts a complete existing deployment contract", async () => {
|
||||||
await expect(preflight()).resolves.toMatchObject({ stdout: expect.stringContaining("[remote-preflight][PASS]") });
|
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 () => {
|
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 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 preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8");
|
||||||
const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8");
|
const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8");
|
||||||
const bootstrap = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP, "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("PRISMA_STATUS_OUTPUT_UNREADABLE");
|
||||||
expect(preflight).toContain("CANDIDATE_COMMIT_MISSING");
|
expect(preflight).toContain("CANDIDATE_COMMIT_MISSING");
|
||||||
expect(preflight).not.toContain("rev-parse");
|
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("redacted datasource line");
|
||||||
expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH");
|
expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH");
|
||||||
expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan(
|
expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan(
|
||||||
@@ -149,6 +190,9 @@ describe("deployment safety contract", () => {
|
|||||||
expect(preflight.indexOf("reportRef=${reportRef(reportDir)}")).toBeLessThan(
|
expect(preflight.indexOf("reportRef=${reportRef(reportDir)}")).toBeLessThan(
|
||||||
preflight.lastIndexOf("assertRemotePreflightResult({"),
|
preflight.lastIndexOf("assertRemotePreflightResult({"),
|
||||||
);
|
);
|
||||||
|
expect(preflight.lastIndexOf("assertRemotePreflightResult({")).toBeLessThan(
|
||||||
|
preflight.lastIndexOf("validatePrismaStatusText(rawStatus);"),
|
||||||
|
);
|
||||||
expect(remote).toContain("PRISMA_STATUS_BEGIN");
|
expect(remote).toContain("PRISMA_STATUS_BEGIN");
|
||||||
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
|
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
|
||||||
expect(remote).toContain("logged_failure");
|
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 () => {
|
it("captures a redacted pre-status evidence copy from a controlled SSH fixture", async () => {
|
||||||
const fakeBin = path.join(fixtureRoot, "bin");
|
const fakeBin = path.join(fixtureRoot, "bin");
|
||||||
const reportDir = path.join(fixtureRoot, "report");
|
const reportDir = path.join(fixtureRoot, "report");
|
||||||
@@ -312,9 +445,9 @@ describe("deployment safety contract", () => {
|
|||||||
);
|
);
|
||||||
await chmod(fakeSsh, 0o700);
|
await chmod(fakeSsh, 0o700);
|
||||||
|
|
||||||
const candidateCommit = require("node:child_process")
|
const candidateCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" }).trim();
|
||||||
.execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" })
|
const provenance = await import(pathToFileURL(path.join(ROOT_DIR, "deploy/trialrun/check-source-provenance.mjs")).href);
|
||||||
.trim();
|
const candidateBuildEnv = provenance.deriveCandidateBuildEnv(candidateCommit);
|
||||||
const result = await execFileAsync("node", [PRODUCTION_PREFLIGHT], {
|
const result = await execFileAsync("node", [PRODUCTION_PREFLIGHT], {
|
||||||
cwd: ROOT_DIR,
|
cwd: ROOT_DIR,
|
||||||
env: {
|
env: {
|
||||||
@@ -351,8 +484,8 @@ describe("deployment safety contract", () => {
|
|||||||
expect(sshCommand).not.toContain("hyb_oa");
|
expect(sshCommand).not.toContain("hyb_oa");
|
||||||
expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256");
|
expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256");
|
||||||
expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
|
expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
|
||||||
expect(sshCommand).toContain(`BUILD_SHA='${candidateCommit}'`);
|
expect(sshCommand).toContain(`BUILD_SHA='${candidateBuildEnv.BUILD_SHA}'`);
|
||||||
expect(sshCommand).toContain(`VITE_BUILD_SHA='${candidateCommit.slice(0, 12)}'`);
|
expect(sshCommand).toContain(`VITE_BUILD_SHA='${candidateBuildEnv.VITE_BUILD_SHA}'`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bootstraps only an exact historical identity and a unique refresh probe", async () => {
|
it("bootstraps only an exact historical identity and a unique refresh probe", async () => {
|
||||||
@@ -476,13 +609,14 @@ describe("deployment safety contract", () => {
|
|||||||
"set -euo pipefail",
|
"set -euo pipefail",
|
||||||
"joined=\"$*\"",
|
"joined=\"$*\"",
|
||||||
"mode=\"${FAKE_REMOTE_MODE:-happy}\"",
|
"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\" == compose && \"${2:-}\" == version ]]; then exit 0; fi",
|
||||||
"if [[ \"$1\" == ps ]]; then",
|
"if [[ \"$1\" == ps ]]; then",
|
||||||
" if [[ \"$joined\" == *service=server* ]]; then printf 'server1\\n'; else printf 'db1\\n'; fi",
|
" if [[ \"$joined\" == *service=server* ]]; then printf 'server1\\n'; else printf 'db1\\n'; fi",
|
||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
"if [[ \"$1\" == inspect ]]; then printf 'NODE_ENV=production\\nCOOKIE_SECURE=true\\nAPP_ORIGIN=https://oa.example.com\\n'; 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",
|
"if [[ \"$joined\" == *' exec '* && \"$joined\" == *' sh -lc '* ]]; then",
|
||||||
" case \"$mode\" in",
|
" case \"$mode\" in",
|
||||||
" pending) printf 'Following migration have not yet been applied:\\n20260807193001_followup_migration\\n'; exit 1 ;;",
|
" 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 hash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
||||||
const buildSha = "a".repeat(40);
|
const buildSha = "a".repeat(40);
|
||||||
|
const dockerTrace = path.join(fixtureRoot, "fake-docker-calls.log");
|
||||||
const runRemote = (mode, expectedPending = "", buildOverrides = {}) => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], {
|
const runRemote = (mode, expectedPending = "", buildOverrides = {}) => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], {
|
||||||
cwd: remoteDir,
|
cwd: remoteDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
|
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
|
||||||
FAKE_REMOTE_MODE: mode,
|
FAKE_REMOTE_MODE: mode,
|
||||||
|
FAKE_DOCKER_TRACE: dockerTrace,
|
||||||
REMOTE_DIR: remoteDir,
|
REMOTE_DIR: remoteDir,
|
||||||
REMOTE_ENV_FILE: envFile,
|
REMOTE_ENV_FILE: envFile,
|
||||||
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
|
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
|
||||||
@@ -566,6 +702,19 @@ describe("deployment safety contract", () => {
|
|||||||
await expect(runRemote("logs")).rejects.toMatchObject({
|
await expect(runRemote("logs")).rejects.toMatchObject({
|
||||||
stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"),
|
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({
|
await expect(runRemote("happy", "", { BUILD_SHA: "a".repeat(39) })).rejects.toMatchObject({
|
||||||
stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_INVALID"),
|
stderr: expect.stringContaining("PROD_TARGET_BUILD_SHA_INVALID"),
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户