fix(git-tools): ci-queue-wait mishandles Gitea statuses:null — push guard hard-fails on any repo without CI
ci/woodpecker/pr/ci Pipeline failed

Gitea's combined-status endpoint returns statuses:null (not []) plus a
synthetic aggregate state of "pending" for a commit with zero status
contexts (captured live 2026-08-09). The bash parser called that payload
malformed -> ASSERTED_NOT_READY exit 3, blocking every push to a CI-less
repo; hit twice by velma on two independent clones (shared checkout +
fresh sparse clone), proving it environment-independent.

- treat statuses:null as empty (both .sh and .ps1)
- classify zero contexts as no-status BEFORE consulting the synthetic
  aggregate state (both twins; honoring it would poll to timeout)
- no-status on --purpose push without --require-status is now queue-clear
  exit 0 (a repo with no CI has no queue), mirroring record_cannot_assert
  dispositions (push=degraded-pass, merge=hold); merge + --require-status
  stay fail-closed at exit 3
- red-first regression harness test-ci-queue-wait-no-status.sh (5 cases,
  incl. the verbatim live Gitea payload and a genuine-pending guard);
  branch-absent harness still green

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
This commit is contained in:
Jason Woltje
2026-08-09 03:02:25 -05:00
co-authored by Claude Fable 5
parent 4df478cdd1
commit 74b3b9e869
3 changed files with 218 additions and 11 deletions
@@ -52,7 +52,11 @@ except Exception:
print("malformed")
raise SystemExit(0)
# Gitea returns "statuses": null (not []) for a commit with zero status
# contexts -- e.g. any repo with no CI configured. Treat null as empty.
raw_statuses = payload.get("statuses", [])
if raw_statuses is None:
raw_statuses = []
raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
@@ -75,14 +79,18 @@ for item in statuses:
raise SystemExit(0)
values.append(raw_value.lower())
if any(value in pending_values for value in values) or state in pending_values:
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
# state of "pending" alongside total_count:0, and an aggregate with no
# contexts behind it must not read as an in-flight pipeline (it would poll
# to the timeout). With zero contexts there is nothing to wait on.
if not values:
print("no-status")
elif any(value in pending_values for value in values) or state in pending_values:
print("pending")
elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure")
elif values and all(value in success_values for value in values) and state in {"", "success"}:
elif all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success")
elif not values:
print("no-status")
else:
print("unknown")
'
@@ -465,9 +473,18 @@ while true; do
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
fi
# A head with zero status contexts has no CI queue to wait on.
# For push, that is queue-clear (a repo with no CI must remain
# pushable) -- mirroring record_cannot_assert's dispositions
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
# no-status there may just mean CI has not reported yet.
if [[ "$PURPOSE" == "push" ]]; then
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
exit 0
fi
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
terminal-failure|malformed|unknown)