Files
姚章浩 d167a2b937 fix(ci): 第一步改最小 bootstrap(空工作区可启动),新分支范围以默认分支为基准
空工作区启动:workflow 第一步不再调用仓内脚本(此时它还不存在,实测 rc=127),改成只用 runner 环境变量的最小 bootstrap:校验协议/凭据 → git init+remote add → 取事件完整 SHA → checkout → 断言 HEAD==事件 SHA;检出后才调用 ci_checkout.sh --post-checkout 做再断言与补取(默认分支引用、事件 before)。按任意 SHA fetch 在服务端默认不可用(服务器不允许请求未公开的对象),改成取分支历史 + --deepen 再核事件 SHA。新分支范围:after --not --remotes 会把刚 fetch 的同名远端分支(=after)当排除集、排除自身得到 0 条;改为以远端默认分支为基准 refs/remotes/origin/<default>..<after>,取不到基准就明确失败。新增 scripts/ci_chain_fixture.py:本地 HTTP git 服务 + 把 workflow 的 bootstrap run 块原样抽出来在空目录执行,正/负例 12 项断言。
2026-10-12 16:45:10 +08:00

326 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""完整调用链夹具:**空工作区 → 最小 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/<repo>.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:<port>,仓库路径 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())