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
187 lines
6.2 KiB
Bash
Executable File
187 lines
6.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression harness for ci-queue-wait.sh's zero-status-context handling.
|
|
#
|
|
# Gitea's combined-status endpoint returns, for a commit with NO status
|
|
# contexts (e.g. a repo with no CI configured at all):
|
|
# {"state":"pending","sha":"...","total_count":0,"statuses":null,...}
|
|
# -- statuses is JSON null (not []), and state is a synthetic "pending"
|
|
# even though nothing is running. Captured live from a Gitea 1.22 host,
|
|
# 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline).
|
|
#
|
|
# Before the fix, `payload.get("statuses", [])` received null, failed the
|
|
# isinstance(list) check, and the guard reported ASSERTED_NOT_READY
|
|
# state=malformed (exit 3) -- blocking every push to a CI-less repo. Had
|
|
# null been tolerated, the synthetic aggregate "pending" would instead
|
|
# have polled to the timeout (exit 124). The fix must:
|
|
# 1. treat statuses:null as an empty list, and
|
|
# 2. classify zero status VALUES as "no-status" regardless of the
|
|
# synthetic aggregate state, and
|
|
# 3. treat no-status for --purpose push (without --require-status) as
|
|
# queue-clear (exit 0) -- a repo with no CI has no queue to wait on;
|
|
# this mirrors record_cannot_assert's disposition table
|
|
# (push=degraded-pass, merge=hold).
|
|
#
|
|
# Covers:
|
|
# (a) statuses:null + synthetic state:pending, purpose=push
|
|
# -> exit 0, queue-clear/no-status message (THE live fault).
|
|
# (b) same payload, purpose=merge -> still fail-closed (exit 3).
|
|
# (c) same payload, push + --require-status -> still fail-closed (exit 3).
|
|
# (d) statuses:[] + state:"" -> same as (a) (exit 0).
|
|
# (e) a real pending context -> still polls (times out, 124),
|
|
# proving the relaxation didn't swallow genuine pending states.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
|
|
REPO_DIR="$WORK_DIR/repo"
|
|
STUB_DIR="$WORK_DIR/stubs"
|
|
|
|
rm -rf "$WORK_DIR"
|
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
|
|
|
git -C "$REPO_DIR" init -q
|
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
|
|
|
# Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh):
|
|
# branch lookup answers 200 with a fixed SHA; the status endpoint's payload
|
|
# is selected by MOSAIC_STUB_STATUS_MODE.
|
|
cat > "$STUB_DIR/curl" <<'SH'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
has_w=0
|
|
url=""
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
-w) has_w=1 ;;
|
|
http://*|https://*) url="$arg" ;;
|
|
esac
|
|
done
|
|
|
|
case "$url" in
|
|
*/branches/*)
|
|
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
|
if [[ "$has_w" == 1 ]]; then
|
|
printf '%s\n200' "$body"
|
|
else
|
|
printf '%s' "$body"
|
|
fi
|
|
exit 0
|
|
;;
|
|
*/status)
|
|
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
|
;;
|
|
*)
|
|
echo "curl stub: unrecognized URL: $url" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
case "$mode" in
|
|
# Verbatim shape of the live Gitea response (repository object elided).
|
|
gitea-null-statuses)
|
|
body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}'
|
|
;;
|
|
empty-statuses)
|
|
body='{"state":"","statuses":[]}'
|
|
;;
|
|
real-pending)
|
|
body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}'
|
|
;;
|
|
*)
|
|
echo "curl stub: unknown mode=$mode" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
printf '%s' "$body"
|
|
SH
|
|
chmod +x "$STUB_DIR/curl"
|
|
|
|
run_ci_queue_wait() {
|
|
(
|
|
cd "$REPO_DIR"
|
|
export PATH="$STUB_DIR:$PATH"
|
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
|
export GITEA_TOKEN="stub-token"
|
|
export GITEA_URL="https://git.example.test"
|
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
|
)
|
|
}
|
|
|
|
fail=0
|
|
|
|
# (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0.
|
|
set +e
|
|
out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1)
|
|
status_a=$?
|
|
set -e
|
|
if [[ "$status_a" -ne 0 ]]; then
|
|
echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2
|
|
echo "$out_a" >&2
|
|
fail=1
|
|
elif [[ "$out_a" == *"malformed"* ]]; then
|
|
echo "FAIL(a): null statuses must not be classified as malformed" >&2
|
|
echo "$out_a" >&2
|
|
fail=1
|
|
elif [[ "$out_a" != *"no-status"* ]]; then
|
|
echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2
|
|
echo "$out_a" >&2
|
|
fail=1
|
|
fi
|
|
|
|
# (b) Same payload, purpose=merge -> hold, fail-closed exit 3.
|
|
set +e
|
|
out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1)
|
|
status_b=$?
|
|
set -e
|
|
if [[ "$status_b" -ne 3 ]]; then
|
|
echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2
|
|
echo "$out_b" >&2
|
|
fail=1
|
|
fi
|
|
|
|
# (c) Same payload, push + --require-status -> strictness opt-in still fails.
|
|
set +e
|
|
out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1)
|
|
status_c=$?
|
|
set -e
|
|
if [[ "$status_c" -ne 3 ]]; then
|
|
echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2
|
|
echo "$out_c" >&2
|
|
fail=1
|
|
fi
|
|
|
|
# (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push.
|
|
set +e
|
|
out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1)
|
|
status_d=$?
|
|
set -e
|
|
if [[ "$status_d" -ne 0 ]]; then
|
|
echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2
|
|
echo "$out_d" >&2
|
|
fail=1
|
|
fi
|
|
|
|
# (e) A REAL pending context must still block: polls to timeout, exit 124.
|
|
set +e
|
|
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1)
|
|
status_e=$?
|
|
set -e
|
|
if [[ "$status_e" -ne 124 ]]; then
|
|
echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2
|
|
echo "$out_e" >&2
|
|
fail=1
|
|
elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then
|
|
echo "FAIL(e): expected the pending context to be reported, got:" >&2
|
|
echo "$out_e" >&2
|
|
fail=1
|
|
fi
|
|
|
|
if [[ "$fail" -eq 0 ]]; then
|
|
echo "ci-queue-wait no-status regression passed (5/5 cases)"
|
|
fi
|
|
|
|
exit "$fail"
|