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 f38be51e..baeb8afb 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") ' @@ -467,6 +475,11 @@ while true; do echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${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] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI." exit 0 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..895c468c --- /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, on a Gitea-hosted repo with no pipeline configured. +# +# 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" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 5164571e..0323c6bc 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",