#!/bin/bash # ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head) # Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" BRANCH="" TARGET_REPO="" HEAD_SHA="" TIMEOUT_SEC=900 INTERVAL_SEC=15 PURPOSE="merge" REQUIRE_STATUS=0 NO_CI_EXPECTED=0 usage() { cat <&2 return 70 fi if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY' import datetime import json import os import sys path, reason, platform, purpose, branch, repo = sys.argv[1:] record = { "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), "outcome": "CANNOT_ASSERT", "reason": reason, "platform": platform, "purpose": purpose, "disposition": "hold" if purpose == "merge" else "degraded-pass", "branch": branch, "repo": repo, } fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) try: os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode()) finally: os.close(fd) PY then echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2 return 70 fi if [[ "$PURPOSE" == "merge" ]]; then echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2 return 75 fi echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2 return 0 } # Durable audit record for an explicit no-CI assertion event (granted or # refused). Same JSONL sink and field shape as record_cannot_assert so one # reader covers all three outcomes; the outcome value distinguishes them. # rc 70 on an unwritable sink: a merge pass that cannot be audited must not # be reachable, mirroring record_cannot_assert's refusal of a degraded pass. record_assertion_event() { local outcome="$1" reason="$2" asserted_by="$3" local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}" if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then echo "Error: could not write ${outcome} audit record (audit directory unavailable at ${audit_log})." >&2 return 70 fi if ! python3 - "$audit_log" "$outcome" "$reason" "$asserted_by" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY' import datetime import json import os import sys path, outcome, reason, asserted_by, platform, purpose, branch, repo = sys.argv[1:] record = { "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), "outcome": outcome, "reason": reason, "platform": platform, "purpose": purpose, "branch": branch, "repo": repo, "asserted_by": asserted_by, } fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) try: os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode()) finally: os.close(fd) PY then echo "Error: could not write ${outcome} audit record at ${audit_log}; refusing to proceed unaudited." >&2 return 70 fi return 0 } github_get_branch_head_sha() { local owner="$1" local repo="$2" local branch="$3" gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha' } # Repository-admin state for the acting credential, GitHub flavor. The # repository object's permissions.admin is the field; read through the same # gh CLI the guard already authenticates with. rc 0 = admin, 1 = not admin # (or field absent), 2 = indeterminate (transport/API failure). github_repo_admin_state() { local owner="$1" local repo="$2" local perm if ! perm=$(gh api "repos/${owner}/${repo}" --jq '.permissions.admin' 2>/dev/null); then return 2 fi case "$perm" in true) return 0 ;; false|null|"") return 1 ;; *) return 2 ;; esac } github_get_commit_status_json() { local owner="$1" local repo="$2" local sha="$3" local work_root status_file checks_file work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}" mkdir -p "$work_root" || return 1 status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1 checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || { rm -f "$status_file" return 1 } if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" || ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then rm -f "$status_file" "$checks_file" return 1 fi python3 - "$status_file" "$checks_file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: status_pages = json.load(handle) with open(sys.argv[2], encoding="utf-8") as handle: check_pages = json.load(handle) if not isinstance(status_pages, list) or not isinstance(check_pages, list): raise SystemExit(1) # The statuses endpoint is newest-first and can contain retries for one context. # Keep only the newest entry per context after flattening every page. combined = [] seen_contexts = set() for page in status_pages: if not isinstance(page, list): raise SystemExit(1) for status in page: if not isinstance(status, dict): raise SystemExit(1) context = status.get("context") if not isinstance(context, str) or not context or context in seen_contexts: continue seen_contexts.add(context) combined.append(status) check_runs = [] reported_total = 0 for page in check_pages: if not isinstance(page, dict): raise SystemExit(1) page_runs = page.get("check_runs") or [] total_count = page.get("total_count") if not isinstance(page_runs, list) or not isinstance(total_count, int): raise SystemExit(1) reported_total = max(reported_total, total_count) check_runs.extend(page_runs) if len(check_runs) < reported_total: raise SystemExit(1) for run in check_runs: if not isinstance(run, dict): raise SystemExit(1) status = run.get("status") conclusion = run.get("conclusion") if status != "completed": value = "pending" elif conclusion == "success": value = "success" elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}: value = "failure" else: value = "unknown" combined.append({ "context": run.get("name") or "github-check", "status": value, "target_url": run.get("html_url") or run.get("details_url") or "", }) json.dump({"state": "", "statuses": combined}, sys.stdout) PY local status=$? rm -f "$status_file" "$checks_file" return "$status" } gitea_get_branch_head_sha() { local host="$1" local repo="$2" local branch="$3" local token="$4" local url="https://${host}/api/v1/repos/${repo}/branches/${branch}" # Capture HTTP status so an absent branch (404) is distinguished from an API # error. A not-yet-pushed feature branch has no in-flight pipeline, so the # pre-push queue guard must treat 404 as "queue clear", not crash. local resp code body resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") code="${resp##*$'\n'}" body="${resp%$'\n'*}" if [[ "$code" == "404" ]]; then echo "__BRANCH_ABSENT__" return 0 fi if [[ "$code" != "200" ]]; then return 1 fi printf '%s' "$body" | python3 -c ' import json, sys data = json.load(sys.stdin) commit = data.get("commit") or {} print((commit.get("id") or "").strip()) ' } gitea_get_commit_status_json() { local host="$1" local repo="$2" local sha="$3" local token="$4" local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status" curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" } # Repository-admin state for the acting credential, Gitea flavor. The guard's # existing fetches (branch head, combined status) carry no permissions object # (measured: neither response includes one), so the elevation check reads the # repository object's permissions.admin, the one documented carrier of that # field. rc 0 = admin, 1 = not admin (or field absent), 2 = indeterminate # (non-200 or unparseable). gitea_repo_admin_state() { local host="$1" local repo="$2" local token="$3" local url="https://${host}/api/v1/repos/${repo}" local resp code body resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2 code="${resp##*$'\n'}" body="${resp%$'\n'*}" if [[ "$code" != "200" ]]; then return 2 fi printf '%s' "$body" | python3 -c ' import json import sys try: payload = json.load(sys.stdin) except Exception: raise SystemExit(2) if not isinstance(payload, dict): raise SystemExit(2) permissions = payload.get("permissions") if not isinstance(permissions, dict) or permissions.get("admin") is not True: raise SystemExit(1) raise SystemExit(0) ' } while [[ $# -gt 0 ]]; do case "$1" in -B|--branch) BRANCH="$2" shift 2 ;; -R|--repo) TARGET_REPO="$2" shift 2 ;; --sha) HEAD_SHA="$2" shift 2 ;; -t|--timeout) TIMEOUT_SEC="$2" shift 2 ;; -i|--interval) INTERVAL_SEC="$2" shift 2 ;; --purpose) PURPOSE="$2" shift 2 ;; --require-status) REQUIRE_STATUS=1 shift ;; --no-ci-expected) NO_CI_EXPECTED=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac done if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then echo "Error: timeout and interval must be integer seconds." >&2 exit 1 fi if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2 exit 1 fi if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then echo "Error: --repo must be OWNER/REPO." >&2 exit 1 fi if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then echo "Error: --purpose must be push or merge." >&2 exit 1 fi if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then echo "Error: --no-ci-expected and --require-status contradict each other: one asserts the repository has no CI, the other demands status contexts. Pass at most one." >&2 exit 1 fi OWNER="unknown" REPO="unknown" PLATFORM="unknown" if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then record_cannot_assert "repository-owner-unresolvable" exit $? fi if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then record_cannot_assert "repository-name-unresolvable" exit $? fi if ! detect_platform > /dev/null; then PLATFORM="${PLATFORM:-unknown}" record_cannot_assert "unsupported-platform" exit $? fi PLATFORM="${PLATFORM:-unknown}" if [[ -n "$TARGET_REPO" ]]; then OWNER="${TARGET_REPO%%/*}" REPO="${TARGET_REPO##*/}" fi if [[ -z "$BRANCH" ]]; then if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then record_cannot_assert "current-branch-unresolvable" exit $? fi fi if [[ "$PLATFORM" == "github" ]]; then if ! command -v gh >/dev/null 2>&1; then record_cannot_assert "github-cli-unavailable" exit $? fi if [[ -z "$HEAD_SHA" ]]; then if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then record_cannot_assert "branch-head-unavailable" exit $? fi fi echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" elif [[ "$PLATFORM" == "gitea" ]]; then if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then record_cannot_assert "remote-host-unresolvable" exit $? fi if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then record_cannot_assert "credential-unresolvable" exit $? fi if [[ -z "$HEAD_SHA" ]]; then if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then record_cannot_assert "branch-head-unavailable" exit $? fi if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear." exit 0 fi if [[ -z "$HEAD_SHA" ]]; then record_cannot_assert "branch-head-unavailable" exit $? fi fi echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" else record_cannot_assert "unsupported-platform" exit $? fi START_TS=$(date +%s) DEADLINE_TS=$((START_TS + TIMEOUT_SEC)) while true; do NOW_TS=$(date +%s) if (( NOW_TS > DEADLINE_TS )); then echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2 exit 124 fi if [[ "$PLATFORM" == "github" ]]; then if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then record_cannot_assert "status-provider-unreachable" exit $? fi else if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then record_cannot_assert "status-provider-unreachable" exit $? fi fi STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json) echo "[ci-queue-wait] state=${STATE} purpose=${PURPOSE} branch=${BRANCH}" case "$STATE" in pending) printf '%s' "$STATUS_JSON" | print_pending_contexts sleep "$INTERVAL_SEC" ;; terminal-success) exit 0 ;; no-status) if [[ "$REQUIRE_STATUS" -eq 1 ]]; then 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 fi if [[ "$NO_CI_EXPECTED" -eq 1 ]]; then # Explicit, elevated, audit-visible assertion that this # repository has no CI to wait on. The zero-context case is # the ONLY state the flag reclassifies: a pending or failed # context still holds or fails exactly as without it, and a # non-admin token is refused rather than trusted. # The assertion must name an asserting identity: "unknown" # attributes nothing, so a caller with MOSAIC_GIT_IDENTITY # unset or empty is refused (exit 78) BEFORE the permission # lookup -- an unattributable caller never triggers that # network call. if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \ || echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2 echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2 exit 78 fi ASSERTED_BY="${MOSAIC_GIT_IDENTITY}" ADMIN_STATE=2 if [[ "$PLATFORM" == "github" ]]; then if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi else if gitea_repo_admin_state "$HOST" "$OWNER/$REPO" "$TOKEN"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi fi case "$ADMIN_STATE" in 0) record_assertion_event "NO_CI_ASSERTED" "no-ci-expected" "$ASSERTED_BY" || exit $? echo "[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}" exit 0 ;; 1) record_assertion_event "ASSERTION_REFUSED" "actor-not-repo-admin" "$ASSERTED_BY" \ || echo "Warning: could not write the ASSERTION_REFUSED audit record; the refusal itself stands." >&2 echo "Error: ASSERTION_REFUSED state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires repository admin and the acting token is not an admin of ${OWNER}/${REPO} (exit 77)." >&2 exit 77 ;; *) record_cannot_assert "repo-permissions-unavailable" exit $? ;; esac fi echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2 exit 3 ;; terminal-failure) if [[ "$PURPOSE" == "push" ]]; then echo "[ci-queue-wait] queue-clear state=terminal-failure purpose=push branch=${BRANCH}; no queued or running CI." exit 0 fi echo "Error: ASSERTED_NOT_READY state=terminal-failure purpose=${PURPOSE} branch=${BRANCH}." >&2 exit 3 ;; malformed|unknown) echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2 exit 3 ;; *) echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2 exit 3 ;; esac done