fix(git-tools): add admin-gated --no-ci-expected merge assertion to ci-queue-wait
A repository with no CI configured has no sanctioned wrapper merge path: the queue guard fails closed on zero status contexts for purpose=merge (exit 3). That fail-closed default stays correct, because at merge time no-status may also mean CI has not reported yet; but it left CI-less repos unmergeable without the break-glass override, which makes the override a gap rather than an exception. --no-ci-expected reclassifies only the zero-context case for merge as queue-clear, and only when the acting token holds repository admin. The elevation check reads the repository object's permissions.admin: the branch-head and combined-status responses the guard already fetches carry no permissions object at all. The assertion prints its own audit line (purpose, branch, asserting identity, reason) and writes a NO_CI_ASSERTED JSONL record to the existing audit sink; an unauditable pass is refused (exit 70). A non-admin caller is refused with exit 77 (ASSERTION_REFUSED, own text, audited), kept distinct from ASSERTED_NOT_READY's exit 3; an unavailable permissions lookup holds as CANNOT_ASSERT exit 75. --require-status contradicts the flag and is a usage error; a pending or failed context still holds or fails exactly as before; push behavior is unchanged. pr-merge.sh gains a pass-through --no-ci-expected that only forwards the flag to the guard invocation. PowerShell twins are unchanged: no existing test exercises their guard path (pr-merge.ps1 only runs with -SkipQueueGuard; ci-queue-wait.ps1 has no test). Closes #1372
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#!/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]
|
||||
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,10 +14,11 @@ TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
REQUIRE_STATUS=0
|
||||
NO_CI_EXPECTED=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
@@ -27,6 +28,7 @@ Options:
|
||||
-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
|
||||
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
@@ -175,6 +177,50 @@ PY
|
||||
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"
|
||||
@@ -182,6 +228,24 @@ github_get_branch_head_sha() {
|
||||
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"
|
||||
@@ -306,6 +370,41 @@ gitea_get_commit_status_json() {
|
||||
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)
|
||||
@@ -336,6 +435,10 @@ while [[ $# -gt 0 ]]; do
|
||||
REQUIRE_STATUS=1
|
||||
shift
|
||||
;;
|
||||
--no-ci-expected)
|
||||
NO_CI_EXPECTED=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -365,6 +468,10 @@ 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"
|
||||
@@ -484,6 +591,37 @@ while true; do
|
||||
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.
|
||||
ASSERTED_BY="${MOSAIC_GIT_IDENTITY:-unknown}"
|
||||
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
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user