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
This commit is contained in:
2026-10-22 06:16:13 +00:00
parent 3080c0f4af
commit ba1d3b2b81
5 changed files with 322 additions and 1 deletions
+2
View File
@@ -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
+100
View File
@@ -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"
+16 -1
View File
@@ -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):
+187
View File
@@ -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()
+17
View File
@@ -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()