d167a2b937
空工作区启动: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 项断言。
326 lines
16 KiB
Python
326 lines
16 KiB
Python
#!/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()) |