Files
hermes 1b0cce283a feat(templates): 同步通知链接基址(CI_PUBLIC_BASE_URL)与可信来源规则
templates/ci_report.py 同步项目版(链接基址/提交链接/回退来源 + 通知字段);templates/gitea-build.yml 上报步骤加非秘密 CI_PUBLIC_BASE_URL;templates/README.md 加'上报脚本的非秘密配置'一节;01 正本 §4.7 增链接基址与'禁止全局 URL 替换';02 Runbook 增排障条目;04 加 T-9(通知链接指向内网地址)。
2026-10-13 10:27:43 +08:00

522 lines
27 KiB
Python
Executable File
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
"""核对门禁报告并上报 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: str, default: str = "") -> str:
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return default
def git(*args: str) -> str:
try:
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True).stdout.strip()
except OSError:
return ""
def verify(rep: dict, expect_sha: str, in_ci: bool) -> tuple[str, list[str], list[str]]:
"""独立核对报告。返回 (重算的总体, 问题, 提示)。纯函数,--self-test 直接打它。"""
problems: list[str] = []
notes: list[str] = []
if rep.get("schema") != "ci-report/1":
problems.append(f"报告 schema 不是 ci-report/1:{rep.get('schema')!r}")
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"等于把身份核对关掉")
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,
}
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}")
# 通知链接基址:优先 CI_PUBLIC_BASE_URL,缺/非法都必须**明确回退**(不猜、不静默)
env_backup = {k: os.environ.get(k) for k in ("CI_PUBLIC_BASE_URL", "GITHUB_SERVER_URL")}
try:
os.environ["GITHUB_SERVER_URL"] = "http://internal:3000"
os.environ.pop("CI_PUBLIC_BASE_URL", None)
b, src = _link_base()
if b != "http://internal:3000" or "GITHUB_SERVER_URL" not in src:
problems.append(f"没配 CI_PUBLIC_BASE_URL 时没回退到 GITHUB_SERVER_URL:{b!r}/{src!r}")
os.environ["CI_PUBLIC_BASE_URL"] = "https://gitea.zheromiku.top/"
b2, src2 = _link_base()
if b2 != "https://gitea.zheromiku.top" or src2 != "CI_PUBLIC_BASE_URL":
problems.append(f"配了 CI_PUBLIC_BASE_URL 没用它(或没去掉尾斜杠):{b2!r}/{src2!r}")
for bad in ("gitea.zheromiku.top", "ftp://gitea.zheromiku.top",
"https://u:p@gitea.zheromiku.top", "https://a b/"):
os.environ["CI_PUBLIC_BASE_URL"] = bad
b3, src3 = _link_base()
if b3 != "http://internal:3000" or "回退" not in src3:
problems.append(f"非法 CI_PUBLIC_BASE_URL({bad!r}) 没明确回退:{b3!r}/{src3!r}")
os.environ["CI_PUBLIC_BASE_URL"] = "https://gitea.zheromiku.top"
if _commit_url("a/b", "c" * 40, "https://gitea.zheromiku.top") != \
f"https://gitea.zheromiku.top/a/b/commit/{'c' * 40}":
problems.append("提交链接拼错")
for bad_repo, bad_sha in (("onlyone", "c" * 40), ("a/b", "c" * 12), ("a/b", "")):
if _commit_url(bad_repo, bad_sha, "https://gitea.zheromiku.top"):
problems.append(f"仓库/短 SHA 不合法却仍拼出链接:{bad_repo!r}/{bad_sha[:12]!r}")
finally:
for k2, v2 in env_backup.items():
if v2 is None:
os.environ.pop(k2, None)
else:
os.environ[k2] = v2
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
# ── 通知消息规范(ci-notification-message-standard §5)需要的字段 ──────────────
# 全部**由程序从真实事实算**:job 名来自 workflow 文件、范围来自门禁的同一个解析器、
# 统计来自权威清单。缺就缺(消息里显示"未取得"),绝不用写死的值凑格式。
def _job_name() -> str:
"""真实 job 名:从 workflow 文件里读 `jobs:` 下的第一个 job(不写死 build)。"""
for p in (ROOT / ".gitea" / "workflows").glob("*.y*ml"):
try:
txt = p.read_text(encoding="utf-8")
except Exception:
continue
m = re.search(r"^jobs:\s*$\n(.*)", txt, re.M | re.S)
if m:
m2 = re.search(r"^ ([A-Za-z0-9_.-]+):", m.group(1), re.M)
if m2:
return m2.group(1)
return os.environ.get("GITHUB_JOB", "")
def _scope_for_notify():
"""本次**准确范围**:复用门禁的同一个解析器(不另写一套范围逻辑)。
返回 (commits:[{sha,subject}], source:str, err:str)。取不到就给空 —— 不猜。
"""
try:
sys.path.insert(0, str(ROOT / ".gitea" / "scripts"))
import ci_gate # type: ignore
commits, source, err = ci_gate.resolve_scope()
out = []
for c in (commits or [])[:10]:
msg = str(c.get("message") or "").strip().splitlines()
out.append({"sha": str(c.get("sha") or ""), "subject": (msg[0] if msg else "")})
return out, source, err or ""
except Exception as e:
return [], "", f"{e.__class__.__name__}"
def _link_base() -> tuple:
"""通知里的链接用哪个基址:**CI_PUBLIC_BASE_URL 优先**,没配/非法就退回 GITHUB_SERVER_URL。
只接受 `http(s)://host[:port][/path]` 的绝对地址、不接受带凭据(userinfo)的写法。
返回 (base, source);source 会写进上报与 CI 日志,方便事后核对"这条链接是谁给的基址"。
⚠️ 这是**唯一的**链接基址来源:不做"把消息文本里的 URL 全部替换一遍"那种事 ——
那样会把提交标题、日志摘要里的 URL 也改掉(那些是事实,不许动)。
"""
pub = (os.environ.get("CI_PUBLIC_BASE_URL") or "").strip()
fallback = (os.environ.get("GITHUB_SERVER_URL") or "").strip().rstrip("/")
if pub:
bad = None
if not re.match(r"^https?://", pub):
bad = "不是 http(s) 绝对地址"
elif " " in pub or " " in pub:
bad = "含空白字符"
else:
m = re.match(r"^[a-z]+://([^/]+)", pub, re.I)
if not m or "@" in m.group(1):
bad = "主机部分不合法或带凭据(userinfo)"
if bad:
return fallback, f"CI_PUBLIC_BASE_URL 非法({bad})⇒ 回退 GITHUB_SERVER_URL"
return pub.rstrip("/"), "CI_PUBLIC_BASE_URL"
return fallback, "GITHUB_SERVER_URL(未配 CI_PUBLIC_BASE_URL)"
def _repo_ok(repo: str) -> bool:
"""链接里的仓库必须是真的 owner/repo(两段、都非空),否则宁可不给链接。"""
parts = [x for x in str(repo or "").split("/")]
return len(parts) == 2 and all(x.strip() for x in parts)
def _commit_url(repo: str, sha: str, base: str) -> str:
if not _repo_ok(repo) or not base or not re.match(r"^[0-9a-f]{40}$", str(sha or "")):
return ""
return f"{base}/{repo}/commit/{sha}"
def _compare_url(repo: str, base: str) -> str:
"""变更列表链接:只有拿到真实的 before/after 才给(不然就是编一个打不开的地址)。"""
ev = (os.environ.get("GITHUB_EVENT_PATH") or "").strip()
if not base or not repo or not ev or not os.path.exists(ev):
return ""
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
before, after = str(d.get("before") or ""), str(d.get("after") or "")
if (len(before) == 40 and len(after) == 40 and set(before) != {"0"}
and set(after) != {"0"} and before != after):
return f"{base}/{repo}/compare/{before}...{after}"
except Exception:
pass
return ""
def _first_failure(checks: list) -> dict:
"""首个失败项(优先真失败,其次"未运行/缺项"—— 两者都不等于通过)。"""
for c in checks or []:
if str(c.get("status")) == "failure":
return {"id": c.get("id"), "reason": str(c.get("reason") or "").strip()[:400]}
for c in checks or []:
if str(c.get("status")) in ("not_run", "unknown", "skipped"):
return {"id": c.get("id"),
"reason": f"未运行/跳过({c.get('status')})—— 不算通过"}
return {}
def _next_step(checks: list) -> str:
"""可复现入口:指向项目唯一门禁入口与该检查项(真实命令,不是建议口号)。"""
ff = _first_failure(checks)
if ff.get("id"):
return f"本地复现:make verify(或 python3 scripts/ci_checks.py --only {ff['id']})"
return "本地复现:make verify(唯一门禁入口)后查看报告"
def _limitations(checks: list, rep: dict) -> list:
"""范围限制:**只写事实**(跳过了哪些检查、格式债基线还剩多少),不写"全部通过"。"""
out = []
skipped = [str(c.get("id")) for c in (checks or []) if str(c.get("status")) == "skipped"]
if skipped:
out.append("已跳过的检查(不算通过):" + "、".join(skipped[:6]))
try:
base = json.loads((ROOT / "web" / ".prettier-baseline.json").read_text(encoding="utf-8"))
n = len(base.get("files") or base if isinstance(base, (list, dict)) else [])
if isinstance(base, dict):
n = len(base.get("files") or base.get("entries") or {})
if n:
out.append(f"前端格式债基线:{n} 个文件(只减不增,到期前必须清零)")
except Exception:
pass
for x in (rep.get("limitations") or [])[:3]:
out.append(str(x)[:160])
return out[:4]
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)
_lb_base, _lb_source = _link_base()
repo_full = (rep.get("repository") or os.environ.get("GITHUB_REPOSITORY") or "").strip()
if _lb_source != "CI_PUBLIC_BASE_URL":
print(f"提示:通知链接基址 = {_lb_base or '(未取得)'}({_lb_source})", flush=True)
else:
print(f"通知链接基址 = {_lb_base}(来自 CI_PUBLIC_BASE_URL)", flush=True)
if not _repo_ok(repo_full):
print(f"⚠️ 仓库名不是 owner/repo({repo_full[:40]!r})⇒ 通知里不给 run/提交/变更 链接",
file=sys.stderr)
_scope_commits, _scope_source, _scope_err = _scope_for_notify()
_scope_payload = {"commits": _scope_commits, "source": _scope_source,
"count": len(_scope_commits)}
if _scope_err:
print(f"⚠️ 通知里不含本次范围摘要(范围解析:{_scope_err[:120]})", file=sys.stderr)
payload_count = rd(os.environ.get("CI_COMMITS_COUNT_FILE", ""), "") or git(
"rev-list", "--count", "HEAD~1..HEAD")
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": payload_count,
"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"{_lb_base}/{repo_full}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}"
if (os.environ.get("GITHUB_RUN_ID") and _lb_base and _repo_ok(repo_full)) 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:没有部署,报告里也这么写
# —— 通知消息规范需要的字段(缺就缺,不许编)——
"schema_version": 2,
"job": _job_name(), # 真实 job 名(来自 workflow 文件)
"run_number": os.environ.get("GITHUB_RUN_NUMBER", ""),
"run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", "1"),
"commits": _scope_payload["commits"], # 准确范围里的提交标题(≤10 条)
"range_source": _scope_payload["source"], # 范围是怎么算出来的(可复盘)
"commit_count": (_scope_payload["count"] or payload_count), # 条数由 Git 算
"compare_url": _compare_url(repo_full, _lb_base),
"commit_url": _commit_url(repo_full, rep.get("commit_sha") or "", _lb_base),
"link_base": _lb_base, # 链接基址(可复盘)
"link_base_source": _lb_source, # 基址从哪来:CI_PUBLIC_BASE_URL / GITHUB_SERVER_URL 回退
"run_duration_s": "", # 整次运行耗时:本 workflow 没留起点 ⇒ 不编
"duration_kind": "gate",
"first_failure": _first_failure(checks),
"next_step": _next_step(checks),
"limitations": _limitations(checks, rep),
}
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(sys.argv[1:]))