agent-exec transport e4ff44791a4210144c6165ceadebb342/692536c1c7d0

This commit is contained in:
agent-exec-transport
2026-10-18 02:29:25 +00:00
parent 2757b6f68e
commit b7f6a8bb9c
8 changed files with 430 additions and 7 deletions
+2
View File
@@ -153,6 +153,8 @@ policy/delivery.md。
- 主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`
四段;任务卡与 PR 描述为同一份文档。
- 跨仓 gitlink 变更须经组合验证(`make test`)后在 Dev 仓推进;受保护分支经 PR 合入。
- **等待 CI** 只用一条阻塞命令 `make ci-wait SHA=<完整 SHA>`(15 s 轮询、失败即停、默认 20 分钟超时);
禁止手写 `sleep` + `curl` 轮询循环,禁止对同一 SHA 重复/并行轮询;等待期间去做其他独立工作。
- **高后果操作双钥匙**:改写历史、解除/变更分支保护、删除归档引用、发布版本——Agent 只准备
方案与命令,由用户确认并亲手执行;Agent 不得自行实施(baseline 硬边界 1 的授权范围要求)。
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
+8 -2
View File
@@ -13,7 +13,7 @@ PLUGINS := plugins
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check ci-wait facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
version:
@echo "agent-system $(VERSION)"
@@ -32,6 +32,7 @@ export TASK_INITIATIVE
setup:
git config core.hooksPath scripts/hooks
git config submodule.fetchJobs 8
chmod +x scripts/hooks/pre-commit scripts/hooks/post-merge scripts/task.sh scripts/evidence.sh scripts/facts-verify.sh scripts/agent-exec
@echo "OK: pre-commit/post-merge 钩子已启用(core.hooksPath=scripts/hooks)"
@@ -66,6 +67,10 @@ task-finish:
task-pr-check:
python3 scripts/task_pr_check.py local $(if $(PR),--pr $(PR)) $(if $(FINAL),--final)
# CI 等待(单条阻塞命令):轮询 Gitea 合并状态,失败即停;两次连续全绿才算成功。默认 SHA=HEAD、20 分钟超时。
ci-wait:
python3 scripts/ci_wait.py $(if $(SHA),--sha $(SHA)) $(if $(TIMEOUT),--timeout $(TIMEOUT)) $(if $(INTERVAL),--interval $(INTERVAL)) $(if $(CONTEXTS),--contexts $(CONTEXTS))
# 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。
# 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。
# 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。
@@ -100,6 +105,7 @@ help:
@echo "make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW [INITIATIVE=<branch>] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)"
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)"
@echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)"
@echo "make ci-wait [SHA=<完整 SHA>] [TIMEOUT=1200] [INTERVAL=15] [CONTEXTS=a,b] 阻塞等待该 SHA 的 CI 结论(失败即停)"
agent-exec-validate:
scripts/agent-exec validate --repo .
@@ -112,7 +118,7 @@ agent-exec-admit:
scripts/agent-exec admit --repo .
sync:
git submodule update --init --recursive
git submodule update --init --recursive --jobs 8
status:
@git submodule status
+1 -1
View File
@@ -97,7 +97,7 @@ def materialize(repo):
for child, expected in submodules(repo):
check_children(repo, "before-update")
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
"--checkout", "--", str(child.relative_to(repo))])
"--jobs", "8", "--checkout", "--", str(child.relative_to(repo))])
if result.returncode:
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
materialize(child)
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。
用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]]
--sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
截止前从未成功轮询才以 6 放弃)。
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<sha>`。
退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import re
import subprocess
import sys
import time
import urllib.parse
from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug
EXIT_FAILED = 1
EXIT_TIMEOUT = 2
EXIT_USAGE = 2
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
FAILING = ("failure", "error")
FATAL_HTTP = (400, 401, 403, 404, 410)
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
return proc.stdout.strip() if proc.returncode == 0 else None
def resolve_sha(sha):
value = (sha or "HEAD").strip()
if not SHA_RE.match(value.lower()):
value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or ""
value = value.lower()
if not SHA_RE.match(value):
raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA")
return value
def interval_arg(text):
value = float(text)
if value < 5:
raise argparse.ArgumentTypeError("interval must be at least 5 seconds")
return value
def timeout_arg(text):
value = float(text)
if value <= 0:
raise argparse.ArgumentTypeError("timeout must be positive")
return value
def observe(data):
"""[(context, state, target_url)],按 context 排序;忽略非 dict 项。"""
items = data.get("statuses") if isinstance(data, dict) else None
rows = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown"
rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or "")))
return sorted(rows)
def describe(rows):
return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)"
def green_key(rows, contexts):
"""满足全绿与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state != "success" for _, state, _ in rows):
return None
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
return None
return tuple((ctx, state) for ctx, state, _ in rows)
def wait(sha, interval, timeout, contexts, sleep, clock):
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)")
origin = git("remote", "get-url", "origin")
if not origin:
raise Stop(EXIT_REMOTE, "cannot read origin remote URL")
owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/"))
url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status"
deadline = clock() + timeout
rows, shown, previous_green, polled, last_error = [], None, None, False, None
while True:
try:
data = http_get_json(url, token)
except Stop as stop:
if stop.status in FATAL_HTTP:
raise
last_error = stop.message
else:
polled = True
rows = observe(data)
line = describe(rows)
if line != shown:
print(f"[ci-wait] sha={sha[:12]} {line}", flush=True)
shown = line
failing = [row for row in rows if row[1] in FAILING]
if failing:
for ctx, _, target in failing:
print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}")
print(f"[verdict] CI_FAILED sha={sha}")
return EXIT_FAILED
key = green_key(rows, contexts)
if key is not None and key == previous_green:
print(f"[verdict] CI_SUCCESS sha={sha}")
return 0
previous_green = key
remaining = deadline - clock()
if remaining <= 0:
break
sleep(min(interval, remaining))
if not polled:
raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline")
print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}")
missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)]
if missing:
print(f"[ci-wait] missing contexts: {', '.join(missing)}")
print(f"[verdict] CI_TIMEOUT sha={sha}")
return EXIT_TIMEOUT
def main(argv=None, sleep=time.sleep, clock=time.monotonic):
parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论")
parser.add_argument("--sha")
parser.add_argument("--interval", type=interval_arg, default=15.0)
parser.add_argument("--timeout", type=timeout_arg, default=1200.0)
parser.add_argument("--contexts", default="")
args = parser.parse_args(argv)
contexts = [name.strip() for name in args.contexts.split(",") if name.strip()]
try:
sha = resolve_sha(args.sha)
return wait(sha, args.interval, args.timeout, contexts, sleep, clock)
except Stop as stop:
print(f"[ci-wait] ERROR {stop.message}")
if stop.code == EXIT_REMOTE:
print("[verdict] REMOTE_UNVERIFIED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -230,7 +230,7 @@ cmd_start() {
git worktree add -q "$wt" -b "$name" "$start_ref"
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
(cd "$wt" && git submodule update --init --recursive --jobs 8 >/dev/null 2>&1) \
|| echo "[warn] submodule update 非零退出,见下方状态核对"
local sub_drift
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
@@ -956,7 +956,7 @@ cmd_finish() {
return 1
fi
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
echo "[next] inspect task-card closure with make task-close in the main checkout when authorized"
echo "[next] cards are normally fold-closed before merge with make task-close SELF=1; only legacy cards reported by precheck [cards] need make task-close in the main checkout when authorized"
}
case "${1:-}" in
+3 -2
View File
@@ -51,10 +51,11 @@ EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message):
def __init__(self, code, message, status=None):
super().__init__(message)
self.code = code
self.message = message
self.status = status
def utc_now():
@@ -152,7 +153,7 @@ def http_get_json(url, token):
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}")
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code)
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
try:
+36
View File
@@ -0,0 +1,36 @@
"""scripts/check-worktree.py 的组件物化参数:逐个 submodule 物化,并以 --jobs 8 并行拉取。"""
import importlib.util
import subprocess
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check-worktree.py"
spec = importlib.util.spec_from_file_location("check_worktree", SCRIPT)
check_worktree = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_worktree)
class MaterializeTests(unittest.TestCase):
def test_submodule_update_uses_parallel_jobs_per_component(self):
repo = Path("/fixture/repo")
child = repo / "modules" / "core"
calls = []
def fake_submodules(path):
return [(child, "a" * 40)] if path == repo else []
def fake_run(argv, *args, **kwargs):
calls.append(argv)
return subprocess.CompletedProcess(argv, 0)
with mock.patch.object(check_worktree, "submodules", fake_submodules), \
mock.patch.object(check_worktree, "check_children", lambda *a: None), \
mock.patch.object(check_worktree.subprocess, "run", fake_run):
check_worktree.materialize(repo)
self.assertEqual(calls, [["git", "-C", str(repo), "submodule", "update", "--init",
"--jobs", "8", "--checkout", "--", "modules/core"]])
if __name__ == "__main__":
unittest.main()
+219
View File
@@ -0,0 +1,219 @@
"""CI 等待(scripts/ci_wait.py):临时 Git 仓库 + 进程内 Gitea 合并状态桩 + 注入的时钟与 sleep。
全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。
"""
import contextlib
import http.server
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "ci_wait.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import ci_wait # noqa: E402
TOKEN = "synthetic-gitea-token-not-real"
def status(context, state, url=None):
return {"context": context, "status": state, "target_url": url or f"https://ci.invalid/{context}"}
class FakeGitea:
"""Gitea 合并状态桩(127.0.0.1):校验 token;每次 GET 依次返回脚本中的下一份 statuses(末项重复)。"""
def __init__(self, token=TOKEN):
self.token = token
self.script = []
self.requests = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parts = self.path.split("?", 1)[0].split("/")
if parts[1:4] != ["api", "v1", "repos"] or len(parts) != 9 or parts[6] != "commits" \
or parts[8] != "status":
return self._send(404, {"message": "not found"})
stub.requests.append("/".join(parts[4:6]) + "@" + parts[7])
statuses = stub.script.pop(0) if len(stub.script) > 1 else (stub.script or [[]])[0]
return self._send(200, {"sha": parts[7], "statuses": statuses})
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/api/v1"
class FakeClock:
def __init__(self):
self.now = 0.0
self.sleeps = []
def clock(self):
return self.now
def sleep(self, seconds):
self.sleeps.append(seconds)
self.now += seconds
class CiWaitTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="ci-wait-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.repo = root / "repo"
env = {k: v for k, v in os.environ.items()
if not k.startswith("GIT_") and k not in {"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN"}}
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "CI Wait Test", "GIT_AUTHOR_EMAIL": "ci-wait@example.invalid",
"GIT_COMMITTER_NAME": "CI Wait Test", "GIT_COMMITTER_EMAIL": "ci-wait@example.invalid",
"HOME": str(root / "no-home"),
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_TOKEN": TOKEN})
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
env["GITEA_API_URL"] = self.gitea.url
self.env = env
env_patch = mock.patch.dict(os.environ, env, clear=True)
env_patch.start()
self.addCleanup(env_patch.stop)
self.git(root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", "ssh://git@git-ssh.example.invalid:31411/fixture/repo.git")
self.sha = self.git(self.repo, "rev-parse", "HEAD")
cwd = os.getcwd()
os.chdir(self.repo)
self.addCleanup(os.chdir, cwd)
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()
def run_wait(self, *args, env=None):
clock = FakeClock()
out = io.StringIO()
with mock.patch.dict(os.environ, env or {}), contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(out):
code = ci_wait.main(list(args), sleep=clock.sleep, clock=clock.clock)
text = out.getvalue()
self.assertNotIn(TOKEN, text)
return code, text, clock
def test_success_requires_two_consecutive_all_green_polls(self):
self.gitea.script = [[status("ci/test", "pending")], [status("ci/test", "pending")],
[status("ci/test", "success")], [status("ci/test", "success")]]
code, out, clock = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertEqual(self.gitea.requests[0], f"fixture/repo@{self.sha}")
self.assertEqual(clock.sleeps, [15.0, 15.0, 15.0], out)
# 状态行只在变化时打印:pending 与 success 各一行。
lines = [line for line in out.splitlines() if line.startswith("[ci-wait] sha=")]
self.assertEqual(len(lines), 2, out)
def test_late_registering_job_does_not_succeed_early(self):
self.gitea.script = [[status("ci/test", "success")],
[status("ci/test", "success"), status("ci/build", "pending")],
[status("ci/test", "success"), status("ci/build", "success")],
[status("ci/test", "success"), status("ci/build", "success")]]
code, out, _ = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertIn("ci/build=pending", out)
def test_failure_exits_fast_with_target_url(self):
url = "https://ci.invalid/runs/42"
self.gitea.script = [[status("ci/test", "pending"), status("ci/build", "success")],
[status("ci/test", "failure", url), status("ci/build", "success")]]
code, out, _ = self.run_wait("--sha", self.sha, "--timeout", "300")
self.assertEqual(code, 1, out)
self.assertEqual(len(self.gitea.requests), 2, out)
self.assertIn(f"[ci-wait] FAIL ci/test {url}", out)
self.assertNotIn("FAIL ci/build", out)
self.assertIn(f"[verdict] CI_FAILED sha={self.sha}", out)
def test_missing_required_context_waits_until_timeout(self):
self.gitea.script = [[status("ci/test", "success")]]
code, out, clock = self.run_wait("--contexts", "ci/test,e2e", "--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertEqual(clock.now, 30.0)
self.assertIn("last observed: ci/test=success", out)
self.assertIn("missing contexts: e2e", out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_context_names_match_as_substrings(self):
self.gitea.script = [[status("CI / test (pull_request)", "success")]]
code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60")
self.assertEqual(code, 0, out)
def test_unreachable_api_exits_6_after_retrying_until_deadline(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_API_URL": closed_port_url()})
self.assertEqual(code, 6, out)
self.assertEqual(clock.now, 60.0)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_unauthorized_or_missing_token_exits_6_immediately(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_TOKEN": "wrong-token"})
self.assertEqual(code, 6, out)
self.assertEqual(clock.sleeps, [], out)
self.assertIn("HTTP 401", out)
with mock.patch.dict(os.environ):
del os.environ["GITEA_TOKEN"]
code, out, _ = self.run_wait("--timeout", "60")
self.assertEqual(code, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_sha_must_resolve_to_full_sha(self):
code, out, _ = self.run_wait("--sha", "not-a-ref")
self.assertEqual(code, 2, out)
self.assertIn("full 40-hex SHA", out)
self.assertEqual(self.gitea.requests, [])
def test_cli_entry_point_reports_remote_unverified(self):
env = self.env | {"GITEA_API_URL": closed_port_url()}
result = subprocess.run([sys.executable, str(SCRIPT), "--interval", "5", "--timeout", "0.2"],
cwd=self.repo, env=env, capture_output=True, text=True, timeout=60)
out = result.stdout + result.stderr
self.assertEqual(result.returncode, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
self.assertNotIn(TOKEN, out)
if __name__ == "__main__":
unittest.main()