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:
2026-08-23 12:34:11 -05:00
parent 24294d3b77
commit 28f4002a70
6 changed files with 524 additions and 11 deletions
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -16,6 +16,7 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
NO_CI_EXPECTED=false
usage() {
cat <<EOF
@@ -29,6 +30,7 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
@@ -70,6 +72,10 @@ while [[ $# -gt 0 ]]; do
EXPECT_HEAD="$2"
shift 2
;;
--no-ci-expected)
NO_CI_EXPECTED=true
shift
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
@@ -154,13 +160,18 @@ if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
guard_args=(
--purpose merge
-B "$HEAD_BRANCH"
-R "$BASE_REPO"
--sha "$HEAD_SHA"
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
)
if [[ "$NO_CI_EXPECTED" == true ]]; then
guard_args+=(--no-ci-expected)
fi
"$SCRIPT_DIR/ci-queue-wait.sh" "${guard_args[@]}"
fi
PLATFORM=$(detect_platform)