diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 5f78de9e..f2763b75 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -111,7 +111,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict placed. -The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin, and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. +The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. ### Code Review (Codex) diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index d2054458..99556511 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -28,7 +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) + --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); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty -h, --help Show this help Examples: @@ -597,7 +597,18 @@ while true; do # 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}" + # 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 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh index 056f1359..e1fc70df 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh @@ -19,6 +19,9 @@ # unchanged; no admin consultation on push. # (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), # not a silent pass and not a refusal. +# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE +# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear +# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call. set -u @@ -111,6 +114,25 @@ run_guard() { ) } +# The suite exports test-identity globally, so the unattributable-caller +# case must strip it from the child environment at invocation with env -u, +# not rely on the export order. +run_guard_no_identity() { + local name="$1"; shift + ( + cd "$REPO_DIR" || exit + export PATH="$STUB_DIR:$PATH" + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl" + export MOSAIC_STUB_URL_LOG="$URL_LOG" + export GITEA_TOKEN="stub-token" + export GITEA_URL="https://git.example.test" + export MOSAIC_GIT_IDENTITY="test-identity" + env -u MOSAIC_GIT_IDENTITY \ + "$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@" + ) +} + expect_rc() { local name="$1" want="$2" got="$3" if [[ "$want" == "not3" ]]; then @@ -272,6 +294,27 @@ if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null; failures=$((failures + 1)) fi +# (h) flag + admin stub + no asserting identity -> refusal before queue-clear +# and before the admin lookup: rc 78, no queue-clear line, an +# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls. +: > "$URL_LOG" +set +e +out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1) +rc_h=$? +set -u +if expect_rc h 78 "$rc_h"; then + expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h" + expect_text h "queue-clear" "$out_h" absent + if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then + echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2 + failures=$((failures + 1)) + fi +fi +if repo_root_fetched; then + echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2 + failures=$((failures + 1)) +fi + if [[ "$failures" -ne 0 ]]; then echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2 exit 1