feat-chv-closeup:收尾(executor 覆盖到 100%、合并/灰度准备,堆叠于 #264)

- 后端新增 cloud/tests/test_chv_closeup.py 85 项:唤醒失败
  (vm.create/vm.boot/API 超时/guest 就绪超时/host key 不匹配/
  provisioned 缺钉死)、恢复与快照失败、取消与清理(残留
  socket/登记/磁盘/密钥、脏标记)、中途死亡分类、只读
  execute 归类、入口校验、preflight 事后改写、防御性
  except、worker_adapter 剩余分支;executor.py 覆盖 100%。
- 前端新增 src/api/chv.closeup.test.ts 13 项:显式 false、
  undefined、可用性优先级、Workers 旧 bug 文案不复发、
  120 秒边界;控制按钮与新页面仍不做。
- 新增 tools/chv_merge_precheck.py(只读):逐个核 base/head/
  mergeable/CI/链衔接,实测 11 个 PR 全 NOT-READY(CI 全
  FAIL、mergeState 全 UNSTABLE),退出码 1,绝不合并。
- docs/chv-production-readiness.md 新增 §14:审计落盘运维
  runbook(定位/轮转/读取/清理,ADR-14 取舍不变)、mypy
  默认口径 2 错评估(沙箱验证过的标注修法,本轨不改
  其它链文件)、灰度三阶段可执行清单、V-1~V-9 检查表
  (全部未验证)、逐项评分。
- 安全语义不变:egress、key seed 投递、磁盘删除、默认
  provisioned、tap 校验、前端不绕后端白名单均未放宽。
- 只到 PR OPEN,不合并、不部署;CI 账单仍是硬门禁。
This commit is contained in:
host committed 2026-10-31 12:15:56 +00:00
1 parent 2f3ff8b3ec
commit 54f20dfe55
4 files changed
+1759

No files matched your search

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isWorkerAlive } from "./chv";
// closeup 轨补边角:上一轨 chv.test.ts 已覆主路径后仍缺的
// 显式 false、undefined、优先级与“旧 bug 文案不复发”断言。
// 只测 chv.ts 纯逻辑,不渲染组件、不发请求。
describe("chvVmDisplay 边角(closeup)", () => {
it("字段显式 false 与 absent 同属 not-reported", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: false });
expect(d.state).toBe("not-reported");
expect(d.text).toContain("不代表 VM 活着");
});
it("任务 undefined 与 null 同属 unknown", () => {
expect(chvVmDisplay(undefined).state).toBe("unknown");
expect(chvVmDisplay(undefined).text).toContain("无法判断");
});
it("非 CHV 且无 executor 字段时也是 not-applicable", () => {
expect(chvVmDisplay({}).state).toBe("not-applicable");
expect(chvVmDisplay({ executor: undefined }).text).toBe("");
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: false })).toContain("未报告 VM 死亡");
});
it("列表徽标:显式 false 不出徽标", () => {
expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: false })).toBe("");
});
});
describe("chvAvailability 优先级(closeup)", () => {
it("加载失败优先于聚合文案:不把失败写成 aggregate-only", () => {
expect(chvAvailability(null, "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
{ state: "unknown", reason: "load-failed" },
);
});
it("有清单时忽略错误与聚合文案", () => {
expect(chvAvailability([{ executor: "cloud-hypervisor", alive: false }], "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
{ state: "known", online: 0, total: 1 },
);
});
it("空清单与空文案是 not-loaded,不是已知 0", () => {
expect(chvAvailability(null, "", "")).toEqual({ state: "unknown", reason: "not-loaded" });
});
it("空 worker 数组是已知 0(与 null 区分)", () => {
expect(chvAvailability([], "", "")).toEqual({ state: "known", online: 0, total: 0 });
expect(countChvWorkers([])).toEqual({ online: 0, total: 0 });
});
});
describe("chvAvailabilityText:Workers 旧 bug 不复发(closeup)", () => {
it("load-failed / not-loaded 绝不写“仅全局管理员可见”", () => {
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).not.toContain("仅全局管理员可见");
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).not.toContain("仅全局管理员可见");
});
it("known 总数为 0 时仍是已知 0,不写未知", () => {
const text = chvAvailabilityText({ state: "known", online: 0, total: 0 });
expect(text).toContain("在线 0 / 共 0");
expect(text).not.toContain("未知");
});
});
describe("附件拦截与排队提示边角(closeup)", () => {
it("executor 为 undefined/null 时附件不拦", () => {
expect(chvAttachmentBlocked(undefined, true)).toBe(false);
expect(chvAttachmentBlocked(null, true)).toBe(false);
});
it("排队提示:未知三原因一律为空串", () => {
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "unknown", reason })).toBe("");
}
});
});
describe("isWorkerAlive 边界(closeup)", () => {
it("last_seen 恰好 120 秒算在线(<=120)", () => {
const now = Date.now() / 1000;
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 120 })).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
+213
View File
@@ -572,3 +572,216 @@
6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。 6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。
7. 前端 live 点击验证(用户侧 live browser 检查清单见 7. 前端 live 点击验证(用户侧 live browser 检查清单见
本 PR 描述;回环地址限制下浏览器任务无法代劳)。 本 PR 描述;回环地址限制下浏览器任务无法代劳)。
## 14. 收尾轨 feat-chv-closeup(堆叠于 #264 之上)
本节是准备工作,不是启用结论。本轨只到 PR OPEN,不合并、
不部署;生产就绪与动态安全的外部关卡(§13.4)原样未动。
### 14.1 本轨量化结果(实测,非上一轨回执转述)
- 后端:232 项 → 317 项(新增 `cloud/tests/test_chv_closeup.py`
85 项),全过。命令(仓库根目录,cwd 须为根目录——
在 `cloud/` 下跑会撞 `cloud/queue.py` 遮蔽 stdlib queue,
mypy 同理):
`python3 -m unittest cloud.tests.test_chv_arch95
cloud.tests.test_chv_arch95plus cloud.tests.test_chv_arch_maint_fe
cloud.tests.test_chv_fix cloud.tests.test_chv_gaps
cloud.tests.test_chv_optimize cloud.tests.test_chv_secfix
cloud.tests.test_chv_to10 cloud.tests.test_cloud_hypervisor
cloud.tests.test_chv_closeup`
- 覆盖率(同命令 + `coverage run --source=cloud/chv`):
chv 总 87%(1925 行,缺 258)→ 96%(缺 80);
`executor.py` 85%(缺 157)→ 100%(缺 0);
`worker_adapter.py` 82%(缺 16)→ 100%(缺 0)。
本轨基线与上一轨回执的行数口径略有出入(上一轨称
1826 行/缺 242,本轨同命令实测 1925 行/缺 258),
本轨前后对比只用本轨同一命令,不混用上一轨数字。
- 剩余未覆 80 行(按模块,全部在 executor/worker_adapter
之外,本轨未声称覆盖):
`api_client.py` 11 行(socket 层 chunked/超时/异常分支,
需 socket 级故障注入);`compat215.py` 2 行(旧兼容回落);
`config.py` 5 行(非法 env 构造分支);`disk.py` 22 行
(快照登记/剪枝边角与配额异常分支);`experimental_pool.py`
16 行(实验池,非生产路径);`hardening.py` 11 行
(真实 socket 权限与 net 校验边角);`hostkeys.py` 13 行
(ssh-keygen/genisoimage 子进程失败分支)。以上均非死
代码的断言——未逐行证伪,留待后续轨按模块补,不在本
轨凑数。
- mypy:silent 口径 `cloud/chv/` 0 错(不变);
默认口径 `cloud/marker_tools.py` 仍 2 错(见 §14.3,
本轨未改该文件)。
- ruff:`--select F,E9,W,I` 口径 `cloud/chv/` +
本轨新测试 0 错(不变;默认全规则基线不声称通过)。
- 前端:Vitest 23 项 → 36 项(新增
`src/api/chv.closeup.test.ts` 13 项),全过。
### 14.2 审计落盘文件运维 runbook(ADR-14 取舍不变,只补可操作性)
- 定位:`<state_dir>/chv-audit-spill.jsonl` 与轮转文件
`<state_dir>/chv-audit-spill.jsonl.old`。`state_dir`
默认 `~/.local/share/codex-app-server/chv-vms`,
可由 `CHV_STATE_DIR` 改址;以部署的实际值为准,
不要猜。目录本身 0700(见 §13.1 的 H6 门)。
- 触发:只有 `_emit` 的审计回调抛错时才写一行;回调
成功、无回调(只记日志)时不写。触发时宿主日志必有
`chv_audit_failed`,且 `audit_failure_count` +1——
落盘行是“哪次事件丢了”的唯一取证,计数只是数字。
- 格式:JSONL,每行 `{ts, event, kind, detail, task_id}`。
`detail` 沿用 `_emit` 契约,只含 VM 名/路径/URL/kind,
绝不含密钥值;读文件时仍按敏感文件对待(0700 目录内)。
- 轮转:当前文件 >1MB 时,先 `os.replace` 成 `.old`
再写新行;只留最新两代,更早的丢失事件会丢——
这是 ADR-14 已定的取舍,不是 bug,不要指望它当
完整审计账本。
- 读取(宿主侧,只读):
`cat <state_dir>/chv-audit-spill.jsonl`;
按事件过滤:`jq -c 'select(.event=="vm_failed")'
<state_dir>/chv-audit-spill.jsonl`;
两代一起看时先 `.old` 后当前文件(时间顺序)。
- 清理:`.old` 可随时删(删的是已被轮转覆盖的旧代);
当前 `.jsonl` 只有在已人工核对、且确认丢失事件已
无需追查时才删——删了就再也找不回那批事件。
落盘文件不是 DB 替代:任务 timeline 查询看不到它,
不要把它当成审计表去对账;审计完整性本身仍无保证
(见 ADR-14),保证需要 DB 事务级审计写入,那是
worker 全局语义改动,不在本轨。
- 落盘写失败:只记 `chv_audit_spill_failed` 日志,
绝不中断任务;此时连落盘都没有,唯一信号就是这条
日志与计数。
### 14.3 mypy 默认口径剩余 2 错评估(结论:本轨不修)
- 位置:`cloud/marker_tools.py:142`,`try: from zoneinfo
import ZoneInfo / except Exception: ZoneInfo = None`。
mypy 把 import 推断为 `type[ZoneInfo]`,再把 `None`
赋值判为两错(misc + assignment)。
- 仅标注修法(沙箱验证过,未进仓库):在 import 区后加
`from typing import Any` 与 `ZoneInfo: Any`(标注语句
本身不产生绑定),默认口径 2 错 → 0 错;运行时
`ZoneInfo` 仍是 stdlib `zoneinfo.ZoneInfo` 同一对象,
行为不变。验证在 `/tmp` 副本上做,不碰仓库文件。
- 不修的理由(两条,任一条已足够):① 该文件属标记
工具链(git 历史为标记工具提交),不在本链 §13.1
文件清单内,本轨改它就是动其它链的文件,徒增与
仍 OPEN 的审计/标记 PR 的 rebase 冲突面;② CHV 链
自己的门是 silent 口径(`--follow-imports=silent
cloud/chv/`),该口径已 0 错,默认口径的 2 错是
作用域外既有问题。修法留给 marker_tools 的归属轨,
本轨只把配方写在这里,不为清零越界改文件。
### 14.4 合并前检查脚本(只读)
- 脚本:`tools/chv_merge_precheck.py`,用法见文件头。
只读:`gh pr view` / `gh api` 查询 + 本地 `git` 读
remote,不 fetch、不 checkout、不 merge、不改 CI。
退出码:0 就绪、1 未就绪、2 查询失败(结论不可信)。
- 本轨实测(未含本轨新 PR,建好后须追加新 PR 号重跑):
`python3 tools/chv_merge_precheck.py` → 退出码 1,
结论 NOT-READY:11 个 PR 全部 OPEN / MERGEABLE,
链衔接全部 OK,当前 main 头仍 `aebc6238`(与 §13.1
一致),但 11 个 PR 的 CI 全部 `FAIL [verify:FAILURE]`、
mergeState 全部 UNSTABLE。脚本不诊断失败原因,
不把秒级失败改判——账单恢复变绿前结论保持 NOT-READY。
### 14.5 灰度三阶段可执行清单(准备工作,未执行、未验证)
通用停止条件(任一触发即停在该阶段,不进下一阶段):
`vm_failed` 审计激增、脏标记(`dirty_marker`)出现、
`chv-audit-spill.jsonl` 持续增长、残留盘/残留密钥扫描
清出本阶段不应有的项、任一任务静默降级到 Docker/Local。
通用回滚:停止向 CHV 路由新任务(改回 docker/local
执行器选择),已起 VM 走取消/关闭路径(`cancel_vm` /
`shutdown_vm`);代码回滚另议(revert 合并),不与
“停路由”混为一谈。
- 阶段 0(合并后、默认关闭):
1. 按 §14.4 脚本逐项核对,退出码 0 前不合并;合并
顺序即 §14.4 的链序,不得跳号。
2. 合并后确认 CHV 仍默认关闭(无生产任务路由到
`cloud-hypervisor`,任务创建白名单外的执行器值
仍被后端拒绝)。
3. 在 KVM 宿主(须见 `/dev/kvm`,本 VM 与生产宿主
均无 KVM,不能替代)上跑 §14.6 的 V-1~V-9,
全部通过才可谈阶段 1;任一未跑即记未验证。
回滚点:合并本身可 revert;未进生产路由前无任务影响。
- 阶段 1(单租户可信项目):
1. 只对明确指定的单租户项目派发 CHV 任务;复用
实验仍需 `CHV_REUSE_SINGLE_TENANT_EXPERIMENT` 与
`CHV_SINGLE_TENANT_PROJECT` 同时声明,缺一即
preflight 拒绝(代码行为,不放宽)。
2. 检查点:每任务 VM 销毁后 `state_dir` 无该任务
残留盘与 seed;`chv-audit-spill.jsonl` 无新增;
取消路径真关 VM(V-5 口径)。
3. 停止条件见通用条件;回滚:该项目任务改回原
执行器,CHV worker 停接新任务。
- 阶段 2(受限多租户):
1. 放开到受邀项目,连续观察一周:VM 残留盘、
审计落盘、容量门(`CHV_MAX_VMS`,含重启后残留
socket 扫描)、快照配额四项指标。
2. 检查点:每周复核四项指标零异常 + §14.2 落盘
文件复核;任一指标异常即退回阶段 1。
3. 停止条件见通用条件;回滚:收紧到阶段 1 项目集。
- 阶段 3(全量多租户):仅在阶段 2 指标干净 +
动态安全审计结论由 No-Go 改为有条件启用后,才可
讨论;绝不从阶段 0 直跳全量。本轨不给阶段 3 的
时间表,不写成已就绪。
### 14.6 真机验证用例 V-1~V-9 验收检查表(全部未验证)
每行格式:用例 — 验收标准(§13.2 细化)— 证据栏(待填)。
状态栏一律“未验证”,跑过才改,跑过≠通过。
- [ ] V-1 冷启动 — 任务 completed,宿主 state_dir 无该
任务残留盘与 seed。证据:______ 状态:未验证
- [ ] V-2 seed 首启时序 — `sshd -T` 显示
PasswordAuthentication no 等加固项,known_hosts 钉死
校验通过,非 TOFU 回落。证据:______ 状态:未验证
- [ ] V-3 快照恢复 — 恢复后 known_hosts 不变,快照文件
落盘非空且经 `snapshot_not_persisted` 校验。证据:______
状态:未验证
- [ ] V-4 网关真实任务 — 任务完成 + 宿主进程表抽查无密钥
(密钥只走 seed 客内文件)。证据:______ 状态:未验证
- [ ] V-5 取消与配额真杀 — VM 进程消失,任务 interrupted
且原因写回正确。证据:______ 状态:未验证
- [ ] V-6 审计落盘 — 落盘行 event/task_id/detail 与丢失
事件一致,`audit_failure_count` +1,任务不中断。
证据:______ 状态:未验证
- [ ] V-7 动态安全负向 — 直连被 tap/防火墙挡、跨 VM 探活
与 IP 冒用被 SSH 钉死拒、崩溃注入后残留被清理或脏
标记拒起,每项都有“被挡住”的证据。证据:______
状态:未验证
- [ ] V-8 负载 — 4 VM 并发下容量门计数含重启后残留
socket 扫描,死 socket 不占名额、活 VM 不被超发,
无静默降级。证据:______ 状态:未验证
- [ ] V-9 供应链清单 — 构建宿主产出真实 lockfile,
`--verify` 通过且 unresolved 为空,再用
`check_lockfile.py` 对 BUILDINFO.versions 复核。
证据:______ 状态:未验证
### 14.7 本轨逐项评分(目标 10,无一项硬写 10)
- 代码质量 9.8 → 9.8,未达 10:executor/worker_adapter
覆盖到 100%,但 chv 仍有 80 行未覆(非 executor
模块),且默认口径 mypy 余 2 错在作用域外。差:代码内。
- 架构 9.7 → 9.7,未达 10:本轨未改架构,ADR-13/14
取舍不变。差:外部(生产验证后才可复评)。
- 可维护性 9.7 → 9.8,未达 10:新增合并检查脚本与
§14 运维/灰度文档,落盘可操作性补齐;但跨链文件
的 mypy 归属未落。差:代码内(小)。
- 文档 9.8 → 9.9,未达 10:§14 补齐 runbook、灰度
清单、V 检查表、评分表;仍缺真机证据填表。差:外部。
- 测试 9.7 → 9.8,未达 10:后端 317 项、前端 36 项全过,
executor 100%;但 chv 总覆盖 96% 未满,且真机用例
0 项已跑。差:代码内(80 行)+ 外部(真机)。
- 静态安全 9.6 → 9.6,未达 10:取舍不变,只补落盘
运维;lockfile 仍待构建宿主产出,绝不编造。差:外部。
- 前端 9.5 → 9.6,未达 10:只读四态与可用性文案边角
全覆盖(显式 false、undefined、优先级、旧 bug 文案
不复发、120 秒边界);控制按钮与新页面仍不做,
live 点击未验。差:代码内(有意不做)+ 外部(live)。
- 生产就绪 5.0 → 5.5,未达 10:仅因合并/灰度准备
工作(可跑脚本 + 可执行清单)小幅上升;CI 仍
UNSTABLE、未合并、未部署、真机 0 项。差:外部为主。
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证,
V-7 未跑。差:外部(KVM 宿主 + 审计结论)。
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""CHV 合并前检查(只读,绝不合并、绝不改 CI、绝不写库)。
用途:合并 §13.1/§14 链上 PR 之前,逐个核对 base/head、
mergeable、CI 状态、与 main 头的关系与堆叠顺序,输出
诚实结论。CI 仍是 UNSTABLE 就如实报 NOT-READY,不把
账单模式的秒级失败读成代码失败,也不把 UNSTABLE 读成绿。
用法:
python3 tools/chv_merge_precheck.py [PR号 ...]
缺省检查整条 CHV 链(不含本轨新 PR;新 PR 建好后把
它的号追加到参数末尾再跑一次):
python3 tools/chv_merge_precheck.py 216 221 230 232 234 \\
239 246 252 254 257 264 <本轨新 PR 号>
依赖:gh(已登录)与 git(仅读本地 remote,不 fetch、
不 checkout、不 merge)。gh 不可用或 API 失败时退出码 2。
退出码:0 = 全部就绪(可按顺序合并);1 = 未就绪(见原因);
2 = 工具/查询失败(结论不可信,不要据此合并)。
"""
import json
import subprocess
import sys
DEFAULT_STACK = [216, 221, 230, 232, 234, 239, 246, 252, 254, 257, 264]
def _run(cmd):
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError("%s 失败:%s" % (
" ".join(cmd), (proc.stderr or proc.stdout).strip()[:500]))
return proc.stdout
def _gh_json(args):
return json.loads(_run(["gh"] + args))
def _check_summary(rollup):
"""把 statusCheckRollup 归纳成一句话,不猜失败原因。"""
if not rollup:
return "NO-CHECKS"
states = []
for item in rollup:
name = item.get("name") or item.get("workflowName") or "?"
conclusion = item.get("conclusion")
status = item.get("status")
states.append("%s:%s" % (name, conclusion or status))
conclusions = {item.get("conclusion") for item in rollup}
if "FAILURE" in conclusions or "CANCELLED" in conclusions:
verdict = "FAIL"
elif conclusions <= {"SUCCESS", "SKIPPED", "NEUTRAL", None} \
and all(item.get("status") == "COMPLETED" for item in rollup):
verdict = "GREEN"
else:
verdict = "PENDING"
return "%s [%s]" % (verdict, "; ".join(states))
def main(argv):
try:
prs = [int(a) for a in argv] or list(DEFAULT_STACK)
except ValueError:
print("参数须为 PR 号(整数)", file=sys.stderr)
return 2
try:
repo = _gh_json(["repo", "view", "--json", "nameWithOwner"])[
"nameWithOwner"]
main_head = _run(
["gh", "api", "repos/%s/commits/main" % repo, "--jq",
".sha"]).strip()
except Exception as exc:
print("查询仓库/main 头失败:%s" % exc, file=sys.stderr)
return 2
print("仓库:%s" % repo)
print("当前 main 头:%s" % main_head)
print("链(按合并顺序):%s" % " → ".join(str(p) for p in prs))
print("")
problems = []
tool_failed = False
prev_head_branch = "main"
for number in prs:
try:
pr = _gh_json(["pr", "view", str(number), "--json",
"number,title,state,baseRefName,headRefName,"
"headRefOid,mergeable,mergeStateStatus,"
"statusCheckRollup,additions,deletions,"
"changedFiles"])
except Exception as exc:
print("#%d:查询失败:%s" % (number, exc))
tool_failed = True
continue
chain_ok = pr["baseRefName"] == prev_head_branch
ci = _check_summary(pr.get("statusCheckRollup") or [])
ready = (pr["state"] == "OPEN" and pr["mergeable"] == "MERGEABLE"
and pr["mergeStateStatus"] == "CLEAN" and chain_ok
and ci.startswith("GREEN"))
print("#%(number)d %(title)s" % pr)
print(" base=%(baseRefName)s head=%(headRefName)s@%(headRefOid)s"
% pr)
print(" state=%(state)s mergeable=%(mergeable)s "
"mergeState=%(mergeStateStatus)s" % pr)
print(" diff: +%s/-%s, %s files" % (
pr.get("additions"), pr.get("deletions"),
pr.get("changedFiles")))
print(" CI: %s" % ci)
print(" 链衔接(base 应为上一 PR 的 head 分支 %s):%s" % (
prev_head_branch, "OK" if chain_ok else "MISMATCH"))
print(" 本项:%s" % ("READY" if ready else "NOT-READY"))
if not ready:
reasons = []
if pr["state"] != "OPEN":
reasons.append("state=%s" % pr["state"])
if pr["mergeable"] != "MERGEABLE":
reasons.append("mergeable=%s" % pr["mergeable"])
if pr["mergeStateStatus"] != "CLEAN":
reasons.append("mergeState=%s"
% pr["mergeStateStatus"])
if not chain_ok:
reasons.append("base 衔接断裂")
if not ci.startswith("GREEN"):
reasons.append("CI 未绿(%s)" % ci)
problems.append("#%d:%s" % (number, "; ".join(reasons)))
prev_head_branch = pr["headRefName"]
print("")
if tool_failed:
print("结论:查询失败,结论不可信,不要据此合并。")
return 2
if problems:
print("结论:NOT-READY,禁止合并。原因:")
for problem in problems:
print(" - %s" % problem)
print("注意:CI 的秒级 verify 失败此前被定性为账单模式,"
"脚本不据此改判——账单恢复变绿前,结论保持 NOT-READY。")
return 1
print("结论:READY,可按上面顺序逐个合并(合并后仍默认关闭,"
"再跑 §14 灰度清单)。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))