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:
@@ -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:]))
|
||||
Executable
+725
@@ -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:]))
|
||||
Executable
+100
@@ -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
@@ -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:]))
|
||||
Executable
+208
@@ -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
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user