agent-exec transport e4ff44791a4210144c6165ceadebb342/692536c1c7d0
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user