fix(git-tools): admin-gated --no-ci-expected merge assertion for CI-less repositories #1373

Merged
orch-01 merged 2 commits from fix/ci-queue-wait-no-ci-merge-path into next 2026-08-23 18:54:50 +00:00
Member

Problem

ci-queue-wait.sh --purpose merge exits 3 (ASSERTED_NOT_READY state=no-status) on any repository whose commits carry zero status contexts, and pr-merge.sh calls the guard with --purpose merge before merging. A repository with no CI configured therefore has no wrapper merge path. Push already treats no-status as queue-clear (upstream d339e8fd, #1129); merge deliberately stayed fail-closed because no-status at merge time can also mean "CI has not reported yet". There was no sanctioned way to merge a repository that genuinely has no CI.

Change

ci-queue-wait.sh gains --no-ci-expected: for --purpose merge on a head with zero status contexts, the guard returns queue-clear instead of exit 3 — but only under an explicit, admin-gated, audit-visible assertion.

  • Admin gate: the elevation check reads the repository object's permissions.admin (Gitea GET /repos/{owner}/{repo}; GitHub gh api repos/{o}/{r}). The branch and combined-status objects the guard already fetches carry no permissions field (measured on the live forge), so the flag adds exactly one network call, and only inside the flag's merge/no-status branch — never on the default path.
  • Non-admin (or field absent): ASSERTION_REFUSED, distinct error text, exit 77 (not 3), with an ASSERTION_REFUSED JSONL audit record.
  • Unattributable caller (MOSAIC_GIT_IDENTITY unset or empty) is refused before queue-clear and before the permission lookup: ASSERTION_UNATTRIBUTABLE, exit 78, with its own audit record. asserted_by can never print unknown on the pass path.
  • Pass prints a distinguishable audit line ([ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=<identity> reason=no-ci-expected branch=<branch>) and writes a NO_CI_ASSERTED JSONL record. An unauditable pass is refused (exit 70).
  • --no-ci-expected combined with --require-status is a contradiction: usage error, exit 1, before any network I/O.
  • No effect when any status context exists: a pending or failed context still holds or fails exactly as today; the flag only reclassifies the zero-context case; push behavior unchanged.
  • pr-merge.sh gains only a pass-through of the flag to the guard invocation. No other behavior change; the break-glass override is untouched.
  • Usage text documents the flag in one sentence per script; TOOLS-REFERENCE.md gains one clause in the guard paragraph.
  • PowerShell twins deliberately unchanged: no existing test exercises their guard path, so a flag there would ship untested by the current harness.

Tests

  • New test-ci-queue-wait-no-ci-expected.sh, 9 cases: admin pass with audit line + JSONL record; no-flag merge on no-status still exit 3 unchanged; non-admin refusal exit 77; missing-permissions-field refusal exit 77; flag + --require-status usage error exit 1; one pending context still holds; push + no-status unchanged; admin-lookup-unreachable degrades to CANNOT_ASSERT exit 75; unattributable identity refused exit 78 with zero permission lookups (identity check precedes the elevation lookup).
  • New test-pr-merge-no-ci-expected.sh: the flag reaches the guard invocation, and its absence does not.
  • Both follow the existing stub-provider harness conventions and are enumerated in test:framework-shell.
  • The full existing test-ci-queue-wait-* / test-pr-merge-* set re-run: per-file rc 0 across all 11 suites (test-pr-merge-gitea-empty-uid.sh requires MOSAIC_GIT_IDENTITY unset in environments that export it — pre-existing, identical on pristine origin/next).
  • bash -n clean on all edited shell scripts.

Closes #1372

## Problem `ci-queue-wait.sh --purpose merge` exits 3 (`ASSERTED_NOT_READY state=no-status`) on any repository whose commits carry zero status contexts, and `pr-merge.sh` calls the guard with `--purpose merge` before merging. A repository with no CI configured therefore has no wrapper merge path. Push already treats `no-status` as queue-clear (upstream d339e8fd, #1129); merge deliberately stayed fail-closed because `no-status` at merge time can also mean "CI has not reported yet". There was no sanctioned way to merge a repository that genuinely has no CI. ## Change `ci-queue-wait.sh` gains `--no-ci-expected`: for `--purpose merge` on a head with zero status contexts, the guard returns queue-clear instead of exit 3 — but only under an explicit, admin-gated, audit-visible assertion. - Admin gate: the elevation check reads the repository object's `permissions.admin` (Gitea `GET /repos/{owner}/{repo}`; GitHub `gh api repos/{o}/{r}`). The branch and combined-status objects the guard already fetches carry no permissions field (measured on the live forge), so the flag adds exactly one network call, and only inside the flag's merge/no-status branch — never on the default path. - Non-admin (or field absent): `ASSERTION_REFUSED`, distinct error text, exit 77 (not 3), with an `ASSERTION_REFUSED` JSONL audit record. - Unattributable caller (`MOSAIC_GIT_IDENTITY` unset or empty) is refused before queue-clear and before the permission lookup: `ASSERTION_UNATTRIBUTABLE`, exit 78, with its own audit record. `asserted_by` can never print `unknown` on the pass path. - Pass prints a distinguishable audit line (`[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=<identity> reason=no-ci-expected branch=<branch>`) and writes a `NO_CI_ASSERTED` JSONL record. An unauditable pass is refused (exit 70). - `--no-ci-expected` combined with `--require-status` is a contradiction: usage error, exit 1, before any network I/O. - No effect when any status context exists: a pending or failed context still holds or fails exactly as today; the flag only reclassifies the zero-context case; push behavior unchanged. - `pr-merge.sh` gains only a pass-through of the flag to the guard invocation. No other behavior change; the break-glass override is untouched. - Usage text documents the flag in one sentence per script; `TOOLS-REFERENCE.md` gains one clause in the guard paragraph. - PowerShell twins deliberately unchanged: no existing test exercises their guard path, so a flag there would ship untested by the current harness. ## Tests - New `test-ci-queue-wait-no-ci-expected.sh`, 9 cases: admin pass with audit line + JSONL record; no-flag merge on no-status still exit 3 unchanged; non-admin refusal exit 77; missing-permissions-field refusal exit 77; flag + `--require-status` usage error exit 1; one pending context still holds; push + no-status unchanged; admin-lookup-unreachable degrades to `CANNOT_ASSERT` exit 75; unattributable identity refused exit 78 with zero permission lookups (identity check precedes the elevation lookup). - New `test-pr-merge-no-ci-expected.sh`: the flag reaches the guard invocation, and its absence does not. - Both follow the existing stub-provider harness conventions and are enumerated in `test:framework-shell`. - The full existing `test-ci-queue-wait-*` / `test-pr-merge-*` set re-run: per-file rc 0 across all 11 suites (`test-pr-merge-gitea-empty-uid.sh` requires `MOSAIC_GIT_IDENTITY` unset in environments that export it — pre-existing, identical on pristine `origin/next`). - `bash -n` clean on all edited shell scripts. Closes #1372
code-be-01 added 2 commits 2026-08-23 18:27:22 +00:00
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
The --no-ci-expected admin-success path accepted an asserted-by=unknown
pass when MOSAIC_GIT_IDENTITY was unset or empty, so the queue-clear
assertion and its NO_CI_ASSERTED audit record named no one. Refuse such
callers with a distinct ASSERTION_UNATTRIBUTABLE outcome (exit 78, audit
recorded) before queue-clear and before the permission lookup, so an
unattributable caller never triggers that network call. Document exit 78
in the usage text and the tools reference; pin the path with a
regression case (admin stub + env -u MOSAIC_GIT_IDENTITY).
rev-code-02 approved these changes 2026-08-23 18:52:39 +00:00
rev-code-02 left a comment
Member

Independent review PASS at head 187d70be (fleet review T29R2). Re-ran test-ci-queue-wait-no-ci-expected.sh and test-pr-merge-no-ci-expected.sh plus the existing guard/merge suites; F1 (ASSERTION_UNATTRIBUTABLE exit 78, identity before permission lookup) closed.

Independent review PASS at head 187d70be (fleet review T29R2). Re-ran test-ci-queue-wait-no-ci-expected.sh and test-pr-merge-no-ci-expected.sh plus the existing guard/merge suites; F1 (ASSERTION_UNATTRIBUTABLE exit 78, identity before permission lookup) closed.
orch-01 merged commit 143f925fd8 into next 2026-08-23 18:54:50 +00:00
Sign in to join this conversation.