Files
姚章浩 a3a1319a11 feat(ci): 唯一权威门禁 make verify(14 项必需检查,汇总不认"未运行")
一次性把本地与 CI 的门禁收成一条命令:make verify = python3 scripts/ci_checks.py。
两边跑的是同一份逻辑,不再各写一套步骤(两边各写一套的结局是迟早只改一边)。

14 项必需检查:identity(检出 == 事件完整 SHA,只在 CI 必需)、commits、deps-install、
format、static-go、static-web、build-go、build-web、tests-go、tests-web、contracts、
security-secrets、security-deps、manifests-locked。make list 打印清单。

三条判据写死在代码里:
- 失败即停,但剩下的记 not_run 并如实写进报告;aggregate() 只认 success,
  未运行/被跳过都不算通过(不适用要在清单里声明 required_in,不许运行时悄悄 skip)。
- 工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败,不当成"检查通过"。
- 测试必须真跑:go test 加 -count=1 并盘点通过/跳过数;前端读 vitest 的 JSON 报告,
  空套件不算通过。跳过原因会打进报告(当前 72 个集成用例因缺 TEST_DATABASE_URL 跳过)。

新增四个自带反向自检的脚本(--self-test 故意弄坏必须红):
scripts/check_format.py(Go 判输出而非退出码 + 前端只能减的基线)、
scripts/check_secrets.py(只打印位置与类别,绝不打印值)、
scripts/check_deps_vuln.py(govulncheck 锁 v1.1.4 + npm audit 分级 + 到期例外)、
scripts/ci_checks.py(门禁 + 汇总)。

验证(本机实跑,全部为真实退出码):
make verify → 总体 success(档案 local),identity=skipped 已在清单里声明为 CI 必需;
tests-go 执行 31 个包、通过 549 / 失败 0 / 跳过 72;tests-web 106 个套件、191 个用例全过;
security-deps:govulncheck v1.1.4 0 命中、npm audit 生产与全量均 0。
2026-10-12 00:10:51 +08:00

158 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""格式门禁:Go(gofmt)+ 前端(prettier)。
判据(两条独立的账):
· Go :`gofmt -l` 对**被跟踪的 .go 文件**输出必须为空 —— 一条都不许欠。
· 前端:`prettier --list-different` 的输出必须**完全落在基线里**(`web/.prettier-baseline.json`)。
基线是**一次性账面债**,只能减不能增:
· 出现基线之外的文件 ⇒ 红(新写的代码必须格式化过);
· 基线里的文件已格式化 ⇒ 提示删条目(不判红);
· 基线到期(review_by)⇒ 红 —— 要么一次清干净、要么由人重新批准,**不许无声续命**。
为什么前端不一刀切全量格式化:本仓有一部分是刻意压行的紧凑写法,一次性重排约 2 万行,
那会把"接入 CI"变成"重写前端"。所以先用**列明 + 只能减 + 到期**的基线接住存量,
把增量卡住;全量清理留作独立提交(由人决定何时做)。
用法:
python3 scripts/check_format.py
python3 scripts/check_format.py --self-test # 只跑判定逻辑的反向自检
python3 scripts/check_format.py --web-only # 只查前端(调试用,不算全绿)
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WEB = ROOT / "web"
BASELINE = WEB / ".prettier-baseline.json"
def compare(unformatted: list[str], baseline: dict, today: dt.date) -> tuple[list[str], list[str]]:
"""纯判定:返回 (问题, 提示)。--self-test 直接打这个函数。"""
problems: list[str] = []
notes: list[str] = []
listed = baseline.get("unformatted")
if not isinstance(listed, list):
return [f"{BASELINE.name} 里没有 unformatted 列表 —— 基线坏掉时不能静默放行"], notes
review_by = baseline.get("review_by")
try:
due = dt.date.fromisoformat(str(review_by))
except (TypeError, ValueError):
problems.append(f"{BASELINE.name} 的 review_by 不是 YYYY-MM-DD —— 没有到期日的基线会永久留在仓库里")
due = None
if due and today > due:
problems.append(f"前端格式债基线已于 {due} 到期:要么 `npm --prefix web run format` 一次清干净,"
f"要么由人重新批准并更新 review_by(基线不许无声续命)")
known, now = set(listed), set(unformatted)
for f in sorted(now - known):
problems.append(f"新出现未格式化的文件:{f} —— 跑 `npm --prefix web run format` 再提交")
for f in sorted(known - now):
notes.append(f"基线里的 {f} 已经格式化,可以删掉这条")
notes.append(f"存量格式债:基线 {len(known)} 个文件,实际未格式化 {len(now)} 个")
return problems, notes
def self_test() -> int:
today = dt.date(2026, 10, 11)
good = {"review_by": "2026-12-31", "unformatted": ["a.ts", "b.ts"]}
cases = [
("存量文件仍欠 = 只提示不红", ["a.ts", "b.ts"], good, 0),
("出现基线外的新欠账 = 红", ["a.ts", "c.ts"], good, 1),
("基线里已修好的条目 = 只提示不红", ["a.ts"], good, 0),
("基线到期 = 红", ["a.ts", "b.ts"], {**good, "review_by": "2026-01-01"}, 1),
("基线没有 unformatted = 红", ["a.ts"], {"review_by": "2026-12-31"}, 1),
("基线没有到期日 = 红", ["a.ts"], {"unformatted": ["a.ts"]}, 1),
]
problems: list[str] = []
for name, unformatted, baseline, want in cases:
got, _ = compare(unformatted, baseline, today)
if len(got) != want:
problems.append(f"{name}:期望 {want} 个问题,实际 {len(got)} 个 {got}")
if problems:
print("❌ 格式门禁自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 格式门禁自检通过:{len(cases)} 个判定分支全部符合预期")
return 0
def check_go() -> tuple[bool, list[str]]:
files = subprocess.run(["git", "ls-files", "*.go"], cwd=ROOT,
capture_output=True, text=True).stdout.split()
if not files:
return True, ["没有 .go 文件,跳过(本仓不是 Go 仓时这里应当为空)"]
gofmt = shutil.which("gofmt") or "/opt/citools/go/bin/gofmt"
if not (shutil.which("gofmt") or Path(gofmt).exists()):
return False, ["找不到 gofmt —— 工具缺失 ⇒ 失败,不当成'格式没问题'"]
p = subprocess.run([gofmt, "-l", *files], cwd=ROOT, capture_output=True, text=True)
# ⚠️ gofmt -l 列出文件时**退出码仍是 0** —— 只看退出码就是永远的假绿。
bad = [l for l in p.stdout.splitlines() if l.strip()]
if p.returncode != 0:
return False, [f"gofmt 执行失败(exit={p.returncode}):{p.stderr[:300]}"]
if bad:
return False, [f"{f} 未格式化" for f in bad] + ["跑 `gofmt -w <文件>` 再提交"]
return True, [f"gofmt 干净({len(files)} 个文件)"]
def check_web() -> tuple[bool, list[str]]:
if not (WEB / "package-lock.json").is_file():
return False, ["web/package-lock.json 不存在 ⇒ 依赖没按锁文件装,格式门禁不可信"]
prettier = WEB / "node_modules" / ".bin" / "prettier"
if not prettier.exists():
return False, ["web/node_modules 里没有 prettier —— 先 `npm --prefix web ci`(工具缺失 ⇒ 失败)"]
p = subprocess.run([str(prettier), "--list-different", "."], cwd=WEB,
capture_output=True, text=True)
if p.returncode not in (0, 1):
return False, [f"prettier 执行失败(exit={p.returncode}):{(p.stderr or p.stdout)[:300]}"]
unformatted = [l.strip() for l in p.stdout.splitlines() if l.strip()]
if not BASELINE.is_file():
return False, [f"找不到 {BASELINE.relative_to(ROOT)} —— 没有基线就不能判断'新欠账'"]
try:
baseline = json.loads(BASELINE.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
return False, [f"{BASELINE.name} 解析失败:{e}"]
problems, notes = compare(unformatted, baseline, dt.date.today())
for n in notes:
print(f" · {n}")
return not problems, problems
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--web-only", action="store_true", help="只查前端(调试用,不算全绿)")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
ok = True
print("── 格式检查 ──")
if not args.web_only:
go_ok, go_msgs = check_go()
print(" Go(gofmt):" + ("✅" if go_ok else "❌"))
for m in go_msgs:
print((" " if go_ok else " ❌ ") + m, file=sys.stdout if go_ok else sys.stderr)
ok = ok and go_ok
if not go_ok:
print(" ⇒ 后续检查继续跑(格式债不阻塞其它结论,但总体已经不可能全绿)")
web_ok, web_msgs = check_web()
print(" 前端(prettier):" + ("✅" if web_ok else "❌"))
for m in web_msgs:
print((" " if web_ok else " ❌ ") + m, file=sys.stdout if web_ok else sys.stderr)
ok = ok and web_ok
if args.web_only:
print(" ⚠️ 只跑了前端(--web-only):**不算全绿**")
print("✅ 格式检查通过" if ok else "❌ 格式检查未通过")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))