diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 b/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 index 10b13f0f..02ed77e5 100644 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.ps1 @@ -85,10 +85,6 @@ function Get-QueueState { $state = "$($Payload.state)".ToLowerInvariant() } - if ($pending -contains $state) { return "pending" } - if ($failure -contains $state) { return "terminal-failure" } - if ($success -contains $state) { return "terminal-success" } - $values = @() $statuses = @() if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) } @@ -101,7 +97,15 @@ function Get-QueueState { if (-not [string]::IsNullOrEmpty($v)) { $values += $v } } - if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" } + # Zero contexts is classified FIRST: Gitea reports a synthetic aggregate + # state of "pending" alongside statuses:null / total_count:0 (a commit + # with no CI at all), and honoring that aggregate would poll to the + # timeout. With zero contexts there is nothing to wait on. + if ($values.Count -eq 0) { return "no-status" } + + if ($pending -contains $state) { return "pending" } + if ($failure -contains $state) { return "terminal-failure" } + if ($success -contains $state) { return "terminal-success" } if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" } if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" } if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" } diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index d4f49b87..4d3c18ef 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -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) diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh new file mode 100755 index 00000000..f92b8a53 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh @@ -0,0 +1,186 @@ +#!/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"