#!/usr/bin/env python3 """完整调用链夹具:**空工作区 → 最小 bootstrap → 仓内脚本 → 提交范围闸门**。 为什么必须单独有它(第三轮审查的两个阻断就是这里漏掉的): · 前几轮的"检出脚本负例"是在**已经有代码的目录**里跑的 —— 那验证的是脚本本身, **不覆盖 Runner 从空工作区启动**:工作区为空时 `.gitea/scripts/ci_checkout.sh` 还不存在, 直接调用它返回 127。而 workflow 的第一步如果就是调用它,整条流水线根本起不来。 · 新分支推送的范围用 `after --not --remotes` 算 —— 检出后 origin/<本分支> 通常已经等于 after, 那个写法会把自身排除掉,得到空集合(实测 0 条并失败)。这个形状只有 **fetch → checkout → gate** 走完整一遍才会出现。 做法:起一个本地 HTTP git 服务(真 `git http-backend`,不是 mock),把 workflow 里 **最小 bootstrap 那个 run 块原样抽出来执行**(所以 YAML 一旦改坏,这里立刻红), 然后调用仓内脚本、再跑提交范围闸门。 python3 scripts/ci_chain_fixture.py # 在项目仓里跑(需要 git 与 python3) python3 scripts/ci_chain_fixture.py --workflow /path/to/build.yml 全程只在本机临时目录 + 127.0.0.1 上:不连真实 Gitea、不碰远端状态、不发任何通知。 """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys import tempfile import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT = Path(__file__).resolve().parent.parent FAILED: list[str] = [] PASSED: list[str] = [] def ok(msg: str) -> None: PASSED.append(msg) print(f" ✅ {msg}") def no(msg: str) -> None: FAILED.append(msg) print(f" ❌ {msg}") def git(*args, cwd=None, env=None, check=False): p = subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **(env or {})}, capture_output=True, text=True) if check and p.returncode != 0: raise RuntimeError(f"git {' '.join(args)} 失败:{p.stderr[:300]}") return p # ── 本地 HTTP git 服务(真 git http-backend)──────────────────────────────── class GitHTTPHandler(BaseHTTPRequestHandler): root = "/tmp" seen: list[str] = [] def log_message(self, *a): pass def _serve(self, method: str) -> None: parsed = urllib.parse.urlparse(self.path) body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) if method == "POST" else b"" self.seen.append(f"{method} {parsed.path}") env = { "PATH": os.environ.get("PATH", "/usr/bin:/bin"), "GIT_PROJECT_ROOT": self.root, "GIT_HTTP_EXPORT_ALL": "1", "PATH_INFO": urllib.parse.unquote(parsed.path), "QUERY_STRING": parsed.query, "REQUEST_METHOD": method, "CONTENT_TYPE": self.headers.get("Content-Type") or "", "CONTENT_LENGTH": str(len(body)), "REMOTE_ADDR": "127.0.0.1", "REMOTE_USER": "ci-fixture", } p = subprocess.run(["git", "http-backend"], input=body, env=env, capture_output=True) raw = p.stdout head, _, payload = raw.partition(b"\r\n\r\n") if not payload: head, _, payload = raw.partition(b"\n\n") if p.returncode != 0 and not head: self.send_response(500) self.end_headers() self.wfile.write(p.stderr[:300]) return code = 200 headers = [] for line in head.decode("utf-8", "replace").splitlines(): if line.lower().startswith("status:"): code = int(line.split(":", 1)[1].strip().split()[0]) elif ":" in line: k, v = line.split(":", 1) headers.append((k.strip(), v.strip())) self.send_response(code) for k, v in headers: self.send_header(k, v) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def do_GET(self): self._serve("GET") def do_POST(self): self._serve("POST") # ── 从 workflow 里抽出 bootstrap 的 run 块(原样执行 ⇒ YAML 漂移会立刻暴露)── def extract_bootstrap(workflow: Path) -> str: text = workflow.read_text(encoding="utf-8") lines = text.splitlines() start = None for i, line in enumerate(lines): if line.strip().startswith("- name: 检出代码"): start = i break if start is None: raise RuntimeError(f"{workflow} 里找不到「检出代码」这一步") run_at = None for j in range(start, min(start + 12, len(lines))): if lines[j].strip() == "run: |": run_at = j break if run_at is None: raise RuntimeError("「检出代码」这一步里没有 `run: |` 块") indent = len(lines[run_at]) - len(lines[run_at].lstrip()) body: list[str] = [] for k in range(run_at + 1, len(lines)): line = lines[k] if line.strip() and (len(line) - len(line.lstrip())) <= indent: break body.append(line[indent + 2:] if line.strip() else "") return "\n".join(body) + "\n" def seed_origin(host: Path, repo: str, work: Path) -> dict: """在 $host/.git 造一个 bare 源:main 基线 + 一个**新分支**(含违规提交)。""" bare = host / f"{repo}.git" bare.parent.mkdir(parents=True, exist_ok=True) git("init", "-q", "--bare", "-b", "main", str(bare), check=True) git("init", "-q", "-b", "main", str(work), check=True) empty_hooks = work.parent / "no-hooks" empty_hooks.mkdir(exist_ok=True) env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", # 夹具要造一条**故意违规**的提交来验证闸门;本机的 commit-msg 钩子会先把它拦掉。 # 这不是绕过门禁:沙箱仓本来就没有钩子,那条提交只存在于临时目录里。 "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": str(empty_hooks)} (work / "README.md").write_text("fixture\n", encoding="utf-8") # 种子仓里要有**本仓真实的 .gitea/scripts/** —— 否则检出后调用仓内脚本会 127, # 而那不是我们要验的东西(我们要验的是"空工作区能不能启动 + 检出后脚本能不能被调用")。 shutil.copytree(ROOT / ".gitea" / "scripts", work / ".gitea" / "scripts", ignore=shutil.ignore_patterns("__pycache__")) (work / "scripts").mkdir(exist_ok=True) shutil.copy2(ROOT / "scripts" / "ci_checks.py", work / "scripts" / "ci_checks.py") git("add", "-A", cwd=work, env=env, check=True) git("commit", "-q", "-m", "chore(fixture): 建立基线提交", cwd=work, env=env, check=True) git("remote", "add", "origin", str(bare), cwd=work, check=True) git("push", "-q", "origin", "main", cwd=work, env=env, check=True) base = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip() # 新分支:一条合规 + 一条**违规**提交(用来证明新分支的提交真的被检查了) git("checkout", "-q", "-b", "ci-trial", cwd=work, env=env, check=True) (work / "good.txt").write_text("1\n", encoding="utf-8") git("add", "-A", cwd=work, env=env, check=True) git("commit", "-q", "-m", "feat(trial): 新分支上的合规提交", cwd=work, env=env, check=True) good_tip = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip() (work / "bad.txt").write_text("2\n", encoding="utf-8") git("add", "-A", cwd=work, env=env, check=True) git("commit", "-q", "-m", "update stuff", cwd=work, env=env, check=True) # 违规 bad_tip = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip() git("push", "-q", "origin", "ci-trial", cwd=work, env=env, check=True) return {"base": base, "good_tip": good_tip, "bad_tip": bad_tip, "good_msg_tip": good_tip} def run_bootstrap(script: Path, ws: Path, env_extra: dict) -> subprocess.CompletedProcess: ws.mkdir(parents=True, exist_ok=True) env = {**os.environ, "GITHUB_WORKSPACE": str(ws), "GITHUB_ENV": str(ws.parent / "gh_env"), "GITHUB_REPOSITORY": env_extra.pop("_repo"), **env_extra} env.pop("CI_COMMIT_RANGE", None) return subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True, timeout=300) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--workflow", default=str(ROOT / ".gitea" / "workflows" / "build.yml")) args = ap.parse_args() workflow = Path(args.workflow) if not workflow.is_file(): print(f"❌ 找不到 workflow:{workflow}", file=sys.stderr) return 2 if not (ROOT / ".gitea" / "scripts" / "ci_checkout.sh").is_file(): print("❌ 本仓里没有 .gitea/scripts/ci_checkout.sh(请在项目仓里跑)", file=sys.stderr) return 2 tmp = Path(tempfile.mkdtemp(prefix="ci-chain-")) try: host = tmp / "host" work = tmp / "seed" info = seed_origin(host, "owner/repo", work) # 本地 HTTP git 服务:GITHUB_SERVER_URL → http://127.0.0.1:,仓库路径 owner/repo.git GitHTTPHandler.root = str(host) srv = ThreadingHTTPServer(("127.0.0.1", 0), GitHTTPHandler) port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True).start() server_url = f"http://127.0.0.1:{port}" print(f"── 本地 git 服务 {server_url}(root={host},repo=owner/repo.git)──") boot = tmp / "bootstrap.sh" boot.write_text(extract_bootstrap(workflow), encoding="utf-8") print(f"── bootstrap 来自 {workflow.name}(原样执行,不镜像、不复制)──") # ── 正例 1:空工作区启动,取 main 基线 ── print("\n== A. 空工作区 → bootstrap 检出(main 基线)==") ws = tmp / "wsA" p = run_bootstrap(boot, ws, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "main", "GITHUB_SHA": info["base"], "GIT_TOKEN": "fixture-token", "_repo": "owner/repo"}) if p.returncode == 0: ok("空工作区里 bootstrap 退出 0") else: no(f"空工作区里 bootstrap 退出 {p.returncode}:{(p.stderr or p.stdout).strip()[-200:]}") head = git("rev-parse", "HEAD", cwd=ws).stdout.strip() if head == info["base"]: ok(f"检出 == 事件完整 SHA({head[:12]})") else: no(f"检出 {head[:12]} != 事件 SHA {info['base'][:12]}") url = git("remote", "get-url", "origin", cwd=ws).stdout.strip() if url == f"{server_url}/owner/repo.git" and "@" not in url: ok(f"remote 保留了服务端协议且不含凭据:{url}") else: no(f"remote 不对:{url}") if (ws / ".gitea" / "scripts" / "ci_checkout.sh").is_file(): ok("检出之后仓内脚本才就位(bootstrap 之前确实不存在)") else: no("检出后仓内脚本仍然不存在") print(f" bootstrap 输出末行:{(p.stdout or '').strip().splitlines()[-1:]}") # ── 负例:bootstrap 之前就先调用仓内脚本 ⇒ 必须 127(这就是第三轮阻断的复现)── print("\n== B. 空工作区里**先调用仓内脚本**(旧写法的复现)==") wsB = tmp / "wsB" wsB.mkdir() q = subprocess.run(["bash", ".gitea/scripts/ci_checkout.sh", "--post-checkout"], cwd=wsB, capture_output=True, text=True) if q.returncode == 127: ok("空工作区里调用仓内脚本返回 127(证明第一步不能是它)") else: no(f"期望 127,实际 {q.returncode}") # ── 负例:事件 SHA 不存在 ⇒ 必须失败,不许继续 ── print("\n== C. 取不到事件 SHA ⇒ bootstrap 必须失败 ==") wsC = tmp / "wsC" r = run_bootstrap(boot, wsC, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "main", "GITHUB_SHA": "f" * 40, "GIT_TOKEN": "fixture-token", "_repo": "owner/repo"}) if r.returncode != 0: ok(f"取不到 SHA 时 bootstrap 非 0({r.returncode})") else: no("取不到事件 SHA 时 bootstrap 竟然返回 0") # ── 正/负例:完整 fetch → checkout → gate(新分支推送)── print("\n== D. 新分支推送:fetch → checkout → gate ==") # 新分支上有 2 条提交(1 合规 + 1 违规):good 只到第一条(声明 1 条), # bad 到第二条(声明 2 条)——这样"违规提交真的被判到"才有意义。 for label, tip, declared, want_rc in (("合规提交结尾", info["good_msg_tip"], 1, 0), ("违规提交结尾", info["bad_tip"], 2, 1)): wsD = tmp / f"wsD-{want_rc}" env_file = tmp / f"event-{want_rc}.json" env_file.write_text(json.dumps({ "event_name": "push", "before": "0" * 40, "after": tip, "total_commits": declared, "commits": [{"id": info["good_tip"], "message": "feat(trial): 新分支上的合规提交"}], "repository": {"default_branch": "main", "full_name": "owner/repo"}, }), encoding="utf-8") p = run_bootstrap(boot, wsD, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "ci-trial", "GITHUB_SHA": tip, "GIT_TOKEN": "fixture-token", "GITHUB_EVENT_PATH": str(env_file), "_repo": "owner/repo"}) if p.returncode != 0: no(f"{label}:bootstrap 失败 {(p.stderr or p.stdout).strip()[-200:]}") continue g = subprocess.run([sys.executable, ".gitea/scripts/ci_gate.py"], cwd=wsD, env={**os.environ, "GITHUB_EVENT_PATH": str(env_file), "GITHUB_ACTIONS": "true", "GITHUB_SHA": tip}, capture_output=True, text=True, timeout=120) out = g.stdout + g.stderr m = re.search(r"校验范围:(.+?)|(\d+) 条提交", out) scope = m.group(1) if m else "(没解析到范围)" count = int(m.group(2)) if m else -1 if count <= 0: no(f"{label}:范围算成 {count} 条 —— 空范围(第三轮阻断的复现)|{scope[:80]}") else: ok(f"{label}:范围 {count} 条|{scope[:70]}") if g.returncode == want_rc: ok(f"{label}:闸门退出码 {g.returncode}(符合预期)") else: no(f"{label}:闸门期望退出码 {want_rc},实际 {g.returncode}|{out.strip()[-200:]}") if want_rc == 1: if "update stuff" in out: ok("违规提交『update stuff』真的被判到(不是范围为空偶然红)") else: no(f"红的原因不是那条违规提交:{out.strip()[-200:]}") elif "全部合规" in out: ok("合规新分支上的提交全部被判为合规") else: no(f"合规新分支没有被判成合规:{out.strip()[-200:]}") srv.shutdown() print(f"\n── 收到的 git HTTP 请求 {len(GitHTTPHandler.seen)} 次:" f"{sorted(set(GitHTTPHandler.seen))[:4]} ──") print(f"\n════ 完整调用链夹具:✅ {len(PASSED)} | ❌ {len(FAILED)} ════") return 1 if FAILED else 0 finally: shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": raise SystemExit(main())