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:
@@ -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
|
||||
|
||||
Executable
+100
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user