From ba1d3b2b8122335f1861b2823f68ff8ffdd07b9e Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 22 Oct 2026 06:16:13 +0000 Subject: [PATCH] Add the pin-regression gate to pins checks and the PR gate Why: PR #682 merged a stale branch that reset five component pins to pre-batch values, silently dropping the batch-1 composition on main. Evidence: materialized byte-exact from the reviewed transport (gatemat-run.json); regression/forward/same/skip/missing-object cases (verify-pingate.json); make test-governance and test-pins re-run below. Impact: new check script wired into check-pins and task-pr-check; PIN_REGRESSION_SKIP=1 documents deliberate downgrades. Refs: feature/audit-followups-pin-gate-m5 --- scripts/check-pins.sh | 2 + scripts/check_pin_regression.sh | 100 +++++++++++++++ scripts/task_pr_check.py | 17 ++- tests/test_check_pin_regression.py | 187 +++++++++++++++++++++++++++++ tests/test_check_pins.py | 17 +++ 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100755 scripts/check_pin_regression.sh create mode 100644 tests/test_check_pin_regression.py diff --git a/scripts/check-pins.sh b/scripts/check-pins.sh index cab88a2b..ac986a47 100755 --- a/scripts/check-pins.sh +++ b/scripts/check-pins.sh @@ -64,6 +64,8 @@ for container in "${containers[@]}"; do done < <(git -C "$container" ls-tree "$commit" deps/) done +bash "$root/scripts/check_pin_regression.sh" || fail=1 + if [ "$fail" -ne 0 ]; then exit 1 fi diff --git a/scripts/check_pin_regression.sh b/scripts/check_pin_regression.sh new file mode 100755 index 00000000..41782269 --- /dev/null +++ b/scripts/check_pin_regression.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# 组合指针不倒退:HEAD 中每个 gitlink(deps/*、modules/*)相对 origin/main +# 只能前进——新 pin 必须以 origin/main 的旧 pin 为祖先。事故原型:PR #682 +# 合入了过期分支,其树把五个组件 pin 重置回批次前的值,静默丢掉了 main 上 +# batch-1 的组合。 +# +# ┌─ 逃生口(须 owner Review)──────────────────────────────────────────────┐ +# │ PIN_REGRESSION_SKIP=1 声明这是一次有意的降级:失败改为醒目的 warn 并 │ +# │ 退出 0。只用于经组件 owner 审阅的刻意回退,PR │ +# │ 描述须写明原因;不得用来绕过过期分支。 │ +# └─────────────────────────────────────────────────────────────────────────┘ +# +# 其他开关: +# PIN_REGRESSION_NO_FETCH=1 旧 pin 在子仓本地缺失时不尝试 fetch(测试/离线用)。 +# +# 在当前工作目录所属仓库的顶层运行。origin/main 本地不可解析(浅检出)时只告警、退出 0。 +set -euo pipefail +root=$(git rev-parse --show-toplevel) +cd "$root" + +base=origin/main +skip=${PIN_REGRESSION_SKIP:-0} + +if ! git rev-parse --verify -q "$base^{commit}" >/dev/null; then + echo "warn: $base is not resolvable locally; pin regression check skipped" >&2 + exit 0 +fi + +fail=0 + +declare -A main_pins=() +while read -r mode _typ sha path; do + [ "$mode" = "160000" ] || continue + main_pins["$path"]=$sha +done < <(git ls-tree -r "$base") + +# 子仓须是独立检出的仓库;未初始化时 git -C 会落到 Dev 自身。 +own_repo() { + local path="$1" top + top=$(git -C "$path" rev-parse --show-toplevel 2>/dev/null) || return 1 + [ "$top" = "$root/$path" ] +} + +has_commit() { + git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null +} + +ensure_commit() { + local path="$1" sha="$2" + has_commit "$path" "$sha" && return 0 + [ "${PIN_REGRESSION_NO_FETCH:-0}" = "1" ] && return 1 + git -C "$path" fetch -q origin "$sha" >/dev/null 2>&1 || true + has_commit "$path" "$sha" +} + +regress() { + if [ "$skip" = "1" ]; then + echo "warn: PIN_REGRESSION_SKIP=1 — $* (deliberate downgrade; requires owner review)" >&2 + else + echo "pin: $*" >&2 + fail=1 + fi +} + +while read -r mode _typ new path; do + [ "$mode" = "160000" ] || continue + old=${main_pins["$path"]:-} + if [ -z "$old" ]; then + echo "ok $path (new, not on $base)" + continue + fi + if [ "$old" = "$new" ]; then + echo "ok $path" + continue + fi + if ! own_repo "$path"; then + regress "$path is not checked out; cannot verify ${new:0:12} descends from $base ${old:0:12}" + continue + fi + if ! ensure_commit "$path" "$old"; then + regress "$path $base pin $old is missing locally and could not be fetched; cannot verify no regression" + continue + fi + if ! ensure_commit "$path" "$new"; then + regress "$path HEAD pin $new is missing locally and could not be fetched; cannot verify no regression" + continue + fi + if git -C "$path" merge-base --is-ancestor "$old" "$new"; then + echo "ok $path (forward ${old:0:12}..${new:0:12})" + continue + fi + regress "$path moves BACKWARD: $base is ${old:0:12}, HEAD is ${new:0:12} (not a descendant)." \ +"This is the PR #682 pattern (a stale branch resetting pins over main): merge $base and re-pin," \ +"or set PIN_REGRESSION_SKIP=1 for an owner-reviewed deliberate downgrade" +done < <(git ls-tree -r HEAD) + +if [ "$fail" -ne 0 ]; then + exit 1 +fi +echo "pin regression ok" diff --git a/scripts/task_pr_check.py b/scripts/task_pr_check.py index 19b6d92d..3ede2d2b 100644 --- a/scripts/task_pr_check.py +++ b/scripts/task_pr_check.py @@ -13,6 +13,8 @@ 不联网、不需要 token;非 pull_request 事件跳过(退出 0)。 期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。 +local 与 ci 均运行 check_pin_regression.sh(gitlink 相对 origin/main 不得倒退);非零即拒绝, +PIN_REGRESSION_SKIP=1 由该脚本降级为告警(有意降级须 owner Review)。 退出码:0 通过;1 门禁拒绝(PR_CHECK_FAILED);2 用法;6 远端无法核验(REMOTE_UNVERIFIED,fail closed)。 仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。 @@ -33,6 +35,7 @@ EXIT_USAGE = 2 PRE_MERGE_CLOSED = "已完成(合入前闭合)" CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR") DONE_STATES = ("已合入", "已延期") +PIN_REGRESSION = Path(__file__).resolve().parent / "check_pin_regression.sh" def sh(*args): @@ -216,6 +219,17 @@ def final_problems(api, slug, token, branch, meta, text): return problems +def pin_regression_problems(): + proc = subprocess.run(["bash", str(PIN_REGRESSION)], capture_output=True, text=True, + encoding="utf-8", errors="replace") + for line in (proc.stdout + proc.stderr).splitlines(): + print(f"[pin-regression] {line}") + if proc.returncode != 0: + return [f"pin regression gate failed (exit {proc.returncode}): a gitlink moves backward against " + "origin/main or cannot be verified; PIN_REGRESSION_SKIP=1 only for owner-reviewed downgrades"] + return [] + + def verdict(problems, kind, base, head): if problems: for problem in problems: @@ -264,6 +278,7 @@ def cmd_local(args): problems.append(f"parent initiative {expected} has no open PR into {meta['root_target']}") if args.final and kind == "initiative": problems.extend(final_problems(api, slug, token, branch, meta, text)) + problems.extend(pin_regression_problems()) return verdict(problems, kind, expected, head) @@ -281,7 +296,7 @@ def cmd_ci(_args): if base != expected: raise Stop(EXIT_FAILED, f"PR base {base!r} differs from expected {expected!r} " f"for {meta['kind']} branch {head}") - return verdict([], meta["kind"], base, head) + return verdict(pin_regression_problems(), meta["kind"], base, head) def main(argv=None): diff --git a/tests/test_check_pin_regression.py b/tests/test_check_pin_regression.py new file mode 100644 index 00000000..15f066ad --- /dev/null +++ b/tests/test_check_pin_regression.py @@ -0,0 +1,187 @@ +"""check_pin_regression.sh:gitlink 相对 origin/main 只能前进(PR #682 事故形态)。 + +全部离线:临时目录内的普通 Git 仓库充当子仓,origin 只指向本地 bare 仓库。 +""" +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_pin_regression.sh" +PR_CHECK = ROOT / "scripts" / "task_pr_check.py" +COMP = "deps/comp" + + +class PinRegressionTests(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + cfg = self.tmp / "gitconfig" + cfg.write_text("") + self.env = dict( + os.environ, + GIT_CONFIG_GLOBAL=str(cfg), + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="t", + GIT_AUTHOR_EMAIL="t@example.invalid", + GIT_COMMITTER_NAME="t", + GIT_COMMITTER_EMAIL="t@example.invalid", + ) + for key in ("PIN_REGRESSION_SKIP", "PIN_REGRESSION_NO_FETCH"): + self.env.pop(key, None) + self.dev = self.tmp / "dev" + self.dev.mkdir() + self.git(self.dev, "init", "-q") + # 子仓:A ← B,另有从 A 分叉的 C。 + self.comp = self.dev / COMP + self.comp.mkdir(parents=True) + self.git(self.comp, "init", "-q") + self.a = self.commit(self.comp, "a") + self.b = self.commit(self.comp, "b") + self.git(self.comp, "checkout", "-q", "-b", "side", self.a) + self.c = self.commit(self.comp, "c") + + def git(self, cwd, *args): + return subprocess.run( + ["git", *args], cwd=cwd, env=self.env, check=True, capture_output=True, text=True + ).stdout.strip() + + def commit(self, repo, text): + (repo / "f").write_text(text) + self.git(repo, "add", "f") + self.git(repo, "commit", "-qm", text) + return self.git(repo, "rev-parse", "HEAD") + + def pin(self, sha, path=COMP): + self.git(self.dev, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}") + self.git(self.dev, "commit", "-qm", f"pin {path} {sha[:7]}") + return self.git(self.dev, "rev-parse", "HEAD") + + def compose(self, main_pin, head_pin): + """origin/main 钉 main_pin,HEAD(PR 分支)钉 head_pin。""" + main = self.pin(main_pin) + self.git(self.dev, "update-ref", "refs/remotes/origin/main", main) + if head_pin != main_pin: + self.pin(head_pin) + + def run_check(self, **extra): + return subprocess.run( + ["bash", str(SCRIPT)], cwd=self.dev, env=self.env | extra, capture_output=True, text=True + ) + + def test_same_pin_passes(self): + self.compose(self.a, self.a) + res = self.run_check() + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn(f"ok {COMP}\n", res.stdout) + self.assertIn("pin regression ok", res.stdout) + + def test_forward_pin_passes(self): + self.compose(self.a, self.b) + res = self.run_check() + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn(f"ok {COMP} (forward", res.stdout) + + def test_pr682_stale_branch_carrying_old_pin_fails(self): + self.compose(self.b, self.a) + res = self.run_check() + self.assertEqual(res.returncode, 1, res.stdout) + self.assertIn(f"pin: {COMP} moves BACKWARD", res.stderr) + self.assertIn("PR #682", res.stderr) + self.assertIn("PIN_REGRESSION_SKIP=1", res.stderr) + self.assertNotIn("pin regression ok", res.stdout) + + def test_diverged_pin_fails(self): + self.compose(self.b, self.c) + res = self.run_check() + self.assertEqual(res.returncode, 1, res.stdout) + self.assertIn(f"pin: {COMP} moves BACKWARD", res.stderr) + + def test_skip_env_downgrades_to_warning(self): + self.compose(self.b, self.a) + res = self.run_check(PIN_REGRESSION_SKIP="1") + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("warn: PIN_REGRESSION_SKIP=1", res.stderr) + self.assertIn("requires owner review", res.stderr) + + def test_missing_origin_main_warns_and_passes(self): + self.pin(self.a) + res = self.run_check() + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("warn: origin/main is not resolvable locally", res.stderr) + + def test_new_component_not_on_main_passes(self): + self.compose(self.a, self.a) + other = self.dev / "deps" / "other" + other.mkdir() + self.git(other, "init", "-q") + self.pin(self.commit(other, "o"), "deps/other") + res = self.run_check() + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("ok deps/other (new, not on origin/main)", res.stdout) + + def test_missing_old_object_with_fetch_disabled_fails(self): + self.compose("1" * 40, self.a) + res = self.run_check(PIN_REGRESSION_NO_FETCH="1") + self.assertEqual(res.returncode, 1, res.stdout) + self.assertIn(f"pin: {COMP} origin/main pin {'1' * 40} is missing locally", res.stderr) + + def test_missing_old_object_unfetchable_from_local_origin_fails(self): + bare = self.tmp / "comp-origin.git" + self.git(self.tmp, "init", "-q", "--bare", str(bare)) + self.git(self.comp, "remote", "add", "origin", str(bare)) + self.compose("2" * 40, self.a) + res = self.run_check() + self.assertEqual(res.returncode, 1, res.stdout) + self.assertIn("could not be fetched", res.stderr) + + def test_missing_old_object_is_fetched_from_local_origin(self): + upstream = self.tmp / "upstream" + self.git(self.tmp, "clone", "-q", str(self.comp), str(upstream)) + newer = self.commit(upstream, "z") + bare = self.tmp / "comp-origin.git" + self.git(self.tmp, "clone", "-q", "--bare", str(upstream), str(bare)) + self.git(self.comp, "remote", "add", "origin", str(bare)) + self.compose(newer, self.b) + res = self.run_check() + self.assertEqual(res.returncode, 1, res.stdout) + self.assertNotIn("could not be fetched", res.stderr) + self.assertIn(f"pin: {COMP} moves BACKWARD", res.stderr) + self.git(self.comp, "cat-file", "-e", f"{newer}^{{commit}}") + + def test_uninitialized_component_cannot_be_verified(self): + self.compose(self.b, self.a) + shutil.rmtree(self.comp) + self.comp.mkdir() + res = self.run_check() + self.assertEqual(res.returncode, 1, res.stdout) + self.assertIn(f"pin: {COMP} is not checked out", res.stderr) + + def run_pr_check_ci(self, **extra): + env = self.env | {"GITHUB_EVENT_NAME": "pull_request", "GITHUB_BASE_REF": "main", + "GITHUB_HEAD_REF": "feature/stale", "TASK_TARGET_BRANCH": "main"} | extra + return subprocess.run([sys.executable, str(PR_CHECK), "ci"], cwd=self.dev, env=env, + capture_output=True, text=True, timeout=60) + + def test_task_pr_check_ci_hard_fails_on_regression(self): + self.compose(self.b, self.a) + res = self.run_pr_check_ci() + self.assertEqual(res.returncode, 1, res.stdout + res.stderr) + self.assertIn(f"[pin-regression] pin: {COMP} moves BACKWARD", res.stdout) + self.assertIn("[pr-check] FAIL pin regression gate failed", res.stdout) + self.assertIn("[verdict] PR_CHECK_FAILED", res.stdout) + + def test_task_pr_check_ci_skip_env_passes(self): + self.compose(self.b, self.a) + res = self.run_pr_check_ci(PIN_REGRESSION_SKIP="1") + self.assertEqual(res.returncode, 0, res.stdout + res.stderr) + self.assertIn("[pin-regression] warn: PIN_REGRESSION_SKIP=1", res.stdout) + self.assertIn("[verdict] PR_CHECK_OK", res.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_pins.py b/tests/test_check_pins.py index 009281ae..d8099781 100644 --- a/tests/test_check_pins.py +++ b/tests/test_check_pins.py @@ -8,6 +8,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "check-pins.sh" +REGRESSION = ROOT / "scripts" / "check_pin_regression.sh" CONTAINERS = ("modules/backend", "deps/agent-native", "deps/agent-squad", "deps/agent-intent") @@ -29,6 +30,7 @@ class CheckPinsTests(unittest.TestCase): self.dev = self.tmp / "dev" (self.dev / "scripts").mkdir(parents=True) shutil.copy(SCRIPT, self.dev / "scripts" / "check-pins.sh") + shutil.copy(REGRESSION, self.dev / "scripts" / "check_pin_regression.sh") self.git(self.dev, "init", "-q") def git(self, cwd, *args): @@ -112,6 +114,21 @@ class CheckPinsTests(unittest.TestCase): self.assertEqual(res.returncode, 1) self.assertIn("has no Dev pin", res.stderr) + def test_pin_regression_against_origin_main_fails(self): + old, new = self.repo("deps/error-code-system", "e1", "e2") + self.build({"error-code-system": old}, {"deps/error-code-system": old}) + self.git(self.dev, "commit", "-qm", "pr") + stale = self.git(self.dev, "rev-parse", "HEAD") + self.link(self.dev, "deps/error-code-system", new) + self.git(self.dev, "commit", "-qm", "main") + self.git(self.dev, "update-ref", "refs/remotes/origin/main", "HEAD") + self.git(self.dev, "reset", "-q", stale) + res = self.run_pins() + self.assertEqual(res.returncode, 1, res.stderr) + self.assertIn("ok modules/backend error-code-system", res.stdout) + self.assertIn("pin: deps/error-code-system moves BACKWARD", res.stderr) + self.assertNotIn("pins ok", res.stdout) + if __name__ == "__main__": unittest.main()