ci/woodpecker/pr/ci Pipeline was canceled
Both python sites piped a status payload into `python3 - <<'PY'`. The heredoc binds stdin to the program text, so `json.load(sys.stdin)` saw EOF, the bare `except` fired, and the parser returned "unknown" for every input — success, pending and failure alike. "unknown" then reaches a silent `exit 0` arm. Consequence: the gate-6 queue guard has never made a determination. It exits 0 on every invocation, on both the gitea and github paths (one shared parser). The pending wait loop, --require-status and the 124 timeout were all unreachable code. What it still validated was connectivity — an unresolved token, head sha, or platform could exit 1. Fix is the one already shipped in the sibling: capture the payload with `payload=$(cat)` before invoking python, pass it by environment. pr-ci-wait.sh:38 has carried a comment describing this exact bug — "yielding EOF and returning unknown every time" — along with the remedy. It was never backported to the sibling the constitution makes mandatory before every push and merge. Adds test-ci-queue-wait-parse.sh, enumerated in test:framework-shell. Every assertion is on the RETURNED STATE STRING; a suite asserting only rc=0 passes against the broken build, which is how this survived. Verified by mutation: against the pre-fix wrapper the suite fails 10 of 14, and the four that pass are the four for which passing is correct (the undecodable-input control, the unknown-vocabulary case, and both needle halves). The needle scans with heredoc semantics rather than matching text. `json.load(sys.stdin)` is correct under `python3 -c`, where the program comes from argv and stdin really is the payload — ci-queue-wait.sh uses that form legitimately in gitea_get_branch_head_sha. A flat grep flags that innocent site; the scanner names only the two defective ones. Out of scope, deliberately, and recorded on #1019: token-in-argv (ps-visible) and the hardcoded BRANCH="main". Bundling them would make a safety-critical parse fix harder to review. Refs #1019
301 lines
8.9 KiB
Bash
Executable File
301 lines
8.9 KiB
Bash
Executable File
#!/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]
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
BRANCH="main"
|
|
TIMEOUT_SEC=900
|
|
INTERVAL_SEC=15
|
|
PURPOSE="merge"
|
|
REQUIRE_STATUS=0
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
|
|
|
Options:
|
|
-B, --branch BRANCH Branch head to inspect (default: main)
|
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
|
--purpose VALUE Log context: push|merge (default: merge)
|
|
--require-status Fail if no CI status contexts are present
|
|
-h, --help Show this help
|
|
|
|
Examples:
|
|
$(basename "$0")
|
|
$(basename "$0") --purpose push -B main -t 600 -i 10
|
|
EOF
|
|
}
|
|
|
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
|
|
|
get_state_from_status_json() {
|
|
# Capture piped JSON BEFORE invoking `python3 - <<PY`. The heredoc binds
|
|
# stdin to the Python program text — so json.load(sys.stdin) inside would
|
|
# try to re-read stdin after `-` already consumed it for the program,
|
|
# yielding EOF and returning "unknown" every time. Pass payload via env.
|
|
local payload
|
|
payload=$(cat)
|
|
CI_QUEUE_STATUS_JSON="$payload" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
payload = json.loads(os.environ.get("CI_QUEUE_STATUS_JSON", ""))
|
|
except Exception:
|
|
print("unknown")
|
|
raise SystemExit(0)
|
|
|
|
statuses = payload.get("statuses") or []
|
|
state = (payload.get("state") or "").lower()
|
|
|
|
pending_values = {"pending", "queued", "running", "waiting"}
|
|
failure_values = {"failure", "error", "failed"}
|
|
success_values = {"success"}
|
|
|
|
if state in pending_values:
|
|
print("pending")
|
|
raise SystemExit(0)
|
|
if state in failure_values:
|
|
print("terminal-failure")
|
|
raise SystemExit(0)
|
|
if state in success_values:
|
|
print("terminal-success")
|
|
raise SystemExit(0)
|
|
|
|
values = []
|
|
for item in statuses:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
value = (item.get("status") or item.get("state") or "").lower()
|
|
if value:
|
|
values.append(value)
|
|
|
|
if not values and not state:
|
|
print("no-status")
|
|
elif any(v in pending_values for v in values):
|
|
print("pending")
|
|
elif any(v in failure_values for v in values):
|
|
print("terminal-failure")
|
|
elif values and all(v in success_values for v in values):
|
|
print("terminal-success")
|
|
else:
|
|
print("unknown")
|
|
PY
|
|
}
|
|
|
|
print_pending_contexts() {
|
|
# Same stdin hazard as get_state_from_status_json above — pass payload via env.
|
|
local payload
|
|
payload=$(cat)
|
|
CI_QUEUE_STATUS_JSON="$payload" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
payload = json.loads(os.environ.get("CI_QUEUE_STATUS_JSON", ""))
|
|
except Exception:
|
|
print("[ci-queue-wait] unable to decode status payload")
|
|
raise SystemExit(0)
|
|
|
|
statuses = payload.get("statuses") or []
|
|
if not statuses:
|
|
print("[ci-queue-wait] no status contexts reported")
|
|
raise SystemExit(0)
|
|
|
|
pending_values = {"pending", "queued", "running", "waiting"}
|
|
found = False
|
|
for item in statuses:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get("context") or item.get("name") or "unknown-context"
|
|
value = (item.get("status") or item.get("state") or "unknown").lower()
|
|
target = item.get("target_url") or item.get("url") or ""
|
|
if value in pending_values:
|
|
found = True
|
|
if target:
|
|
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
|
else:
|
|
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
if not found:
|
|
print("[ci-queue-wait] no pending contexts")
|
|
PY
|
|
}
|
|
|
|
github_get_branch_head_sha() {
|
|
local owner="$1"
|
|
local repo="$2"
|
|
local branch="$3"
|
|
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
|
|
}
|
|
|
|
github_get_commit_status_json() {
|
|
local owner="$1"
|
|
local repo="$2"
|
|
local sha="$3"
|
|
gh api "repos/${owner}/${repo}/commits/${sha}/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"
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-B|--branch)
|
|
BRANCH="$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
|
|
;;
|
|
-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
|
|
|
|
OWNER=$(get_repo_owner)
|
|
REPO=$(get_repo_name)
|
|
detect_platform > /dev/null
|
|
PLATFORM="${PLATFORM:-unknown}"
|
|
|
|
if [[ "$PLATFORM" == "github" ]]; then
|
|
if ! command -v gh >/dev/null 2>&1; then
|
|
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
|
exit 1
|
|
fi
|
|
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
if [[ -z "$HEAD_SHA" ]]; then
|
|
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
|
exit 1
|
|
fi
|
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
|
HOST=$(get_remote_host) || {
|
|
echo "Error: Could not determine remote host." >&2
|
|
exit 1
|
|
}
|
|
TOKEN=$(get_gitea_token "$HOST") || {
|
|
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
|
exit 1
|
|
}
|
|
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
|
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
|
|
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
|
exit 1
|
|
fi
|
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
|
else
|
|
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
|
exit 1
|
|
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: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
|
exit 124
|
|
fi
|
|
|
|
if [[ "$PLATFORM" == "github" ]]; then
|
|
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
|
else
|
|
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
|
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"
|
|
;;
|
|
no-status)
|
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
|
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
|
exit 1
|
|
fi
|
|
echo "[ci-queue-wait] no status contexts present; proceeding."
|
|
exit 0
|
|
;;
|
|
terminal-success|terminal-failure|unknown)
|
|
# Queue guard only blocks on pending/running/queued states.
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|