fix(template): 通用门禁真执行 Python/前端/安全项,预览先核对报告再发布

ci-gate.py:Python 的依赖/格式/静态/构建/测试改成真的执行(uv sync --frozen / uv run ruff / compileall / uv run pytest),工具缺失即失败,不再空跑成功;前端测试把子进程退出码当判据(报告写 pass 但退出 1 也判红),框架按 test-scope.json 声明而不是写死 vitest;清单新增 security(密钥扫描 + 依赖漏洞)。预览逻辑进 ci_preview.py:先调 ci_report.verify(),核对不过一个 publish 请求都不发。检出进 ci_checkout.sh:协议原样保留并断言不降级、URL 不放凭据、顺带取事件 before。workflow 收成单作业,不再用 /tmp 跨作业传文件。
This commit is contained in:
姚章浩
2026-10-12 11:15:31 +08:00
parent ee28c0968f
commit 7090aafefc
7 changed files with 2030 additions and 288 deletions
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""密钥泄漏扫描(检出树 → 退出码)。
为什么自己写而不是用 gitleaks:
· 作业容器里没有 gitleaks,本机也没有;在 CI 运行时联网下载一个二进制,
等于把"扫描器本身"变成供应链风险,而且违反"工具缺失直接失败、不在运行中静默安装"。
· 本仓不需要把源码发给任何外部服务(正本 §7:不得发送项目源码给未授权外部服务)。
⚠️ 代价:规则集比 gitleaks 小。这是**已知缺口**,不是等价替代(见 README/交付说明)。
纪律:
1. **只打印位置与类别,绝不打印命中的值**(正本 §4.5)。掩码也不打 ——
掩码 + 上下文足以在聊天记录里复原短密钥。
2. 扫描范围 = `git ls-files`(检出树里的**被跟踪文件**)。未跟踪的 `.env`、
`secrets/`、`node_modules/` 不在范围里 —— 它们要么被忽略,要么根本不该在检出树里。
3. 每次运行先跑 `--self-test`(用合成密钥夹具 + 良性对照):
一个不再命中的扫描器比没有扫描器更糟,它会给人"已经查过"的错觉(正本 §0-2)。
用法:
python3 scripts/check_secrets.py # 扫描 + 先跑自检
python3 scripts/check_secrets.py --no-self-test # 只扫描(调试用)
python3 scripts/check_secrets.py --self-test # 只跑自检
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# ── 规则集:每条都必须能定位到具体文件/行,且必须能被 --self-test 证明"真的会命中" ──
# 形状:(rule_id, 类别, 正则, 密钥分组名或 None)
# 「密钥分组」= 命中里"真的是秘密"的那一段,用来判断它是不是占位符 / 模板表达式。
# 没有分组的规则(私钥标记、固定前缀的令牌)整段都算秘密。
RULES: list[tuple[str, str, re.Pattern[str], str | None]] = [
("private-key", "私钥块",
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"), None),
("aws-ak", "AWS Access Key ID", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), None),
("aliyun-ak", "阿里云 AccessKey ID", re.compile(r"\bLTAI[0-9A-Za-z]{12,}\b"), None),
("forge-token", "代码托管平台令牌",
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|glpat-[A-Za-z0-9_\-]{20,})\b"), None),
("jwt", "JWT",
re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b"), None),
# 口令段只收"干净字符":`***`(脱敏)、`{u.quote(password)}`(模板表达式)、
# `$PGPASSWORD`(变量)都不该命中 —— 那三种在 scripts/*.sh 里到处都是,收了就是永远的假红。
("pg-url", "带口令的数据库连接串",
re.compile(r"\bpostgres(?:ql)?://[A-Za-z0-9_.\-]+:(?P<secret>[A-Za-z0-9+/=_\-]{6,})@"), "secret"),
# 前缀不写 `\b`:`DINGTALK_APP_SECRET` 这类"带前缀的名字"才是最常见的写法,
# 而 `_` 是 word 字符,`\b` 会把它们全部漏掉。
("assigned-secret", "被赋值的密钥字面量",
re.compile(r"(?i)\b[A-Za-z0-9_]*(?:api[_-]?key|app[_-]?secret|client[_-]?secret|access[_-]?key"
r"|secret|token|passwd|password)\b\s*[:=]\s*[\"'](?P<secret>[A-Za-z0-9+/=_\-]{16,})[\"']"), "secret"),
]
# 占位符:整段值就是这些形状时不算密钥(否则模板 / 示例文件会把门禁撑成永远的假红)
PLACEHOLDER = re.compile(
r"(?i)^(?:"
r"<[^>]*>|\{\{[^}]*\}\}|\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|"
r"x{3,}|\.{3,}|\*{3,}|-+|_+|"
r"(?:your|my|some|test|dummy|example|sample|placeholder|fake|redacted|changeme|change[_-]?me|"
r"secret|token|password|api[_-]?key|none|null|todo|tbd)[A-Za-z0-9_\-]*"
r")$"
)
TEXT_EXT = {
".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".yaml", ".yml",
".md", ".sh", ".sql", ".css", ".html", ".env", ".example", ".txt", ".toml", ".ini",
".cfg", ".conf", ".properties", ".xml", ".tsv", ".csv",
}
MAX_BYTES = 2_000_000
def tracked_files() -> list[str]:
"""被跟踪的文件(含未跟踪但已 add 的)。检出树里没有的东西不扫。"""
out = subprocess.run(["git", "ls-files"], cwd=ROOT, capture_output=True, text=True)
if out.returncode != 0:
print("❌ 拿不到 git ls-files —— 扫描范围无法确定,直接失败", file=sys.stderr)
sys.exit(2)
return [l for l in out.stdout.splitlines() if l.strip()]
def looks_textual(path: Path) -> bool:
if path.suffix.lower() not in TEXT_EXT and path.name not in {".env.example", "Makefile", "Dockerfile"}:
return False
try:
if path.stat().st_size > MAX_BYTES:
return False
return b"\x00" not in path.read_bytes()[:4096]
except OSError:
return False
def mask_scan(text: str) -> list[tuple[str, str, int]]:
"""返回 [(rule_id, 类别, 行号)]。**不返回命中的值**。"""
hits = []
for rule_id, label, rx, secret_group in RULES:
for m in rx.finditer(text):
value = (m.group(secret_group) if secret_group else m.group(0)) or ""
if PLACEHOLDER.match(value.strip().strip("\"'")):
continue
hits.append((rule_id, label, text.count("\n", 0, m.start()) + 1))
return hits
def scan(files: list[str]) -> list[tuple[str, str, int]]:
found = []
for rel in files:
p = ROOT / rel
if not looks_textual(p):
continue
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for rule_id, label, line in mask_scan(text):
found.append((rel, f"{rule_id}({label})", line))
return found
# ── 反向自检:合成密钥必须命中,良性对照必须干净 ──
def self_test() -> int:
problems: list[str] = []
# 值都在运行期拼出来:否则本文件自己就会成为一条真命中
synthetic = {
"aws-ak": "AKIA" + "Q7ZP2M4XR9TLB6VW",
"aliyun-ak": "LTAI" + "5tG8kQ2mZx7Vb3Np",
"forge-token": "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8",
"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0." + "s1gn4tur3Pad1234567890ab",
"pg-url": "postgres://kg_admin:" + "hunter2xyz" + "@db.internal:5432/kg",
"assigned-secret": 'DINGTALK_APP_SECRET = "' + "9f2a7c1e4b8d3065" + '"',
# 拼起来写:本机的 pre-commit 钩子会拦"整段私钥头"的字面量 —— 那是**对的**,
# 所以不绕过它,而是让本文件里不出现那段连续文本(扫描逻辑本身不受影响)。
"private-key": "-----BEGIN " + "RSA PRIVATE KEY-----",
}
benign = "\n".join([
'token = os.environ["CI_BRIDGE_TOKEN"]',
'api_key: ${DOBOTAI_API_KEY}',
'password = "<your-password>"',
'X-CI-Token: ${BRIDGE_TOKEN}',
'const url = `${API_BASE}/healthz`',
'PGURL=postgres://user@host/db',
'self.assertEqual(result.Skipped, 1)',
# 下面三条都是**真实误报过**的形状(scripts/*.sh 与 Go 夹具),锁进对照防回归:
'print(f"postgres://{u.quote(user)}:{u.quote(password)}@{host}:{port}/{name}")',
'"postgres://governance:***@postgres/governance"',
'DINGTALK_APP_SECRET = "${DINGTALK_APP_SECRET}"',
])
with tempfile.TemporaryDirectory() as d:
for rule_id, value in synthetic.items():
probe = Path(d) / "probe.txt"
probe.write_text(f"setting {rule_id} here\n{value}\n", encoding="utf-8")
hit_ids = {r for r, _, _ in mask_scan(probe.read_text(encoding="utf-8"))}
if not hit_ids:
problems.append(f"合成夹具没有任何规则命中({rule_id})—— 这条规则是死的")
elif rule_id not in hit_ids:
problems.append(f"合成夹具命中的是 {sorted(hit_ids)},而不是 {rule_id} —— 规则串了")
clean = mask_scan(benign)
if clean:
problems.append(f"良性对照被误报:{clean} —— 门禁会因为正常代码变红(假红)")
if problems:
print("❌ 密钥扫描器自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 密钥扫描器自检通过:{len(synthetic)} 条合成密钥全部命中,良性对照零误报")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--self-test", action="store_true", help="只跑反向自检")
ap.add_argument("--no-self-test", action="store_true", help="跳过自检(调试用,不算全绿)")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
if not args.no_self_test and self_test() != 0:
return 1
files = tracked_files()
found = scan(files)
print(f"── 密钥扫描 ── 范围:{len(files)} 个被跟踪文件(只打印位置与类别,不打印值)")
if found:
print("❌ 疑似密钥泄漏:", file=sys.stderr)
for rel, label, line in sorted(found):
print(f" {rel}:{line} {label}", file=sys.stderr)
print(" 处理:把值挪到 Gitea Secrets / 目标机文件;已入库的要轮换密钥,"
"光删提交不够(历史里还在)。", file=sys.stderr)
return 1
print("✅ 未发现密钥泄漏")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+725
View File
@@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""通用权威门禁(模板版):生成器把它落到新工程的 `scripts/ci_checks.py`。
make verify # = python3 scripts/ci_checks.py
python3 scripts/ci_checks.py --list
python3 scripts/ci_checks.py --self-test
它和 CI 的关系:**.gitea/workflows/build.yml 只调用这一条命令**,不再自己写一套步骤。
判据来自正本 §4.8(必需检查清单与结果口径):
1. 失败即停,但汇总照做:剩下的记 `not_run` 并如实写进报告;
2. `not_run` / `skipped` 都不算过;不适用要在清单里声明 `required_in`;
3. 工具缺失 / 依赖没按锁装 / 输出解析不了 ⇒ **失败**,不当成"检查通过"。
⚠️ 三条"不许假检查"的具体落实(都踩过):
· **每一门语言都必须真的执行**:不支持的语言 / 工具缺失 ⇒ 明确失败,
绝不允许"这个语言我没写检查所以返回成功"(Python 曾经就是这样空跑成功的)。
· **子进程退出码也算判据**:测试报告写 pass,但进程退出 1 ⇒ 失败。
· **前端测试框架按声明来**:不是所有项目都用 vitest;用什么、能不能报出用例数,
都读 `scripts/test-scope.json` 的声明;认不出来 ⇒ 明确失败。
⚠️ 本文件是**模板**:改它等于改以后所有新工程的默认行为。改完请跑 `--self-test`。
"""
from __future__ import annotations
import argparse
import contextlib
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
POLICY_REVISION = "ci-checks/generic-2"
SUCCESS, FAILURE, SKIPPED, NOT_RUN = "success", "failure", "skipped", "not_run"
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
GOVULNCHECK = "golang.org/x/vuln/cmd/govulncheck@v1.1.4" # 锁版本:不静默升级工具
MANIFESTS = ["go.mod", "go.sum", "package.json", "package-lock.json", "pnpm-lock.yaml",
"yarn.lock", "requirements.txt", "pyproject.toml", "uv.lock"]
SCOPE_FILE = ROOT / "scripts" / "test-scope.json"
# 必需检查清单的声明(id → 在哪些档案下必需)。汇总拿它对账,不遍历"跑过的项"。
CHECK_MANIFEST: dict[str, tuple[str, ...]] = {
"identity": ("ci",),
"deps-install": ("local", "ci"),
"format": ("local", "ci"),
"static": ("local", "ci"),
"build": ("local", "ci"),
"tests": ("local", "ci"),
"security": ("local", "ci"),
"manifests-locked": ("local", "ci"),
}
def which(name: str, *extra: str) -> str:
for cand in (shutil.which(name), *extra):
if cand and Path(cand).exists():
return cand
raise FileNotFoundError(name)
def need(name: str, why: str) -> str:
"""工具缺失 ⇒ 抛错(调用方转成 FAILURE)。不允许"没有工具所以跳过"。"""
try:
return which(name, f"/opt/citools/bin/{name}", f"/opt/citools/go/bin/{name}")
except FileNotFoundError:
raise FileNotFoundError(f"{name}({why})")
def go_bin() -> str:
return need("go", "Go 检查需要它;缺失就是红,不跳过")
def gofmt_bin() -> str:
return need("gofmt", "Go 格式检查需要它;缺失就是红,不跳过")
def go_env() -> dict:
env = dict(os.environ)
env["GOFLAGS"] = "-mod=readonly" # 禁止隐式改写 go.mod / go.sum
env.setdefault("GOPROXY", "https://goproxy.cn,direct")
env.setdefault("GOTOOLCHAIN", "local")
cache = Path("/opt/citools-cache")
if cache.is_dir() and os.access(cache, os.W_OK):
for key, sub in (("GOCACHE", "gocache"), ("GOMODCACHE", "gomod"), ("GOPATH", "gopath")):
p = cache / sub
try:
p.mkdir(parents=True, exist_ok=True)
except OSError:
continue
env.setdefault(key, str(p))
return env
def digest(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else "missing"
def detect():
"""识别本仓的语言入口。识别不到 ⇒ 调用方判红(不许"跳过编译然后成功")。"""
langs = []
if (ROOT / "go.mod").is_file():
langs.append("go")
if any((ROOT / f).is_file() for f in ("pyproject.toml", "requirements.txt", "setup.py")):
langs.append("python")
web = next((d for d in (ROOT / "web", ROOT, ROOT / "frontend", ROOT / "ui")
if (d / "package.json").is_file()), None)
if web is not None:
langs.append("node")
return langs, web
def load_scope() -> tuple[dict, list[str]]:
"""读 `scripts/test-scope.json`:前端测试框架声明 + 允许跳过的用例。
严格 schema("不能用空对象逃逸"):
· optional 条目必须给出 package 或 test **至少一个**,否则通配任意跳过;
· 必须有 reason 与 owner;package 级条目还要给 expected_skips(跳过数不能悄悄涨);
· review_by 过期 ⇒ 整份声明失效(红)。
"""
if not SCOPE_FILE.is_file():
return {}, [f"找不到 {SCOPE_FILE.relative_to(ROOT)}:测试范围/前端框架声明不出来"]
try:
raw = json.loads(SCOPE_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
return {}, [f"{SCOPE_FILE.name} 解析失败:{e}"]
if not isinstance(raw, dict):
return {}, [f"{SCOPE_FILE.name} 顶层必须是对象"]
problems: list[str] = []
today = dt.date.today()
entries = raw.get("optional") or []
if not isinstance(entries, list):
problems.append("optional 必须是数组")
entries = []
for i, e in enumerate(entries):
if not isinstance(e, dict):
problems.append(f"optional[{i}] 不是对象")
continue
if not e.get("package") and not e.get("test"):
problems.append(f"optional[{i}] 既没有 package 也没有 test —— 它会通配任意跳过(不许这样)")
for f in ("reason", "owner"):
if not str(e.get(f) or "").strip():
problems.append(f"optional[{i}] 缺 {f}")
if e.get("package") and "expected_skips" not in e:
problems.append(f"optional[{i}](package 级)必须给 expected_skips:跳过数不许无声明地涨")
rb = str(e.get("review_by") or "").strip()
if not rb:
problems.append(f"optional[{i}] 缺 review_by(到期日)—— 永久豁免不允许")
else:
try:
if dt.date.fromisoformat(rb) < today:
problems.append(f"optional[{i}] 的 review_by {rb} 已过期,重新走一遍评审")
except ValueError:
problems.append(f"optional[{i}] 的 review_by 不是 YYYY-MM-DD")
return raw, problems
def run(argv, cwd=None, env=None):
p = subprocess.run(argv, cwd=cwd or ROOT, env={**os.environ, **(env or {})},
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
# ── 判定纯函数:--self-test 直接打它们 ──────────────────────────────────
def judge_frontend(rc: int, report: dict | None, declared: str | None,
evidence: str | None, scope_problems: list[str]) -> tuple[str, str]:
"""前端测试的判据。两件事必须都想清楚:
· **子进程退出码也是判据**:报告写 pass 但进程退出非零 ⇒ 失败(实测会漏);
· **框架不写死 vitest**:用声明/依赖里认出来的框架;认不出来 ⇒ 明确失败
("认不出用什么测"不等于"测过了")。
"""
if scope_problems:
return FAILURE, ";".join(scope_problems)
if declared and declared not in ("vitest", "jest"):
return FAILURE, f"test-scope.json 声明的 frontend_runner={declared!r} 不在支持列表(vitest/jest)"
if rc != 0:
return FAILURE, f"前端测试进程退出码 {rc}(报告就算写 pass 也不算过)"
if evidence == "exitcode":
return FAILURE, ("声明 frontend_evidence=exitcode 不足以判定:"
"空套件与用例数都不再可知。请换成能报用例数的框架,"
"或由人批准后在清单里显式声明这条缺口。")
if not report:
return FAILURE, "前端测试没有可解析的用例报告(既不认框架也没有 JSON)—— 不允许只凭退出码"
total = int(report.get("numTotalTests") or 0)
failed = int(report.get("numFailedTests") or 0)
pend = int(report.get("numPendingTests") or 0) + int(report.get("numTodoTests") or 0)
npass = int(report.get("numPassedTests") or 0)
detail = f"通过 {npass} / 失败 {failed} / 待跑 {pend} / 共 {total}"
if failed:
return FAILURE, f"前端用例失败:{detail}"
if total == 0:
return FAILURE, f"前端一个用例都没执行(空套件不算通过):{detail}"
if pend:
return FAILURE, f"前端有未执行用例(skip/todo):{detail}"
return SUCCESS, detail
def judge_python_tests(rc: int, out: str) -> tuple[str, str]:
"""pytest 退出码有语义:0 全过 / 1 有用例失败 / 2 中断 / 5 一个用例都没收集到。"""
if rc == 5:
return FAILURE, "pytest 一个用例都没收集到(空套件不能算通过)"
if rc == 0:
tail = [l for l in out.splitlines() if l.strip()][-1:] or [""]
return SUCCESS, f"pytest 退出 0|{tail[0][:120]}"
return FAILURE, f"pytest 退出码 {rc}(1=有用例失败,2=中断,3=内部错误,4=用法错误)"
def manifest_mismatch(declared: dict, actual: list[tuple[str, tuple[str, ...]]]) -> list[str]:
problems, seen = [], {cid: tuple(req) for cid, req in actual}
for cid in declared:
if cid not in seen:
problems.append(f"清单声明了 {cid},实现里没有")
for cid, req in seen.items():
if cid not in declared:
problems.append(f"实现里有 {cid},清单没声明")
elif tuple(declared[cid]) != req:
problems.append(f"{cid} 档案不一致:清单 {declared[cid]} vs 实现 {req}")
return problems
def aggregate(results: list[dict], profile: str,
declared: dict[str, tuple[str, ...]] | None = None) -> tuple[str, list[str]]:
declared = declared if declared is not None else CHECK_MANIFEST
problems, counts = [], {}
for r in results:
cid = str(r.get("id") or "")
counts[cid] = counts.get(cid, 0) + 1
if cid not in declared:
problems.append(f"报告里有清单之外的检查项 {cid}")
continue
if profile not in set(declared[cid]):
if r["status"] in (FAILURE, "unknown"):
problems.append(f"{cid} 在 {profile} 档案下不要求,但状态是 {r['status']}")
continue
if r["status"] != SUCCESS:
problems.append(f"{cid} 状态 {r['status']}|{str(r.get('reason') or '')[:160]}")
for cid, n in counts.items():
if n > 1:
problems.append(f"{cid} 重复出现 {n} 次")
for cid, req in declared.items():
if profile in set(req) and cid not in counts:
problems.append(f"清单里的必需项 {cid} 在报告里缺失")
return (SUCCESS if not problems else FAILURE), problems
def self_test() -> int:
problems: list[str] = []
cases = [
("全绿", [{"id": "a", "status": SUCCESS, "reason": ""}], "local", 0),
("失败", [{"id": "a", "status": FAILURE, "reason": ""}], "local", 1),
("缺失", [], "local", 1),
("重复", [{"id": "a", "status": SUCCESS}, {"id": "a", "status": SUCCESS}], "local", 1),
("未知项", [{"id": "a", "status": SUCCESS}, {"id": "zz", "status": SUCCESS}], "local", 1),
]
for name, results, profile, want in cases:
_, got = aggregate(results, profile, {"a": ("local", "ci")})
if len(got) != want:
problems.append(f"汇总 {name}:期望 {want} 个问题,实际 {len(got)}:{got}")
ok_rep = {"numTotalTests": 3, "numPassedTests": 3, "numFailedTests": 0,
"numPendingTests": 0, "numTodoTests": 0}
fe = [
("rc=0 + 报告全过 ⇒ 绿", (0, ok_rep, "vitest", None, []), True),
("rc=1 + 报告写 pass ⇒ 红", (1, ok_rep, "vitest", None, []), False),
("空套件 ⇒ 红", (0, {"numTotalTests": 0}, "vitest", None, []), False),
("有 todo ⇒ 红", (0, {**ok_rep, "numTodoTests": 1}, "vitest", None, []), False),
("没有报告且未声明 ⇒ 红", (0, None, "vitest", None, []), False),
("声明只用退出码 ⇒ 仍然红(不足以判定)", (0, None, None, "exitcode", []), False),
("声明了不支持的框架 ⇒ 红", (0, ok_rep, "mocha", None, []), False),
("范围声明有问题 ⇒ 红", (0, ok_rep, "vitest", None, ["optional[0] 缺 owner"]), False),
]
for name, cargs, want in fe:
status, why = judge_frontend(*cargs)
if (status == SUCCESS) != want:
problems.append(f"前端 {name}:期望 {'绿' if want else '红'},实际 {status}({why})")
for name, (rc, want) in {"0 ⇒ 绿": (0, True), "1 ⇒ 红": (1, False),
"5(没收集到用例)⇒ 红": (5, False)}.items():
status, _ = judge_python_tests(rc, "1 passed")
if (status == SUCCESS) != want:
problems.append(f"pytest {name}:实际 {status}")
if not FULL_SHA.match("a" * 40) or FULL_SHA.match("a" * 12):
problems.append("完整 SHA 形状判定不对")
if not manifest_mismatch({"a": ("local",)}, [("a", ("local",)), ("b", ("ci",))]):
problems.append("清单比对没报出实现比清单多一项")
if problems:
print("❌ 门禁自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 门禁自检通过:汇总 {len(cases)} 分支 + 前端 {len(fe)} 判据"
f"(含「进程非零必红」「不写死 vitest」)+ pytest 退出码语义 + 完整 SHA + 清单比对")
return 0
# ── 各检查项 ─────────────────────────────────────────────────────────────
def c_identity() -> tuple[str, str]:
want = (os.environ.get("GITHUB_SHA") or "").strip()
if not want:
return SKIPPED, "本地运行没有事件负载(GITHUB_SHA 未设置);CI 上这一项是必需项"
if not FULL_SHA.match(want):
return FAILURE, f"事件 SHA 不是完整 40 位:{want[:20]!r}"
rc, out, _ = run(["git", "rev-parse", "--verify", "HEAD"])
got = out.strip() if rc == 0 else ""
if not FULL_SHA.match(got):
return FAILURE, f"取不到完整 HEAD:{got[:20]!r}"
if got != want:
return FAILURE, f"实际 HEAD {got[:12]} != 事件 SHA {want[:12]}"
return SUCCESS, f"检出 {got} 与事件完整 SHA 逐字符相同"
def c_deps() -> tuple[str, str]:
langs, web = detect()
if "node" in langs:
assert web is not None
pm = which("npm")
if not (web / "package-lock.json").is_file() and not (web / "pnpm-lock.yaml").is_file():
return FAILURE, f"{web.name}/ 下没有锁文件:不接受无约束安装(先提交锁文件)"
cmd = ([pm, "ci", "--prefix", str(web)] if (web / "package-lock.json").is_file()
else [which("pnpm"), "install", "--frozen-lockfile", "--dir", str(web)])
rc, out, err = run(cmd)
if rc:
return FAILURE, f"依赖安装失败(锁与清单不一致):{(err or out).strip()[-200:]}"
if "go" in langs:
rc, out, err = run([go_bin(), "mod", "download"], env=go_env())
if rc:
return FAILURE, f"go mod download 失败:{(err or out).strip()[-200:]}"
if "python" in langs:
uv = need("uv", "Python 依赖按锁文件安装需要 uv;没有就明确失败,不空跑")
if (ROOT / "uv.lock").is_file():
rc, out, err = run([uv, "sync", "--frozen"])
elif (ROOT / "requirements.txt").is_file():
rc, out, err = run([uv, "pip", "install", "--system", "-r", "requirements.txt"])
else:
rc, out, err = run([uv, "sync"])
if rc:
return FAILURE, f"Python 依赖安装失败:{(err or out).strip()[-200:]}"
if not langs:
return FAILURE, "识别不到任何依赖入口(go.mod / pyproject.toml / package.json 都没有)"
return SUCCESS, f"依赖按锁文件安装完成({'/'.join(langs)})"
def c_format() -> tuple[str, str]:
langs, web = detect()
problems = []
if "go" in langs:
rc, out, _ = run(["git", "ls-files", "*.go"])
files = out.split()
rc, listed, err = run([gofmt_bin(), "-l", *files]) if files else (0, "", "")
# ⚠️ gofmt -l 列出文件时**退出码仍是 0**:必须判输出
bad = [l for l in listed.splitlines() if l.strip()]
if rc != 0:
problems.append(f"gofmt 执行失败:{err[:120]}")
elif bad:
problems.append(f"{len(bad)} 个 Go 文件未格式化(例:{bad[0]})")
if "python" in langs:
# 工具要**从项目环境里跑**(uv run):锁文件装了哪个版本就用哪个版本,
# 不依赖 PATH 上碰巧存在的 ruff(那会让本地与 CI 跑出不同结果)。
uv = need("uv", "Python 格式检查要经 uv 在项目环境里跑;没有就明确失败,不空跑")
rc, out, err = run([uv, "run", "ruff", "format", "--check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
if rc:
problems.append(f"ruff format --check 非零:{(out or err).strip()[-160:]}")
if "node" in langs and web is not None:
pm = web / "node_modules" / ".bin" / "prettier"
if pm.exists():
rc, out, _ = run([str(pm), "--check", "."], cwd=web)
if rc != 0:
problems.append("前端未格式化(prettier --check 非零)")
else:
problems.append("package.json 已声明 prettier 但 node_modules 里没有它(先 npm ci)")
if not problems:
return SUCCESS, "格式检查通过"
return FAILURE, ";".join(problems)
def c_static() -> tuple[str, str]:
langs, web = detect()
problems = []
if "go" in langs:
rc, out, err = run([go_bin(), "vet", "./..."], env=go_env())
if rc:
problems.append(f"go vet 非零:{(err or out).strip()[-200:]}")
if "python" in langs:
uv = need("uv", "Python 静态检查要经 uv 在项目环境里跑")
rc, out, err = run([uv, "run", "ruff", "check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
if rc:
problems.append(f"ruff check 非零:{(out or err).strip()[-160:]}")
if "node" in langs and web is not None:
pkg = json.loads((web / "package.json").read_text(encoding="utf-8"))
if "typecheck" in (pkg.get("scripts") or {}):
rc, out, err = run([which("npm"), "run", "typecheck", "--prefix", str(web)])
if rc:
problems.append(f"前端类型检查非零:{(err or out).strip()[-200:]}")
else:
problems.append("package.json 里没有 typecheck 脚本 —— 前端静态检查没有可执行入口")
if not problems:
return SUCCESS, "静态检查通过"
return FAILURE, ";".join(problems)
def c_build() -> tuple[str, str]:
langs, web = detect()
problems = []
if "go" in langs:
out_dir = Path(os.environ.get("CI_BUILD_OUT") or (ROOT / "bin"))
out_dir.mkdir(parents=True, exist_ok=True)
rc, out, err = run([go_bin(), "build", "-trimpath", "-o", f"{out_dir}/", "./cmd/..."], env=go_env())
if rc:
problems.append(f"go build 非零:{(err or out).strip()[-200:]}")
if "python" in langs:
# Python 没有"通用产物":这里做**真实**的字节码编译校验(不是空跑)
skip = {"node_modules", "web", "docs", "scripts", ".git", ".venv", "tmp", "build", "dist"}
targets = [p.name for p in sorted(ROOT.iterdir()) if p.is_dir() and p.name not in skip]
rc, out, err = run([sys.executable or "python3", "-m", "compileall", "-q", *(targets or ["."])])
if rc:
problems.append(f"Python 编译校验(compileall)非零:{(out or err).strip()[-200:]}")
if "node" in langs and web is not None:
pkg = json.loads((web / "package.json").read_text(encoding="utf-8"))
if "build" in (pkg.get("scripts") or {}):
rc, out, err = run([which("npm"), "run", "build", "--prefix", str(web)])
if rc:
problems.append(f"前端构建非零:{(err or out).strip()[-200:]}")
else:
problems.append("package.json 里没有 build 脚本 —— 构建没有可执行入口")
if not problems:
return SUCCESS, "构建/编译校验通过(" + "/".join(langs) + ")"
return FAILURE, ";".join(problems)
def c_tests() -> tuple[str, str]:
langs, web = detect()
scope, scope_problems = load_scope()
problems, notes = [], []
optional = scope.get("optional") or []
def optional_hit(pkg: str, test: str) -> dict | None:
for e in optional:
if e.get("package") and e["package"] != pkg:
continue
if e.get("test") and e["test"] != test:
continue
return e
return None
if "go" in langs:
rc, out, err = run([go_bin(), "test", "-count=1", "-json", "./..."], env=go_env())
passed = failed = 0
required_skips: list[str] = []
opt_counts: dict[str, int] = {}
reasons: dict[str, str] = {}
for line in out.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
pkg, test = ev.get("Package") or "", ev.get("Test") or ""
m = re.match(r"\s*\S+_test\.go:\d+:\s*(.+)$", ev.get("Output") or "")
if m and test:
reasons[test] = m.group(1).strip()
if not test:
continue
if ev.get("Action") == "pass":
passed += 1
elif ev.get("Action") == "fail":
failed += 1
elif ev.get("Action") == "skip":
if optional_hit(pkg, test):
opt_counts[pkg] = opt_counts.get(pkg, 0) + 1
else:
required_skips.append(f"{pkg}::{test}({reasons.get(test, '未取到原因')})")
if failed or rc != 0:
problems.append(f"go 测试失败 {failed} 个:{(err or out).strip()[-200:]}")
elif passed == 0:
problems.append("go 测试一个用例都没执行 —— 无测试不能算全绿")
elif required_skips:
problems.append("必需用例被跳过(未在 scripts/test-scope.json 里声明):"
+ ";".join(required_skips[:3]))
for e in optional:
if e.get("package") and "expected_skips" in e:
got = opt_counts.get(e["package"], 0)
if got > int(e["expected_skips"]):
problems.append(f"{e['package']} 的跳过数 {got} > 声明的 expected_skips "
f"{e['expected_skips']}(涨了就要重新声明)")
notes.append(f"go 用例 通过 {passed} / 失败 {failed} / 已声明可选未运行 {sum(opt_counts.values())}")
if "python" in langs:
uv = need("uv", "Python 测试要按锁文件跑(uv run pytest);没有 uv 就明确失败")
rc, out, err = run([uv, "run", "pytest", "-q"])
status, why = judge_python_tests(rc, out or err)
notes.append(f"pytest 退出码 {rc}")
if status != SUCCESS:
problems.append(why)
if "node" in langs and web is not None:
pkg = json.loads((web / "package.json").read_text(encoding="utf-8"))
dev = {**(pkg.get("devDependencies") or {}), **(pkg.get("dependencies") or {})}
declared = scope.get("frontend_runner")
evidence = scope.get("frontend_evidence")
runner = declared or ("vitest" if "vitest" in dev else ("jest" if "jest" in dev else None))
rep = None
rc = 1
with contextlib.ExitStack() as stack:
if runner == "vitest":
d = stack.enter_context(tempfile.TemporaryDirectory())
out_file = Path(d) / "vitest.json"
rc, out, err = run([which("npm"), "run", "test", "--prefix", str(web), "--",
"--reporter=json", f"--outputFile={out_file}"])
if out_file.is_file():
try:
rep = json.loads(out_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
problems.append(f"vitest 报告解析失败:{e}")
elif runner == "jest":
d = stack.enter_context(tempfile.TemporaryDirectory())
out_file = Path(d) / "jest.json"
rc, out, err = run([which("npm"), "run", "test", "--prefix", str(web), "--",
"--json", f"--outputFile={out_file}"])
if out_file.is_file():
try:
j = json.loads(out_file.read_text(encoding="utf-8"))
rep = {"numTotalTests": j.get("numTotalTests"),
"numPassedTests": j.get("numPassedTests"),
"numFailedTests": j.get("numFailedTests"),
"numPendingTests": j.get("numPendingTests")}
except json.JSONDecodeError as e:
problems.append(f"jest 报告解析失败:{e}")
else:
rc, out, err = run([which("npm"), "run", "test", "--prefix", str(web)])
status, why = judge_frontend(rc, rep, runner, evidence, scope_problems)
notes.append(f"前端框架 {runner or '未声明/未识别'}|退出码 {rc}")
if status != SUCCESS:
problems.append(why)
if not problems:
return SUCCESS, "测试通过|" + "|".join(notes)
return FAILURE, ";".join(problems) + "|" + "|".join(notes)
def c_security() -> tuple[str, str]:
"""密钥扫描 + 依赖漏洞。工具缺失 / 库不可达 ⇒ 失败(不许伪装成"没有漏洞")。"""
langs, web = detect()
problems, notes = [], []
scanner = ROOT / "scripts" / "check_secrets.py"
if not scanner.is_file():
problems.append("找不到 scripts/check_secrets.py(密钥扫描器):缺失即红,不跳过")
else:
rc, out, err = run([sys.executable or "python3", str(scanner)])
if rc == 0:
notes.append("密钥扫描通过")
else:
problems.append(f"密钥扫描未通过:{(out or err).strip()[-200:]}")
if "go" in langs:
rc, out, err = run([go_bin(), "run", GOVULNCHECK, "./..."], env=go_env())
text = out + err
if "No vulnerabilities found" in text:
notes.append("govulncheck:无命中")
elif rc == 0 and "Vulnerability" not in text:
notes.append("govulncheck:无命中")
else:
problems.append(f"govulncheck 有命中或未完成:{text.strip()[-200:]}")
if "node" in langs and web is not None:
if not (web / "package-lock.json").is_file():
problems.append("前端没有锁文件 ⇒ npm audit 无法按锁判定")
else:
rc, out, err = run([which("npm"), "audit", "--omit=dev", "--json"], cwd=web)
try:
data = json.loads(out or "{}")
v = ((data.get("metadata") or {}).get("vulnerabilities") or {})
high = int(v.get("high") or 0) + int(v.get("critical") or 0)
if high:
problems.append(f"生产依赖有 {high} 个 high/critical 漏洞")
else:
notes.append(f"npm audit(生产依赖):0 high/critical")
except json.JSONDecodeError:
problems.append(f"npm audit 输出解析不了(退出码 {rc})—— 未完成不算过")
if "python" in langs:
problems.append("Python 依赖漏洞扫描尚未接入(pip-audit 需另行审批)—— 显式缺口,不默认为通过")
if not problems:
return SUCCESS, "安全门禁通过|" + "|".join(notes)
return FAILURE, ";".join(problems) + "|" + "|".join(notes)
def c_manifests(before: dict) -> tuple[str, str]:
after = {f: digest(ROOT / f) for f in MANIFESTS}
drift = [f for f in MANIFESTS if before.get(f) != after.get(f)]
if drift:
return FAILURE, f"依赖清单在门禁过程中被改写了:{drift}(改写必须由人显式提交)"
n = len([f for f in MANIFESTS if (ROOT / f).is_file()])
return SUCCESS, f"依赖清单未被改写({n} 个文件)"
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--list", action="store_true")
ap.add_argument("--only", default="", help="只跑指定检查(调试用;**不算全绿**)")
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--report", default=str(ROOT / os.environ.get("CI_REPORT_NAME", "ci-report.json")))
ap.add_argument("--profile", default="ci" if os.environ.get("GITHUB_ACTIONS") else "local",
choices=["local", "ci"])
args = ap.parse_args(argv)
if args.self_test:
return self_test()
langs, web = detect()
if args.list:
print(f"── 必需检查清单(策略 {POLICY_REVISION})──")
for cid, req in CHECK_MANIFEST.items():
print(f" {cid:<18} {'/'.join(req)}")
print(f" 识别到的语言入口:{langs or '(无 —— 会判红)'}")
_, scope_problems = load_scope()
for p in scope_problems:
print(f" ⚠️ 范围声明:{p}")
return 0
before = {f: digest(ROOT / f) for f in MANIFESTS}
results: list[dict] = []
stopped = False
started = time.time()
def record(cid: str, status: str, reason: str, dur: float = 0.0) -> None:
results.append({"id": cid, "status": status, "reason": reason,
"required_in": list(CHECK_MANIFEST[cid]), "duration_s": round(dur, 2)})
steps = [("identity", None), ("deps-install", c_deps), ("format", c_format),
("static", c_static), ("build", c_build), ("tests", c_tests),
("security", c_security)]
want = [x for x in args.only.split(",") if x] if args.only else None
if want:
unknown = [x for x in want if x not in CHECK_MANIFEST]
if unknown:
print(f"❌ 没有这些检查 id:{unknown}", file=sys.stderr)
return 2
steps = [s for s in steps if s[0] in want]
print(f"⚠️ --only {args.only}:只跑了子集,**不算全绿**(报告里 overall_status 会写 not_run_only)")
for cid, fn in steps:
if stopped:
record(cid, NOT_RUN, "前一项失败即停,未运行")
continue
print(f"── [{cid}] ──")
if fn is None:
status, reason = c_identity()
record(cid, status, reason)
stopped = status == FAILURE
print(f" {reason}")
continue
if not langs:
status, reason = FAILURE, "识别不到任何语言入口(go.mod / pyproject.toml / package.json)"
record(cid, status, reason)
stopped = True
print(f" ❌ {reason}")
continue
t0 = time.time()
try:
status, reason = fn()
except FileNotFoundError as e:
status, reason = FAILURE, f"工具缺失:{e}"
record(cid, status, reason, time.time() - t0)
stopped = status != SUCCESS
print(f" {'✅' if status == SUCCESS else '❌'} {reason}")
if want is None:
run_manifests = True
else:
run_manifests = "manifests-locked" in want
if run_manifests:
if stopped:
record("manifests-locked", NOT_RUN, "前一项失败即停,未做改写核对")
else:
status, reason = c_manifests(before)
record("manifests-locked", status, reason)
# `--only` 时"必需清单"就是被选中的那几项,否则汇总会把没选的项报成"缺失"(噪声)
selected = {s[0] for s in steps} | ({"manifests-locked"} if run_manifests else set())
declared = ({cid: CHECK_MANIFEST[cid] for cid in selected} if args.only else CHECK_MANIFEST)
overall, problems = aggregate(results, args.profile, declared)
extra = []
if os.environ.get("GITHUB_ACTIONS") and args.profile != "ci":
extra.append("运行环境是 CI 但档案是 local —— identity 会被合法跳过,等于关掉身份核对")
sha = (os.environ.get("GITHUB_SHA") or "").strip()
if not sha:
rc, out, _ = run(["git", "rev-parse", "--verify", "HEAD"])
sha = out.strip() if rc == 0 else ""
if not FULL_SHA.match(sha or ""):
extra.append(f"commit_sha 不是完整 40 位({sha[:20]!r})—— 仓库是不是还没有提交?")
problems += extra
if args.only:
overall = "not_run_only" if overall == SUCCESS and not problems else FAILURE
elif problems and overall == SUCCESS:
overall = FAILURE
report = {
"schema": "ci-report/1", "policy_revision": POLICY_REVISION,
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
"commit_sha": sha, "event_sha": (os.environ.get("GITHUB_SHA") or "").strip(),
"branch": os.environ.get("GITHUB_REF_NAME", ""), "event": os.environ.get("GITHUB_EVENT_NAME", "local"),
"run_id": os.environ.get("GITHUB_RUN_ID", ""), "profile": args.profile, "run_mode": "full",
"duration_s": round(time.time() - started, 2), "checks": results,
"manifest": {cid: list(req) for cid, req in CHECK_MANIFEST.items()},
"overall_status": overall, "problems": problems,
"notification_status": "not_attempted", "deployed": False,
}
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("\n════════ 汇总 ════════")
print(" " + "|".join(f"{r['id']}={r['status']}" for r in results))
print(f" 总体:{overall}(档案 {args.profile}) 报告:{args.report}")
for p in problems:
print(f" ❌ {p}", file=sys.stderr)
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# 检出:固定到**事件的完整 SHA**,协议按服务端给的来(不许静默降级成 http),URL 里不放凭据。
#
# 为什么要单独成一个脚本(而不是写在 workflow 的 `run:` 里):
# workflow 里的 shell 没法在本地跑,也就没法给它补负例 —— 而"协议被降级""remote 里带了令牌"
# 这两件事恰恰是**只有真跑一遍才看得出来**的。
#
# 用法:
# .gitea/scripts/ci_checkout.sh # CI 里跑(读下面的环境变量)
# .gitea/scripts/ci_checkout.sh --print-remote # 只打印将要使用的 remote(本地测协议用,不联网)
#
# 环境变量(CI 提供):
# GITHUB_SERVER_URL / GITHUB_REPOSITORY / GITHUB_REF_NAME / GITHUB_SHA
# GITHUB_TOKEN(步骤级注入)/ GITHUB_WORKSPACE / GITHUB_EVENT_PATH(用来取 before)
set -uo pipefail
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
WS="${GITHUB_WORKSPACE:-$(pwd)}"
BR="${GITHUB_REF_NAME:-${GITHUB_REF:-}}"
BR="${BR#refs/heads/}"
EVENT_SHA="${GITHUB_SHA:-}"
# ── ① 协议:**保留服务端给的协议**,并显式禁止 https → http 的降级 ──────────────
case "$SRV" in
https://*|http://*) ;;
*) echo "❌ GITHUB_SERVER_URL 的协议不认识:${SRV:0:40}"; exit 1 ;;
esac
case "$SRV" in
*[[:space:]]*) echo "❌ GITHUB_SERVER_URL 里有空白字符"; exit 1 ;;
esac
case "$SRV" in https://*) WANT_SCHEME="https" ;; *) WANT_SCHEME="http" ;; esac
REMOTE="${SRV%/}/${GITHUB_REPOSITORY}.git"
case "$REMOTE" in https://*) GOT_SCHEME="https" ;; http://*) GOT_SCHEME="http" ;; *) GOT_SCHEME="?" ;; esac
if [ "$GOT_SCHEME" != "$WANT_SCHEME" ]; then
echo "❌ remote 的协议被降级了:拼出来是 $GOT_SCHEME,服务端给的是 $WANT_SCHEME"
exit 1
fi
# 凭据一律走 credential helper(URL 内嵌凭据会进 ps / 配置 / 日志)
case "$REMOTE" in *@*) echo "❌ remote URL 里带上了凭据"; exit 1 ;; esac
if [ "${1:-}" = "--print-remote" ]; then
echo "$REMOTE"
exit 0
fi
[ -n "$EVENT_SHA" ] || { echo "❌ 没有 GITHUB_SHA:检出必须固定到事件的完整 SHA"; exit 1; }
cd "$WS" || exit 1
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "$REMOTE"
[ -n "${GITHUB_TOKEN:-}" ] || { echo "❌ 没有拿到读仓库用的令牌(GITHUB_TOKEN 为空)"; exit 1; }
gitauth() { git -c credential.helper='!f() { printf "username=x-access-token\npassword=%s\n" "$GITHUB_TOKEN"; }; f' "$@"; }
gitauth fetch -q --depth 50 origin "$BR"
git checkout -q -B "$BR" FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
echo "⚠️ 分支 $BR 的 HEAD 已经不等于事件 SHA(有人在之后又推了一次)—— 改按精确 SHA 取"
gitauth fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
GOT="$(git rev-parse HEAD)"
if [ "$GOT" != "$EVENT_SHA" ]; then
echo "❌ 检出 SHA 与事件 SHA 不一致:实际 $GOT / 事件 $EVENT_SHA"
echo " (门禁的 identity 项会再拦一次;这里先失败,避免拿错误的代码下结论)"
exit 1
fi
# ── ② 把 before 也取下来:否则"本次推送范围 before..after"在浅克隆里算不出来 ──
# 不取的话,范围校验要么报错要么静默收缩 —— 两种都不是"检查过本次改动"。
BEFORE=""
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then
BEFORE="$(python3 -c 'import json,os,sys
try:
d = json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8"))
print(d.get("before") or "")
except Exception:
print("")' 2>/dev/null || echo "")"
fi
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
echo "✅ 事件 before ${BEFORE:0:12} 已在克隆里(本次推送范围可直接算)"
else
gitauth fetch -q --depth 50 origin "$BEFORE" 2>/dev/null \
&& echo "✅ 已补取事件 before ${BEFORE:0:12}(本次推送范围可算)" \
|| echo "⚠️ 取不到事件 before ${BEFORE:0:12}:范围校验会明确失败,不会静默收缩"
fi
else
echo "(事件里没有 before / before 为全 0:新分支推送或手工运行,范围按 after 判)"
fi
# 令牌不该出现在 remote 配置里:顺手断言一次(防有人改回去)
if git remote get-url origin | grep -q '@'; then
echo "❌ remote URL 里带上了凭据:$(git remote get-url origin | sed 's#//.*@#//***@#')"
exit 1
fi
git rev-list --count FETCH_HEAD > "${WS}/.ci-commits-count" 2>/dev/null || echo 1 > "${WS}/.ci-commits-count"
: > "${WS}/.ci-preview-url"
echo "✅ 已检出 ${GITHUB_REPOSITORY} 分支 $BR @ ${GOT:0:12}(== 事件完整 SHA,协议 $GOT_SCHEME)"
git log -1 --format=' %h %an %s'
echo "CI_COMMITS_COUNT_FILE=${WS}/.ci-commits-count" >> "${GITHUB_ENV:-/dev/null}"
+350 -34
View File
@@ -3,19 +3,39 @@
规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。
放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。
**校验范围必须是"本次这一批"**,按优先级取,取不到就报错 —— 不回退成"最近 N 条":
1. `CI_COMMIT_RANGE=A..B`(显式指定,调试/重放用);
2. Gitea 事件负载:`commits[]`(推送事件自带本次推送的每一条);
并且核对负载里的 `after` 与当前 HEAD 一致 —— 不一致说明事件与检出不是同一批;
3. 本地:`git log --not --remotes`(还没推到任何远端的提交 = 本次要推的范围);
本地**一个远端都没有**时,明确只核 HEAD(`--not --remotes` 在那种情况下会退化成整部历史,
连 Initial commit 都算进来 —— 那不是"本次改动")。
为什么不能退化成 `git log -n 5`:那个范围和"本次改动"没有任何关系。
典型假红/假绿两种情况都会发生 —— 五条里混进一条历史坏记录(假红,且红在别人头上),
或者本次推的第 6 条以后有坏记录(假绿,直接放过去)。两种都不是"检查",是噪声。
用法:
python3 .gitea/scripts/ci_gate.py # 按上面的优先级取范围
python3 .gitea/scripts/ci_gate.py --range HEAD~1..HEAD # 显式指定
python3 .gitea/scripts/ci_gate.py --self-test # 反向自检(临时仓库,四种范围)
"""
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
RE = re.compile(r"^(?:" + TYPES + r")(?:\([a-zA-Z0-9._/-]+\))?!?: .{3,}$")
ALLOW = re.compile(r'^(?:Merge |Revert "|fixup! |squash! |amend! )')
HELP = """
\u274c 提交信息不合规,已拦截。
❌ 提交信息不合规,已拦截。
规范 : <类型>(<范围>): <简短描述>
类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑)
@@ -25,35 +45,19 @@ HELP = """
fix(parser): 修复表格解析越界导致的 500
docs: 补充 OSS 短签名接口说明
要点 : 类型小写、冒号后一个空格、描述 \u22653 字符
标题 \u226472 字符、结尾不加句号
要点 : 类型小写、冒号后一个空格、描述 ≥3 字符
标题 ≤72 字符、结尾不加句号
破坏性变更类型后加 !,正文写 BREAKING CHANGE:
详见 : docs/提交规范.md
"""
def collect():
"""优先用 Gitea 事件负载里的本次推送提交,取不到则退回 git log。"""
msgs = []
ev = os.environ.get("GITHUB_EVENT_PATH") or ""
if ev and os.path.exists(ev):
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
for c in (d.get("commits") or []):
m = (c.get("message") or "").strip()
if m:
msgs.append(m)
except Exception as e:
print(" 事件负载解析失败(%s),回退到 git log" % e)
if not msgs:
out = subprocess.run(["git", "log", "-n", "5", "--format=%B%x00"],
capture_output=True, text=True).stdout
msgs = [x.strip() for x in out.split("\x00") if x.strip()]
return msgs
def git(*args, cwd=None):
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def subject_of(m):
def subject_of(m: str) -> str:
for line in m.splitlines():
s = line.strip()
if not s or s.startswith("#"):
@@ -62,7 +66,7 @@ def subject_of(m):
return ""
def judge(subj):
def judge(subj: str) -> str:
if not subj:
return "空提交信息"
if ALLOW.match(subj):
@@ -76,29 +80,341 @@ def judge(subj):
return ""
def main():
msgs = collect()
def _range_commits(spec: str, cwd=None):
# spec 可能是一个 rev 范围(A..B),也可能是带开关的写法(--not --remotes)——
# 必须拆成多个参数,整串当**一个** argv 会被 git 判成"未能识别的参数"(实测踩过)。
rc, out, err = git("log", "--format=%H%x00%B%x00%x00", *shlex.split(spec), cwd=cwd)
if rc != 0:
return None, f"范围 {spec} 取不到提交:{(err or out).strip()[:200]}"
msgs = [x for x in out.split("\x00\x00") if x.strip()]
out_msgs = []
for chunk in msgs:
parts = chunk.split("\x00", 1)
out_msgs.append({"sha": parts[0].strip(), "message": parts[1] if len(parts) > 1 else ""})
return out_msgs, ""
def _payload(env):
"""读事件负载。返回 (payload, 错误)。错误非空 ⇒ 范围无法确定。"""
ev = (env.get("GITHUB_EVENT_PATH") or "").strip()
if not ev or not os.path.exists(ev):
return {}, ""
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
except Exception as e:
return None, f"事件负载解析失败({e})—— 范围无法确定,不许放行"
if not isinstance(d, dict):
return None, "事件负载不是 JSON 对象 —— 范围无法确定,不许放行"
return d, ""
def resolve_scope(explicit="", env=None, cwd=None):
"""返回 (提交列表, 来源说明, 错误)。错误非空 ⇒ 不许放行。
**范围以 Git 为准,不拿事件负载里那一串当依据**:负载可能是截断的(实测声明 6 条、
列表只给 1 条),而且负载来自事件、代码来自检出,两者本来就可能不是同一批。
"""
env = env if env is not None else os.environ
in_ci = bool(env.get("GITHUB_ACTIONS"))
zero = "0" * 40
spec = (explicit or env.get("CI_COMMIT_RANGE", "") or "").strip()
if spec:
commits, err = _range_commits(spec, cwd)
if err:
return [], "", err
if not commits and in_ci:
return [], f"显式范围 {spec}", (f"显式范围 {spec} 为空 —— 空范围没有可核的提交,"
f"在 CI 里必须显式拒绝,不许静默通过")
return commits, f"显式范围 {spec}", ""
d, err = _payload(env)
if err:
return [], "", err
event = str(d.get("event_name") or env.get("GITHUB_EVENT_NAME") or "").strip()
after = str(d.get("after") or env.get("GITHUB_SHA") or "").strip()
before = str(d.get("before") or "").strip()
listed = [{"sha": str(c.get("id") or c.get("sha") or ""), "message": str(c.get("message") or "")}
for c in (d.get("commits") or []) if isinstance(c, dict)]
declared_raw = d.get("total_commits")
for label, v in (("before", before), ("after", after)):
if v and not set(v) <= set("0123456789abcdef"):
return [], "", f"事件负载的 {label} 不是 SHA:{v[:20]!r}"
if after and after != zero:
rc, head, _ = git("rev-parse", "HEAD", cwd=cwd)
head = head.strip()
if rc != 0 or head != after:
return [], "", (f"事件负载 after={after[:12]} 与当前 HEAD={head[:12]} 不一致 —— "
f"事件与检出不是同一批,范围无法确定")
# ── ① 推送事件:before..after 是权威范围 ─────────────────────────────
if before and after and after != zero:
if before == zero:
spec2 = f"{after} --not --remotes" # 新分支:远端没有这个分支的历史
src = "新分支推送(before 为全 0)⇒ 范围 = after 之后未被远端覆盖的提交"
else:
spec2 = f"{before}..{after}"
src = f"事件 Git 范围 {before[:12]}..{after[:12]}"
commits, gerr = _range_commits(spec2, cwd)
if gerr:
return [], src, (
f"{gerr}。若这是浅克隆(CI 检出默认 `--depth 50`),before 可能根本不在克隆里 —— "
f"**范围不全就是漏查**,不许放行:提高 fetch 深度,或用 --range 显式指定。")
if not commits:
return [], src, ("本次推送的 Git 范围是空的(before == after?)—— 空范围没有可核的提交,"
"不许静默通过")
if declared_raw not in (None, ""):
try:
declared = int(declared_raw)
except (TypeError, ValueError):
return [], src, f"事件负载 total_commits 不是整数:{declared_raw!r}"
if declared != len(commits):
return [], src, (
f"事件声明 {declared} 条提交,但 Git 范围算出 {len(commits)} 条"
f"(负载列表给了 {len(listed)} 条)—— 两边的'本次范围'对不上,"
f"谁也不能采信,不许放行")
if declared != len(listed):
src += (f"(⚠️ 事件列表被截断:声明 {declared} 条、列表只给 {len(listed)} 条"
f" ⇒ 以 Git 范围为准)")
else:
src += "(与事件列表一致)"
elif len(listed) != len(commits):
src += (f"(⚠️ 事件列表 {len(listed)} 条、Git 范围 {len(commits)} 条"
f" ⇒ 以 Git 范围为准)")
return commits, src, ""
# ── ② 负载里有 commits 但没有 before:用列表,但**没有 Git 范围可交叉核对**,要写明 ──
if listed and not before:
bad = [c["sha"][:12] for c in listed
if not c["sha"] or git("cat-file", "-e", c["sha"], cwd=cwd)[0] != 0]
if bad:
return [], "", (f"事件列表里的提交在检出里不存在:{bad[:3]} —— "
f"事件与检出不是同一批,范围无法确定")
if declared_raw not in (None, ""):
try:
declared = int(declared_raw)
except (TypeError, ValueError):
return [], "", f"事件负载 total_commits 不是整数:{declared_raw!r}"
if declared != len(listed):
return [], "", (f"事件声明 {declared} 条提交、列表只给 {len(listed)} 条,"
f"而这次又没有 before 可以算 Git 范围 —— 列表被截断且无法交叉核对,"
f"不许拿截断后的列表去核")
return listed, ("事件列表 commits[](**没有 before,无法用 Git 范围交叉核对**)"
"—— 已逐条确认这些提交在检出里存在"), ""
# ── ② 手工运行 / 没有 before:与远端默认分支比,比不出来就只核 HEAD(写明)──
if in_ci or event == "workflow_dispatch" or after:
base = ""
rc, out, _ = git("symbolic-ref", "--short", "refs/remotes/origin/HEAD", cwd=cwd)
if rc == 0 and out.strip():
base = out.strip()
else:
_, out2, _ = git("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin", cwd=cwd)
cand = [l.strip() for l in out2.splitlines() if l.strip().endswith(("/main", "/master"))]
base = cand[0] if cand else ""
if base:
commits, gerr = _range_commits(f"{base}..HEAD", cwd)
if gerr:
return [], "", gerr
if commits:
return commits, f"手工运行:取 {base}..HEAD(与远端默认分支比)", ""
note = f"手工运行:{base}..HEAD 为空(HEAD 与远端默认分支一致)"
else:
note = "手工运行:取不到远端默认分支(refs/remotes/origin 里没有 main/master)"
rc, head, _ = git("rev-parse", "HEAD", cwd=cwd)
head = head.strip()
if rc != 0 or not head:
return [], "", note + ",而且取不到 HEAD —— 范围无法确定"
rc2, body, _ = git("log", "-1", "--format=%B", cwd=cwd)
return ([{"sha": head, "message": body}],
note + " ⇒ **只核 HEAD 这一条提交**(已写明:这次比不出范围,不是'最近 N 条')", "")
# ── ③ 本地(无事件负载):未推到远端的提交 ───────────────────────────
# ⚠️ 先看有没有远端引用:**一个远端都没有**时,`--not --remotes` 会退化成"整部历史"
# (连 Initial commit 都算进来)—— 那不是"本次改动",而是一条历史坏记录就能让本地门禁常红。
# 这种情况明确只核 HEAD,并写明(要核更多用 --range)。
_, refs, _ = git("for-each-ref", "--format=%(refname)", "refs/remotes", cwd=cwd)
if not refs.strip():
if in_ci:
return [], "", ("在 CI 里既没有事件范围,也没有任何远端引用可以比 —— "
"范围无法确定,不许静默通过")
rc, head, _ = git("rev-parse", "HEAD", cwd=cwd)
head = head.strip()
if rc != 0 or not head:
return [], "", "本地:没有任何远端引用,也取不到 HEAD —— 范围无法确定"
_, body, _ = git("log", "-1", "--format=%B", cwd=cwd)
return ([{"sha": head, "message": body}],
"本地:没有可比远端(refs/remotes 为空)⇒ **只核 HEAD 这一条提交**"
"(要核更多请用 --range A..B)", "")
commits, err = _range_commits("HEAD --not --remotes", cwd)
if err:
return [], "", err
if commits:
return commits, "本地:范围 = 还没推到远端的提交(git log HEAD --not --remotes)", ""
if in_ci:
return [], "", ("在 CI 里范围为空(既没有事件范围,也没有未推送的提交)—— "
"空范围没有可核的提交,不许静默通过")
return [], "本地:本次范围为空(没有未推送的提交)", ""
def main(argv):
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--range", default="")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
commits, source, err = resolve_scope(explicit=args.range)
if err:
print(f"❌ 提交校验范围无法确定:{err}", file=sys.stderr)
print(" 处理:用 --range A..B 显式指定,或确认事件负载/远端引用可用。"
"(不会退化成'最近 N 条'—— 那个范围与本次改动无关)", file=sys.stderr)
return 1
print(f"校验范围:{source}|{len(commits)} 条提交")
bad = []
print("待校验提交 %d 条:" % len(msgs))
for m in msgs:
subj = subject_of(m)
for c in commits:
subj = subject_of(c["message"])
why = judge(subj)
if why:
bad.append((subj, why))
print(" \u274c [%s] %s" % (why, subj))
print(" ❌ [%s] %s" % (why, subj))
else:
print(" \u2705 %s" % subj)
print(" ✅ %s" % subj)
try:
with open("/tmp/ci-gate-status", "w") as fh:
fh.write("failure" if bad else "success")
except Exception:
except OSError:
pass
if bad:
print(HELP)
return 1
print("\u2705 提交信息全部合规")
print("✅ 本次范围的提交信息全部合规")
return 0
def self_test() -> int:
"""在临时仓库里造"range 准不准",四种情形都要符合预期。"""
problems = []
with tempfile.TemporaryDirectory() as d:
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t"}
base = {**os.environ, **env}
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=d, env=base, check=True)
# 夹具仓库里**不装**本机提交钩子(core.hooksPath 指到一个空目录):
# 这里要构造一条"违规提交"来验证闸门的范围取法,而本机钩子会先把它拦掉。
# 这不是绕过门禁 —— 沙箱仓本来就没有钩子,而且这条提交永远不会离开临时目录。
empty_hooks = os.path.join(d, "no-hooks")
os.makedirs(empty_hooks, exist_ok=True)
base["GIT_CONFIG_COUNT"] = "1"
base["GIT_CONFIG_KEY_0"] = "core.hooksPath"
base["GIT_CONFIG_VALUE_0"] = empty_hooks
def commit(msg):
subprocess.run(["git", "commit", "-q", "--allow-empty", "-F", "-"], cwd=d, env=base,
input=msg, text=True, check=True)
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
text=True).stdout.strip()
good1 = commit("feat(a): 第一条合规\n")
bad = commit("update stuff\n") # 违规,但在历史里
good2 = commit("fix(b): 第三条合规\n")
# ① 范围只含最后一条(干净):即使历史里有违规也不许红 —— 证明不是"最近 N 条"
commits, src, err = resolve_scope(explicit=f"{bad}..{good2}", cwd=d)
if err or len(commits) != 1:
problems.append(f"显式范围没有取到恰好 1 条:{err or len(commits)}")
elif any(judge(subject_of(c["message"])) for c in commits):
problems.append("范围只含合规提交,却判成不合规")
# ② 范围含那条违规:必须红(good1..HEAD 覆盖 bad 与 good2)
commits, src, err = resolve_scope(explicit=f"{good1}..HEAD", cwd=d)
flagged = [c for c in commits if judge(subject_of(c["message"]))]
if err or len(commits) != 2 or len(flagged) != 1:
problems.append(f"范围含违规提交时没有恰好判出 1 条:{err or (len(commits), len(flagged))}")
# ③ 本地无事件负载:按"未推到远端的提交"取,且**不是**固定条数
os.environ.pop("GITHUB_EVENT_PATH", None)
os.environ.pop("CI_COMMIT_RANGE", None)
commits, src, err = resolve_scope(env={}, cwd=d)
if err or len(commits) != 1 or "最近" in src:
problems.append(f"无事件负载、无远端时没有按'只核 HEAD'取:{err or (len(commits), src)}")
# ④ 事件负载的 after 与 HEAD 不一致 ⇒ 报错,不许蒙
evf = os.path.join(d, "event.json")
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"after": "f" * 40, "commits": [{"id": good2, "message": "fix(b): 第三条合规"}]}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf}, cwd=d)
if not err:
problems.append("事件 after 与 HEAD 不一致时竟然没有报错")
# ⑤ 事件负载正常(after == HEAD)⇒ 用负载里的 commits
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"after": good2, "commits": [{"id": good2, "message": "fix(b): 第三条合规"}]}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf}, cwd=d)
if err or len(commits) != 1 or "commits[]" not in src:
problems.append(f"事件负载正常时没有按 commits[] 取:{err or (len(commits), src)}")
# ⑫ 本地一个远端都没有 ⇒ 只核 HEAD,并写明(不许退化成"整部历史")
commits, src, err = resolve_scope(env={}, cwd=d)
if err or len(commits) != 1 or "只核 HEAD" not in src:
problems.append(f"没有远端引用时没有退成'只核 HEAD 并写明':{err or (len(commits), src)}")
# ⑥ 负载声明 6 条、列表只给 1 条、Git 范围 2 条 ⇒ 对不上,必须报错(不许采信被截断的列表)
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"before": good1, "after": good2, "total_commits": 6,
"commits": [{"id": good2, "message": "fix(b): 第三条合规"}]}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if not err:
problems.append("事件声明 6 条、Git 范围 2 条时竟然没有报错(接受了被截断的列表)")
# ⑦ 声明 2 条、列表 1 条、Git 范围 2 条 ⇒ 用 Git 范围,并在来源里写明"列表被截断"
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"before": good1, "after": good2, "total_commits": 2,
"commits": [{"id": good2, "message": "fix(b): 第三条合规"}]}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 2 or "截断" not in src:
problems.append(f"列表被截断但 Git 范围可用时没有以 Git 为准:{err or (len(commits), src)}")
# ⑧ 空范围(before == after)⇒ CI 里必须显式拒绝
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"before": good2, "after": good2, "total_commits": 0, "commits": []}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if not err:
problems.append("空范围(before == after)竟然没有报错")
# ⑨ 新分支推送(before 全 0)⇒ 用 after --not --remotes 取
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"before": "0" * 40, "after": good2, "total_commits": 3, "commits": []}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 3 or "新分支" not in src:
problems.append(f"新分支推送没有按 after --not --remotes 取:{err or (len(commits), src)}")
# ⑩ 手工运行(没有事件负载、也没有远端可比)⇒ 只核 HEAD,并且**写明**这一点
commits, src, err = resolve_scope(env={"GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 1 or "只核 HEAD" not in src:
problems.append(f"手工运行没有退成'只核 HEAD 并写明':{err or (len(commits), src)}")
# ⑪ 列表里的提交在检出里不存在 ⇒ 事件与检出不是同一批,必须报错
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"after": good2, "commits": [{"id": "d" * 40, "message": "fix(b): 第三条合规"}]}, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if not err:
problems.append("事件列表里的提交在检出里不存在,竟然没有报错")
if problems:
print("❌ 提交闸门自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 提交闸门自检通过:范围取法 12 个分支(显式范围 / Git 范围 before..after / "
"负载声明与列表不一致 / 列表截断仍以 Git 为准 / 空范围拒绝 / 新分支 / 无 before 的列表 / "
"列表提交不在检出里 / after 与 HEAD 核对 / 手工运行只核 HEAD 并写明 / "
"无远端只核 HEAD / 未推送提交)")
return 0
if __name__ == "__main__":
sys.exit(main())
sys.exit(main(sys.argv[1:]))
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""静态预览发布:**先核对完整报告,再发**。
为什么必须在这一步核对,而不是等后面的通知步骤:
预览是**发出去就收不回来**的(URL 会被别人打开、会被转到群里)。等到通知步骤才发现
"报告缺项 / SHA 不对 / 档案不对",那时预览早就发出去了 —— 再红也撤不回来。
所以这里的顺序是:
① 调 `ci_report.verify()`(与上报**同一份**核对逻辑,不另写一套);
② 有任何问题 ⇒ **一个 publish 请求都不发**,直接返回 0(质量结论由门禁那一步负责);
③ 全部通过 ⇒ 打包(不产生 `.` 根成员)→ 断言包里有 index.html → POST publish
→ 报告里给出的**每个地址都探测 200** 才算发布成功。
用法:
python3 .gitea/scripts/ci_preview.py # 真发(CI 用)
python3 .gitea/scripts/ci_preview.py --check-only # 只核对报告,不发
python3 .gitea/scripts/ci_preview.py --self-test # 判定路径自检
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(ROOT / "scripts"))
import ci_report # noqa: E402 (与上报共用同一份核对逻辑)
ARTIFACT_CANDIDATES = ["web/dist", "dist", "build", "out", "public", "web/build", "frontend/dist"]
BRIDGE_CANDIDATES = ["http://172.17.0.1:8899", "http://192.168.50.134:8899", "http://100.92.49.78:8899"]
def find_artifact_dir() -> Path | None:
for rel in ARTIFACT_CANDIDATES:
d = ROOT / rel
if (d / "index.html").is_file():
return d
return None
def pack(web: Path, dest: Path) -> tuple[bool, str]:
"""打包成 tar.gz。⚠️ 不要产生 `.` 根成员(那会让预览 404 而流水线显示成功)。"""
names = []
with tarfile.open(dest, "w:gz") as tf:
for p in sorted(web.rglob("*")):
rel = p.relative_to(web)
if rel.parts and rel.parts[0] in (".", ".."):
continue
tf.add(p, arcname=str(rel))
names.append(str(rel))
if "index.html" not in names:
return False, f"包里没有 index.html(成员 {names[:5]}…)—— 发出去也是 404 的预览"
if any(n.startswith("./") or n == "." for n in names):
return False, f"包里出现了 `.` 根成员:{[n for n in names if n.startswith('./')][:3]}"
return True, f"{len(names)} 个成员"
def resolve_bridge(explicit: str = "") -> str:
cands = [explicit] + BRIDGE_CANDIDATES
for c in cands:
if not c:
continue
try:
with urllib.request.urlopen(c.rstrip("/") + "/healthz", timeout=5) as r:
if r.status == 200:
return c.rstrip("/")
except (urllib.error.URLError, OSError):
continue
return ""
def probe(url: str, timeout: int = 10) -> int:
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
except (urllib.error.URLError, OSError):
return 0
def publish(base: str, repo: str, sha: str, token: str, blob: bytes) -> tuple[int, dict]:
req = urllib.request.Request(
f"{base}/api/ci/publish?repo={repo}&sha={sha}", data=blob, method="POST",
headers={"X-CI-Token": token, "Content-Type": "application/gzip"})
try:
with urllib.request.urlopen(req, timeout=90) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:200]}
except (urllib.error.URLError, OSError) as e:
return 0, {"_error": str(e)}
def self_test() -> int:
"""打包器的判定:`.` 根成员必须被发现(不然就是"预览 404 而流水线绿")。"""
problems = []
with tempfile.TemporaryDirectory() as d:
web = Path(d) / "dist"
(web / "assets").mkdir(parents=True)
(web / "index.html").write_text("<html></html>", encoding="utf-8")
(web / "assets" / "a.js").write_text("x", encoding="utf-8")
ok, why = pack(web, Path(d) / "good.tgz")
if not ok:
problems.append(f"正常产物打包失败:{why}")
empty = Path(d) / "empty"
empty.mkdir()
ok, why = pack(empty, Path(d) / "bad.tgz")
if ok:
problems.append("空产物目录竟然打包成功(没有 index.html 也算过)")
if problems:
print("❌ 预览脚本自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 预览脚本自检通过:正常产物可打包;空产物/缺 index.html 被拒")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--report", default=os.environ.get("CI_REPORT", str(ROOT / "ci-report.json")))
ap.add_argument("--check-only", action="store_true")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
if ci_report.GATE_ERROR:
print(f"❌ 核对不了报告({ci_report.GATE_ERROR})⇒ 不发预览", file=sys.stderr)
return 2
rpath = Path(args.report)
if not rpath.is_file():
print(f"❌ 读不到报告 {rpath} ⇒ 不发预览", file=sys.stderr)
return 1
try:
rep = json.loads(rpath.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"❌ 报告解析失败({e})⇒ 不发预览", file=sys.stderr)
return 1
in_ci = bool(os.environ.get("GITHUB_ACTIONS"))
expect_sha = (os.environ.get("GITHUB_SHA") or "").strip()
recomputed, problems, notes = ci_report.verify(rep, expect_sha, in_ci)
print(f"── 发布前核对 ── 重算总体 {recomputed}|报告自称 {rep.get('overall_status')}"
f"|档案 {rep.get('profile')}|run_mode {rep.get('run_mode')}")
for n in notes:
print(f" · {n}")
if problems:
# 关键:**一个字节都不发**。预览发出去了就收不回来。
for p in problems:
print(f" ❌ {p}", file=sys.stderr)
print("❌ 报告核对不通过 ⇒ **不发出任何 publish 请求**", file=sys.stderr)
return 0
if args.check_only:
print("✅ 核对通过(--check-only:不发)")
return 0
web = find_artifact_dir()
if web is None:
print("未发现静态网页产物目录 ⇒ 不发预览(这是配置问题,不是通过)")
return 0
sha = str(rep.get("commit_sha") or "")
with tempfile.TemporaryDirectory() as d:
tar = Path(d) / "preview.tgz"
ok, why = pack(web, tar)
if not ok:
print(f"❌ 打包不通过:{why} ⇒ 不发", file=sys.stderr)
return 0
blob = tar.read_bytes()
print(f" 打包完成:{why}({len(blob)} 字节,目录 {web.relative_to(ROOT)})")
base = resolve_bridge(os.environ.get("CI_BRIDGE_RESOLVED") or os.environ.get("BRIDGE_URL") or "")
if not base:
print("❌ 没有可达的 ci-bridge ⇒ 不发预览(打包成功不等于预览发出去了)", file=sys.stderr)
return 0
code, body = publish(base, rep.get("repository") or os.environ.get("GITHUB_REPOSITORY", ""),
sha, os.environ.get("BRIDGE_TOKEN", ""), blob)
print(f" publish HTTP {code} {json.dumps(body, ensure_ascii=False)[:200]}")
if code != 200:
print(f"❌ 发布接口返回 {code} ⇒ 不发这条预览", file=sys.stderr)
return 0
for key in ("url", "url_tailscale"):
url = str(body.get(key) or "")
if not url:
print(f" ⚠️ 报告里 {key} 为空")
continue
st = probe(url)
print(f" 预览探测 {key}: {url} → {st}")
if st != 200:
print(f"❌ {url} 不是 200(通知里给出打不开的链接等于没给)", file=sys.stderr)
return 0
if key == "url":
(ROOT / ".ci-preview-url").write_text(url + "\n", encoding="utf-8")
print("✅ 预览已发布,且报告里的每个地址都 200")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+301 -58
View File
@@ -1,82 +1,325 @@
#!/usr/bin/env python3
"""把 CI 结果上报给主机侧 ci-bridge(bridge 负责落报告页 + 推微信)。"""
"""核对门禁报告并上报 ci-bridge(bridge 负责落报告页 + 推微信)。
**不只看 `overall_status` 一个字段** —— 那是"信一句没有任何依据的结论"。上报前先独立核对:
1. 清单一致性:报告里的 `manifest` 必须与实现里的 `CHECK_MANIFEST` 逐项相同;
2. 完整清单核对:报告必须**恰好一次**覆盖清单里在本档案下必需的每一项 ——
缺项 / 重复项 / 清单之外的项,都算不通过;
3. 逐项状态重算总体:只有"必需项全部 success"才算 success,并与报告自称的
`overall_status` 对照;**两者不一致 ⇒ 汇总不可信**;
4. 归属:`commit_sha` 必须是**完整 40 位 SHA**,且(CI 上)等于事件 SHA;
5. 档案:CI 里必须是 `ci` 档案(`local` 档案下 identity 不要求,等于把身份核对关掉);
6. 运行模式:`only`(只跑了子集)的报告**不能**当作 CI 证据。
退出码只表达一件事:**这次运行的质量能否判定**。
· 报告读不到 / 核对不过 ⇒ 非 0(无从判定,绝不能报绿);
· 报告核对通过、结论是 failure ⇒ 0(作业在门禁那一步已经红了,这里不重复判)。
通知发不出去也只标注,不改结论。
用法:
CI_BRIDGE_RESOLVED=http://172.17.0.1:8899 python3 .gitea/scripts/ci_report.py
python3 .gitea/scripts/ci_report.py --report ci-report.json --no-post
python3 .gitea/scripts/ci_report.py --self-test # 核对逻辑的反向自检
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
# 清单与汇总逻辑**只应有一份**:直接 import 本仓的门禁实现(scripts/ci_checks.py),
# 而不是在这里再写一遍核对规则 —— 两份规则迟早会漂移,而漂移的表现是"门禁绿、上报却放行"。
GATE_PATH = ROOT / "scripts" / "ci_checks.py"
try:
sys.path.insert(0, str(ROOT / "scripts"))
import ci_checks # type: ignore # noqa: E402
from ci_checks import CHECK_MANIFEST, FULL_SHA, SUCCESS # noqa: E402
aggregate = ci_checks.aggregate
GATE_ERROR = ""
except Exception as _e: # 缺文件 / 语法错 / 导入期异常
CHECK_MANIFEST, FULL_SHA, SUCCESS = {}, re.compile(r"^$"), "success"
aggregate = None
GATE_ERROR = f"{GATE_PATH} 不可用:{_e}"
# bridge 的 status 只认 success/failure/cancelled,它的"失败步骤"判定是
# `status not in (success, skipped)`;把 not_run 归到 failure 是**保守**方向:
# 未运行绝不能被渲染成"通过"。
BRIDGE_STATUS = {"success": "success", "skipped": "skipped", "failure": "failure",
"not_run": "failure", "unknown": "failure"}
def rd(path, default=""):
def rd(path: str, default: str = "") -> str:
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except Exception:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return default
def git(*args):
def git(*args: str) -> str:
try:
return subprocess.run(["git"] + list(args), capture_output=True,
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True).stdout.strip()
except Exception:
except OSError:
return ""
def main():
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown")
sha = rd("/tmp/ci-sha") or git("rev-parse", "HEAD")
branch = os.environ.get("GITHUB_REF_NAME") or \
os.environ.get("GITHUB_REF", "").replace("refs/heads/", "")
gate = rd("/tmp/ci-gate-status", "skipped")
build = rd("/tmp/ci-build-status", "failure")
status = "success" if (gate == "success" and build == "success") else "failure"
def verify(rep: dict, expect_sha: str, in_ci: bool) -> tuple[str, list[str], list[str]]:
"""独立核对报告。返回 (重算的总体, 问题, 提示)。纯函数,--self-test 直接打它。"""
problems: list[str] = []
notes: list[str] = []
log = rd("/tmp/ci-build-tail")
if gate != "success":
log = "提交信息规范校验未通过\n" + log
if rep.get("schema") != "ci-report/1":
problems.append(f"报告 schema 不是 ci-report/1:{rep.get('schema')!r}")
try:
count = int(rd("/tmp/ci-commits-count", "1") or 1)
except Exception:
count = 1
profile = rep.get("profile")
if profile not in ("local", "ci"):
problems.append(f"报告档案非法:{profile!r}")
elif in_ci and profile != "ci":
problems.append(f"运行环境是 CI,但报告档案是 {profile!r} —— local 档案下 identity 不要求,"
f"等于把身份核对关掉")
rep = {
"repo": repo,
"sha": sha,
"branch": branch,
"actor": os.environ.get("GITHUB_ACTOR", ""),
"status": status,
"commit_subject": git("log", "-1", "--format=%s"),
"commit_count": count,
"duration_s": rd("/tmp/ci-duration", "?"),
"preview_url": rd("/tmp/ci-preview-url"),
"log": log[-3500:],
"steps": [
{"name": "提交信息规范校验", "status": gate},
{"name": "编译与自检", "status": build},
],
if rep.get("run_mode") != "full":
problems.append(f"报告的 run_mode 是 {rep.get('run_mode')!r} —— 只跑了子集的报告不能当 CI 证据")
sha = str(rep.get("commit_sha") or "")
if not FULL_SHA.match(sha):
problems.append(f"报告的 commit_sha 不是完整 40 位 SHA:{sha[:20]!r}")
elif expect_sha and sha != expect_sha:
problems.append(f"报告的 commit_sha {sha[:12]} != 事件 SHA {expect_sha[:12]} —— 归属对不上")
event_sha = str(rep.get("event_sha") or "")
if event_sha and not FULL_SHA.match(event_sha):
problems.append(f"报告的 event_sha 不是完整 40 位:{event_sha[:20]!r}")
elif event_sha and sha and event_sha != sha:
problems.append(f"报告里 event_sha {event_sha[:12]} 与 commit_sha {sha[:12]} 不一致")
manifest = rep.get("manifest")
if not isinstance(manifest, dict) or not manifest:
problems.append("报告里没有 manifest —— 没有清单就无法核对'必需项齐不齐'")
else:
mine = {cid: list(req) for cid, req in CHECK_MANIFEST.items()}
if {cid: list(req) for cid, req in manifest.items()} != mine:
diff = sorted(set(mine) ^ set(manifest)) or [
cid for cid in mine if list(manifest.get(cid) or []) != mine[cid]]
problems.append(f"报告里的清单与实现不一致(差异:{diff[:6]})—— 有一次读完的结论不能作数")
checks = rep.get("checks")
if not isinstance(checks, list):
problems.append("报告的 checks 不是数组")
checks = []
# 用**报告自带的清单**(已确认与实现一致)重算,而不是信 overall_status
declared = {cid: tuple(req) for cid, req in (manifest or {}).items()} or CHECK_MANIFEST
recomputed, agg_problems = aggregate(checks, profile if profile in ("local", "ci") else "ci", declared)
problems += agg_problems
claimed = rep.get("overall_status")
if recomputed != claimed and claimed != "not_run_only":
problems.append(f"报告自称 overall_status={claimed!r},按清单逐项重算是 {recomputed!r} —— "
f"汇总与清单不一致,不能采信")
elif claimed == "not_run_only":
problems.append("报告的 overall_status 是 not_run_only(只跑了子集)—— 不能作为 CI 证据")
if not problems:
notes.append(f"核对通过:{len(checks)} 项、档案 {profile}、SHA {sha[:12]}、"
f"总体 {recomputed}(与报告自称一致)")
return recomputed, problems, notes
def self_test() -> int:
if GATE_ERROR:
# 模板目录里没有 scripts/ci_checks.py(实现随项目走),所以自检跑不了 ——
# 明说,而不是拿一份空清单去跑出一堆莫名其妙的结论。
print(f"❌ 自检跑不了:{GATE_ERROR}", file=sys.stderr)
print(" 本脚本的核对逻辑依赖本仓的 scripts/ci_checks.py;请在项目仓里跑 --self-test。",
file=sys.stderr)
return 2
sha = "a" * 40
other = "b" * 40
good_checks = [{"id": cid, "status": SUCCESS, "reason": "", "required_in": list(req),
"duration_s": 1} for cid, req in CHECK_MANIFEST.items()]
# 自检里的"挑一项"要**动态取**清单里的第一项:硬编码某个 id(例如 tests-go)在模板仓/新工程里
# 根本不存在,自检会因为"找不到那一项"而自作聪明地通过或失败。
first_id = sorted(CHECK_MANIFEST)[0]
good = {
"schema": "ci-report/1", "profile": "ci", "run_mode": "full", "commit_sha": sha,
"event_sha": sha, "manifest": {c: list(r) for c, r in CHECK_MANIFEST.items()},
"checks": good_checks, "overall_status": SUCCESS,
}
if rep["preview_url"]:
print("\U0001F310 网页预览: %s" % rep["preview_url"])
print("结果: %s | 构建: %s | 提交闸门: %s | 耗时: %ss"
% (status, build, gate, rep["duration_s"]))
base = os.environ.get("CI_BRIDGE_RESOLVED", "").rstrip("/")
if not base:
print("跳过上报:未找到可达的 ci-bridge")
return 0
data = json.dumps(rep, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
base + "/api/ci/report", data=data, method="POST",
headers={"Content-Type": "application/json",
"X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print("\u2705 已上报 ci-bridge:", resp.read().decode()[:300])
except Exception as e:
print("\u274c 上报 ci-bridge 失败:", e)
cases = [
("合规报告 ⇒ 通过", good, sha, True, 0),
("短 SHA ⇒ 不过", {**good, "commit_sha": sha[:12], "event_sha": ""}, sha, True, 1),
("归错提交 ⇒ 不过", {**good, "commit_sha": other, "event_sha": other}, sha, True, 1),
# 下面这些会同时命中两条判据(例如"缺项"既触发清单核对,又让报告自称的 success 与重算不符),
# 所以期望 2 条 —— 两条都要报出来,不能只报一条就被掩盖。
("缺一项必需检查 ⇒ 不过(缺项 + 自称与重算不符)",
{**good, "checks": [c for c in good_checks if c["id"] != first_id]}, sha, True, 2),
("重复一项 ⇒ 不过(重复 + 自称与重算不符)",
{**good, "checks": good_checks + [good_checks[0]]}, sha, True, 2),
("清单之外的项 ⇒ 不过(未知项 + 自称与重算不符)",
{**good, "checks": good_checks + [{"id": "zz", "status": SUCCESS, "reason": ""}]}, sha, True, 2),
("清单被改过 ⇒ 不过(清单漂移 + 未知项 + 自称不符)",
{**good, "manifest": {k: list(v) for k, v in CHECK_MANIFEST.items() if k != "format"}}, sha, True, 3),
("自称成功但某项 failure ⇒ 不过(逐项 + 自称不符)",
{**good, "overall_status": SUCCESS,
"checks": [{**c, "status": "failure"} if c["id"] == first_id else c for c in good_checks]},
sha, True, 2),
("CI 里用 local 档案 ⇒ 不过", {**good, "profile": "local"}, sha, True, 1),
("只跑子集的报告 ⇒ 不过(run_mode + 结论不可用)",
{**good, "overall_status": "not_run_only", "run_mode": "only"}, sha, True, 2),
("没有 manifest ⇒ 不过", {k: v for k, v in good.items() if k != "manifest"}, sha, True, 1),
]
problems = []
for name, rep, expect, in_ci, want in cases:
_, got, _ = verify(rep, expect, in_ci)
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)} 个分支(短 SHA / 归错提交 / 缺项 / 重复 / 未知项 / "
f"清单漂移 / 自称成功 / 档案不符 / 子集报告 / 无清单 全部拦下)")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--report", default=os.environ.get("CI_REPORT", str(ROOT / "ci-report.json")))
ap.add_argument("--no-post", action="store_true")
ap.add_argument("--verify-only", action="store_true",
help="只核对报告、不发上报(预览发布前用;退出码=0 通过 / 1 不通过 / 2 核不了)")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
if GATE_ERROR:
print(f"❌ 核对不了报告:{GATE_ERROR}", file=sys.stderr)
print(" 本脚本需要本仓的权威门禁实现(scripts/ci_checks.py)来做清单核对;"
"找不到就不能给出任何结论。", file=sys.stderr)
return 2 if args.verify_only else 1
in_ci = bool(os.environ.get("GITHUB_ACTIONS"))
expect_sha = (os.environ.get("GITHUB_SHA") or "").strip()
rpath = Path(args.report)
if not rpath.is_file():
print(f"❌ 读不到门禁报告 {rpath} —— 这次运行的质量无从判定,不能报绿", file=sys.stderr)
return 2 if args.verify_only else 1
try:
rep = json.loads(rpath.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"❌ 门禁报告解析失败({e})—— 不能报绿", file=sys.stderr)
return 2 if args.verify_only else 1
if not isinstance(rep, dict):
print("❌ 门禁报告不是 JSON 对象 —— 不能报绿", file=sys.stderr)
return 2 if args.verify_only else 1
if args.verify_only:
# 预览发布前的这一道:只回答"这份报告能不能采信",**不做任何网络请求**。
recomputed, vp, vn = verify(rep, expect_sha, in_ci)
print(f"── 发布前核对 —— 重算总体 {recomputed}|报告自称 {rep.get('overall_status')}"
f"|档案 {rep.get('profile')}|run_mode {rep.get('run_mode')}|"
f"SHA {(rep.get('commit_sha') or '')[:12]}")
for n in vn:
print(f" · {n}")
for p in vp:
print(f" ❌ {p}", file=sys.stderr)
print("✅ 报告可以采信" if not vp else "❌ 报告不可采信", file=sys.stderr if vp else sys.stdout)
return 1 if vp else 0
recomputed, problems, notes = verify(rep, expect_sha, in_ci)
checks = rep.get("checks") or []
overall = "failure" if problems else recomputed
print(f"── 门禁报告核对 ── 档案 {rep.get('profile')}|"
f"提交 {(rep.get('commit_sha') or '')[:12]}|重算总体 {recomputed}"
f"{'' if not problems else '(核对不通过)'}")
for c in checks:
mark = {"success": "✅", "skipped": "⏭️"}.get(c.get("status"), "❌")
print(f" {mark} {c.get('id',''):<18} {str(c.get('status')):<8} {str(c.get('reason') or '')[:100]}")
for n in notes:
print(f" · {n}")
for p in problems:
print(f" ❌ {p}", file=sys.stderr)
# 质量**无法判定**(报告缺失/核对不过)时,不能给出任何"成功"结论
if problems:
print("❌ 汇总不可信/不完整 ⇒ 不能报绿(作业这次不算通过)", file=sys.stderr)
payload = {
# —— bridge 既有字段(保持兼容,不改 bridge)——
"repo": rep.get("repository") or os.environ.get("GITHUB_REPOSITORY", "unknown/unknown"),
"sha": rep.get("commit_sha") or "",
"branch": rep.get("branch") or os.environ.get("GITHUB_REF_NAME", ""),
"actor": os.environ.get("GITHUB_ACTOR", ""),
"workflow": os.environ.get("GITHUB_WORKFLOW", "build"),
"status": BRIDGE_STATUS.get(overall, "failure"),
"commit_subject": git("log", "-1", "--format=%s"),
"commit_count": rd(os.environ.get("CI_COMMITS_COUNT_FILE", ""), "") or git("rev-list",
"--count", "HEAD~1..HEAD"),
"duration_s": rep.get("duration_s", "?"),
"preview_url": rd(str(ROOT / ".ci-preview-url")),
"log": rd(os.environ.get("CI_GATE_LOG", ""))[-3500:],
"steps": [{"name": c.get("id"), "status": BRIDGE_STATUS.get(c.get("status"), "failure"),
"duration_s": c.get("duration_s")} for c in checks],
# —— 结构化字段(正本 §5 的检查报告形状)——
"schema": rep.get("schema"),
"policy_revision": rep.get("policy_revision"),
"overall_status": overall,
"claimed_overall_status": rep.get("overall_status"),
"recomputed_overall_status": recomputed,
"profile": rep.get("profile"),
"event": rep.get("event"),
"run_mode": rep.get("run_mode"),
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
"run_url": (f"{os.environ.get('GITHUB_SERVER_URL', '')}/"
f"{rep.get('repository', '')}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}"
if os.environ.get("GITHUB_RUN_ID") else ""),
"checks": checks,
"manifest": rep.get("manifest"),
"problems": (rep.get("problems") or []) + problems,
"verification": {"ok": not problems, "problems": problems, "notes": notes,
"expected_sha": expect_sha, "reported_sha": rep.get("commit_sha")},
"notification_status": "not_attempted",
"deployed": False, # 本 workflow 只做 CI:没有部署,报告里也这么写
}
base = (os.environ.get("CI_BRIDGE_RESOLVED") or os.environ.get("BRIDGE_URL") or "").rstrip("/")
if args.no_post or not base:
print(f"跳过上报(--no-post={args.no_post},bridge={base or '未找到'})")
return 1 if problems else 0
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(base + "/api/ci/report", data=data, method="POST",
headers={"Content-Type": "application/json",
"X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")})
status = "failed"
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()[:300]
status = "sent"
print(f"✅ 已上报 ci-bridge:{body}")
except (urllib.error.URLError, OSError) as e:
status = f"failed: {e}"
# 通知失败**不**改变质量结论:这里只标注,不把运行判红。
print(f"❌ 上报 ci-bridge 失败(质量结论不变,仅通知失败):{e}", file=sys.stderr)
payload["notification_status"] = status
rep["notification_status"] = status
rpath.write_text(json.dumps(rep, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main(sys.argv[1:]))
+148 -196
View File
@@ -2,28 +2,58 @@
# 来自 zheromiku/eng-standards-main 的 templates/gitea-build.yml;正本见 01-规范-Git与CI-CD(Gitea版).md §4
#
# ⚠️ 本模板**只做 CI**:不含任何部署作业,也不启动 compose / 不改线上。
# CD(部署测试/生产)是**每个项目自己设计**的独立 workflow(正本 §8)。
# 这里**不再**放 `deploy:test` 占位骨架 —— 占位作业会打印 TODO 然后返回成功,
# 那不是"还没做",那是**假报成功**(规范里明确禁止)。
# CD(部署测试/生产)是**每个项目自己设计**的独立 workflow(正本 §8),而且**默认不生成** ——
# 生成器不会给你一个 deploy-production.yml 让你"顺手填"。要 CD 时显式拷
# templates/gitea-prod-deploy.yml 过去,并接受它"未实现的步骤就是红的"。
# 这里**不放** `deploy:test` 占位骨架 —— 占位作业会打印 TODO 然后返回成功,那不是"还没做",
# 那是**假报成功**。
#
# ⚠️ 四处形状不许动(动了会被规范判为不合规):
# · 触发只有:推任意分支(校验+编译,不部署)、workflow_dispatch
# · 提交信息闸门(gate)必须在编译之前,且**独立作业**(失败不编译,也便于一眼看出是信息问题不是代码问题)
# · 生产部署**不在这里**(独立 workflow,只由 workflow_dispatch 触发,见 §5.2)
# · 不用第三方 action(`uses:`)—— 本 runner 取不到就随机红;全部逻辑写在 run: 里
# ⚠️ 本 workflow **只有一条校验命令**:调用项目自己的权威门禁(`make verify` / `scripts/verify.sh`),
# 不在这里内联第二套"探测语言 → 依次跑"。两边各写一套的结局是迟早只改一边,
# 症状是本地绿、CI 红(或反过来),谁都说不清哪边是对的。
# 门禁入口**缺失即失败** —— 不接受"没有入口所以跳过"。
#
# ⚠️ 本 runner 实测特性(act 语义,见正本 §4.4):
# · act_runner 不会自动检出代码 ⇒ 必须自己 git init + fetch + checkout
# ⚠️ 凭据处理三条硬规矩:
# 1. **不把令牌写进 remote URL**(会落进 .git/config,也会出现在任何打印 remote 的地方):
# 用 credential helper,只在这一次 git 调用里生效;URL 里带凭据会被检出脚本直接拦下。
# 2. **不把密钥放在 workflow 级 env**:那等于把 bridge 令牌发给每一个步骤(包括跑待测代码的
# 步骤)。谁需要谁在自己步骤上声明。
# 3. 检出只用**只读**令牌(secrets.GITHUB_TOKEN),不请求任何写操作。
#
# ⚠️ 顺序不能换(每一步都在补上一步的窟窿,见 docs/agents/traps.md):
# 检出到事件完整 SHA → 提交信息闸门 → 环境 → 门禁入口存在性 → 权威门禁 →
# **核对完整报告后才发预览** → 上报(通知失败不改变质量结论)。
#
# ⚠️ 本 runner 实测特性(act 语义,正本 §4.4):
# · act_runner 不会自动检出代码 ⇒ 必须自己 git init + fetch + checkout(仓内脚本 ci_checkout.sh)
# · 每步以 `bash -e -o pipefail` 执行 ⇒ 禁止 `cmd | head`(SIGPIPE 假红)
# · 工具链来自主机只读挂载:容器内 /opt/citools;可写缓存 /opt/citools-cache
#
# ⚠️ 三条"不许假绿"的写法(都踩过):
# · 判"输出即问题"的工具(gofmt -l / prettier --list-different)**要看输出**,不能看退出码
# · 缺锁文件 ⇒ 失败,**不回落** `npm install`(那会静默重写锁文件)
# · 识别不到任何构建入口 ⇒ 失败,**不**打印"跳过编译"然后成功
# · 校验逻辑放仓内脚本(scripts/ci_checks.py、.gitea/scripts/*.py|sh),YAML 只负责调用
# · 单作业串行:跨作业用 /tmp 传文件是个假契约(每个作业是独立容器,/tmp 不共享)
name: build
# 提交 → 本机 Runner 跑**唯一权威门禁**(make verify)→ 全部必需检查通过才发预览 → 通知
#
# ⚠️ 本 workflow **只做 CI,不做 CD**:这里没有 deploy:test / deploy-production,
# 也不启动 compose。部署是各项目自己的事(正本 §8:通用 CI 默认不生成部署作业)。
#
# ⚠️ 凭据处理(三条硬规矩):
# 1. **不把令牌写进 remote URL**:URL 里的凭据会落进 .git/config,还会出现在任何打印 remote
# 的地方;这里用 credential helper(只在那一次 git 调用里生效)。
# 2. **不把密钥放在 workflow 级 env**:那等于把 bridge 令牌发给每一个步骤(包括跑待测代码的
# 步骤)。谁需要谁在自己的步骤上声明 —— 见「静态预览」「上报 ci-bridge」。
# 3. **不用写权限的令牌**:检出只用 GITHUB_TOKEN(只读),不请求任何写操作。
# `permissions:` 在 Gitea 1.26.2 上按实例支持情况而定(不支持时会被忽略)。
#
# 实测结论(勿随意改动):
# 1. 不用任何第三方 action(本 runner 的 github_mirror 为空,取 action 会随机红)
# 2. 必须自己 clone —— Runner 不会自动检出代码,工作区初始为空(trap-6)
# 3. act 以 `bash -e -o pipefail` 执行每步 → 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道
# 4. 工具链来自主机只读挂载 /opt/citools(go/uv),缓存写 /opt/citools-cache
# 5. bridge 从容器内走 docker 网关 172.17.0.1:8899(实测可达)
# 6. 校验逻辑放仓内脚本(scripts/ci_checks.py + .gitea/scripts/*.py),YAML 只负责调用
# 7. 检出固定到**事件完整 SHA**,不是"当时那个分支 HEAD"(分支随时会前进)
on:
push:
branches:
@@ -31,214 +61,136 @@ on:
workflow_dispatch:
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
# 同一 ref 的新推送取消旧的**校验/编译**运行(注意:生产部署 workflow 不许这么设,见 §5.4)
permissions:
contents: read
# 同一 ref 的新推送取消旧的运行。本 workflow 不含部署,取消它不会动任何线上东西;
# 生产部署是另一个 workflow,那种必须 cancel-in-progress: false —— 取消不是回滚。
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 检出代码(固定到事件完整 SHA)
run: |
set -uo pipefail
SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HOSTPORT="${SERVER#*://}"
BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
EVENT_SHA="${GITHUB_SHA}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BRANCH"
git checkout -q -B "$BRANCH" FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
git fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
# ⚠️ 必须比对**完整** SHA:分支 HEAD 会随时前进,"构建成功"必须对应到某个确定的提交
[ "$(git rev-parse HEAD)" = "$EVENT_SHA" ] \
|| { echo "❌ 检出 SHA 与事件 SHA 不一致:$(git rev-parse HEAD) / $EVENT_SHA"; exit 1; }
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BRANCH} @ ${EVENT_SHA:0:12}(== 事件完整 SHA)"
git log -1 --format=' %h %an %s'
- name: 提交信息规范校验(权威闸门,失败不编译)
run: python3 .gitea/scripts/ci_gate.py
build:
needs: [gate]
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码(固定到事件完整 SHA)
- name: 检出代码(固定到事件完整 SHA;协议不降级;URL 不放凭据)
env:
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HOSTPORT="${SERVER#*://}"
BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
EVENT_SHA="${GITHUB_SHA}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BRANCH"
git checkout -q -B "$BRANCH" FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
git fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
[ "$(git rev-parse HEAD)" = "$EVENT_SHA" ] \
|| { echo "❌ 检出 SHA 与事件 SHA 不一致"; exit 1; }
git rev-parse HEAD > /tmp/ci-sha
git rev-parse --short=12 HEAD > /tmp/ci-sha12
date +%s > /tmp/ci-start
: > /tmp/ci-preview-url
echo skipped > /tmp/ci-build-status
echo skipped > /tmp/ci-gate-status
git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count
# 依赖清单摘要:编译完再比一次,抓"某一步顺手改了 go.mod / 锁文件"
for f in go.mod go.sum package.json package-lock.json pnpm-lock.yaml yarn.lock requirements.txt; do
[ -f "$f" ] && printf '%s %s\n' "$(sha256sum "$f" | cut -c1-16)" "$f"
done > /tmp/ci-manifests-before
# 检出逻辑单独成脚本(.gitea/scripts/ci_checkout.sh),这样它能被本地负例打到:
# ① 协议按 GITHUB_SERVER_URL 原样保留,**禁止 https 降级成 http**;
# ② remote URL 里不放凭据(凭据只走 credential helper);
# ③ 检出必须等于事件的**完整 SHA**;
# ④ 顺带把事件的 before 也取下来 —— 否则"本次推送范围 before..after"
# 在浅克隆里算不出来,范围校验只能要么报错要么静默收缩。
GITHUB_TOKEN="${GIT_TOKEN:-}" GITHUB_ENV="${GITHUB_ENV:-/dev/null}" \
bash .gitea/scripts/ci_checkout.sh
- name: 编译与自检
- name: 环境与工具链(缺什么一次性报全)
run: |
set -uo pipefail
{
echo "PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH"
echo "GOCACHE=/opt/citools-cache/gocache"
echo "GOMODCACHE=/opt/citools-cache/gomod"
echo "GOPATH=/opt/citools-cache/gopath"
echo "UV_CACHE_DIR=/opt/citools-cache/uv"
echo "GOPROXY=https://goproxy.cn,direct"
echo "GOTOOLCHAIN=local"
echo "npm_config_cache=/opt/citools-cache/npm"
echo "CI_GATE_LOG=${GITHUB_WORKSPACE}/ci-gate.log"
echo "CI_REPORT=${GITHUB_WORKSPACE}/ci-report.json"
} >> "$GITHUB_ENV"
mkdir -p /opt/citools-cache/gocache /opt/citools-cache/gomod /opt/citools-cache/gopath \
/opt/citools-cache/uv /opt/citools-cache/npm 2>/dev/null || true
export PATH="/opt/citools/go/bin:/opt/citools/bin:$PATH"
miss=""
for t in make python3 node npm go gofmt git curl tar sha256sum; do
command -v "$t" >/dev/null 2>&1 || miss="$miss $t"
done
if [ -n "$miss" ]; then
echo "❌ 作业镜像里缺工具:$miss"
echo " 处理:补镜像/挂载,或把该检查从清单里删掉并写明理由 —— 不允许运行时静默跳过。"
exit 1
fi
echo "工具链: $(go version) | node $(node -v) | npm $(npm -v) | $(python3 -V)"
- name: 断言门禁入口存在(缺了就是红,不许静默跳过)
run: |
set -uo pipefail
if [ ! -f scripts/ci_checks.py ]; then
echo "❌ 找不到权威门禁入口 scripts/ci_checks.py"
echo " 门禁入口是本仓唯一的检查实现;缺了就不能跑 —— 不接受'没有入口所以跳过'。"
exit 1
fi
make -n verify | head -3
echo "✅ 门禁入口就位:make verify"
- name: 提交信息规范校验(权威闸门,失败不往下跑)
run: |
set -uo pipefail
python3 .gitea/scripts/ci_gate.py
- name: 权威门禁(调用项目自己的唯一入口;缺入口就是红)
run: |
set +e; set +o pipefail
LOG=/tmp/ci-build.log; : > "$LOG"
run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
runin() { local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod
export UV_CACHE_DIR=/opt/citools-cache/uv GOPATH=/opt/citools-cache/gopath
export npm_config_cache=/opt/citools-cache/npm
# ⚠️ GOFLAGS 必须是 readonly:`-mod=mod` 会在构建时**静默改写** go.mod/go.sum
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=readonly
mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null
echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)"
fail=0; ran=""
if [ -f go.mod ]; then
ran="$ran go"
# ⚠️ `gofmt -l` 列出文件时**退出码仍是 0** —— 只看退出码就是永远的假绿
UGLY="$(gofmt -l $(git ls-files '*.go') 2>/dev/null)"
if [ -n "$UGLY" ]; then echo "❌ 这些 Go 文件没格式化:"; printf '%s\n' "$UGLY"; fail=1; fi
run go vet ./... || fail=1
run go build ./... || fail=1
run go test ./... || fail=1
if [ -f scripts/verify.sh ]; then
GATE="bash scripts/verify.sh"
elif [ -f Makefile ] && grep -qE '^verify:' Makefile; then
GATE="make verify"
else
echo "❌ 找不到权威门禁入口(scripts/verify.sh 或 Makefile 的 verify 目标)"
echo " 门禁入口是本仓唯一的检查实现;缺了不能跑 —— 不接受'没有入口所以跳过'。"
exit 1
fi
echo "使用门禁入口:$GATE"
$GATE 2>&1 | tee "${CI_GATE_LOG:-/tmp/ci-gate.log}"
rc=${PIPESTATUS[0]} # pipefail 已关,用 PIPESTATUS 取真退出码
echo "门禁退出码=$rc"
exit "$rc"
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then
ran="$ran python"
run ruff format --check . || fail=1
run ruff check . || fail=1
if [ -f requirements.txt ]; then run uv pip install -r requirements.txt || fail=1; fi
run uv run pytest -q || fail=1
fi
WEBPKG=""
for p in package.json web/package.json frontend/package.json ui/package.json; do
if [ -f "$p" ]; then WEBPKG="$p"; break; fi
done
if [ -n "$WEBPKG" ]; then
ran="$ran node"; D="$(dirname "$WEBPKG")"
# ⚠️ 缺锁文件就失败:`npm install` 会按 package.json 重算依赖树并**重写锁文件**
if [ -f "$D/package-lock.json" ]; then
runin "$D" npm ci || fail=1
else
echo "❌ $D 下没有 package-lock.json:不接受无约束安装(先提交锁文件)"; fail=1
fi
if grep -q '"format:check"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run format:check || fail=1
fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run build || fail=1
fi
if grep -q '"test"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm test || fail=1
fi
fi
if [ -z "$ran" ]; then
# ⚠️ 这里必须失败:识别不到入口 = 什么都没验 = 不能报成功
echo "❌ 未识别到任何构建入口(go.mod / pyproject.toml / package.json 都没有)"
echo " 要么把入口补上,要么在项目清单里声明'不适用'并写明理由 —— 不允许静默跳过。"
fail=1
fi
# 依赖清单不许被编译过程改写
for f in go.mod go.sum package.json package-lock.json pnpm-lock.yaml yarn.lock requirements.txt; do
[ -f "$f" ] && printf '%s %s\n' "$(sha256sum "$f" | cut -c1-16)" "$f"
done > /tmp/ci-manifests-after
if ! diff -q /tmp/ci-manifests-before /tmp/ci-manifests-after >/dev/null; then
echo "❌ 依赖清单在编译过程中被改写了(下面是对比):"; diff /tmp/ci-manifests-before /tmp/ci-manifests-after
echo " 这通常是 GOFLAGS=-mod=mod 或缺锁回落 npm install 造成的;改写必须由人显式提交。"
fail=1
fi
if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi
tail -c 4000 "$LOG" > /tmp/ci-build-tail
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 发布网页预览(只在全部必需检查通过后)
- name: 静态预览(**先核对完整报告**;核对不过一个 publish 请求都不发)
if: always()
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
CI_REPORT: ${{ github.workspace }}/ci-report.json
run: |
set +e; set +o pipefail
: > /tmp/ci-preview-url
if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then
echo "编译未成功,跳过预览发布"; exit 0
fi
WEB=""
for d in web/dist dist build out public web/build frontend/dist; do
if [ -f "$d/index.html" ]; then WEB="$d"; break; fi
done
if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi
# ⚠️ 打包不要产生 `.` 根成员:先 cd 再让通配展开成员名(否则预览 404 而流水线显示成功)
( cd "$WEB" && { shopt -s dotglob; tar -czf /tmp/ci-web.tgz *; } ) || { echo "打包失败"; exit 0; }
tar -tzf /tmp/ci-web.tgz | grep -qx 'index.html' \
|| { echo "❌ 包里没有 index.html ⇒ 发出去也是 404 的预览,不发"; exit 0; }
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }; fi
done
if [ -z "$B" ]; then echo "❌ 没有可达的 ci-bridge ⇒ 不发预览"; exit 0; fi
SHA="$(cat /tmp/ci-sha)"
echo "发布预览到 $B(产物目录 $WEB)"
CODE="$(curl -sS -m 90 -o /tmp/ci-pub.json -w '%{http_code}' -X POST \
"$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" \
-H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' \
--data-binary @/tmp/ci-web.tgz)"
echo "publish HTTP $CODE $(cat /tmp/ci-pub.json)"
[ "$CODE" = "200" ] || { echo "❌ 发布接口返回 $CODE"; exit 0; }
for key in url url_tailscale; do
U="$(python3 -c "import json,sys;print(json.load(open('/tmp/ci-pub.json')).get(sys.argv[1],''))" "$key")"
[ -n "$U" ] || { echo "⚠️ 报告里 $key 为空"; continue; }
C="$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$U")"
echo "预览探测 $key: $U → $C"
[ "$C" = "200" ] || { echo "❌ $U 不是 200(通知里给出打不开的链接等于没给)"; exit 0; }
[ "$key" = "url" ] && echo "$U" > /tmp/ci-preview-url
done
echo "✅ 预览已发布,且每个地址都 200"
: > "${GITHUB_WORKSPACE}/.ci-preview-url"
# 顺序钉死在这里:ci_preview.py 内部**先**调 ci_report.verify()(与上报步骤
# 同一份核对逻辑:清单齐不齐 / 重复 / 未知项 / 完整 SHA / 档案 / 运行模式),
# 有任何问题就一个 publish 请求都不发。
# 旧写法只读 overall_status + SHA,缺项报告会**先发出去**、等到通知步骤才发现 ——
# 预览发出去收不回来(URL 会被打开、会被转群),那时再红也撤不回来。
python3 .gitea/scripts/ci_preview.py
rc=$?
[ "$rc" = "0" ] || echo "❌ 预览步骤非 0($rc):报告核对不过或读不到报告"
exit "$rc"
- name: 上报 ci-bridge(通知失败不改变质量结果)
- name: 上报 ci-bridge(核对报告 + 通知失败不改变质量结果)
if: always()
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
run: |
set +e; set +o pipefail
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }; fi
done
echo "使用 ci-bridge: $B"
S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S
echo $((S - T)) > /tmp/ci-duration
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
echo "使用 ci-bridge: ${B:-(不可达)}"
# 这一步**先独立核对报告**(清单齐不齐 / 完整 SHA / 档案 / 自称是否与重算一致),
# 再上报;核对不过 ⇒ 非 0(质量无从判定,绝不能报绿)。
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
rc=$?
if [ "$rc" -ne 0 ]; then
echo "❌ 上报脚本非 0 退出:报告核对不过或读不到 —— 这次运行的质量无从判定"
fi
exit "$rc"