From 16b9cad98c4687b8328a69a47f192077f0c63dab Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 19 Sep 2026 14:25:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=8F=91=E5=B8=83=E5=80=99?= =?UTF-8?q?=E9=80=89=E5=9B=9E=E6=89=A7=E5=BC=BA=E5=88=B6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8809-06-release-gate-integrity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 7 +- .../09-06-release-gate-integrity/design.md | 69 ++++++ .../09-06-release-gate-integrity/implement.md | 62 ++++- .../tasks/09-06-release-gate-integrity/prd.md | 22 ++ .../09-06-release-gate-integrity/task.json | 50 ++++- deploy/trialrun/deploy-existing.sh | 17 +- deploy/trialrun/deploy.sh | 6 + .../production-identity-bootstrap.mjs | 8 +- scripts/qa/release-gate-result.mjs | 55 ++++- scripts/qa/run-release-gate.sh | 45 +++- .../qa/tests/fixtures/release-deploy-fake.cjs | 89 ++++++++ .../qa/tests/fixtures/release-fs-guard.cjs | 15 ++ .../tests/fixtures/release-gate-fixture.mjs | 85 +++++++ .../tests/release-gate-enforcement.test.mjs | 211 ++++++++++++++++++ scripts/qa/tests/release-gate-shell.test.mjs | 72 +----- 15 files changed, 722 insertions(+), 91 deletions(-) create mode 100644 scripts/qa/tests/fixtures/release-deploy-fake.cjs create mode 100644 scripts/qa/tests/fixtures/release-fs-guard.cjs create mode 100644 scripts/qa/tests/fixtures/release-gate-fixture.mjs create mode 100644 scripts/qa/tests/release-gate-enforcement.test.mjs diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 7f6007d6..c78f29c3 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -2,13 +2,18 @@ ## Convention: Local Release Candidate Receipt -- `bash scripts/qa/run-release-gate.sh verify-candidate` reuses existing static and managed full-local stages and never calls deployment. R1 enforcement in existing deployment/bootstrap entries remains a separately approved change. +- `bash scripts/qa/run-release-gate.sh verify-candidate` reuses existing static and managed full-local stages and never calls deployment. The approved R1 local integration requires this receipt in every write entry; actual push/deployment still needs separate authorization. - `release-gate-result.mjs` owns an exclusive empty directory under `artifacts/release-gate/`, records INCOMPLETE before execution, and derives final results from fixed ordered phases, source/index/tracked and untracked input digests, current policy/toolchain, owned environment/cleanup, G19 full discovery and every local result plus witnesses/acceptance specs. Missing, truncated, linked, changed, interrupted or partial evidence is not PASS. - `qa-stage-record.mjs` records full-local phases through failure cleanup. Keep plain shell calls under errexit: wrapping compound helpers in `if`/`||` can suppress their internal failures. Preserve the original failing exit code if evidence cleanup also fails. - Persist actual browser selection separately from discovery: exact derived spec arguments, supplementary arguments, mode, retries, forbid-only and worker count. Ordinary release verification rejects supplementary filters, shards, missing projects, changed budgets, skips, retries, expected failures and inconsistent statistics/time ranges. - A dirty development tree may have `localChecksPassed=true`, but returns PARTIAL/exit 2 and `deploymentEligible=false`. Offline `verify` re-reads and hashes evidence and recomputes against the current checkout. Local proof does not discharge the 12 same-candidate public obligations, independent reproduction, or operator approval. - Existing project-wide skip flags accept only exact 0/1. Each or both require nonempty reason/operator/approval reference, persist affected stages, and end PARTIAL/exit 2. Other release modes reject them before stage side effects. These metadata do not grant authorization; no approval time is invented. - Validate with pure receipt counterexamples, actual shell/fake-tool invocation, source mutations, and existing deployment lifecycle/safety regressions. Fake tools must intercept every external action. Evidence is auditable, not a cryptographic signature. +- Explicit consumption uses `RELEASE_GATE_RECEIPT_DIR` (absolute or repository-relative, confined beneath `artifacts/release-gate/`); `RELEASE_GATE_ARTIFACT_DIR` only selects new output. Never search for a latest receipt. `release-gate-result.mjs require` re-reads all evidence and current source/policy/toolchain; caller PASS/skip flags do not grant eligibility. The three deployment entries are part of the policy digest. +- `ci-release/all` reuse a supplied eligible receipt or run the verification-only child once, then call production identity bootstrap. `all` retains its extra smoke-local before that verification. Explicit invalid/empty receipts fail without fallback tests. `deploy` and direct wrappers require a receipt and never start full tests themselves. Repeated lower-level assertions only revalidate evidence. +- Pin the returned commit across bootstrap and wrapper preflights with `RELEASE_GATE_EXPECTED_COMMIT`; it is an additional equality constraint, never an override. Match provenance/archived `SOURCE_COMMIT` to it. Recheck before each lower entry and before the first remote write (the mkdir preceding rsync), not merely before container work. Initial rejection must precede SSH/config/provenance-fetch access; do not invoke failure collectors that read real configuration for a rejected publication. +- Both read-only flags accept only exact 0/1. Bootstrap identity-only exits before database lookup; bootstrap/existing preflight-only exits before deploy.sh. Release-runner publication modes and raw deploy.sh reject either read-only flag rather than treating it as write permission or publication success. +- Add boundary checks with synthetic disposable repos, private child HOME/PATH, audited fake config and fail-closed SSH/rsync/curl/git-fetch/Docker tools. Execute actual local entry scripts; remote-body lifecycle tests remain separate. Source mutations must expose an observable violation, not just remove a matching string. Freeze and commit the final reviewed candidate before the single full release run; task-only records and old partial receipts cannot substitute for it. ## Convention: Managed OCR And Concrete Visual Coverage diff --git a/.trellis/tasks/09-06-release-gate-integrity/design.md b/.trellis/tasks/09-06-release-gate-integrity/design.md index 172626b7..8ad9c821 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/design.md +++ b/.trellis/tasks/09-06-release-gate-integrity/design.md @@ -1,3 +1,72 @@ +## 2026-09-19 F1诊断处置及最终冻结 + +未改执行输入的单文件52项一次通过;独立复核承认三次R1校验不在该测试执行范围内。保留原失败与根因未知,不做推测性修复/预算扩容,不合并分阶段重验。定向证据足以支持本地提交,不能替代完整候选验收。 + +按已批准顺序:本次本地提交封存15路径及验收记录;最终clean SHA仅运行一次既有verify-candidate,沿现有受管环境/原浏览器版本/并发/预算/零重试/33页7组/分层覆盖,不另造验收流程。使用新独占证据目录和已有浏览器缓存的物理副本,无下载、无历史覆盖;运行期间仅写ignored证据。失败即保留并回报,不自动重试。具体SHA/命令与环境边界在提交后的ignored交接中固定,不再改本提交的受管文件。推送/部署未授权。 + +## 2026-09-19 F1超时最小诊断(沿用已批准定向验证范围) + +静态提取与摘要已排除“三次R1 require启动开销进入生命周期用例”的假设:测试只执行REMOTE片段,三个校验都在片段外,片段及测试夹具未变。不改变生产校验、测试预算、并发、重试或断言,不凭一次耗时比较修改产品。 + +Claude仅运行未改的deployment-db-lifecycle.test.js完整52项一次;ignored封装器核执行输入前后SHA、记录真实exit/UTC/耗时、每秒采样本地load/CPU与内存压力/cgroup计数。采样只描述当次环境,不追认前次因果。成功也保留原exit1并交主会话判断;失败停报,不循环重跑。此前125/另外46项/独立代码结论沿原输入保留,本轮不重跑。该诊断属于既有失败验证,不改变验收范围;真实部署/推送仍未授权。 + +## R1 接入方案(2026-09-19,已批准本地实施) + +### 当前事实与授权 + +最新:用户已明确回复“是”,批准本节本地实施及新版执行顺序;以下规划时点描述保留追溯。测试交Claude,推送与部署未授权。 + +基线为本地分支codex/20260919-reviewed-candidate的1f8d5b65,前两笔本地提交已完成。用户同意推进R1方案;本轮仅源码只读分析和任务合同更新,没有执行测试、真实发布入口、生产访问或提交。本地接入/启用已获用户具体方案批准(实现结果与验收状态见implement顶部),生产部署仍独立授权。 + +旧r6全绿属于旧输入,budget-full-r1中止FAIL/130;当前候选不能借它们取得资格。本节建议将“本地接入拒绝未验候选”与“真正对外发布”分开:本地补丁与定向验证先完成,最终clean SHA只跑一次完整门禁,合格后才可在另获部署授权时使用真实入口。该顺序取代旧设计中容易导致先全量、再改入口、又全量的执行歧义,不降低完整通过前不得发布的要求。 + +### 最小修改范围与行为 + +| 文件 | 接入位置与行为 | +| --- | --- | +| scripts/qa/release-gate-result.mjs | 复用verifyReleaseEvidence/readBundle/facts等,增加供入口使用的导出断言及require CLI;重算资格,失败非零,不信任回执中的PASS或调用者布尔值。明确校验期望发布commit。 | +| scripts/qa/run-release-gate.sh | verify-candidate维持无部署;ci-release/all复用同一验证路径或消费显式指定有效回执,再走production-identity-bootstrap;deploy只消费回执,不隐式跑全量。保留all额外smoke-local,安排在唯一verify-candidate之前;不将它冒充必需阶段,也不重复static/full-local。 | +| deploy/trialrun/production-identity-bootstrap.mjs | 正常发布在读取SSH配置/探测真实目标前要求回执;保留身份发现、origin核验和数据库身份。进入deploy-existing前再核对。identity-only/preflight-only明确只读分支不产生发布资格。 | +| deploy/trialrun/deploy-existing.sh | 正常发布在远端聚合体检前要求回执;保留来源/身份/迁移/回滚窗口preflight,并在调用deploy.sh前用实际source_commit再校验。只读preflight仍能单独运行且必须退出。 | +| deploy/trialrun/deploy.sh | 参数合法性检查后、来源fetch或环境读取前验证;取得SOURCE_COMMIT后绑定同一SHA,并在第一条远端写入mkdir之前再次重验。保留git archive不可变同步、existing/initialize边界、数据库生命周期和部署健康检查。 | + +策略摘要显式纳入新增消费入口;全仓源码快照、工具链/CI并发、证据文件摘要、分层清单、清理完成与clean判据全部保留。无schema/业务/迁移/账号/编号/差旅状态变更。仅这5个执行文件及直接配套测试、已有部署规约/任务记录;若暴露新的业务风险另列。 + +### 回执输入与无重复执行合同 + +- 拟用RELEASE_GATE_RECEIPT_DIR传递显式回执目录,仅允许工作树artifacts/release-gate下的受限真实目录;沿用反路径逃逸/符号链接/证据SHA检查。RELEASE_GATE_ARTIFACT_DIR继续只表示生成新回执的位置,两者职责不混用。 +- 共享断言重算完整release-predeploy资格,并把当前HEAD、回执commit及实际SOURCE_COMMIT绑定;缺失、失效、dirty、工具/策略变化或不完整均拒绝。不得产生GATE_PASSED、SKIP_RELEASE_GATE等信任开关,不自动选择“最新”或历史通过回执。 +- verify-candidate仅验证。ci-release/all是显式完整发布命令:有显式回执则重验复用;无回执才调用verify-candidate一次。bootstrap/deploy-existing/deploy.sh及deploy模式无回执立即拒绝并提示先验证,不自动启动耗时全量;这替代旧§2“高层缺回执自动验证”的默认行为。 +- 下层重复的是廉价回执校验,不是测试执行。verify-candidate绝不回调部署,生产引导器绝不回调ci-release/all;成功路径部署链只调用一次。 +- 对PRODUCTION_IDENTITY_ONLY/PRODUCTION_READ_ONLY_PREFLIGHT严格校验0/1。只读入口不要求发布回执,但任何只读标记都不能继续进入写入;ci-release/all/deploy拒绝以只读标记伪装发布成功。原纯只读脚本不变,本轮也不实际执行它们。 +- 阻断时只输出稳定原因与下一步,不回显环境/SSH配置/原始payload。回执不是部署授权,也不是不可伪造签名;源码有写权限的操作者不在防伪承诺内。 + +### Claude负责的限定验证(获批实施后) + +主会话实现后交Claude执行测试与只读review;现在不运行。本轮新增/扩充现有release-gate-result.test.mjs、release-gate-shell.test.mjs,必要的跨入口执行测试放release-gate-enforcement.test.mjs及私有fixture;复用已有部署生命周期/来源测试,不新增生产测试开关。 + +1. 表驱动覆盖6种入口:ci-release、all、deploy、bootstrap、deploy-existing、deploy.sh。缺/旧/错profile/伪PASS/dirty/不完整回执时,正常发布不得读取真实配置或触发远端命令;写动作计数必须0。 +2. 合成有效回执只允许原生产链一次;核static/full-local至多一次、显式回执复用不再跑static/full-local(all仍保留额外smoke-local)、无递归;仅捕获fake命令,不真实发布。 +3. 上层已过、下层调用前改HEAD/index/受控文件或篡改证据时仍拒绝;实际归档SOURCE_COMMIT与受验SHA不同必须拒绝。 +4. 独立只读分支可完成fake只读检查但写动作0;各种内部/skip/只读标记不能变成写入豁免。来源、迁移、init/server/worker隔离与120秒健康检查合同保留。 +5. 对真实入口/判定源码作隔离副本变异:去掉上层前置、下层重验或放宽commit/clean判定时,对应负例必须失败;不只grep字符串。所有SSH/Docker/rsync/git-fetch、SSH配置和真实env读取必须受测试替身封闭,禁止真实外联/配置读取,临时文件仅清理本轮owner。 + +定向命令(实际补丁落地后Claude执行,数量按实际记录,不提前写通过): + +```bash +node --test scripts/qa/tests/release-gate-result.test.mjs scripts/qa/tests/release-gate-shell.test.mjs scripts/qa/tests/release-gate-enforcement.test.mjs +npm test --prefix server -- tests/unit/scripts/deployment-db-lifecycle.test.js tests/unit/scripts/deployment-safety-contract.test.js tests/unit/scripts/check-source-provenance.test.js +``` + +仅因修改相关文件才纳入对应测试,不跑浏览器/PG/业务全量。规划时新enforcement测试尚不存在;现已落地,命令待Claude执行,不能当作已通过。若验证需要超出这里的范围,先报告缺口;不恢复冻结差旅、不修无关旧警告。 + +### 提交、完整验收与回退 + +- 本地补丁获批→主会话实施→Claude定向测试及只读复核→主会话按批准范围提交最终输入;不先跑完整再改产品/QA代码。 +- 最终候选冻结后,完整门禁交Claude一次执行;期间不改受管代码、测试、任务或规约,只写ignored证据。失败保留并按具体缺陷处理,不盲目重跑。 +- 完整通过后仍须另获推送/部署授权;首次部署独立执行/回执比对及同候选12项公网义务保留,不能把NOT_RUN_LOCAL或本地PASS当成公网通过。生产编号与银行接受性另列。 +- 若R1补丁有误,停止发布并保留失败,修复或按授权回退本补丁;不增加disable开关,不借回退无门禁发布。没有数据库/真实配置变更,无须数据恢复;既有线上版本不受本地规划/修复影响。 + ## 2026-09-19 r5-fixed-r1登录复用边界 四完整文件31项30通过/1失败,采购整链、R75及视觉25项全部通过;付款在经理复核登录后按已有redirect参数返回付款页,而共享login仍应验portal。最小修正仅允许显式reuseLoginPage且当前为无search/hash的规范/login时省略goto;带redirect的登录页必须走原goto('/login')清除返回目标。保留真实表单和portal/profile断言、全部付款下游及300秒;不修改认证产品或接受任意登录落点。原失败保留,类型检查后相同四完整文件复验,再从static完整候选。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/implement.md b/.trellis/tasks/09-06-release-gate-integrity/implement.md index 88e07eb0..2ef18e12 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/implement.md +++ b/.trellis/tasks/09-06-release-gate-integrity/implement.md @@ -1,4 +1,64 @@ -## 2026-09-19 用户已授权主会话执行两笔本地提交(最新) +## 2026-09-19 R1定向验收完成,保存本地候选后进入一次完整门禁(最新) + +Claude单次诊断52/52通过、exit0、57.515秒,原三例2.027/1.837/1.940秒,5秒预算不变;27条执行输入前后零漂移。主会话实算与原125项及另两份46项所依赖代码一致,六项独立代码review均PASS。F1原“三次require导致这52项超时”的归因被独立更正;原exit1/3次超时保留,实际成因仍未确定,不宣称已根治或整组98曾一次全绿。 + +决策:不修改无证据的生产/测试逻辑,不合并三处校验,不提高预算。F1不作为已知R1产品缺陷阻止本地提交,保留时序观察;最终完整门禁仍必须独立通过。证据见r1-enforcement-20260919/f1-triage-20260919/diagnostic-reception.json。 + +- [x] 五执行文件接入、跨入口保护及源码变异测试、部署规约完成。 +- [x] 125项定向通过、六项独立代码复核通过;原失败文件52项补充验证通过,历史失败保留。 +- [x] 主会话决定按用户既有明确授权提交本轮15路径,不含ignored证据。 +- [ ] 本地提交执行/候选SHA及clean核验以artifacts/qa-governance/r1-enforcement-20260919/local-commit/result.json为准。 +- [ ] 提交后冻结整树(含任务记录),Claude一次verify-candidate;不重复前置定向测试,不拼接旧结果。结果只写ignored路径,避免记录变更使候选dirty。 +- [ ] 推送/部署另行授权;12项同候选公网验收、生产编号/银行真实接受性及差旅冻结等外部边界保持。 + +本轮受测执行/测试/spec代码未在独立验证后改变,仅本任务四份接收/决策记录变化。本提交用于固定最终待验输入,不能宣称新SHA已有完整通过。完整门禁具体执行说明在artifacts/qa-governance/r1-enforcement-20260919/final-gate-claude-request.md;主会话不启动测试或部署。 + +## 2026-09-19 R1独立结果接回;F1归因更正与单文件诊断(最新) + +Claude组1为125/125通过、无跳过;组2为98项中95通过/3个5000ms超时,exit1;组3治理及diff检查通过。六项只读代码复核均PASS。接收前实算原15份after及28条输入全部匹配;报告/日志原样保存,reception登记其SHA及四份后续任务记录变更。 + +主会话静态核实:生命周期测试只提取deploy.sh的REMOTE heredoc执行;before/HEAD/current片段均8419字节、SHA=d47bdba0ae64f6ca31e5af7d08b067671434fc1c42090d04050296d3a88baff1。三处新增require都在片段外、在该套件中不执行;测试及Python/CJS夹具、Vitest/Compose输入亦未变。因此原F1“新增三次node校验导致这52项超时”的归因不成立,不能据此合并保护;三项超时事实与原因未定状态保留。 + +- [x] 接收125通过、六项独立静态PASS及原组2失败;旧证据不覆盖。 +- [x] 排除F1指称的直接执行路径,保留三处发布重验和原5秒预算。 +- [ ] Claude仅对未改的deployment-db-lifecycle.test.js 52项执行一次带本地资源采样的定向诊断;不重跑另外已通过125/46项或全量。 +- [ ] 接回诊断后决定必要修复/提交;本批仍未提交,deploymentEligible=false。 + +本轮只改这四份任务记录并追加ignored证据/诊断封装器;执行/测试/spec源码均保持Claude审查后的字节。主会话不运行测试,不凭当前压力猜测前次原因,不以一次补充通过改写原失败。执行边界、精确命令及输入摘要见artifacts/qa-governance/r1-enforcement-20260919/f1-triage-20260919/claude-request.md。 + +## 2026-09-19 R1本地实现完成,待Claude定向验证(最新) + +已完成五个执行文件接入:共享require断言重算资格并绑定HEAD/回执/来源提交;正常发布入口缺回执立即拒绝;ci-release/all无显式回执时只调用一次verify-candidate,有回执则重验;all仍保留额外smoke-local。bootstrap→deploy-existing→deploy.sh各层独立复验,最后复验在远端mkdir之前。只读标记严格0/1,空值无豁免,只读分支保留并终止。拒绝发布时不调用可能读真实配置的失败收集器。回执路径统一相对仓库解析。 + +原shell测试共用fixture已抽出,新增跨入口行为/源码变异测试;fake环境只用合成HOME/SSH配置、私有PATH和受控命令,不执行真实远端脚本。生命周期仍由既有独立测试验证。没有放宽断言、重试、业务范围或全量门禁。部署规约首节同步稳定接口与时序。 + +主会话仅执行node --check、bash -n和git diff --check,结果见syntax-checks.json;未执行任何unit/browser/full-local/PG测试,未运行真实发布入口。测试结果及独立复核仍PENDING,当前候选deploymentEligible=false。本批未提交,待Claude通过后按已有授权本地提交;之后才冻结最终候选,安排一次完整门禁。旧budget-full-r1 FAIL/130、各历史通过及三条例外均保持原归属。 + +交接:artifacts/qa-governance/r1-enforcement-20260919/claude-request.md。包含before快照、15路径增量清单/SHA、incremental-review.diff与受测输入摘要。Claude仅执行六份指定测试及治理检查、只读审增量,不改代码/任务/原始证据、不接触线上。独立报告回收后再登记真实计数,不预填PASS。 + +## 2026-09-19 R1本地实施已获批准(最新) + +用户明确回复“是”,批准design顶部五文件接入、Claude定向测试/只读复核及最终clean候选一次完整门禁顺序。主会话实施与自查,测试执行交Claude。无推送/生产部署授权。底层首个远端写入实际是mkdir,最后复验必须位于它之前,不能只放在rsync前。 + +- [x] 具体方案及执行顺序获批。 +- [x] 五入口文件与直接配套测试落地;仅语法/格式检查通过,运行验证交Claude。 +- [ ] Claude定向验证和独立复核。 +- [ ] 验证通过后本地提交最终候选。 +- [ ] 冻结候选一次完整门禁;外部义务独立保留。 + +## 2026-09-19 R1具体方案已完成(最新) + +用户同意先准备R1接入方案。已只读核对统一runner、共享回执、bootstrap、deploy-existing、底层deploy.sh与现有fake测试。方案落design顶部:5个执行文件复用一个断言,显式回执目录/commit绑定,正常写入多层重验;保留只读分支、既有生产安全合同及零豁免。直接包装器缺回执拒绝,不偷偷启动全量;ci-release/all只进行一次明确的完整验证或复用有效回执。 + +- [x] 具体入口、变更范围、输入/拒绝/只读行为与回退方案已落盘。 +- [x] Claude定向测试矩阵及预计两条命令已列明;不提前执行不存在的新测试,不重跑浏览器/业务全量。 +- [ ] 单独批准R1本地接入与新版执行顺序;目前没有修改执行代码或实际启用。 +- [ ] 主会话实施,Claude定向验证与独立复核,然后保存最终候选。 +- [ ] 最终clean SHA一次完整门禁;推送/部署及12项公网验收按原独立授权边界执行。 + +本次只更新本任务PRD/design/implement/task.json,不提交、不测试、不连接生产/ARM,不读取真实配置密钥备份。此前本地提交、原回执和冻结包保持。 + +## 2026-09-19 用户已授权主会话执行两笔本地提交(已完成) 用户明确要求“提交就你来提交吧,不用发给claude了”。主会话接管最终分组及本地提交,之前Claude打包请求不再等待执行;授权限本地提交,不含推送、R1启用、部署、生产编号或真实业务动作。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/prd.md b/.trellis/tasks/09-06-release-gate-integrity/prd.md index 00a8f8a9..646d4499 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/prd.md +++ b/.trellis/tasks/09-06-release-gate-integrity/prd.md @@ -1,3 +1,25 @@ +## 2026-09-19 R1本地补丁可提交,最终完整验收待执行 + +独立代码复核六项及125项定向检查通过;生命周期52项在原5秒预算下补充通过,原3项超时与未知成因保持记录。没有已证实的R1缺陷需要改代码,按已有授权保存最终候选。本地定向完成不勾销完整验收:冻结clean提交后一次verify-candidate,外部部署/公网义务仍分别授权与验收。 + +## 2026-09-19 R1限定验收最新余额 + +125项强制门禁测试与六项独立代码复核PASS;部署生命周期文件3项5秒超时仍开放。该测试执行的REMOTE片段修改前后完全相同,新增R1校验未进入其执行路径,不采纳合并校验建议。按design最新诊断计划仅查这一文件,保留原预算/失败,不启动全量或发布。 + +## 2026-09-19 R1当前状态:本地实现完成,未运行验证 + +五文件接入与跨入口合成测试已落地,主会话语法/格式检查通过;Claude定向测试和独立复核待回。不勾销R1最终验收,不具备发布资格;本批未提交/推送/部署。最新清单与交接见implement顶部,以下均保留各自历史时点。 + +## 2026-09-19 R1本地接入及新版顺序已批准 + +用户明确确认design顶部方案:先本地接入拒绝未验候选、Claude定向验证及独立复核,再提交最终候选、一次完整验收;完整通过前不实际发布。此段替代旧R1“本地接入前先完整全绿”的时序限制,部署与推送仍另行授权。历史结果不抵扣新输入。 + +## 2026-09-19 当前阶段:R1接入方案已细化,待单独批准实施 + +两笔本地提交已完成(729d9057、1f8d5b65);预算补丁独立复核及两项必修关闭。当前R1仍未接入,当前候选完整门禁仍未通过,12项公网未执行。本轮用户同意先准备R1方案,未运行测试或真实入口;具体5文件范围、回执接口、只读边界、Claude定向矩阵和一次最终完整验收顺序见design.md顶部R1接入方案。 + +待批准的具体变化:在本地接入拒绝未验候选的校验,最终clean SHA完整通过后才允许实际发布;直接部署包装器缺回执立即拒绝,不隐式启动全量。现行“真实启用另行授权”边界保留;本轮只有规划批准,不等于接入、推送或部署授权。以下旧“最新”记录仅为各历史时点,现状以本段及implement顶部为准。 + ## 2026-09-19 最新本地验收 g20-full-r6同一冻结候选完成static及12个full-local阶段,4437后端通过/1原条件跳过、372本地浏览器全过,零失败/跳过/重试。7组33页完整;PARTIAL仅dirty_candidate,localChecksPassed=true,无部署资格。R1真实入口启用及独立、公网12义务、旧registry保护例外仍开放;完整阶段/计时/来源边界与余额见implement顶部。历史FAIL原样保留,不能把本地通过写为所有交付完成。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/task.json b/.trellis/tasks/09-06-release-gate-integrity/task.json index 529b9d71..4bdc8d7e 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/task.json +++ b/.trellis/tasks/09-06-release-gate-integrity/task.json @@ -2,7 +2,7 @@ "id": "release-gate-integrity", "name": "release-gate-integrity", "title": "部署门禁完整性与候选验收回执", - "description": "G18在用覆盖和三处产品修复已本地验证;原四文件并发4/4、认证/OCR18/18通过。全量合同、WebKit、R1、独立及真实操作仍开放。", + "description": "R1本地接入、125项定向测试及六项独立复核通过;生命周期52项补充通过,原3项超时保留且归因更正。按授权保存最终本地提交,等待同一clean候选一次完整门禁及独立授权后的公网义务。", "status": "in_progress", "dev_type": null, "scope": "release-gate", @@ -21,7 +21,7 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "2026-09-19 根据用户对重复全浏览器成本的反馈,中止budget-full-r1;后端4446与PG28通过保留,浏览器29通过/4中断/339未运行,回执FAIL/130,环境清理PASS。待Claude实际补丁复核,发布完整门禁延期,不再重跑无关测试。\n2026-09-19 预算并发修复已定向通过 PG28/单测526;待新完整候选与独立审查,旧r6为历史输入。\n2026-09-19 用户独立核实确认4437/r6时序及累计diff基线,并报告当前采购测试75 passed/EXIT=0、SHA一致;数量与证据归属争议闭合。完整独立业务review、发布边界及两条例外保持。\n2026-09-19 用户转述局部审查已核对:UTC/北京时间混用及累计diff误作运行后diff;r6实际在P10之后,4437准确,1162源码快照条目与当前一致。证据review-provenance-20260919.json,完整独立review仍PENDING,原证据不改。\n2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n2026-09-18 G19分层验收已批准并本地实施验证,74+7合成检查和2条冻结保护通过;公网12义务/完整门禁/WebKit/R1/独立及真实事实保持开放。\n2026-09-18 G18:QA39、产品131、并发4/4、认证/OCR18/18,33路由项与R108通过;G18时点发现64/363;G19最新发现见g19-discovery/summary.json,旧完整full-r2仍失败。候选与余项见local-coverage-g18-20260918。无提交部署,独立PENDING。\n2026-09-18 G16/G17:普通 full-r2 65/359仍失败;14原失败及2串行缺失已有定向通过,1WebKit/2冻结差旅保留。当前发现64/358,有临时SHA采集告警;QA/治理/保护与增量候选见 full-gates-followup-20260918。整体in_progress、独立PENDING,六组余项见implement顶部,无提交部署/外部操作。\n2026-09-18 G12/G13本地收口,G14真实PWA通过但WebKit1条未验;原58现53重验/4排除/1未验,原历史不改。G15复核及外部事实清单与新候选在retained-gates-closeout-20260917;普通full-local/AC7完整/R1和独立PENDING,不提交部署或真实操作。\n2026-09-17 W8本地对账:四处迁移状态谓词修复已随b87cc55发布;该事实仅闭合追加预检阶段,不代表R1普通完整门禁、阶段B合同用例或58条诊断观测已通过。R1/阶段B继续原冻结,先按保留在用场景分类再另定限定批次,不盲修待精简旧测试。 原回执从主副本接回,见implement顶部;本轮无新的远端/提交发布,原验收未补勾。\n2026-09-07 §10已实施并完成一次环境有效的全套诊断,增量待用户独立复核。原21文件加6个QA环境/测试文件,共27文件;本轮产品源码和业务spec零改动,51项针对性验证通过。前三次启动在环境前置拒绝,资源已核对清理;修复跨user namespace读取父namespace的EACCES,改由父runner经本轮Unix IPC即时实测,不降级。第四次环境PASS、源码前后不变:65文件275项,199 passed/54 failed/4 timedOut/18 skipped,58条观测涉及36文件,不是独立产品缺陷计数;诊断仍PARTIAL/2。服务端193文件1692项通过,workflow场景8两个来源仍失败。R102 6/6通过;合同仍卡旧更多,续约/归档未执行;PWA在serviceWorker.ready超时且该失败无trace附件。诊断67分03秒不是普通全绿成本。旧207撤销决策资格但保留原件;阶段1、阶段B、场景8、经理缺口和R1均未扩大执行。无提交/push/归档/部署/生产操作,整体任务未完成。", + "notes": "2026-09-19 R1定向125通过、另外两份46通过、生命周期52补充通过;原组2 exit1及3次超时保留,新增require不在其执行片段内。六项代码独立复核PASS,无理由合并保护或增预算。允许本地提交,最终完整门禁尚未执行;不具备发布资格。\n2026-09-19 根据用户对重复全浏览器成本的反馈,中止budget-full-r1;后端4446与PG28通过保留,浏览器29通过/4中断/339未运行,回执FAIL/130,环境清理PASS。待Claude实际补丁复核,发布完整门禁延期,不再重跑无关测试。\n2026-09-19 预算并发修复已定向通过 PG28/单测526;待新完整候选与独立审查,旧r6为历史输入。\n2026-09-19 用户独立核实确认4437/r6时序及累计diff基线,并报告当前采购测试75 passed/EXIT=0、SHA一致;数量与证据归属争议闭合。完整独立业务review、发布边界及两条例外保持。\n2026-09-19 用户转述局部审查已核对:UTC/北京时间混用及累计diff误作运行后diff;r6实际在P10之后,4437准确,1162源码快照条目与当前一致。证据review-provenance-20260919.json,完整独立review仍PENDING,原证据不改。\n2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n2026-09-18 G19分层验收已批准并本地实施验证,74+7合成检查和2条冻结保护通过;公网12义务/完整门禁/WebKit/R1/独立及真实事实保持开放。\n2026-09-18 G18:QA39、产品131、并发4/4、认证/OCR18/18,33路由项与R108通过;G18时点发现64/363;G19最新发现见g19-discovery/summary.json,旧完整full-r2仍失败。候选与余项见local-coverage-g18-20260918。无提交部署,独立PENDING。\n2026-09-18 G16/G17:普通 full-r2 65/359仍失败;14原失败及2串行缺失已有定向通过,1WebKit/2冻结差旅保留。当前发现64/358,有临时SHA采集告警;QA/治理/保护与增量候选见 full-gates-followup-20260918。整体in_progress、独立PENDING,六组余项见implement顶部,无提交部署/外部操作。\n2026-09-18 G12/G13本地收口,G14真实PWA通过但WebKit1条未验;原58现53重验/4排除/1未验,原历史不改。G15复核及外部事实清单与新候选在retained-gates-closeout-20260917;普通full-local/AC7完整/R1和独立PENDING,不提交部署或真实操作。\n2026-09-17 W8本地对账:四处迁移状态谓词修复已随b87cc55发布;该事实仅闭合追加预检阶段,不代表R1普通完整门禁、阶段B合同用例或58条诊断观测已通过。R1/阶段B继续原冻结,先按保留在用场景分类再另定限定批次,不盲修待精简旧测试。 原回执从主副本接回,见implement顶部;本轮无新的远端/提交发布,原验收未补勾。\n2026-09-07 §10已实施并完成一次环境有效的全套诊断,增量待用户独立复核。原21文件加6个QA环境/测试文件,共27文件;本轮产品源码和业务spec零改动,51项针对性验证通过。前三次启动在环境前置拒绝,资源已核对清理;修复跨user namespace读取父namespace的EACCES,改由父runner经本轮Unix IPC即时实测,不降级。第四次环境PASS、源码前后不变:65文件275项,199 passed/54 failed/4 timedOut/18 skipped,58条观测涉及36文件,不是独立产品缺陷计数;诊断仍PARTIAL/2。服务端193文件1692项通过,workflow场景8两个来源仍失败。R102 6/6通过;合同仍卡旧更多,续约/归档未执行;PWA在serviceWorker.ready超时且该失败无trace附件。诊断67分03秒不是普通全绿成本。旧207撤销决策资格但保留原件;阶段1、阶段B、场景8、经理缺口和R1均未扩大执行。无提交/push/归档/部署/生产操作,整体任务未完成。", "meta": { "archiveOutputProtection": "implemented_pending_independent_review", "registeredLocalEvidenceVerified": 14, @@ -150,7 +150,7 @@ "remediationStatus": "LOCAL_VERIFIED_INDEPENDENT_REVIEWED" }, "releasePreparation": { - "status": "LOCAL_COMMIT_AUTHORIZED_RELEASE_PENDING", + "status": "LOCAL_COMMITS_COMPLETE_R1_PLAN_READY", "executor": "main Codex session", "request": "artifacts/qa-governance/release-preparation-claude-request-20260919.md", "scope": "read-only candidate inventory and actionable release preparation", @@ -170,6 +170,50 @@ "commitEvidence": "artifacts/qa-governance/local-commits-20260919", "firstLocalCommit": "729d90576f25ba658656508af1af32a179aa7057", "commitResultReceipt": "artifacts/qa-governance/local-commits-20260919/result.json" + }, + "r1IntegrationPlan": { + "status": "LOCAL_REVIEWED_FINAL_FULL_GATE_PENDING", + "planningApproved": true, + "implementationApproved": true, + "enforcementEnabled": true, + "baselineCommit": "1f8d5b658a9fcae15be192af8c82ab557f88a046", + "designSection": "R1 接入方案(2026-09-19,已批准本地实施)", + "executionFiles": [ + "scripts/qa/release-gate-result.mjs", + "scripts/qa/run-release-gate.sh", + "deploy/trialrun/production-identity-bootstrap.mjs", + "deploy/trialrun/deploy-existing.sh", + "deploy/trialrun/deploy.sh" + ], + "testExecutor": "Claude independent session", + "testsRun": true, + "pushAuthorized": false, + "deploymentAuthorized": false, + "approval": "用户确认本地R1接入与先实施再最终一次完整门禁的顺序;不含推送或部署", + "enforcementScope": "local uncommitted code only; no real deployment executed", + "localEnforcementImplemented": true, + "testStatus": "SCOPED_PASS_WITH_RECORDED_PRIOR_TIMEOUT", + "independentReview": "PASS_6_POINTS_CAUSE_CORRECTION_RECEIVED", + "deploymentEligible": false, + "evidenceDirectory": "artifacts/qa-governance/r1-enforcement-20260919", + "handoff": "artifacts/qa-governance/r1-enforcement-20260919/claude-request.md", + "reviewReception": "artifacts/qa-governance/r1-enforcement-20260919/review-reception.json", + "nextHandoff": "artifacts/qa-governance/r1-enforcement-20260919/final-gate-claude-request.md", + "f1": { + "discoveryStatus": "THREE_TIMEOUTS_CONFIRMED", + "attribution": "R1_DIRECT_CAUSE_DISPROVED", + "remediationStatus": "NO_CODE_FIX_INDICATED_SCOPED_REVALIDATION_PASS", + "historicalRootCause": "UNDETERMINED", + "supplementalRun": { + "tests": 52, + "passed": 52, + "exitCode": 0, + "budgetMs": 5000 + }, + "productionChecksPreserved": true + }, + "diagnosticReception": "artifacts/qa-governance/r1-enforcement-20260919/f1-triage-20260919/diagnostic-reception.json", + "localCommitEvidence": "artifacts/qa-governance/r1-enforcement-20260919/local-commit/result.json" } } } diff --git a/deploy/trialrun/deploy-existing.sh b/deploy/trialrun/deploy-existing.sh index c2726413..9c6a2b82 100755 --- a/deploy/trialrun/deploy-existing.sh +++ b/deploy/trialrun/deploy-existing.sh @@ -29,6 +29,19 @@ if [[ "${DEPLOY_ALLOW_UNPUBLISHED_COMMIT:-0}" == "1" || -n "${DEPLOY_UNPUBLISHED fail "生产部署验证不接受未进入上游的提交;灾备 override 必须走独立获批流程" fi +case "${PRODUCTION_IDENTITY_ONLY-0}" in + 0) ;; + *) fail "identity-only 必须使用生产身份引导器,不能进入发布包装器" ;; +esac +case "${PRODUCTION_READ_ONLY_PREFLIGHT-0}" in + 0) + RELEASE_GATE_EXPECTED_COMMIT="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}")" + export RELEASE_GATE_EXPECTED_COMMIT + ;; + 1) ;; + *) fail "PRODUCTION_READ_ONLY_PREFLIGHT 只能为 0 或 1" ;; +esac + declare -A contract=() echo "[deploy-existing] 开始只读聚合体检" DEPLOY_SERVER="$DEPLOY_SERVER" \ @@ -60,7 +73,7 @@ fi if [[ -z "${EXPECTED_COMPOSE_PROJECT_NAME:-}" ]]; then fail "生产部署必须由身份引导器提供 EXPECTED_COMPOSE_PROJECT_NAME" fi -case "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" in +case "${PRODUCTION_READ_ONLY_PREFLIGHT-0}" in 0|1) ;; *) fail "PRODUCTION_READ_ONLY_PREFLIGHT 只能为 0 或 1" ;; esac @@ -99,6 +112,8 @@ if [[ "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" == "1" ]]; then exit 0 fi +node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}" "$source_commit" >/dev/null + DEPLOY_SERVER="$DEPLOY_SERVER" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 2d24b699..cc7a7577 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -125,6 +125,9 @@ if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then exit 1 fi +RELEASE_GATE_EXPECTED_COMMIT="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}")" +export RELEASE_GATE_EXPECTED_COMMIT + echo ">>> 0. 校验 Git 来源与发布提交" PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")" SOURCE_BUILD_ENV_LINES="$(node -e ' @@ -155,6 +158,7 @@ SOURCE_PUBLISHED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout SOURCE_REMOTE_VERIFIED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.remoteVerified))' "$PROVENANCE_JSON")" SOURCE_OVERRIDE_USED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideUsed))' "$PROVENANCE_JSON")" SOURCE_OVERRIDE_REASON="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideReason || ""))' "$PROVENANCE_JSON")" +node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}" "$SOURCE_COMMIT" >/dev/null echo "[deploy-source] commit=${SOURCE_COMMIT} branch=${SOURCE_BRANCH} published=${SOURCE_PUBLISHED} remoteVerified=${SOURCE_REMOTE_VERIFIED}" if [[ "$SOURCE_OVERRIDE_USED" == "true" || "$SOURCE_REMOTE_VERIFIED" != "true" ]]; then echo "[deploy-source][UNPUBLISHED-OVERRIDE] reason=${SOURCE_OVERRIDE_REASON} remoteVerified=${SOURCE_REMOTE_VERIFIED}" >&2 @@ -202,6 +206,8 @@ ssh "${DEPLOY_SERVER}" \ "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' SYNC_ENV='${SYNC_ENV}' bash -s" \ < "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" +node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}" "$SOURCE_COMMIT" >/dev/null + echo ">>> 3.1 创建远端目录" ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'" diff --git a/deploy/trialrun/production-identity-bootstrap.mjs b/deploy/trialrun/production-identity-bootstrap.mjs index 509bcf1a..5408310f 100755 --- a/deploy/trialrun/production-identity-bootstrap.mjs +++ b/deploy/trialrun/production-identity-bootstrap.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { deploymentReadOnlyMode, requireReleaseReceipt, ReleaseGateError } from "../../scripts/qa/release-gate-result.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, "../.."); @@ -198,6 +199,9 @@ function main() { try { const identityOnly = process.env.PRODUCTION_IDENTITY_ONLY ?? "0"; if (!["0", "1"].includes(identityOnly)) fail("PRODUCTION_IDENTITY_ONLY_INVALID"); + const { preflightOnly } = deploymentReadOnlyMode(process.env, true); + // Reject before SSH configuration discovery, target probes or database reads. + const release = identityOnly === "1" || preflightOnly ? null : requireReleaseReceipt(); const historicalTargetScript = requiredCommandOutput( "git", ["show", `${HISTORICAL_TARGET_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`], @@ -228,11 +232,13 @@ function main() { const databaseIdentityHash = bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin); console.log("[production-identity-bootstrap][PASS] exact_identity_group"); + if (release) requireReleaseReceipt(release.directory, release.commit); const result = spawnSync("bash", [DEPLOY_EXISTING_SCRIPT], { cwd: ROOT_DIR, stdio: "inherit", env: { ...process.env, + ...(release ? { RELEASE_GATE_RECEIPT_DIR: release.directory, RELEASE_GATE_EXPECTED_COMMIT: release.commit } : {}), DEPLOY_SERVER: identity.deployServer, DEPLOY_REFRESH_SERVER: refreshTarget, EXPECTED_DEPLOY_SERVER: identity.deployServer, @@ -246,7 +252,7 @@ function main() { }); if (result.status !== 0) fail("DEPLOY_EXISTING_FAILED"); } catch (error) { - const code = error instanceof ProductionIdentityBootstrapError + const code = error instanceof ProductionIdentityBootstrapError || error instanceof ReleaseGateError ? error.code : "PRODUCTION_IDENTITY_BOOTSTRAP_UNEXPECTED_ERROR"; console.error(`[production-identity-bootstrap][PAUSED] code=${code}`); diff --git a/scripts/qa/release-gate-result.mjs b/scripts/qa/release-gate-result.mjs index 386331f9..11eb3f83 100644 --- a/scripts/qa/release-gate-result.mjs +++ b/scripts/qa/release-gate-result.mjs @@ -11,6 +11,7 @@ import { verifyLocalCandidateResults } from './verify-local-candidate-results.mj import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages, validTime } from './qa-stage-record.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const DEPLOYMENT_ENTRIES = ['deploy/trialrun/production-identity-bootstrap.mjs','deploy/trialrun/deploy-existing.sh','deploy/trialrun/deploy.sh']; const hash = value => createHash('sha256').update(value).digest('hex'); const read = file => JSON.parse(readFileSync(file,'utf8')); const PHASES = ['static','full-local']; @@ -37,7 +38,7 @@ export function captureReleaseSource(root=ROOT) { return {...source,untrackedDigest:hash(JSON.stringify(files))}; } export function currentPolicy(root=ROOT) { - return {digest:hash(JSON.stringify(POLICY_FILES.map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}}; + return {digest:hash(JSON.stringify([...POLICY_FILES,...DEPLOYMENT_ENTRIES].map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}}; } function saneSource(source) { return /^[a-f0-9]{40}$/.test(source?.commit ?? '') && /^[a-f0-9]{40}$/.test(source?.tree ?? '') && /^[a-f0-9]{64}$/.test(source?.digest ?? '') && source?.repository?.trackedCoverageComplete === true && typeof source.repository.porcelainV1Z === 'string' && source.dirty === Boolean(source.repository.porcelainV1Z.length); @@ -122,7 +123,7 @@ function readBundle(record,seal=false) { } function facts() {return {root:ROOT,source:captureReleaseSource(),policy:currentPolicy(),contract:read(path.join(ROOT,'scripts/qa/full-local-coverage-contract.json')),manifest:read(path.join(ROOT,'scripts/qa/route-manifest.json'))};} function ownedDirectory(input,create=false) { - const relative=path.relative(ROOT,path.resolve(input)).split(path.sep).join('/'); + const relative=path.relative(ROOT,path.resolve(ROOT,input)).split(path.sep).join('/'); if (!relative.startsWith('artifacts/release-gate/')) throw Error('release_report_outside_artifacts'); const dir=confinedPath(ROOT,relative); if (create && existsSync(dir) && readdirSync(dir).length) throw Error('release_report_not_empty'); @@ -130,10 +131,52 @@ function ownedDirectory(input,create=false) { if (realpathSync(dir)!==dir) throw Error('release_report_symlink'); return dir; } + +export class ReleaseGateError extends Error { + constructor(code) { super(code); this.code=code; } +} + +export function deploymentReadOnlyMode(env=process.env, allowReadOnly=false) { + const flags=['PRODUCTION_IDENTITY_ONLY','PRODUCTION_READ_ONLY_PREFLIGHT'].map(key=>env[key]??'0'); + if (flags.some(value=>!['0','1'].includes(value))) throw new ReleaseGateError('read_only_flag_invalid'); + if (!allowReadOnly && flags.includes('1')) throw new ReleaseGateError('read_only_cannot_deploy'); + return {identityOnly:flags[0]==='1',preflightOnly:flags[1]==='1'}; +} + +// Read-only assertion: never generates evidence or invokes a test/deploy runner. +// A returned commit is pinned by each caller across its subsequent preflights. +export function requireReleaseReceipt(input=process.env.RELEASE_GATE_RECEIPT_DIR, expectedCommit) { + try { + deploymentReadOnlyMode(); + validateSkipRequest('deploy',process.env); + if (typeof input!=='string' || !input.trim()) throw new ReleaseGateError('receipt_required'); + const dir=ownedDirectory(input); + const file=confinedPath(dir,'release-result.json'); + if (!lstatSync(file).isFile()) throw new ReleaseGateError('receipt_invalid'); + const record=read(file), current=facts(); + for (const expected of [expectedCommit,process.env.RELEASE_GATE_EXPECTED_COMMIT]) { + if (expected!==undefined && (!/^[a-f0-9]{40}$/.test(expected) || expected!==current.source.commit)) { + throw new ReleaseGateError('candidate_commit_mismatch'); + } + } + const result=verifyReleaseEvidence(record,readBundle(record),current); + if (!result.deploymentEligible) throw new ReleaseGateError('receipt_not_eligible'); + return {commit:current.source.commit,directory:dir}; + } catch (error) { + if (error instanceof ReleaseGateError) throw error; + throw new ReleaseGateError('receipt_invalid'); + } +} + if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href) { try { const [action,input,arg,rawExit]=process.argv.slice(2); - if (action==='skip') { + if (action==='require') { + console.log(requireReleaseReceipt(input,arg).commit); + } else if (action==='write-mode') { + deploymentReadOnlyMode(); + validateSkipRequest('deploy',process.env); + } else if (action==='skip') { const result=validateSkipRequest(arg,process.env); if (result) { const dir=ownedDirectory(input,true);atomic(confinedPath(dir,'skip-receipt.json'),result);console.log('[release-gate] approved diagnostic skips: PARTIAL'); } } else { @@ -162,5 +205,9 @@ if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv } else throw Error('action_invalid'); } } - } catch {console.error('[release-receipt] invalid_or_incomplete');process.exitCode=1;} + } catch (error) { + const code=error instanceof ReleaseGateError?error.code:'invalid_or_incomplete'; + console.error(`[release-receipt] ${code}; run verify-candidate on the final clean candidate before deployment`); + process.exitCode=1; + } } diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh index 1f8b38b3..f1ca1ae5 100755 --- a/scripts/qa/run-release-gate.sh +++ b/scripts/qa/run-release-gate.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" ARTIFACT_ROOT="${RELEASE_GATE_ARTIFACT_DIR:-${ROOT_DIR}/artifacts/release-gate/$(date +%Y%m%d-%H%M%S)}" MODE="${1:-all}" CURRENT_STAGE="" @@ -16,13 +17,14 @@ Usage: bash scripts/qa/run-release-gate.sh smoke-local -> full-local -> deploy - ci-release 依次执行 static -> full-local -> deploy + deploy 只消费显式回执,经生产身份引导器发布;不隐式运行测试 + all smoke-local -> 单次 verify-candidate(或重验显式回执)-> 生产发布链 + ci-release 单次 verify-candidate(或重验显式回执)-> 生产发布链 r66-trialrun R66 小团队试点门禁:static -> server regression-all -> smoke-local 可选环境变量: RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录 + RELEASE_GATE_RECEIPT_DIR 显式消费的完整候选回执目录;不自动选择历史回执 QA_* 透传给 scripts/qa/run-web-gates.sh LIVE_SMOKE_* 透传给 deploy.sh 与 live smoke PROJECT_WIDE_SKIP_BUSINESS_FLOW=1 跳过业务流 E2E @@ -45,7 +47,11 @@ collect_failure_artifacts() { handle_error() { local exit_code="$?" echo "[release-gate][FAIL] 阶段 ${CURRENT_STAGE:-unknown} 失败(exit=${exit_code})" >&2 - collect_failure_artifacts + # Rejected publication must not trigger diagnostic reads of real configuration. + case "$MODE" in + ci-release|all|deploy) ;; + *) collect_failure_artifacts ;; + esac exit "$exit_code" } trap handle_error ERR @@ -129,10 +135,21 @@ stage_project_wide_live_readiness() { } stage_deploy() { - bash "$ROOT_DIR/deploy/trialrun/deploy.sh" + RELEASE_GATE_EXPECTED_COMMIT="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}")" + export RELEASE_GATE_EXPECTED_COMMIT + node "$ROOT_DIR/deploy/trialrun/production-identity-bootstrap.mjs" } -# This entry does not install enforcement in any existing deployment path. +prepare_release_receipt() { + if [[ -z "${RELEASE_GATE_RECEIPT_DIR+x}" ]]; then + # This verification-only child cannot call back into the deployment chain. + RELEASE_GATE_ARTIFACT_DIR="$ARTIFACT_ROOT" bash "$ROOT_DIR/scripts/qa/run-release-gate.sh" verify-candidate + RELEASE_GATE_RECEIPT_DIR="$ARTIFACT_ROOT" + export RELEASE_GATE_RECEIPT_DIR + fi +} + +# The verification-only mode records its own original exit and never deploys. finish_candidate() { local original_status=$? receipt_status=0 trap - EXIT ERR INT TERM @@ -145,6 +162,16 @@ if [[ "$MODE" != "help" && "$MODE" != "-h" && "$MODE" != "--help" ]]; then # Validate before static, remote readiness or deployment side effects. trap - ERR node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE" + case "$MODE" in + ci-release|all|deploy) + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" write-mode + # Invalid explicit receipts fail before smoke or other side effects. + if [[ -n "${RELEASE_GATE_RECEIPT_DIR+x}" || "$MODE" == "deploy" ]]; then + RELEASE_GATE_EXPECTED_COMMIT="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}")" + export RELEASE_GATE_EXPECTED_COMMIT + fi + ;; + esac if [[ "$MODE" != "verify-candidate" ]]; then trap handle_error ERR; fi fi @@ -186,8 +213,7 @@ case "$MODE" in run_stage deploy stage_deploy ;; ci-release) - run_stage static stage_static - run_stage full-local stage_full_local + prepare_release_receipt run_stage deploy stage_deploy ;; r66-trialrun) @@ -196,9 +222,8 @@ case "$MODE" in run_stage smoke-local stage_smoke_local ;; all) - run_stage static stage_static run_stage smoke-local stage_smoke_local - run_stage full-local stage_full_local + prepare_release_receipt run_stage deploy stage_deploy ;; *) diff --git a/scripts/qa/tests/fixtures/release-deploy-fake.cjs b/scripts/qa/tests/fixtures/release-deploy-fake.cjs new file mode 100644 index 00000000..855944c2 --- /dev/null +++ b/scripts/qa/tests/fixtures/release-deploy-fake.cjs @@ -0,0 +1,89 @@ +// Private subprocess fixture. There is deliberately no real-network fallback. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const root = process.env.HYB_RELEASE_FIXTURE_ROOT; +if (!root || !path.basename(root).startsWith('hyb-release-shell-')) process.exit(97); +const args = process.argv.slice(2), name = path.basename(process.argv[1]); +const trace = path.join(root, 'artifacts/commands.jsonl'); +fs.mkdirSync(path.dirname(trace), { recursive: true }); +fs.appendFileSync(trace, JSON.stringify({ name, args }) + '\n'); +function run(command, values) { + const result = spawnSync(command, values, { cwd: root, env: process.env, stdio: 'inherit' }); + process.exit(result.status ?? 97); +} +function output(value) { process.stdout.write(value + '\n'); process.exit(0); } +function git(values) { + const result = spawnSync('/usr/bin/git', values, { cwd: root, env: process.env, encoding: 'utf8' }); + if (result.status !== 0) process.exit(97); + return result.stdout.trim(); +} +function mutate(point) { + if (process.env.HYB_MUTATE_AT !== point || fs.existsSync(path.join(root, 'artifacts/mutated'))) return; + fs.writeFileSync(path.join(root, 'artifacts/mutated'), point); + const kind = process.env.HYB_MUTATE_KIND || 'tracked'; + if (kind === 'receipt') { + const file = path.join(process.env.RELEASE_GATE_RECEIPT_DIR, 'release-result.json'); + const record = JSON.parse(fs.readFileSync(file)); record.executionExit = 23; + fs.writeFileSync(file, JSON.stringify(record)); + } else if (kind === 'head') { + git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-qm', 'changed']); + } else { + fs.appendFileSync(path.join(root, 'client/package.json'), ' '); + if (kind === 'index') git(['add', 'client/package.json']); + } +} +const fullCommit = () => git(['rev-parse', 'HEAD']); +const contract = 'REMOTE_DIR=/fixture/app REMOTE_ENV_FILE=/fixture/app/deploy/trialrun/.env COMPOSE_FILE=deploy/trialrun/docker-compose.yml COMPOSE_PROJECT_NAME=fixture-project POSTGRES_DATA_DIR=/fixture/db STORAGE_HOST_DIR=/fixture/storage'; +if (name === 'git') { + if (args[0] === 'show' && args[1] === '34181e3:deploy/trialrun/deploy.sh') output('DEPLOY_SERVER="${DEPLOY_SERVER:-fixture-target}"'); + if (args[0] === 'show' && args[1] === '5e2d038938265c926e06d3cc6d0841f6dcc06148:deploy/trialrun/deploy.sh') output('COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-fixture-project}"'); + const values = args[0] === '-C' && args[1] === root ? args.slice(2) : args; + if (['ls-files', 'status', 'rev-parse', 'archive'].includes(values[0])) run('/usr/bin/git', values); + process.exit(97); +} +if (name === 'node') { + if (args[0] === '-e') run(process.execPath, args); + const file = path.basename(args[0] || ''); + if (file === 'release-gate-result.mjs' || file === 'production-identity-bootstrap.mjs') { + run(process.execPath, ['--require', path.join(root, 'bin/fs-guard.cjs'), ...args]); + } + if (file === 'check-source-provenance.mjs') { + mutate('provenance'); + const commit = process.env.HYB_WRONG_SOURCE ? 'f'.repeat(40) : fullCommit(); + output(JSON.stringify({ commit, branch: 'fixture', published: true, remoteVerified: true, overrideUsed: false, buildEnv: { BUILD_SHA: commit, VITE_BUILD_SHA: commit.slice(0, 12) } })); + } + if (file === 'production-preflight.mjs') { mutate('preflight'); process.exit(0); } + if (file === 'validate-env-contract.js' && process.env.FAIL_STATIC) process.exit(23); + if (['validate-env-contract.js', 'check-public-bundle-copy.mjs', 'check-ui-design-rules.mjs', 'check-error-reason-contract.mjs', 'check-mobile-route-contract.mjs', 'check-flow-coverage.mjs', 'check-live-smoke-env.mjs'].includes(file)) process.exit(0); + process.exit(97); +} +if (name === 'bash') { + const file = path.basename(args[0] || ''); + if (['run-release-gate.sh', 'deploy-existing.sh', 'deploy.sh'].includes(file)) run('/bin/bash', args); + if (file === 'run-web-gates.sh' && args[1] === 'full-local') { + if (process.env.OMIT_EVIDENCE) process.exit(0); + run(process.execPath, [path.join(root, 'bin/evidence.mjs')]); + } + if (['run-web-gates.sh', 'run-business-flow-gates.sh', 'run-live-smoke-readiness.sh', 'check-local-migration-drift.sh', 'collect-failure-artifacts.sh', 'check-existing-deployment.sh'].includes(file)) process.exit(0); + process.exit(97); +} +if (name === 'ssh') { + const last = args.at(-1) || ''; + if (last === 'probe') output('[deployment-contract-refresh][PASS] probe'); + if (last === 'refresh') output('[deployment-contract-refresh][PASS] refreshed'); + if (last.startsWith('systemctl show ')) output(contract); + if (last.includes('bash -s')) { + fs.readFileSync(0); // Drain only the synthetic script; never execute it. + if (last.includes("IDENTITY_BOOTSTRAP_MODE='origin'")) { mutate('origin'); output('[production-identity-bootstrap][PASS] origin_identity'); } + if (last.includes("IDENTITY_BOOTSTRAP_MODE='database'")) output('[production-identity-bootstrap][DB_SHA256] ' + 'a'.repeat(64)); + if (!last.includes('BUILD_SHA=')) mutate('remote-preflight'); + process.exit(0); + } + if (last === "mkdir -p '/fixture/app'") process.exit(0); + process.exit(97); +} +if (name === 'rsync') process.exit(0); +if (name === 'curl') output(JSON.stringify({ data: { buildSha: fullCommit().slice(0, 12) } })); +if (name === 'npm' || name === 'python3') process.exit(0); +process.exit(97); diff --git a/scripts/qa/tests/fixtures/release-fs-guard.cjs b/scripts/qa/tests/fixtures/release-fs-guard.cjs new file mode 100644 index 00000000..a9ae947c --- /dev/null +++ b/scripts/qa/tests/fixtures/release-fs-guard.cjs @@ -0,0 +1,15 @@ +// Loaded only in private fixture children; audit config access without real HOME. +const fs = require('node:fs'); +const path = require('node:path'); +const { syncBuiltinESMExports } = require('node:module'); +const root = process.env.HYB_RELEASE_FIXTURE_ROOT; +const original = fs.readFileSync; +fs.readFileSync = function(file, ...args) { + const text = String(file); + if (text.endsWith('/.ssh/config') || /\/\.env(?:$|\.)/.test(text)) { + if (text !== path.join(root, 'bin/home/.ssh/config')) throw Error('fixture_config_read_forbidden'); + fs.appendFileSync(path.join(root, 'artifacts/commands.jsonl'), JSON.stringify({ name: 'config-read', args: ['synthetic-ssh'] }) + '\n'); + } + return original.call(this, file, ...args); +}; +syncBuiltinESMExports(); diff --git a/scripts/qa/tests/fixtures/release-gate-fixture.mjs b/scripts/qa/tests/fixtures/release-gate-fixture.mjs new file mode 100644 index 00000000..3e807d57 --- /dev/null +++ b/scripts/qa/tests/fixtures/release-gate-fixture.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const projectRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../../..'); +const receiptFiles={environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'}; +export function fixture(context,overrides={}) { + const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-shell-')); + context.after(()=>rmSync(root,{recursive:true,force:true})); + for(const dir of ['scripts/qa','deploy/trialrun','client/tests/e2e','server','bin','client/node_modules/@playwright/test','client/node_modules/playwright-core'])mkdirSync(path.join(root,dir),{recursive:true}); + for(const file of readdirSync(path.join(projectRoot,'scripts/qa')).filter(file=>file.endsWith('.mjs')||file.endsWith('.sh')||file.endsWith('.py')))copyFileSync(path.join(projectRoot,'scripts/qa',file),path.join(root,'scripts/qa',file)); + for(const file of readdirSync(path.join(projectRoot,'deploy/trialrun')).filter(file=>/\.(mjs|sh|yml)$/.test(file)))copyFileSync(path.join(projectRoot,'deploy/trialrun',file),path.join(root,'deploy/trialrun',file)); + for(const dir of ['docs/trialrun','server/src/scripts','bin/home/.ssh'])mkdirSync(path.join(root,dir),{recursive:true}); + writeFileSync(path.join(root,'bin/home/.ssh/config'),'Host fixture-refresh\n'); + writeFileSync(path.join(root,'docs/trialrun/OPS7运维交接Runbook.md'),'curl -fsS https://fixture.invalid/ready\n'); + for(const file of ['smoke-trialrun-runtime.js','smoke-external-deps.js'])writeFileSync(path.join(root,'server/src/scripts',file),'// synthetic'); + const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1})); + rows.push({file:'client/tests/e2e/ordinary.spec.ts',id:'ordinary',title:'ordinary',line:1}); + for(const row of rows)writeFileSync(path.join(root,row.file),'synthetic'); + const manifest={localCandidateFlows:rows.slice(0,10).map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}; + const contract={schemaVersion:1,approvalReference:'synthetic',exceptions:[]}; + writeFileSync(path.join(root,'scripts/qa/route-manifest.json'),JSON.stringify(manifest)); + writeFileSync(path.join(root,'scripts/qa/full-local-coverage-contract.json'),JSON.stringify(contract)); + for(const file of ['client/package.json','client/package-lock.json','server/package.json','server/package-lock.json','client/node_modules/playwright-core/browsers.json'])writeFileSync(path.join(root,file),'{}'); + writeFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'),JSON.stringify({browsers:['chromium','webkit'].map(name=>({name,browserVersion:'synthetic'}))})); + writeFileSync(path.join(root,'client/node_modules/@playwright/test/package.json'),JSON.stringify({version:'1.58.2'})); + writeFileSync(path.join(root,'client/playwright.config.ts'),'// synthetic policy'); + writeFileSync(path.join(root,'deploy/trialrun/validate-env-contract.js'),'// fake tooling owns this command'); + writeFileSync(path.join(root,'.gitignore'),'artifacts/\nbin/\nnode_modules/\n'); + // Private child environment: no real HOME/config, credentials or fallback PATH. + const env={PATH:path.join(root,'bin'),HOME:path.join(root,'bin/home'),GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_CONFIG_SYSTEM:'/dev/null',LANG:'C.UTF-8',HYB_RELEASE_FIXTURE_ROOT:root,RELEASE_GATE_ARTIFACT_DIR:path.join(root,'artifacts/release-gate/run'),...overrides}; + const fake=`#!${process.execPath}\nrequire('./deploy-fake.cjs');\n`; + writeFileSync(path.join(root,'bin/fake'),fake,{mode:0o755}); + for(const name of ['node','npm','bash','python3','git','ssh','rsync','curl','docker'])symlinkSync('fake',path.join(root,'bin',name)); + for(const name of ['dirname','date','mkdir','mktemp','tar','rm'])symlinkSync('/usr/bin/'+name,path.join(root,'bin',name)); + copyFileSync(path.join(projectRoot,'scripts/qa/tests/fixtures/release-deploy-fake.cjs'),path.join(root,'bin/deploy-fake.cjs')); + copyFileSync(path.join(projectRoot,'scripts/qa/tests/fixtures/release-fs-guard.cjs'),path.join(root,'bin/fs-guard.cjs')); + writeFileSync(path.join(root,'bin/evidence.mjs'),` +import {mkdirSync,writeFileSync,readFileSync} from 'node:fs'; +import path from 'node:path'; +import {captureReleaseSource,currentPolicy} from '../scripts/qa/release-gate-result.mjs'; +import {FULL_LOCAL_STAGES,newStageRecord,updateStageRecord} from '../scripts/qa/qa-stage-record.mjs'; +import {coveragePlan,localSpecArgs} from '../scripts/qa/full-local-coverage.mjs'; +const root=process.env.HYB_RELEASE_FIXTURE_ROOT,base=process.env.QA_FULL_REPORT_DIR,now=()=>new Date().toISOString(); +const source=captureReleaseSource(root),policy=currentPolicy(root),startedAt=now(); +const phases=newStageRecord(FULL_LOCAL_STAGES,'full-local-stages'); +const rows=${JSON.stringify(rows)},contract=${JSON.stringify(contract)}; +const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))}); +const discovery=report(false),execution=report(true); +execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:policy.toolchain.workers,version:policy.toolchain.playwright}; +for(const stage of FULL_LOCAL_STAGES){updateStageRecord(phases,'start',stage);if(stage==='browser')execution.stats={startTime:now(),duration:0,expected:rows.length,unexpected:0,flaky:0,skipped:0};updateStageRecord(phases,'end',stage,0);}updateStageRecord(phases,'finish',null,0); +const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:policy.environmentDigest,platform:process.platform,status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt,endedAt:now(),sourceBefore:source,sourceAfter:source,sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:policy.environmentDigest,platform:process.platform,runnerDigest:policy.runnerDigest,probeDigest:policy.probeDigest,supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:now(),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}}; +const bundle={environment,phases,discovery,execution,plan:coveragePlan(root,discovery,contract,source)}; +bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:policy.toolchain.workers,mode:'local'}; +if(process.env.BAD_PROFILE)environment.mode='retained-local'; +if(process.env.BAD_SOURCE)environment.sourceBefore={...source,digest:'0'.repeat(64)}; +if(process.env.BAD_COVERAGE)execution.suites.pop(); +if(process.env.BAD_CLEANUP)environment.cleanup.containerRemoved=false; +if(process.env.BAD_STAGE)phases.stages.splice(2,1); +for(const [key,relative] of Object.entries(${JSON.stringify(receiptFiles)})){const file=path.join(base,relative);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(bundle[key]));} +if(process.env.FAIL_FULL)process.exit(17); +`); + for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic fixture']])assert.equal(spawnSync('/usr/bin/git',args,{cwd:root,env}).status,0); + const commands=()=>existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').filter(Boolean).map(JSON.parse):[]; + return {root,env,commands,commit() { + for(const args of [['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic mutation']])assert.equal(spawnSync('/usr/bin/git',args,{cwd:root,env}).status,0); + },invoke(entry,overrides={}) { + rmSync(path.join(root,'artifacts/commands.jsonl'),{force:true}); + const script=entry==='bootstrap'?'deploy/trialrun/production-identity-bootstrap.mjs':entry==='existing'?'deploy/trialrun/deploy-existing.sh':entry==='raw'?'deploy/trialrun/deploy.sh':'scripts/qa/run-release-gate.sh'; + const argv=[path.join(root,script),...(['ci-release','all','deploy'].includes(entry)?[entry]:[])]; + const childEnv={...env,...overrides}; + const executable=entry==='bootstrap'?process.execPath:'/bin/bash'; + if(entry==='bootstrap')argv.unshift('--require',path.join(root,'bin/fs-guard.cjs')); + const result=spawnSync(executable,argv,{cwd:root,env:childEnv,encoding:'utf8',timeout:30000}); + return {...result,commands:commands()}; + },run(mode='verify-candidate') { + rmSync(path.join(root,'artifacts'),{recursive:true,force:true}); + const result=spawnSync('/bin/bash',[path.join(root,'scripts/qa/run-release-gate.sh'),mode],{cwd:root,env,encoding:'utf8',timeout:30000}); + const file=path.join(env.RELEASE_GATE_ARTIFACT_DIR,'release-result.json'); + return {...result,record:existsSync(file)?JSON.parse(readFileSync(file)):null,commands:existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').map(JSON.parse):[]}; + },verify() {return spawnSync(process.execPath,[path.join(root,'scripts/qa/release-gate-result.mjs'),'verify',env.RELEASE_GATE_ARTIFACT_DIR],{cwd:root,env,encoding:'utf8',timeout:30000});}}; +} diff --git a/scripts/qa/tests/release-gate-enforcement.test.mjs b/scripts/qa/tests/release-gate-enforcement.test.mjs new file mode 100644 index 00000000..0f218261 --- /dev/null +++ b/scripts/qa/tests/release-gate-enforcement.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, symlinkSync, rmSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { fixture } from './fixtures/release-gate-fixture.mjs'; + +const entries=['ci-release','all','deploy','bootstrap','existing','raw']; +const syntheticEnv={ + DEPLOY_SERVER:'fixture-target',DEPLOY_REFRESH_SERVER:'fixture-refresh', + REMOTE_DIR:'/fixture/app',POSTGRES_DATA_DIR:'/fixture/db',STORAGE_HOST_DIR:'/fixture/storage', + COMPOSE_PROJECT_NAME:'fixture-project',DEPLOY_DATA_MODE:'existing', + EXPECTED_PRODUCTION_ORIGIN:'https://fixture.invalid',EXPECTED_PRODUCTION_DB_NAME_SHA256:'a'.repeat(64), + EXPECTED_PENDING_MIGRATIONS:'',EXPECTED_DEPLOY_SERVER:'fixture-target',EXPECTED_REMOTE_DIR:'/fixture/app', + EXPECTED_COMPOSE_PROJECT_NAME:'fixture-project', +}; +const output=r=>r.stdout+r.stderr; +const named=(r,name,suffix)=>r.commands.filter(c=>c.name===name&&(!suffix||c.args[0]?.endsWith(suffix))); +const writes=r=>r.commands.filter(c=>c.name==='rsync'||c.name==='ssh'&&(/mkdir -p|BUILD_SHA=|^refresh$/.test(c.args.at(-1)))); +function blockedBeforeAccess(r) { + assert.notEqual(r.status,0,output(r)); + assert.equal(writes(r).length,0); + assert.equal(named(r,'ssh').length,0); + assert.equal(named(r,'config-read').length,0); + assert.equal(named(r,'node','check-source-provenance.mjs').length,0); +} +function complete(f) { + const r=f.run();assert.equal(r.status,0,output(r)); + f.env.RELEASE_GATE_RECEIPT_DIR=f.env.RELEASE_GATE_ARTIFACT_DIR; + return r; +} +function replace(f,relative,from,to) { + const file=path.join(f.root,relative),source=readFileSync(file,'utf8'); + assert.equal(source.split(from).length,2,'mutation must hit exactly once'); + writeFileSync(file,source.replace(from,to));f.commit(); +} + +for(const entry of entries)test(`${entry}: explicit bad receipts cannot reach config, provenance or remote actions`,context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const file=path.join(f.env.RELEASE_GATE_RECEIPT_DIR,'release-result.json'),original=readFileSync(file,'utf8'); + const mutations=[ + record=>{record.profile='focused';}, + record=>{record.sourceBefore.commit='f'.repeat(40);}, + record=>{record.executionExit=130;record.status='PASS';record.deploymentEligible=true;}, + record=>{record.phases.stages.pop();}, + record=>{record.policy.toolchain.node='stale';}, + ]; + for(const mutate of mutations) { + const record=JSON.parse(original);mutate(record);writeFileSync(file,JSON.stringify(record)); + blockedBeforeAccess(f.invoke(entry)); + } + writeFileSync(file,original); + blockedBeforeAccess(f.invoke(entry,{RELEASE_GATE_RECEIPT_DIR:''})); + blockedBeforeAccess(f.invoke(entry,{RELEASE_GATE_RECEIPT_DIR:path.join(f.root,'missing')})); + writeFileSync(file,'{');blockedBeforeAccess(f.invoke(entry));writeFileSync(file,original); + writeFileSync(path.join(f.root,'client/package.json'),'{"changed":true}'); + blockedBeforeAccess(f.invoke(entry));writeFileSync(path.join(f.root,'client/package.json'),'{}'); + for(const value of ['yes','01','']) { + blockedBeforeAccess(f.invoke(entry,{PRODUCTION_READ_ONLY_PREFLIGHT:value})); + blockedBeforeAccess(f.invoke(entry,{PRODUCTION_IDENTITY_ONLY:value,PRODUCTION_READ_ONLY_PREFLIGHT:'1'})); + } +}); + +for(const entry of ['deploy','bootstrap','existing','raw'])test(`${entry}: missing receipt refuses without starting tests`,context=>{ + const f=fixture(context,syntheticEnv),r=f.invoke(entry); + blockedBeforeAccess(r);assert.equal(named(r,'npm').length,0); +}); + +for(const entry of entries)test(`${entry}: valid explicit receipt reaches exactly one synthetic deployment`,context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const r=f.invoke(entry);assert.equal(r.status,0,output(r)); + assert.equal(named(r,'rsync').length,1); + assert.equal(named(r,'npm').length,0); + assert.equal(named(r,'bash','run-web-gates.sh').filter(c=>c.args[1]==='full-local').length,0); + assert.equal(r.commands.filter(c=>c.name==='ssh'&&c.args.at(-1).includes('BUILD_SHA=')).length,1); + const firstWrite=r.commands.findIndex(c=>writes({commands:[c]}).length); + const checks=r.commands.slice(0,firstWrite).filter(c=>c.name==='node'&&c.args[0]?.endsWith('release-gate-result.mjs')&&c.args[1]==='require'); + assert.ok(checks.length>=3,'raw deployment must recheck receipt before every write boundary'); +}); + +for(const entry of ['ci-release','all'])test(`${entry}: no explicit receipt runs static/full-local once with no recursive deployment`,context=>{ + const f=fixture(context,syntheticEnv),r=f.invoke(entry);assert.equal(r.status,0,output(r)); + assert.equal(named(r,'bash','run-release-gate.sh').length,1); + assert.equal(named(r,'bash','run-web-gates.sh').filter(c=>c.args[1]==='full-local').length,1); + assert.equal(named(r,'npm').filter(c=>c.args[0]==='test').length,1); + assert.equal(named(r,'node','production-identity-bootstrap.mjs').length,1); + assert.equal(named(r,'rsync').length,1); + const web=named(r,'bash','run-web-gates.sh').map(c=>c.args[1]); + assert.deepEqual(web,entry==='all'?['smoke-local','full-local']:['full-local']); +}); + +for(const entry of ['ci-release','all'])test(`${entry}: failed verification never enters production chain`,context=>{ + const f=fixture(context,{...syntheticEnv,FAIL_STATIC:'1'}),r=f.invoke(entry); + blockedBeforeAccess(r);assert.equal(named(r,'node','production-identity-bootstrap.mjs').length,0); + assert.equal(named(r,'bash','run-release-gate.sh').length,1); +}); + +for(const [entry,flag] of [['bootstrap','PRODUCTION_IDENTITY_ONLY'],['bootstrap','PRODUCTION_READ_ONLY_PREFLIGHT'],['existing','PRODUCTION_READ_ONLY_PREFLIGHT']])test(`${entry}/${flag}: synthetic read-only route terminates without receipt or writes`,context=>{ + const f=fixture(context,syntheticEnv),r=f.invoke(entry,{[flag]:'1'}); + assert.equal(r.status,0,output(r));assert.equal(writes(r).length,0); + assert.equal(named(r,'bash','deploy.sh').length,0); + if(flag==='PRODUCTION_IDENTITY_ONLY') { + assert.equal(named(r,'bash','deploy-existing.sh').length,0); + assert.ok(!named(r,'ssh').some(c=>c.args.at(-1).includes("IDENTITY_BOOTSTRAP_MODE='database'"))); + } +}); +for(const entry of ['ci-release','all','deploy','raw'])test(`${entry}: read-only flags cannot masquerade as successful publication`,context=>{ + const f=fixture(context,syntheticEnv); + for(const flag of ['PRODUCTION_IDENTITY_ONLY','PRODUCTION_READ_ONLY_PREFLIGHT'])blockedBeforeAccess(f.invoke(entry,{[flag]:'1'})); +}); + +for(const [entry,point] of [['bootstrap','origin'],['existing','preflight'],['raw','remote-preflight']])test(`${entry}: source or receipt changed after initial check cannot write`,context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const r=f.invoke(entry,{HYB_MUTATE_AT:point}); + assert.notEqual(r.status,0,output(r));assert.equal(writes(r).length,0); + assert.equal(named(r,'bash','deploy.sh').length,0); +}); +for(const kind of ['head','index','receipt'])test(`raw: ${kind} drift during remote preflight fails final recheck`,context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const r=f.invoke('raw',{HYB_MUTATE_AT:'remote-preflight',HYB_MUTATE_KIND:kind}); + assert.notEqual(r.status,0,output(r));assert.equal(writes(r).length,0); +}); +test('raw: provenance commit must equal receipt/HEAD before archive or environment reads',context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const r=f.invoke('raw',{HYB_WRONG_SOURCE:'1'}); + assert.notEqual(r.status,0,output(r));assert.equal(writes(r).length,0); + assert.equal(named(r,'node','validate-env-contract.js').length,0); +}); +test('caller expected commit is an additional constraint, never an override',context=>{ + const f=fixture(context,syntheticEnv);complete(f); + blockedBeforeAccess(f.invoke('bootstrap',{RELEASE_GATE_EXPECTED_COMMIT:'f'.repeat(40)})); + blockedBeforeAccess(f.invoke('raw',{RELEASE_GATE_EXPECTED_COMMIT:''})); +}); +test('skip/internal flags never authorize a lower-level write',context=>{ + const f=fixture(context,syntheticEnv);complete(f); + for(const entry of entries)blockedBeforeAccess(f.invoke(entry,{PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture'})); + delete f.env.RELEASE_GATE_RECEIPT_DIR; + for(const entry of ['bootstrap','existing','raw'])blockedBeforeAccess(f.invoke(entry,{GATE_PASSED:'1',SKIP_RELEASE_GATE:'1',DEPLOY_DEFER_FINAL_SUCCESS:'1'})); +}); +test('receipt directory or record symlinks and outside paths are refused',context=>{ + const f=fixture(context,syntheticEnv);complete(f); + const link=path.join(f.root,'artifacts/release-gate/linked'); + symlinkSync(f.env.RELEASE_GATE_RECEIPT_DIR,link); + blockedBeforeAccess(f.invoke('raw',{RELEASE_GATE_RECEIPT_DIR:link})); + blockedBeforeAccess(f.invoke('raw',{RELEASE_GATE_RECEIPT_DIR:f.root})); + const file=path.join(f.env.RELEASE_GATE_RECEIPT_DIR,'release-result.json'); + rmSync(file);symlinkSync('/synthetic-missing-target',file); + blockedBeforeAccess(f.invoke('raw')); +}); + +test('removing bootstrap first check is caught by config-access assertion',context=>{ + const f=fixture(context,syntheticEnv);blockedBeforeAccess(f.invoke('bootstrap')); + replace(f,'deploy/trialrun/production-identity-bootstrap.mjs', + 'const release = identityOnly === "1" || preflightOnly ? null : requireReleaseReceipt();', + 'const release = null; // isolated mutation'); + assert.throws(()=>blockedBeforeAccess(f.invoke('bootstrap')),assert.AssertionError); +}); +test('removing final raw check makes the no-write counterexample fail',context=>{ + const f=fixture(context,syntheticEnv); + replace(f,'deploy/trialrun/deploy.sh', + 'node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}" "$SOURCE_COMMIT" >/dev/null\n\necho ">>> 3.1', + ': # isolated mutation\n\necho ">>> 3.1'); + complete(f);const r=f.invoke('raw',{HYB_MUTATE_AT:'remote-preflight'}); + assert.throws(()=>assert.equal(writes(r).length,0),assert.AssertionError); +}); +test('removing existing-wrapper recheck is caught before lower-level invocation',context=>{ + const f=fixture(context,syntheticEnv); + replace(f,'deploy/trialrun/deploy-existing.sh', + 'node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" require "${RELEASE_GATE_RECEIPT_DIR:-}" "$source_commit" >/dev/null', + ': # isolated mutation'); + complete(f);const r=f.invoke('existing',{HYB_MUTATE_AT:'preflight'}); + assert.equal(writes(r).length,0); // Raw entry still independently protects writes. + assert.throws(()=>assert.equal(named(r,'bash','deploy.sh').length,0),assert.AssertionError); +}); +test('relaxing expected commit binding is caught before environment/archive',context=>{ + const f=fixture(context,syntheticEnv); + replace(f,'scripts/qa/release-gate-result.mjs', + "expected!==undefined && (!/^[a-f0-9]{40}$/.test(expected) || expected!==current.source.commit)", + 'false'); + complete(f);const r=f.invoke('raw',{HYB_WRONG_SOURCE:'1'}); + assert.throws(()=>assert.equal(named(r,'node','validate-env-contract.js').length,0),assert.AssertionError); +}); +test('removing bootstrap second check is caught before wrapper invocation',context=>{ + const f=fixture(context,syntheticEnv); + replace(f,'deploy/trialrun/production-identity-bootstrap.mjs', + 'if (release) requireReleaseReceipt(release.directory, release.commit);', + '// isolated mutation'); + complete(f);const r=f.invoke('bootstrap',{HYB_MUTATE_AT:'origin'}); + assert.equal(writes(r).length,0); + assert.throws(()=>assert.equal(named(r,'bash','deploy-existing.sh').length,0),assert.AssertionError); +}); +test('relaxing clean-candidate validation makes the dirty receipt counterexample fail',context=>{ + const f=fixture(context,syntheticEnv); + replace(f,'scripts/qa/release-gate-result.mjs','source.dirty===false','true'); + writeFileSync(path.join(f.root,'client/package.json'),'{"dirty":true}'); + const r=f.run(); + // Deliberate verifier mutation admits a consistently dirty fixture. The + // ordinary shell suite independently requires PARTIAL/2 for this same case. + assert.throws(()=>assert.equal(r.status,2,output(r)),assert.AssertionError); + assert.equal(r.status,0,output(r)); +}); +test('private fixture rejects unknown tools and never falls back to real SSH/Docker',context=>{ + const f=fixture(context,syntheticEnv); + assert.equal(existsSync(path.join(f.root,'bin/docker')),true); + assert.equal(f.env.PATH,path.join(f.root,'bin')); + assert.equal(f.env.HOME,path.join(f.root,'bin/home')); + for(const command of ['ssh','docker','git']) { + const r=spawnSync(path.join(f.root,'bin',command),['unexpected'],{env:f.env,cwd:f.root,encoding:'utf8'}); + assert.equal(r.status,97,r.stdout+r.stderr); + } +}); diff --git a/scripts/qa/tests/release-gate-shell.test.mjs b/scripts/qa/tests/release-gate-shell.test.mjs index 35b3ee05..402ef96b 100644 --- a/scripts/qa/tests/release-gate-shell.test.mjs +++ b/scripts/qa/tests/release-gate-shell.test.mjs @@ -1,77 +1,9 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; -import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { fixture } from './fixtures/release-gate-fixture.mjs'; -const projectRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../..'); -const receiptFiles={environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'}; -function fixture(context,overrides={}) { - const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-shell-')); - context.after(()=>rmSync(root,{recursive:true,force:true})); - for(const dir of ['scripts/qa','deploy/trialrun','client/tests/e2e','server','bin','client/node_modules/@playwright/test','client/node_modules/playwright-core'])mkdirSync(path.join(root,dir),{recursive:true}); - for(const file of readdirSync(path.join(projectRoot,'scripts/qa')).filter(file=>file.endsWith('.mjs')||file.endsWith('.sh')||file.endsWith('.py')))copyFileSync(path.join(projectRoot,'scripts/qa',file),path.join(root,'scripts/qa',file)); - const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1})); - rows.push({file:'client/tests/e2e/ordinary.spec.ts',id:'ordinary',title:'ordinary',line:1}); - for(const row of rows)writeFileSync(path.join(root,row.file),'synthetic'); - const manifest={localCandidateFlows:rows.slice(0,10).map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}; - const contract={schemaVersion:1,approvalReference:'synthetic',exceptions:[]}; - writeFileSync(path.join(root,'scripts/qa/route-manifest.json'),JSON.stringify(manifest)); - writeFileSync(path.join(root,'scripts/qa/full-local-coverage-contract.json'),JSON.stringify(contract)); - for(const file of ['client/package.json','client/package-lock.json','server/package.json','server/package-lock.json','client/node_modules/playwright-core/browsers.json'])writeFileSync(path.join(root,file),'{}'); - writeFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'),JSON.stringify({browsers:['chromium','webkit'].map(name=>({name,browserVersion:'synthetic'}))})); - writeFileSync(path.join(root,'client/node_modules/@playwright/test/package.json'),JSON.stringify({version:'1.58.2'})); - writeFileSync(path.join(root,'client/playwright.config.ts'),'// synthetic policy'); - writeFileSync(path.join(root,'deploy/trialrun/validate-env-contract.js'),'// fake tooling owns this command'); - writeFileSync(path.join(root,'.gitignore'),'artifacts/\nbin/\nnode_modules/\n'); - const env={PATH:`${path.join(root,'bin')}:${process.env.PATH}`,LANG:'C.UTF-8',HYB_RELEASE_FIXTURE_ROOT:root,RELEASE_GATE_ARTIFACT_DIR:path.join(root,'artifacts/release-gate/run'),...overrides}; - const fake=`#!${process.execPath} -const fs=require('node:fs'),path=require('node:path'),{spawnSync}=require('node:child_process'); -const root=process.env.HYB_RELEASE_FIXTURE_ROOT,args=process.argv.slice(2),name=path.basename(process.argv[1]); -fs.mkdirSync(path.join(root,'artifacts'),{recursive:true});fs.appendFileSync(path.join(root,'artifacts/commands.jsonl'),JSON.stringify({name,args})+'\\n'); -if(name==='node' && args[0]?.endsWith('release-gate-result.mjs')){const result=spawnSync(process.execPath,args,{env:process.env,stdio:'inherit'});process.exit(result.status??1);} -if(name==='node' && args[0]?.endsWith('validate-env-contract.js') && process.env.FAIL_STATIC)process.exit(23); -if(name==='bash' && args[0]?.endsWith('run-web-gates.sh') && args[1]==='full-local'){if(process.env.OMIT_EVIDENCE)process.exit(0);const result=spawnSync(process.execPath,[path.join(root,'bin/evidence.mjs')],{env:process.env,stdio:'inherit'});process.exit(result.status??1);} -if(name==='bash' && args[0]?.includes('deploy/trialrun'))process.exit(91); -if(!['node','npm','bash','python3'].includes(name))throw Error('unexpected command'); -`; - writeFileSync(path.join(root,'bin/fake'),fake,{mode:0o755}); - for(const name of ['node','npm','bash','python3'])symlinkSync('fake',path.join(root,'bin',name)); - writeFileSync(path.join(root,'bin/evidence.mjs'),` -import {mkdirSync,writeFileSync,readFileSync} from 'node:fs'; -import path from 'node:path'; -import {captureReleaseSource,currentPolicy} from '../scripts/qa/release-gate-result.mjs'; -import {FULL_LOCAL_STAGES,newStageRecord,updateStageRecord} from '../scripts/qa/qa-stage-record.mjs'; -import {coveragePlan,localSpecArgs} from '../scripts/qa/full-local-coverage.mjs'; -const root=process.env.HYB_RELEASE_FIXTURE_ROOT,base=process.env.QA_FULL_REPORT_DIR,now=()=>new Date().toISOString(); -const source=captureReleaseSource(root),policy=currentPolicy(root),startedAt=now(); -const phases=newStageRecord(FULL_LOCAL_STAGES,'full-local-stages'); -const rows=${JSON.stringify(rows)},contract=${JSON.stringify(contract)}; -const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))}); -const discovery=report(false),execution=report(true); -execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:policy.toolchain.workers,version:policy.toolchain.playwright}; -for(const stage of FULL_LOCAL_STAGES){updateStageRecord(phases,'start',stage);if(stage==='browser')execution.stats={startTime:now(),duration:0,expected:rows.length,unexpected:0,flaky:0,skipped:0};updateStageRecord(phases,'end',stage,0);}updateStageRecord(phases,'finish',null,0); -const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:policy.environmentDigest,platform:process.platform,status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt,endedAt:now(),sourceBefore:source,sourceAfter:source,sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:policy.environmentDigest,platform:process.platform,runnerDigest:policy.runnerDigest,probeDigest:policy.probeDigest,supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:now(),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}}; -const bundle={environment,phases,discovery,execution,plan:coveragePlan(root,discovery,contract,source)}; -bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:policy.toolchain.workers,mode:'local'}; -if(process.env.BAD_PROFILE)environment.mode='retained-local'; -if(process.env.BAD_SOURCE)environment.sourceBefore={...source,digest:'0'.repeat(64)}; -if(process.env.BAD_COVERAGE)execution.suites.pop(); -if(process.env.BAD_CLEANUP)environment.cleanup.containerRemoved=false; -if(process.env.BAD_STAGE)phases.stages.splice(2,1); -for(const [key,relative] of Object.entries(${JSON.stringify(receiptFiles)})){const file=path.join(base,relative);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(bundle[key]));} -if(process.env.FAIL_FULL)process.exit(17); -`); - for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:root}).status,0); - return {root,env,run(mode='verify-candidate') { - rmSync(path.join(root,'artifacts'),{recursive:true,force:true}); - const result=spawnSync('/bin/bash',[path.join(root,'scripts/qa/run-release-gate.sh'),mode],{cwd:root,env,encoding:'utf8',timeout:30000}); - const file=path.join(env.RELEASE_GATE_ARTIFACT_DIR,'release-result.json'); - return {...result,record:existsSync(file)?JSON.parse(readFileSync(file)):null,commands:existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').map(JSON.parse):[]}; - },verify() {return spawnSync(process.execPath,[path.join(root,'scripts/qa/release-gate-result.mjs'),'verify',env.RELEASE_GATE_ARTIFACT_DIR],{cwd:root,env,encoding:'utf8',timeout:30000});}}; -} function noDeploy(result) {assert.ok(!result.commands.some(c=>c.args.some(arg=>arg.includes('deploy/trialrun/deploy'))));} test('真实 shell 复用 static/full-local、回执离线重验、部署命令零调用',context=>{