diff --git a/.claude/settings.json b/.claude/settings.json index f2969dd80af9..e0a37ef50131 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "bash -c 'i=$(cat); case $i in *\"pr merge\"*|*\"/merge\"*) m=1 ;; *update-branch*) m=0 ;; *) exit 0 ;; esac; d=${CLAUDE_PROJECT_DIR:-.}; if [ \"$m\" = 1 ] && command -v timeout >/dev/null 2>&1; then timeout 10 git -C \"$d\" fetch --quiet --no-tags origin feat-cmux-next 2>/dev/null; fi; f=$(mktemp) || exit 1; trap \"rm -f -- \\\"$f\\\"\" EXIT; if ! git -C \"$d\" show origin/feat-cmux-next:scripts/ci/guards-from-base.sh > \"$f\" 2>/dev/null; then why=\"cmux guards: cannot read scripts/ci/guards-from-base.sh at origin/feat-cmux-next, so the merge guards did not run; fetch origin feat-cmux-next and retry\"; if [ \"$m\" = 1 ]; then printf \"{\\\"systemMessage\\\": \\\"%s; merging anyway\\\", \\\"hookSpecificOutput\\\": {\\\"hookEventName\\\": \\\"PreToolUse\\\", \\\"additionalContext\\\": \\\"%s; merging anyway\\\"}}\\n\" \"$why\" \"$why\"; exit 0; fi; echo \"$why\" >&2; exit 1; fi; printf %s \"$i\" | bash \"$f\" \"$d\" || exit 1'", + "command": "bash -c 'i=$(cat); case $i in *\"pr merge\"*|*\"/merge\"*|*mergePullRequest*|*PullRequestAutoMerge*) m=1 ;; *update-branch*) m=0 ;; *) exit 0 ;; esac; d=${CLAUDE_PROJECT_DIR:-.}; t=$(command -v timeout || command -v gtimeout); if [ \"$m\" = 1 ] && [ -n \"$t\" ]; then \"$t\" 10 git -C \"$d\" fetch --quiet --no-tags origin feat-cmux-next 2>/dev/null; fi; f=$(mktemp) || exit 1; trap \"rm -f -- \\\"$f\\\"\" EXIT; if ! git -C \"$d\" show origin/feat-cmux-next:scripts/ci/guards-from-base.sh > \"$f\" 2>/dev/null; then why=\"cmux guards: cannot read scripts/ci/guards-from-base.sh at origin/feat-cmux-next, so the merge guards did not run; fetch origin feat-cmux-next and retry\"; if [ \"$m\" = 1 ]; then g=$HOME/.claude/hooks/require-cmux-guards.sh; if [ -f \"$g\" ]; then o=$(printf %s \"$i\" | bash \"$g\" 2>/dev/null); if python3 -I -c \"import json, sys; s = json.loads(sys.argv[1]).get(\\\"hookSpecificOutput\\\") or {}; sys.exit(0 if isinstance(s, dict) and s.get(\\\"permissionDecision\\\") == \\\"deny\\\" else 1)\" \"$o\" 2>/dev/null; then printf \"%s\\n\" \"$o\"; exit 0; fi; fi; printf \"{\\\"systemMessage\\\": \\\"%s; merging anyway\\\", \\\"hookSpecificOutput\\\": {\\\"hookEventName\\\": \\\"PreToolUse\\\", \\\"additionalContext\\\": \\\"%s; merging anyway\\\"}}\\n\" \"$why\" \"$why\"; exit 0; fi; echo \"$why\" >&2; exit 1; fi; printf %s \"$i\" | bash \"$f\" \"$d\" || exit 1'", "timeout": 120 } ] diff --git a/.github/workflows/cmux-next.yml b/.github/workflows/cmux-next.yml index 427cafefb537..c3467d0efcc2 100644 --- a/.github/workflows/cmux-next.yml +++ b/.github/workflows/cmux-next.yml @@ -1502,11 +1502,9 @@ jobs: python3 tests/test_dev_release_product_key.py # A reused DEV Release app is trusted and intact, or the job compiles; DEV lane only. python3 tests/test_dev_release_product_reuse.py - # A hand merge of a Swift PR waits for its Release compile (scripts/ci/merge_compile_guard.py). - python3 tests/test_merge_compile_guard.py # The advisory test-compile check: its plan, the lane phase and red only for a compile failure. python3 tests/test_ci_compile_test_plan.py - # The merge guards run from the feat-cmux-next tip, never a pull request's checkout. + # A merge goes to the landing gate installed from cmuxterm-hq, never a guard in a checkout. python3 tests/test_guard_hooks_from_base.py # ci-guards.yml's `ci` group runs for feat-cmux-next too (cmux-next-guards.yml). python3 tests/test_feat_ci_guard_group.py @@ -2203,7 +2201,7 @@ jobs: # A same-repository pull request's app compile, on the build fleet instead # of the Mac Release queue (2026-10-21: 76 of ~350 queued Mac jobs were # Release compiles). It compiles and links the app at the PR's head, the - # commit merge_compile_guard.py and the landing hooks ask about, in about a + # commit the landing gate (require-cmux-guards) asks about, in about a # minute. A fleet compile error fails this job: the PR's own red. No verdict # (no client, the fleet down, a timeout) is a notice, and release-compile # runs on a Mac instead. A full-ci PR, a fork, and every push and dispatch diff --git a/.github/workflows/fleet-compile-check.yml b/.github/workflows/fleet-compile-check.yml index 5c4125072884..7267206f6a44 100644 --- a/.github/workflows/fleet-compile-check.yml +++ b/.github/workflows/fleet-compile-check.yml @@ -10,13 +10,9 @@ run-name: fleet compile-check ${{ inputs.sha }} # Release compile: that build uses Apple's compiler and the optimizer, and can # fail where this passes. So a red Release compile always wins here, and the # Release compile still runs on the base after the merge. -# scripts/ci/merge_compile_guard.py accepts a success here in place of a -# queued "cmux-next Release compile (Xcode 26)" (#22845 sat queued over an -# hour after its fleet pass, with 60 minis offline). It trusts the status only -# when it links a finished, green run of this file dispatched from -# feat-cmux-next (its commit on that branch) and titled with the full sha; a -# status a token posts itself, or a run of a copy of this file on another -# branch or a tag, does not count. It never overrides a red Release compile. +# It does not stand in for the Release compile: the landing gate (cmuxterm-hq +# tools/agent-guards/require-cmux-guards.py) and next-autoland wait for a green +# "cmux-next Release compile (Xcode 26)" on the exact head. # # gh workflow run fleet-compile-check.yml --ref feat-cmux-next -f sha= diff --git a/.github/workflows/fleet-test-compile.yml b/.github/workflows/fleet-test-compile.yml index 1fff65ebaf9c..f0ad2ccbbdd1 100644 --- a/.github/workflows/fleet-test-compile.yml +++ b/.github/workflows/fleet-test-compile.yml @@ -4,15 +4,14 @@ name: fleet test-compile # fleet Mac, and fail this check when one does not compile. # # fleet/compile-check (fleet-compile-check.yml) compiles only the app -# executable, which links no test target, and merge_compile_guard.py accepts -# it in place of a queued Release compile. #23360 (a throwing call inside +# executable, which links no test target. #23360 (a throwing call inside # #expect without try) and #23163 (a fake that missed a protocol requirement) # passed it and broke feat-cmux-next's test build. This check covers that gap # until the Linux pool compiles test targets (its SDK gains Xcode's Testing # search paths with the next pool image, cmuxterm-hq #2750). # -# It gates nothing: fleet/compile-check, merge_compile_guard.py and the -# Release compile neither wait for it nor read it. A red here is a test +# It gates nothing: fleet/compile-check, the landing gate and the Release +# compile neither wait for it nor read it. A red here is a test # target that does not compile at the PR's merge commit. # # It runs on its own gateway label, vars.CI_TEST_COMPILE_GATEWAY, never the diff --git a/scripts/ci/guards-from-base.sh b/scripts/ci/guards-from-base.sh index dc53a4ce3fab..daa0e3044596 100755 --- a/scripts/ci/guards-from-base.sh +++ b/scripts/ci/guards-from-base.sh @@ -3,53 +3,49 @@ # session's checkout (.claude/settings.json reads this file from origin/feat-cmux-next and runs it). # A pull request's worktree holds that branch's copy of the guards: a branch made before a guard fix # kept the old guard, and a pull request could edit the guard that judges its own merge -# (palette-audit, 2026-10-21). So this takes each guard and its hook from origin/feat-cmux-next -# (the settings hook fetched it first for a merge), into a temporary directory: -# - merge-compile-guard-hook.sh (merge_compile_guard.py): refuses a hand merge of a Swift PR -# before its Release compile passed; -# - base-merge-warning-hook.sh (base_merge_warning.py): warns before an update-branch cancels CI. -# A refusal from either is the answer; otherwise their warnings are joined into one answer on stdout. A -# merge whose guards can't be read from the base, or whose guard fails, goes ahead with a warning: only a -# compile that finished red refuses (merge_compile_guard.py). +# (palette-audit, 2026-10-21). +# +# A merge is judged by the landing gate installed from cmuxterm-hq main (tools/agent-guards' +# require-cmux-guards, which guards-sync keeps current every 10 minutes), never by a copy in this +# repository: after hq#2830, #23472 still ran a copy here without that fix (deputy-2, 2026-10-21). When +# this account registers the gate, Claude Code runs it and this defers; when only its file is +# installed, this runs it; when it is not installed, the merge goes ahead with a warning that says how +# to install it (2026-10-21: no guard blocks a merge it can't judge, only a compile that finished red). +# +# The update-branch warning (base-merge-warning-hook.sh, base_merge_warning.py) still comes from +# origin/feat-cmux-next (the settings hook fetched it first for a merge), into a temporary directory. +# A refusal is the answer; otherwise every warning is joined into one answer on stdout. # # Usage: printf '%s' "$payload" | bash guards-from-base.sh CHECKOUT set -uo pipefail dir="${1:-.}" input=$(&2 exit 1 } -base="$(git -C "$dir" rev-parse -q --verify 'origin/feat-cmux-next^{commit}')" \ - || cannot "cmux guards: no origin/feat-cmux-next in $dir to run the merge guards from" -work="$(mktemp -d)" || cannot "cmux guards: no temporary directory for the merge guards" -trap 'rm -rf "$work"' EXIT -git -C "$dir" archive "$base" scripts/ci/merge-compile-guard-hook.sh scripts/ci/merge_compile_guard.py \ - scripts/ci/base-merge-warning-hook.sh scripts/ci/base_merge_warning.py | tar -x -C "$work" \ - || cannot "cmux guards: cannot read the guards at origin/feat-cmux-next ${base:0:12}" -# Each guard runs: a refusal from either is the answer. Otherwise their warnings are joined, so a merge's -# warning never hides the update-branch warning of the same command. A guard that crashes or exits -# non-zero (exit 2 would block the tool call in Claude Code) is only a warning: no guard blocks on its own failure. -said=() -for hook in merge-compile-guard-hook.sh base-merge-warning-hook.sh; do - out="$(printf '%s' "$input" | bash "$work/scripts/ci/$hook" 2>/dev/null)" +# judge NAME COMMAND...: runs one guard on the payload. A refusal is the answer the moment its guard gives it, +# read as JSON whatever its spacing: the next guard could run the hook past its timeout with the refusal +# unprinted. A guard that crashes or exits non-zero (exit 2 would block the tool call in Claude Code) is only a +# warning: no guard blocks on its own failure. +judge() { + local name=$1 out status + shift + out="$(printf '%s' "$input" | "$@" 2>/dev/null)" status=$? if ((status != 0)); then - out="cmux guards: $hook exited $status, so it judged nothing" + out="cmux guards: $name exited $status, so it judged nothing" fi - [[ -n "$out" ]] || continue - # A refusal is the answer the moment its guard gives it, read as JSON whatever its spacing: the next - # guard could run the hook past its timeout with the refusal unprinted. + [[ -n "$out" ]] || return 0 if python3 -I -c 'import json, sys try: spec = json.loads(sys.argv[1]).get("hookSpecificOutput") or {} @@ -60,7 +56,58 @@ sys.exit(0 if isinstance(spec, dict) and spec.get("permissionDecision") == "deny exit 0 fi said+=("$out") -done +} +if ((merge)); then + gate="$HOME/.claude/hooks/require-cmux-guards.sh" # where tools/agent-guards/install.py puts it + if [[ ! -f $gate ]]; then + cannot "cmux guards: the landing gate (require-cmux-guards, cmuxterm-hq tools/agent-guards) is not \ +installed on this machine, so nothing judges this merge. Install it with guards-sync (cmuxterm-hq \ +tools/agent-guards/guards-sync, which also keeps it current) or python3 tools/agent-guards/install.py from a \ +cmuxterm-hq checkout" + # A registration: a PreToolUse command of this account's settings, under a matcher that covers Bash (none, "*", + # or a pattern Bash matches), that runs the gate's file: one of its words is that file, not just a mention of it. + elif ! python3 -I - "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" <<'PY'; then +import json, os, re, shlex, sys +def covers_bash(matcher): + if matcher in (None, "", "*"): + return True + try: + return re.fullmatch(str(matcher), "Bash") is not None + except re.error: + return False +def runs_gate(command): + try: + words = shlex.split(str(command)) + except ValueError: + return False + return any(os.path.basename(word) in ("require-cmux-guards.sh", "require-cmux-guards.py") for word in words) +try: + with open(sys.argv[1]) as f: + pre = (json.load(f).get("hooks") or {}).get("PreToolUse") or [] + sys.exit(0 if any(covers_bash((e or {}).get("matcher")) and runs_gate((h or {}).get("command", "")) + for e in pre for h in (e or {}).get("hooks") or []) else 1) +except (OSError, ValueError, AttributeError, TypeError): + sys.exit(1) +PY + judge require-cmux-guards.sh bash "$gate" + fi +fi +work="" +if base="$(git -C "$dir" rev-parse -q --verify 'origin/feat-cmux-next^{commit}')"; then + if work="$(mktemp -d)"; then + trap 'rm -rf "$work"' EXIT + if git -C "$dir" archive "$base" scripts/ci/base-merge-warning-hook.sh scripts/ci/base_merge_warning.py \ + | tar -x -C "$work"; then + judge base-merge-warning-hook.sh bash "$work/scripts/ci/base-merge-warning-hook.sh" + else + cannot "cmux guards: cannot read the guards at origin/feat-cmux-next ${base:0:12}" + fi + else + cannot "cmux guards: no temporary directory for the guards" + fi +else + cannot "cmux guards: no origin/feat-cmux-next in $dir to run the guards from" +fi ((${#said[@]})) || exit 0 # No refusal: one answer from every warning. A guard's JSON gives its systemMessage and additionalContext, # any other text is a note. The person sees the systemMessage, the agent the additionalContext. diff --git a/scripts/ci/merge-compile-guard-hook.sh b/scripts/ci/merge-compile-guard-hook.sh deleted file mode 100755 index 0166462e1838..000000000000 --- a/scripts/ci/merge-compile-guard-hook.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -# Claude Code PreToolUse(Bash) hook: refuses merging a feat-cmux-next PR that changes Swift before its -# head's Release compile passed (merge_compile_guard.py --claude-hook). Every command that merges no -# pull request exits here without starting Python. -input=$( --app` - job from a verified submitter, with exactly the command and pool that client submits (the worker runs - the command with a shell, and `cmux-ci submit` takes any), and its receipts.json has an `app` receipt that is ok for that sha, - which is what makes the client exit 0. A lane that ran it needs no workflow dispatch (#23606 at - bf14aa7d: job edfa18ea31a796eac433019a passed, the guard refused, and the lane compiled the head - again with run 39662744415). No client on this machine, or no answer from the controller, is no pass; -- or `fleet/compile-check` on the head is a success status posted by GitHub Actions whose run is a - finished, green fleet-compile-check.yml dispatch from feat-cmux-next titled with the full head, whose - workflow file came from a commit on that branch. A token can post any status, and a workflow on - another branch or a tag any run, so the guard trusts only that run. - -A same-repository PR compiles its head on the build fleet (`cmux-next app compile (build fleet)`) and runs -the Release compile only when that gave no verdict: while that job runs, the Release compile does not -exist yet, which is a Hold, and its red is a refusal like a red Release compile. - -A Release compile that finished red (any conclusion but success, skipped, neutral or -cancelled) stays a refusal, even with a rerun queued: the fleet compiles on Linux with the pool -SDK, not Xcode 26 Release. The pass covers what `cmux-ci compile-check --app` -compiles (#22845 sat queued over an hour after its fleet pass, with 60 minis offline). - -A compile the router skipped (iOS, packages outside the CmuxNext graph, forks) or a head cmux-next -never ran on does not apply, and neither does a PR that changes no Swift. The command is read the -way the shell splits it: quoted text and heredoc bodies are data, never a merge. A Hold never hides a -red: every merge in the command is judged, and any red refuses. -""" -from __future__ import annotations - -import json -import os -import re -import shlex -import subprocess -import sys -import tempfile -import time - -BASE = "feat-cmux-next" -GUARDED_REPO = "manaflow-ai/cmux" -COMPILE = "cmux-next Release compile (Xcode 26)" -# A same-repository PR's app compile on the build fleet (cmux-next.yml app-compile-fleet): the Release -# compile runs on a PR only when it gave no verdict, so its red is the PR's compile red. -FLEET_COMPILE = "cmux-next app compile (build fleet)" -# The jobs release-compile `needs`: until they finish, its check run does not exist. -ROUTING = ("cmux-next path routing", "cmux-next push head preflight", "cmux-next macOS placement", FLEET_COMPILE) -RUNNING = frozenset(("queued", "in_progress", "pending", "waiting", "requested")) -NOT_RUN = frozenset(("skipped", "neutral")) -# A cancelled attempt is no verdict on the head: a newer push, a runner shortage or a person stopped it -# (#22845's two cancelled attempts hid its fleet pass behind a queued rerun). Every other finished -# conclusion but success is red. -NO_VERDICT = NOT_RUN | {"cancelled"} -TIMEOUT = 15 # seconds per gh call -try: # tests lower it; it is never raised - TIMEOUT = min(TIMEOUT, max(1, int(os.environ.get("CMUX_MERGE_GUARD_GH_TIMEOUT") or TIMEOUT))) -except ValueError: - pass -BUDGET = 100 # seconds for every call together, under the hook's 120 s: a hook that times out lets the command run -STARTED = time.monotonic() -FLEET_WORKFLOW = "fleet-compile-check.yml" -FLEET_CONTEXT = "fleet/compile-check" -FLEET_TITLE = "fleet compile-check " # + the full sha: the workflow's run-name -FLEET_POSTER = "github-actions[bot]" -FLEET_RUNS_READ = 2 # newest first; each run read is one gh call -# cmux-ci compile-check's job (HQ build-fleet/ci-client.py compile_check_body and compile_check_verdict). -CMUX_CI_KIND = "cmux-next-swift-check" -CMUX_CI_RECIPE = "/Users/Shared/cmux-build-fleet/recipes/cmux-next-swift-check.sh" -CMUX_CI_LABEL = "cmux-next-linux" -CMUX_CI_PACKAGE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{0,63}") -CMUX_CI_JOBS_READ = 2 # newest first; each receipts read is one controller call -SWIFT_SUFFIXES = (".swift", ".xcconfig", ".h", ".m", ".mm", ".c", ".modulemap") -SWIFT_NAMES = ("Package.swift", "Package.resolved") - -SEPARATORS = frozenset((";", "&", "&&", "|", "||", "(", ")", "\n", ";;", "|&", "`")) -REDIRECT = re.compile(r"^[<>]+&?$|^&>+$") -ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -HEREDOC = re.compile(r"<<-?[ \t]*(['\"]?)([A-Za-z_][\w-]*)\1[^\n]*\n.*?^[ \t]*\2[ \t]*$", re.S | re.M) -# `gh pr merge` flags that take a value (the rest are switches). -VALUE_FLAGS = frozenset(("-R", "--repo", "-b", "--body", "-t", "--subject", "-F", "--body-file", - "--match-head-commit", "-A", "--author-email")) -PULL_URL = re.compile(r"^https://github\.com/([\w.-]+/[\w.-]+)/pull/(\d+)") -API_MERGE = re.compile(r"(?:^|/)repos/([^/\s]+/[^/\s]+)/pulls/([^/\s]+)/merge/?$") -UNKNOWN = "?" # a selector the guard can't resolve (a shell variable, a GraphQL mutation) - - -def changes_swift(path: str) -> bool: - return path.endswith(SWIFT_SUFFIXES) or path.rsplit("/", 1)[-1] in SWIFT_NAMES or ".xcodeproj/" in path - - -# Why the last read failed, for the refusal: a timeout, gh's own error, or the budget spent. -FAILED: list[str] = [] - - -def run(args: list[str], cwd: str | None = None) -> str | None: - # The guard reads once per merge and never polls, so its gh reads skip the PATH guard's 30 s repeat throttle - # (~/.local/lib/cmux-local-build-guards/gh): the hook shares the session's CLAUDE_SESSION_ID, and after a lane's - # own `gh pr view N` that throttle held the hook's identical read past its timeout (#23611, 2026-10-21). - env = {**os.environ, "CMUX_GH_GUARD_DISABLE": "1"} if os.path.basename(args[0]) == "gh" else None - FAILED.clear() - left = BUDGET - (time.monotonic() - STARTED) - if left < 1: - FAILED[:] = [f"the guard's {BUDGET} s for GitHub reads was spent"] - return None # out of time: an unread answer is a Hold, or counts as no fleet pass - timeout = min(TIMEOUT, left) - try: - done = subprocess.run(args, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=env) - except subprocess.TimeoutExpired: - FAILED[:] = [f"`{' '.join(args[:3])[:60]}` did not answer within {timeout:.0f} s"] - return None - except (OSError, subprocess.SubprocessError) as error: - FAILED[:] = [f"`{args[0]}` could not run ({error})"] - return None - if done.returncode != 0: - FAILED[:] = [f"`{' '.join(args[:3])[:60]}` failed: {(done.stderr or done.stdout).strip()[:160]}"] - return None - return done.stdout.strip() - - -def checkout_repo(cwd: str | None) -> str | None: - url = run(["git", "remote", "get-url", "origin"], cwd) or "" - found = re.search(r"[:/]([\w.-]+/[\w.-]+?)(?:\.git)?/?$", url) - return found.group(1) if found else None - - -FD_REDIRECT = re.compile(r"(?])") # the fd of `2>&1`, touching its operator -ANSI_C = re.compile(r"\$'(?:[^'\\]|\\.)*'") -WRAPPERS = frozenset(("env", "command", "builtin", "exec", "time", "nice", "nohup", "sudo", "xargs", "caffeinate", - "timeout", "gtimeout")) -KEYWORDS = frozenset(("if", "then", "else", "elif", "do", "while", "until", "{", "}", "!", "time")) -SHELLS = frozenset(("bash", "sh", "zsh", "dash")) -REMOTE = object() # the cwd of a command run on another host: its checkout can't be read here - - -def strip_comments(text: str) -> str: - """`text` without shell comments (a `#` that starts a word, outside quotes, up to its newline).""" - out, quote, i = [], None, 0 - while i < len(text): - c = text[i] - if quote: - if c == "\\" and quote == '"' and i + 1 < len(text): - out.append(text[i:i + 2]) - i += 2 - continue - if c == quote: - quote = None - elif c == "\\" and i + 1 < len(text): - out.append(text[i:i + 2]) - i += 2 - continue - elif c in "'\"": - quote = c - elif c == "#" and (i == 0 or text[i - 1] in " \t\n;&|()"): - while i < len(text) and text[i] != "\n": - i += 1 - continue - out.append(c) - i += 1 - return "".join(out) - - -def commands(text: str) -> list[list[str]] | None: - """The simple commands in `text`, each as its words, split the way the shell splits them; None when - the text can't be split (an unbalanced quote).""" - text = HEREDOC.sub("\n", text) - text = ANSI_C.sub("''", text) - text = FD_REDIRECT.sub("", strip_comments(text)) - lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|()<>\n`") - lexer.whitespace = " \t\r" - lexer.whitespace_split = True - lexer.commenters = "" - try: - tokens = list(lexer) - except ValueError: - return None - out, words, skip = [], [], False - for token in tokens: - if skip: - skip = False - continue - if token in SEPARATORS: - if words: - out.append(words) - words = [] - elif REDIRECT.match(token): - skip = True # its target - else: - words.append(token) - if words: - out.append(words) - return out - - -def unwrap(words: list[str]) -> tuple[list[str], dict]: - """The command `words` runs once its env assignments and wrappers (env, nice -n 10, sudo...) are - peeled off, and the assignments.""" - env = {} - while words: - if words[0] in KEYWORDS: - words = words[1:] - elif ASSIGNMENT.match(words[0]): - name, _, value = words[0].partition("=") - env[name] = value - words = words[1:] - elif os.path.basename(words[0]) in WRAPPERS: - timed = os.path.basename(words[0]) in ("timeout", "gtimeout") - words = words[1:] - while words and words[0].startswith("-"): - words = words[2:] if words[0] in ("-n", "-u", "-g", "-C", "-s", "-k") else words[1:] - if timed and words: - words = words[1:] # its duration - else: - break - if words: - words = [os.path.basename(words[0]), *words[1:]] - return words, env - - -def merge_targets(command: str, cwd) -> list[tuple[str | None, str | None, object]]: - """(repo, selector, cwd) for each merge the command runs. repo None: the checkout's (or GH_REPO's); - selector None: the checkout's branch; UNKNOWN: a PR the guard can't name.""" - parsed = commands(command) - if parsed is None: - return [(None, UNKNOWN, cwd)] if "gh" in command and "merge" in command else [] - targets = [] - for words in parsed: - words, env = unwrap(words) - if not words: - continue - if words[0] == "cd": - target = words[1] if len(words) > 1 else "~" - if cwd is REMOTE or "$" in target or "`" in target: - cwd = REMOTE - else: - cwd = os.path.join(cwd or os.getcwd(), os.path.expanduser(target)) - continue - if words[0] in SHELLS: - for i, word in enumerate(words[1:-1], 1): - if word.startswith("-") and "c" in word.lstrip("-"): - targets += merge_targets(words[i + 1], cwd) - break - continue - if words[0] == "ssh": - rest = words[1:] - while rest and rest[0].startswith("-"): # options, and the value of those that take one - rest = rest[2:] if len(rest[0]) == 2 and rest[0][1] in "bcDEeFIiJLlmOopQRSWw" else rest[1:] - targets += merge_targets(" ".join(rest[1:]), REMOTE) # the remote shell joins the words - continue - if words[0] != "gh": - continue - default_repo = env.get("GH_REPO") or os.environ.get("GH_REPO") - if words[1:3] == ["pr", "merge"]: - repo, selector, rest = default_repo, None, words[3:] - i = 0 - while i < len(rest): - word = rest[i] - flag, eq, value = word.partition("=") - if flag in VALUE_FLAGS: - if not eq: - value = rest[i + 1] if i + 1 < len(rest) else "" - i += 1 - if flag in ("-R", "--repo"): - repo = value - elif not word.startswith("-") and selector is None: - selector = word - i += 1 - if selector: - url = PULL_URL.match(selector) - if url: - repo, selector = repo or url.group(1), url.group(2) - elif "$" in selector or "`" in selector: - selector = UNKNOWN - else: - selector = selector.lstrip("#") - targets.append((repo, selector, cwd)) - elif words[1:2] == ["api"]: - if any("mergePullRequest" in w for w in words): - targets.append((default_repo, UNKNOWN, cwd)) - continue - for word in words[2:]: - found = API_MERGE.search(word) - if found: - repo, number = found.groups() - repo = None if repo == "{owner}/{repo}" else repo - targets.append((repo or default_repo, number if number.isdigit() else UNKNOWN, cwd)) - return targets - - -def gh_json(args: list[str], cwd: str | None): - out = run(["gh", *args], cwd) - if out is None: - return None - try: - return json.loads(out) - except ValueError: - return None - - -def gh_lines(args: list[str], cwd: str | None): - """One JSON value per output line (`--paginate --jq` prints each page's values), or None.""" - out = run(["gh", *args], cwd) - if out is None: - return None - try: - return [json.loads(line) for line in out.splitlines() if line.strip()] - except ValueError: - return None - - -class Hold(str): - """Why the guard would have held a merge it found no red for: the hook warns and the merge goes ahead.""" - - -def verdict(pr: dict, files: list[str], checks: list[dict], fleet_passed: bool = False) -> str: - """"" when the merge goes ahead silently, a Hold when it goes ahead with a warning, else why it is refused.""" - if pr.get("base") != BASE: - return "" - swift = [f for f in files if changes_swift(f)] - if not swift: - return "" - why = compile_refusal(pr, swift, checks) - if not why or not compile_wait(checks): - return why - if fleet_passed: - return "" - # No compile result on the head yet: the default path is the fleet's dev-build workers (COMMON.md), not - # waiting on the Mac Release compile. - what = changes_what(pr, swift) - return Hold(f"{what} and {pr['head'][:12]} has no compile result yet. To compile it now, run " - f"`cmux-ci compile-check --ref {pr['head']} --app` on the fleet dev-build workers. " - f"({why.removeprefix(what + ' and ')})") - - -def compile_wait(checks: list[dict]) -> bool: - """The Release compile has no verdict on the head yet: no checks, queued or running, not created while - cmux-next routes and places it, or every attempt cancelled.""" - named = [c for c in checks if c.get("name") == COMPILE] - finished = [c.get("conclusion") for c in named if c.get("status") == "completed"] - if any(c not in NO_VERDICT and c != "success" for c in finished) and "success" not in finished: - return False # an attempt finished red: a rerun queued after it is no wait (every attempt is read) - if fleet_red(checks): - return False # the build-fleet compile finished red: the Release compile queued beside it is no wait - return (not checks or any(c.get("status") in RUNNING for c in named) - or (not named and any(c.get("name") in ROUTING and c.get("status") in RUNNING for c in checks)) - or (len(finished) == len(named) and set(finished) - NOT_RUN == {"cancelled"}) - or fleet_stopped(checks)) - - -def fleet_red(checks: list[dict]) -> str: - """The red conclusion of the PR's build-fleet compile when no attempt of it, and no Release compile, passed.""" - fleet = [c.get("conclusion") for c in checks if c.get("name") == FLEET_COMPILE and c.get("status") == "completed"] - red = [c for c in fleet if c not in NO_VERDICT and c != "success"] - passed = "success" in fleet or any(c.get("name") == COMPILE and c.get("conclusion") == "success" for c in checks) - return red[0] if red and not passed else "" - - -def fleet_stopped(checks: list[dict]) -> bool: - """The PR's build-fleet compile was cancelled with no pass and no Release compile ran: no verdict at all - (a run cancelled mid-compile also skips the Release compile).""" - fleet = [c.get("conclusion") for c in checks if c.get("name") == FLEET_COMPILE and c.get("status") == "completed"] - ran = [c for c in checks if c.get("name") == COMPILE and c.get("conclusion") not in NOT_RUN] - return bool(fleet) and set(fleet) == {"cancelled"} and not ran - - -def changes_what(pr: dict, swift: list[str]) -> str: - return f"#{pr['number']} changes Swift ({swift[0]}{' and more' if len(swift) > 1 else ''})" - - -def compile_refusal(pr: dict, swift: list[str], checks: list[dict]) -> str: - named = [c for c in checks if c.get("name") == COMPILE] - head = pr["head"][:12] - what = changes_what(pr, swift) - if not checks: - return f"{what} and {head} has no checks yet, so no {COMPILE} either." - red = fleet_red(checks) - if red: - return (f"{what} and its {FLEET_COMPILE} finished {red} on {head}. " - f"Fix it, or merge the base in when the base has the fix, and merge once it passes.") - if any(c.get("status") in RUNNING for c in named): - return f"{what} and its {COMPILE} is still running on {head}." - if not named and any(c.get("name") in ROUTING and c.get("status") in RUNNING for c in checks): - return f"{what} and cmux-next has not created its {COMPILE} on {head} yet." - ran = [c for c in named if c.get("conclusion") not in NOT_RUN] - if ran and not any(c.get("conclusion") == "success" for c in ran): - return (f"{what} and its {COMPILE} finished {ran[0].get('conclusion')} on {head}. " - f"Fix it, or merge the base in when the base has the fix, and merge once it passes.") - if fleet_stopped(checks): - return f"{what} and its {FLEET_COMPILE} was cancelled on {head}, so nothing compiled it; rerun it." - return "" - - -class Unreadable(Exception): - pass - - -def read_pr(repo: str, number: int, cwd: str | None) -> str: - """verdict() for the PR, reading only as much of GitHub as it needs.""" - pr = gh_json(["api", f"repos/{repo}/pulls/{number}", "--jq", "{number: .number, base: .base.ref, head: .head.sha}"], - cwd) - if not pr: - raise Unreadable - if pr.get("base") != BASE: - return "" - files = gh_lines(["api", "--paginate", f"repos/{repo}/pulls/{number}/files?per_page=100", "--jq", - ".[].filename | tojson"], cwd) - if files is None: - raise Unreadable - if not any(changes_swift(f) for f in files): - return "" - checks = gh_lines(["api", "--paginate", f"repos/{repo}/commits/{pr['head']}/check-runs?filter=all&per_page=100", "--jq", - ".check_runs[] | {name, status, conclusion} | tojson"], cwd) - if checks is None: - raise Unreadable - why = verdict(pr, files, checks) - # The controller first: it costs no GitHub call. - if why and compile_wait(checks) and (cmux_ci_passed(pr["head"]) or fleet_passed(repo, pr["head"], cwd)): - return "" - return why - - -def cmux_ci_client() -> str | None: - client = os.environ.get("CMUX_CI_CLIENT") or os.path.expanduser("~/.local/bin/cmux-ci") - return client if os.access(client, os.X_OK) else None - - -def cmux_ci_passed(head: str) -> bool: - """Whether the build fleet's controller holds a passed `cmux-ci compile-check --ref head --app` job, - judged the way the client's exit 0 is: a done job of the recipe on exactly `head` with --app, whose - receipts.json has an ok `app` receipt for `head`. An unreadable controller or artifact is no pass.""" - client = cmux_ci_client() - if client is None or not re.fullmatch(r"[0-9a-f]{40}", head): - return False - try: - jobs = json.loads(run([client, "status"]) or "null") - except ValueError: - return False - if not isinstance(jobs, list): - return False - - def app_check(job) -> bool: - # Only the job compile_check_body submits: the worker runs the command with /bin/sh -c, and - # `cmux-ci submit` takes any kind and command, so one more word could write its own receipts. - if (not isinstance(job, dict) or job.get("kind") != CMUX_CI_KIND or job.get("state") != "done" - or job.get("ref") != head or job.get("submitter_verified") is not True or job.get("repo") - or job.get("labels") != [CMUX_CI_LABEL] or not re.fullmatch(r"[0-9a-f]{6,64}", str(job.get("id", "")))): - return False - try: - argv = shlex.split(job.get("command") or "") - except ValueError: - return False - packages = argv[2:-1] - return (argv[:2] == [CMUX_CI_RECIPE, head] and argv[-1:] == ["--app"] and shlex.join(argv) == job["command"] - and packages[::2] == ["--package"] * (len(packages) // 2) and len(packages) % 2 == 0 - and all(CMUX_CI_PACKAGE.fullmatch(name) for name in packages[1::2])) - - candidates = sorted(filter(app_check, jobs), key=lambda job: str(job.get("created_at") or ""), reverse=True) - for job in candidates[:CMUX_CI_JOBS_READ]: - with tempfile.TemporaryDirectory(prefix="merge-compile-guard-") as scratch: - path = os.path.join(scratch, "receipts.json") - if run([client, "artifact", job["id"], path]) is None: - continue - try: - with open(path) as f: - receipts = json.load(f) - except (OSError, ValueError): - continue - if isinstance(receipts, dict): - receipts = receipts.get("receipts") - if isinstance(receipts, list) and any( - isinstance(r, dict) and r.get("package") == "app" and r.get("ok") is True and r.get("sha") == head - for r in receipts): - return True - return False - - -def fleet_passed(repo: str, head: str, cwd: str | None) -> bool: - """Whether a fleet-compile-check.yml run from the base, dispatched for exactly `head`, passed. The - head's fleet/compile-check statuses only point at runs; an unreadable one counts as no pass.""" - statuses = gh_lines(["api", "--paginate", f"repos/{repo}/commits/{head}/statuses?per_page=100", "--jq", - f'.[] | select(.context == "{FLEET_CONTEXT}") | ' - '{state, creator: .creator.login, url: .target_url} | tojson'], cwd) or [] - prefix = f"https://github.com/{repo}/actions/runs/" - seen = set() - for found in statuses: - url = found.get("url") or "" - run_id = url[len(prefix):] - if (found.get("state") != "success" or found.get("creator") != FLEET_POSTER or not url.startswith(prefix) - or not run_id.isdigit() or run_id in seen): - continue - if len(seen) == FLEET_RUNS_READ: - break - seen.add(run_id) - run = gh_json(["api", f"repos/{repo}/actions/runs/{run_id}", "--jq", - "{path, head_branch, event, status, conclusion, display_title, head_sha}"], cwd) or {} - if (run.get("path") == f".github/workflows/{FLEET_WORKFLOW}" and run.get("head_branch") == BASE - and run.get("event") == "workflow_dispatch" and run.get("status") == "completed" - and run.get("conclusion") == "success" and run.get("display_title") == FLEET_TITLE + head - and on_base(repo, run.get("head_sha") or "", cwd)): - return True - return False - - -def on_base(repo: str, sha: str, cwd: str | None) -> bool: - """Whether `sha` is on the base branch. A dispatch on a tag named like the branch reports that - head_branch too, with whatever workflow file the tag holds.""" - if not re.fullmatch(r"[0-9a-f]{40}", sha): - return False - tip = gh_json(["api", f"repos/{repo}/branches/{BASE}", "--jq", ".commit.sha | tojson"], cwd) - if not isinstance(tip, str) or not re.fullmatch(r"[0-9a-f]{40}", tip): - return False - return gh_json(["api", f"repos/{repo}/compare/{sha}...{tip}", "--jq", ".status | tojson"], cwd) in ( - "ahead", "identical") - - -def refusal(command: str, cwd: str | None, reader=read_pr) -> str: - """The first red among the command's merges; else the first Hold; else "".""" - held = "" - for repo, selector, where in merge_targets(command, cwd): - if repo is None and where is REMOTE: - held = held or Hold("merge-compile-guard: this merges a pull request in a checkout the guard can't read " - "(over ssh or after `cd $VAR`), so its compile was not checked; `gh pr merge N -R " - "OWNER/REPO` lets the guard check it.") - continue - repo = repo or checkout_repo(where) - if repo is None: - held = held or Hold("merge-compile-guard: can't tell which repository this merges in (its checkout's " - "origin could not be read), so its compile was not checked; `gh pr merge N -R " - "OWNER/REPO` lets the guard check it.") - continue - if repo != GUARDED_REPO: - continue - where = None if where is REMOTE else where - if selector == UNKNOWN: - held = held or Hold("merge-compile-guard: this merges a manaflow-ai/cmux pull request the guard can't " - "name (a shell variable or a GraphQL mutation), so its compile was not checked; " - "`gh pr merge N` with its number lets the guard check it.") - continue - if selector is None or not selector.isdigit(): - # gh refuses `pr view -R` without a selector: the checkout's branch is looked up in the checkout. - args = [selector, "-R", repo] if selector else [] - found = gh_json(["pr", "view", *args, "--json", "number"], where) - number = (found or {}).get("number") - if not number: - held = held or Hold(f"merge-compile-guard: could not tell which pull request `gh pr merge " - f"{selector or ''}` merges, so its compile was not checked.") - continue - else: - number = int(selector) - try: - why = reader(repo, number, where) - except Unreadable: - cause = FAILED[0] if FAILED else "GitHub gave no readable answer" - held = held or Hold(f"merge-compile-guard: could not read #{number} from GitHub ({cause}), so it can't " - f"tell whether its Swift compile passed.") - continue - if isinstance(why, Hold): - held = held or Hold(f"merge-compile-guard: {why}") - elif why: - return f"merge-compile-guard: {why}" - return held - - -def claude_hook(stdin) -> int: - try: - payload = json.load(stdin) - except ValueError: - return 0 - command = ((payload.get("tool_input") or {}).get("command") or "") - why = refusal(command, payload.get("cwd")) - if isinstance(why, Hold): - # No permissionDecision: the command goes through the normal permission flow. - text = f"{why} Only a compile that finished red refuses, so merging anyway." - json.dump({"systemMessage": text, "hookSpecificOutput": {"hookEventName": "PreToolUse", - "additionalContext": text}}, sys.stdout) - elif why: - json.dump({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": why}}, sys.stdout) - return 0 - - -if __name__ == "__main__": - if sys.argv[1:] == ["--claude-hook"]: - sys.exit(claude_hook(sys.stdin)) - print(__doc__) - sys.exit(2) diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 5e1a68925cd7..790518083f8c 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1053,10 +1053,6 @@ lane = "linux-guard" path = "tests/test_dev_release_product_reuse.py" lane = "linux-guard" -[[test]] -path = "tests/test_merge_compile_guard.py" -lane = "linux-guard" - [[test]] path = "tests/test_feat_ci_guard_group.py" lane = "linux-guard" diff --git a/tests/test_cmux_next_checks_workflow.py b/tests/test_cmux_next_checks_workflow.py index a83cf2974b82..ec09dec636db 100644 --- a/tests/test_cmux_next_checks_workflow.py +++ b/tests/test_cmux_next_checks_workflow.py @@ -2668,7 +2668,7 @@ def test_the_gateway_is_taken_only_on_an_idle_reading_from_the_same_attempt(self class PullRequestCompileOnTheBuildFleet(unittest.TestCase): """A same-repository PR's app compile runs on the build fleet (`cmux-ci compile-check --ref --app`, about a minute) instead of the Mac Release queue. 2026-10-21: 76 of ~350 queued Mac jobs were Release - compiles. The job compiles the PR's head, the commit merge_compile_guard.py and the landing hooks ask + compiles. The job compiles the PR's head, the commit the landing gate asks about; a fleet red fails it; no verdict hands the PR to the Mac Release compile.""" NAME = "cmux-next app compile (build fleet)" diff --git a/tests/test_guard_hooks_from_base.py b/tests/test_guard_hooks_from_base.py index afe629167df6..42796ec3498a 100644 --- a/tests/test_guard_hooks_from_base.py +++ b/tests/test_guard_hooks_from_base.py @@ -1,10 +1,17 @@ #!/usr/bin/env python3 -"""The PreToolUse merge guards run the feat-cmux-next tip's guard code, never the checkout's. +"""The PreToolUse merge guards never run from the session's checkout. palette-audit, 2026-10-21: `.claude/settings.json` ran `"$CLAUDE_PROJECT_DIR"/scripts/ci/merge-compile-guard-hook.sh`, so a session in a pull request's worktree ran that branch's copy of the guard: a branch made before a guard fix kept the old guard, and a pull request could edit the guard that judges its own merge. The hook now loads -scripts/ci/guards-from-base.sh from origin/feat-cmux-next, which runs the guards from that commit. +scripts/ci/guards-from-base.sh from origin/feat-cmux-next, which runs the update-branch warning from that commit. + +A merge is judged by the landing gate installed from cmuxterm-hq main (tools/agent-guards/require-cmux-guards, +which guards-sync keeps current every 10 minutes), never by a copy in this repository: a copy here reached a +worktree only through a base merge or a fetch, and after hq#2830 #23472 still ran a guard without its gh-throttle +fix (deputy-2, 2026-10-21). guards-from-base.sh defers to the installed gate when this account registers it, runs it +when only the file is there, and lets the merge through with a warning when it is not installed: no guard blocks a +merge it can't judge, only a compile that finished red (2026-10-21, #24265). """ from __future__ import annotations @@ -21,20 +28,31 @@ ROOT = Path(__file__).resolve().parents[1] SETTINGS = ROOT / ".claude/settings.json" GIT_ENV = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} -# Stand-ins for the two guards: each says which copy ran. -GUARD = """import json, sys -json.dump({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "merge guard %s"}}, sys.stdout) -""" -# A stand-in guard that lets the merge through with a warning, as the real guard does for a pending compile. -HOLD = """import json, sys -text = "merge guard %s holds nothing; merging anyway" -json.dump({"systemMessage": text, "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}, - sys.stdout) -""" +# A stand-in for the update-branch warning: it says which copy ran. WARNING = """import json, sys print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": "base merge warning %s"}})) """ +# A stand-in for the installed landing gate (cmuxterm-hq tools/agent-guards/require-cmux-guards.sh). +GATE = """#!/bin/bash +input=$( str: @@ -56,176 +74,297 @@ def setUp(self): self.seed = self.tmp / "seed" git(self.tmp, "clone", "-q", "remote.git", "seed") git(self.seed, "checkout", "-q", "-b", "feat-cmux-next") - for path in (".claude/settings.json", "scripts/ci/guards-from-base.sh", "scripts/ci/merge-compile-guard-hook.sh", - "scripts/ci/base-merge-warning-hook.sh"): - if (ROOT / path).exists(): - (self.seed / path).parent.mkdir(parents=True, exist_ok=True) - shutil.copy(ROOT / path, self.seed / path) + for path in (".claude/settings.json", "scripts/ci/guards-from-base.sh", "scripts/ci/base-merge-warning-hook.sh"): + (self.seed / path).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / path, self.seed / path) self.guards("base v1") git(self.seed, "add", "-A") git(self.seed, "commit", "-qm", "base") git(self.seed, "push", "-q", "origin", "feat-cmux-next") - # The pull request's worktree: branched from the base, with its own edit of the guard and its hook. + # The pull request's worktree: branched from the base, with its own edit of the guards. self.lane = self.tmp / "lane" git(self.tmp, "clone", "-q", "-b", "feat-cmux-next", "remote.git", "lane") git(self.lane, "checkout", "-q", "-b", "pr") - (self.lane / "scripts/ci/merge_compile_guard.py").write_text("") # lets every merge through - (self.lane / "scripts/ci/merge-compile-guard-hook.sh").write_text("exit 0\n") + (self.lane / "scripts/ci/guards-from-base.sh").write_text("exit 0\n") # lets every merge through (self.lane / "scripts/ci/base_merge_warning.py").write_text("") - git(self.lane, "commit", "-qam", "the pull request edits the guard that judges it") + git(self.lane, "commit", "-qam", "the pull request edits the guards that judge it") def isolate_git(self) -> None: """Every git this test starts, its own and the hook's, reads config only from inside self.tmp, and that config turns auto maintenance and gc off. Otherwise a commit, push or fetch leaves a detached `git maintenance run --auto` taking and dropping .git/objects/maintenance.lock while the cleanup's rmtree - walks the directory, and the rmtree fails with FileNotFoundError.""" - home = self.tmp / "home" - home.mkdir() - config = home / ".gitconfig" + walks the directory, and the rmtree fails with FileNotFoundError. HOME is the test's too, so the installed + landing gate is the one the test installs.""" + self.home = self.tmp / "home" + self.home.mkdir() + config = self.home / ".gitconfig" config.write_text("[maintenance]\n\tauto = false\n\tautoDetach = false\n" "[gc]\n\tauto = 0\n\tautoDetach = false\n") - env = {"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), "GIT_CONFIG_GLOBAL": str(config), + env = {"HOME": str(self.home), "XDG_CONFIG_HOME": str(self.home / ".config"), "GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_COUNT": "0"} patcher = mock.patch.dict(os.environ, env) patcher.start() self.addCleanup(patcher.stop) + os.environ.pop("CLAUDE_CONFIG_DIR", None) def guards(self, label: str) -> None: - (self.seed / "scripts/ci/merge_compile_guard.py").write_text(GUARD % label) (self.seed / "scripts/ci/base_merge_warning.py").write_text(WARNING % label) - def hook(self, command: str) -> tuple[int, dict | None, str]: + def install_gate(self, file: bool = True, registered_in: Path | None = None, text: str = GATE, + matcher: str = "Bash") -> Path: + """The gate as tools/agent-guards/install.py installs it: the hook in ~/.claude/hooks/, registered as a + PreToolUse(Bash) command in a config directory's settings.json.""" + gate = self.home / ".claude/hooks/require-cmux-guards.sh" + if file: + gate.parent.mkdir(parents=True, exist_ok=True) + gate.write_text(text) + if registered_in is not None: + registered_in.mkdir(parents=True, exist_ok=True) + (registered_in / "settings.json").write_text(json.dumps({"hooks": {"PreToolUse": [ + {"matcher": matcher, "hooks": [{"type": "command", "command": f"bash {gate}", "timeout": 30}]}]}})) + return gate + + def hook(self, command: str, env: dict | None = None) -> tuple[int, dict | None, str]: + """What the repository's own hooks answer (Claude Code runs a registered installed gate beside them).""" payload = json.dumps({"tool_name": "Bash", "cwd": str(self.lane), "tool_input": {"command": command}}) outputs, code, err = [], 0, "" for hook in pre_tool_use_commands(): done = subprocess.run(["bash", "-c", hook], input=payload, capture_output=True, text=True, timeout=60, - cwd=self.lane, env={**os.environ, "CLAUDE_PROJECT_DIR": str(self.lane)}) + cwd=self.lane, env={**os.environ, "CLAUDE_PROJECT_DIR": str(self.lane), **(env or {})}) code, err = code or done.returncode, err + done.stderr if done.stdout.strip(): outputs.append(json.loads(done.stdout)) self.assertLessEqual(len(outputs), 1, outputs) return code, (outputs[0] if outputs else None), err - def test_a_merge_is_judged_by_the_base_guard_not_the_pull_requests_copy(self): - code, out, err = self.hook("gh pr merge 5 -R manaflow-ai/cmux --squash") + def assert_refused(self, out: dict | None, *words: str) -> None: + self.assertIsNotNone(out, "the merge was let through") + self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "deny") + for word in words: + self.assertIn(word, out["hookSpecificOutput"]["permissionDecisionReason"]) + + def assert_let_through(self, out: dict | None, *words: str) -> None: + """A warning the person (systemMessage) and the agent (additionalContext) both see, and no decision.""" + self.assertIsNotNone(out, "the merge went ahead without a word") + self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) + for word in words: + self.assertIn(word, out["systemMessage"]) + self.assertIn(word, out["hookSpecificOutput"]["additionalContext"]) + + def test_a_merge_defers_to_the_installed_landing_gate_this_account_registers(self): + """Claude Code runs the registered gate itself; judging the merge here too would read GitHub twice.""" + self.install_gate(registered_in=self.home / ".claude") + code, out, err = self.hook(MERGE) + self.assertEqual((code, out), (0, None), err) + + def test_a_merge_runs_the_installed_landing_gate_when_this_account_did_not_register_it(self): + self.install_gate() + code, out, err = self.hook(MERGE) self.assertEqual(code, 0, err) - self.assertIsNotNone(out, "the pull request's own copy of the guard judged its merge") - self.assertEqual(out["hookSpecificOutput"]["permissionDecisionReason"], "merge guard base v1") + self.assert_refused(out, "installed landing gate") + + def test_the_account_in_claude_config_dir_is_the_one_that_must_register_it(self): + account = self.tmp / "account" + self.install_gate(registered_in=account) + self.assert_refused(self.hook(MERGE)[1], "installed landing gate") # ~/.claude has no registration + self.assertIsNone(self.hook(MERGE, env={"CLAUDE_CONFIG_DIR": str(account)})[1]) + self.assert_refused(self.hook(MERGE, env={"CLAUDE_CONFIG_DIR": str(self.tmp / "other")})[1], + "installed landing gate") + + def test_a_merge_with_no_installed_landing_gate_goes_ahead_with_a_warning(self): + """No guard blocks a merge it can't judge (#24265): a merge with no gate to judge it goes ahead, saying how to + install the gate.""" + for registered_in in (None, self.home / ".claude"): # a registration whose file is gone runs nothing + with self.subTest(registered=registered_in is not None): + self.install_gate(file=False, registered_in=registered_in) + code, out, err = self.hook(MERGE) + self.assertEqual(code, 0, err) + self.assert_let_through(out, "require-cmux-guards", "guards-sync", "install.py", "merging anyway") + + def test_the_pull_requests_copy_of_the_launcher_does_not_judge_its_merge(self): + code, out, err = self.hook(MERGE) + self.assertEqual(code, 0, err) + self.assert_let_through(out, "not installed") - def test_a_guard_fix_on_the_base_judges_a_branch_made_before_it(self): + def test_no_merge_guard_runs_from_this_repository(self): + """The landing gate lives in cmuxterm-hq; a copy here would judge a merge by a stale rule again.""" + for path in ("scripts/ci/merge_compile_guard.py", "scripts/ci/merge-compile-guard-hook.sh"): + self.assertFalse((ROOT / path).exists(), path) + self.assertNotIn("merge_compile_guard", (ROOT / "scripts/ci/guards-from-base.sh").read_text()) + + def test_a_warning_fix_on_the_base_reaches_a_branch_made_before_it(self): self.guards("base v2") - git(self.seed, "commit", "-qam", "a guard fix lands") + git(self.seed, "commit", "-qam", "a warning fix lands") git(self.seed, "push", "-q", "origin", "feat-cmux-next") - code, out, err = self.hook("gh pr merge 5 --squash") + git(self.lane, "fetch", "-q", "origin") + code, out, err = self.hook("gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") self.assertEqual(code, 0, err) - self.assertEqual(out["hookSpecificOutput"]["permissionDecisionReason"], "merge guard base v2") + self.assertEqual(out["hookSpecificOutput"]["additionalContext"], "base merge warning base v2") - def test_the_update_branch_warning_comes_from_the_base_too(self): + def test_the_update_branch_warning_comes_from_the_base(self): code, out, err = self.hook("gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") self.assertEqual(code, 0, err) self.assertEqual(out["hookSpecificOutput"]["additionalContext"], "base merge warning base v1") def test_a_stale_origin_is_fetched_before_a_merge(self): """A clone last fetched before the launcher landed has no origin/feat-cmux-next:scripts/ci/guards-from-base.sh; - the hook fetches first, so the merge is still judged by the base guard.""" + the hook fetches first, so the merge still goes to the landing gate.""" + self.install_gate() git(self.lane, "update-ref", "refs/remotes/origin/feat-cmux-next", self.old_base()) - code, out, err = self.hook("gh pr merge 5 --squash") + code, out, err = self.hook(MERGE) self.assertEqual(code, 0, err) - self.assertEqual(out["hookSpecificOutput"]["permissionDecisionReason"], "merge guard base v1") + self.assert_refused(out, "installed landing gate") - def test_a_merge_whose_guards_cannot_be_read_goes_ahead_with_a_warning(self): - """The merge guard refuses only a compile that finished red; a merge it can't judge, because GitHub or the - guards could not be read, goes ahead with a warning the agent and the person both see (2026-10-21: the - guard's timed-out reads held #23660 and #23371 for hours with every gate met).""" + def unreadable_launcher(self) -> None: + """origin/feat-cmux-next has no launcher and can't be fetched again.""" old = self.old_base() git(self.lane, "remote", "set-url", "origin", str(self.tmp / "gone.git")) git(self.lane, "update-ref", "refs/remotes/origin/feat-cmux-next", old) - code, out, err = self.hook("gh pr merge 5 --squash") + + def test_a_merge_whose_guards_cannot_be_read_goes_ahead_with_a_warning(self): + """A merge the hook can't judge goes ahead with a warning the agent and the person both see, never with only a + stderr line (2026-10-21: timed-out reads held #23660 and #23371 for hours with every gate met).""" + self.install_gate(registered_in=self.home / ".claude", text=GATE_HOLD) + self.unreadable_launcher() + code, out, err = self.hook(MERGE) self.assertEqual(code, 0, err) - self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) - self.assertIn("merge guards did not run", out["hookSpecificOutput"]["additionalContext"]) - self.assertEqual(out["systemMessage"], out["hookSpecificOutput"]["additionalContext"]) + self.assert_let_through(out, "merge guards did not run") # The update-branch warning only warns: unreadable, it does not block. - code, out, err = self.hook("gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") + code, out, err = self.hook(UPDATE) self.assertIsNone(out) self.assertNotEqual(code, 2) - def test_a_base_without_the_guards_lets_a_merge_through_with_a_warning(self): - """guards-from-base.sh itself is on the base but the guards are not (`git archive` fails).""" - git(self.seed, "rm", "-q", "scripts/ci/merge_compile_guard.py") - git(self.seed, "commit", "-qm", "the guard leaves the base") - git(self.seed, "push", "-q", "origin", "feat-cmux-next") - code, out, err = self.hook("gh pr merge 5 --squash") + def test_an_unregistered_gate_still_judges_a_merge_whose_launcher_cannot_be_read(self): + """Codex review of #23858: with no launcher to run it, the installed gate's refusal was lost.""" + self.install_gate() + self.unreadable_launcher() + code, out, err = self.hook(MERGE) self.assertEqual(code, 0, err) - self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) - self.assertIn("cannot read the guards", out["systemMessage"]) - self.assertEqual(out["systemMessage"], out["hookSpecificOutput"]["additionalContext"]) + self.assert_refused(out, "installed landing gate") + self.install_gate(text=GATE_HOLD) # a gate that lets it through leaves the warning + code, out, err = self.hook(MERGE) + self.assertEqual(code, 0, err) + self.assert_let_through(out, "merge guards did not run") + + def test_a_registered_gate_still_judges_a_merge_whose_launcher_cannot_be_read(self): + """Codex review of #23858: whether Claude Code runs a registration can't be told for sure from its text, so + with no launcher the installed gate runs here even when registered; that only costs a second read.""" + self.install_gate(registered_in=self.home / ".claude") + self.unreadable_launcher() + code, out, err = self.hook(MERGE) + self.assertEqual(code, 0, err) + self.assert_refused(out, "installed landing gate") + + def test_a_graphql_merge_goes_to_the_installed_gate(self): + """Codex review of #23858: `gh api graphql` merges never matched `pr merge` or `/merge`.""" + self.install_gate() + for command in ("gh api graphql -f query='mutation { mergePullRequest(input: {pullRequestId: \"x\"}) { clientMutationId } }'", + "gh api graphql -f query='mutation { enablePullRequestAutoMerge(input: {pullRequestId: \"x\"}) { clientMutationId } }'"): + with self.subTest(command=command): + code, out, err = self.hook(command) + self.assertEqual(code, 0, err) + self.assert_refused(out, "installed landing gate") + + def test_a_registration_whose_matcher_skips_bash_does_not_count(self): + """Codex review of #23858: a PreToolUse entry that names the gate under another tool's matcher never runs on a + Bash command, so the merge must not defer to it.""" + self.install_gate(registered_in=self.home / ".claude", matcher="Edit|Write") + self.assert_refused(self.hook(MERGE)[1], "installed landing gate") + self.install_gate(registered_in=self.home / ".claude", matcher="Bash|Edit") + self.assertIsNone(self.hook(MERGE)[1]) + + def test_a_command_that_only_mentions_the_gate_is_no_registration(self): + """Codex review of #23858 (round 3): this repository's own settings hook names the gate's file in its fallback; + an account whose settings carry that hook has not registered the gate.""" + account = self.tmp / "account" + account.mkdir() + shutil.copy(SETTINGS, account / "settings.json") + self.install_gate() + self.assert_refused(self.hook(MERGE, env={"CLAUDE_CONFIG_DIR": str(account)})[1], "installed landing gate") + + def test_a_stale_origin_is_fetched_with_gtimeout_where_timeout_is_missing(self): + """Codex review of #23858: stock macOS has no `timeout`, so the merge's fetch never ran there; GNU coreutils + from Homebrew installs it as `gtimeout`.""" + bin_dir = self.tmp / "bin" + bin_dir.mkdir() + for tool in ("bash", "git", "python3", "mktemp", "cat", "rm", "grep", "tar", "sleep"): + found = shutil.which(tool) + if found: + (bin_dir / tool).symlink_to(found) + (bin_dir / "gtimeout").symlink_to(shutil.which("timeout")) + self.install_gate() + git(self.lane, "update-ref", "refs/remotes/origin/feat-cmux-next", self.old_base()) + code, out, err = self.hook(MERGE, env={"PATH": str(bin_dir)}) + self.assertEqual(code, 0, err) + self.assert_refused(out, "installed landing gate") def land_on_base(self, path: str, text: str) -> None: (self.seed / path).write_text(text) git(self.seed, "commit", "-qam", f"the base changes {path}") git(self.seed, "push", "-q", "origin", "feat-cmux-next") - def test_a_guard_that_crashes_never_blocks_a_merge(self): + def test_a_base_without_the_warning_lets_a_merge_through_with_a_warning(self): + """guards-from-base.sh itself is on the base but the update-branch warning is not (`git archive` fails).""" + self.install_gate(registered_in=self.home / ".claude") + git(self.seed, "rm", "-q", "scripts/ci/base_merge_warning.py") + git(self.seed, "commit", "-qm", "the warning leaves the base") + git(self.seed, "push", "-q", "origin", "feat-cmux-next") + code, out, err = self.hook(MERGE) + self.assertEqual(code, 0, err) + self.assert_let_through(out, "cannot read the guards") + + def test_a_gate_or_warning_that_crashes_never_blocks_a_merge(self): """Codex review of #24265: a hook that exits 2 (a bash syntax error does) blocks the tool call in Claude Code, though no compile finished red. Every failure of a guard is a warning.""" for broken in ("if then fi\n", "exit 2\n", "exit 3\n"): - with self.subTest(hook=broken): - self.land_on_base("scripts/ci/merge-compile-guard-hook.sh", broken) - code, out, err = self.hook("gh pr merge 5 --squash") + with self.subTest(gate=broken): + self.install_gate(text=broken) + code, out, err = self.hook(MERGE) self.assertNotEqual(code, 2, err) - if out is not None: - self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) - self.assertIn("merging anyway", out["systemMessage"]) + self.assert_let_through(out, "require-cmux-guards.sh exited", "merging anyway") self.land_on_base("scripts/ci/base-merge-warning-hook.sh", "exit 2\n") - code, out, err = self.hook("gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") + code, out, err = self.hook(UPDATE) self.assertNotEqual(code, 2, err) def test_the_settings_hook_never_exits_2_when_the_launcher_itself_breaks(self): self.land_on_base("scripts/ci/guards-from-base.sh", "exit 2\n") - code, out, err = self.hook("gh pr merge 5 --squash") + code, out, err = self.hook(MERGE) self.assertNotEqual(code, 2, err) def test_a_merge_warning_does_not_hide_the_update_branch_warning(self): - """Codex review of #24265: `gh pr merge ...; gh api .../update-branch` used to be refused whole; now the merge - may go ahead with a warning, and the update-branch warning must still be said.""" - self.land_on_base("scripts/ci/merge_compile_guard.py", HOLD % "base v1") - code, out, err = self.hook("gh pr merge 5 --squash; gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") + """Codex review of #24265: with `gh pr merge ...; gh api .../update-branch` the merge may go ahead with a + warning, and the update-branch warning must still be said.""" + self.install_gate(text=GATE_HOLD) + code, out, err = self.hook(f"{MERGE}; {UPDATE}") self.assertEqual(code, 0, err) - context = out["hookSpecificOutput"]["additionalContext"] - self.assertIn("merge guard base v1 holds nothing", context) - self.assertIn("base merge warning base v1", context) - self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) - self.assertIn("merge guard base v1 holds nothing", out["systemMessage"]) + self.assert_let_through(out, "installed landing gate holds nothing") + self.assertIn("base merge warning base v1", out["hookSpecificOutput"]["additionalContext"]) def test_a_refusal_still_wins_over_a_warning(self): - code, out, err = self.hook("gh pr merge 5 --squash; gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") + self.install_gate() + code, out, err = self.hook(f"{MERGE}; {UPDATE}") self.assertEqual(code, 0, err) - self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "deny") - self.assertEqual(out["hookSpecificOutput"]["permissionDecisionReason"], "merge guard base v1") + self.assert_refused(out, "installed landing gate") def test_a_refusal_in_any_json_spacing_still_refuses(self): - """Codex review of #24265 (round 2): the loop matched `"permissionDecision": "deny"` as text, so a valid - refusal written with other spacing was merged into a warning and the red compile's refusal was lost.""" - spaced = GUARD.replace("sys.stdout)", "sys.stdout, separators=(',', ': '), indent=2)") - self.land_on_base("scripts/ci/merge_compile_guard.py", spaced % "base v1") - for command in ("gh pr merge 5 --squash", - "gh pr merge 5 --squash; gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch"): + """Codex review of #24265 (round 2): a refusal matched as text was lost when written with other spacing.""" + self.install_gate(text=GATE_SPACED) + for command in (MERGE, f"{MERGE}; {UPDATE}"): with self.subTest(command=command): code, out, err = self.hook(command) self.assertEqual(code, 0, err) - self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "deny") - self.assertEqual(out["hookSpecificOutput"]["permissionDecisionReason"], "merge guard base v1") + self.assert_refused(out, "installed landing gate") - def test_a_refusal_never_waits_for_the_next_guard(self): - """Codex review of #24265 (round 3): both guards ran before the refusal was read, so a slow update-branch - warning could run the hook past its timeout with the refusal unprinted, and the merge went through.""" + def test_a_refusal_never_waits_for_the_update_branch_warning(self): + """Codex review of #24265 (round 3): a slow update-branch warning must not run the hook past its timeout with + the gate's refusal unprinted.""" + self.install_gate() self.land_on_base("scripts/ci/base-merge-warning-hook.sh", "sleep 30\n") started = time.monotonic() - code, out, err = self.hook("gh pr merge 5 --squash; gh api -X PUT repos/manaflow-ai/cmux/pulls/5/update-branch") + code, out, err = self.hook(f"{MERGE}; {UPDATE}") self.assertLess(time.monotonic() - started, 20) self.assertEqual(code, 0, err) - self.assertEqual(out["hookSpecificOutput"]["permissionDecision"], "deny") + self.assert_refused(out, "installed landing gate") def old_base(self) -> str: """A commit on the base from before the launcher existed.""" @@ -260,7 +399,7 @@ def test_git_starts_no_background_maintenance_in_the_fixture_repos(self): git(self.seed, "commit", "-q", "--allow-empty", "-m", "another base commit") git(self.seed, "push", "-q", "origin", "feat-cmux-next") git(self.lane, "fetch", "-q", "origin") - self.hook("gh pr merge 5 --squash") + self.hook(MERGE) events = [json.loads(line) for line in trace.read_text().splitlines()] started = [event.get("argv", []) for event in events if event.get("event") == "child_start"] background = [argv for argv in started if "maintenance" in argv or ("gc" in argv and "--auto" in argv)] diff --git a/tests/test_merge_compile_guard.py b/tests/test_merge_compile_guard.py deleted file mode 100644 index 250d946a20ad..000000000000 --- a/tests/test_merge_compile_guard.py +++ /dev/null @@ -1,754 +0,0 @@ -#!/usr/bin/env python3 -"""merge_compile_guard: a hand merge of a feat-cmux-next PR that changes Swift is refused only once its compile -finished red; a compile still pending, and the guard's own slow or failed read, are warnings. - -On 2026-10-21 (UTC) #22835, #21480 and #21487 were merged with `gh pr merge` while their compile was pending. Later -that day, under load on cosmic-gray, the guard's GitHub reads timed out and refused #23660 and #23371 for hours with -every gate met (Leo: "IF THE MERGE HOOK IS WRONG THEN JUST FUCKING MERGE"). -""" -from __future__ import annotations - -import json -import os -import stat -import subprocess -import sys -import tempfile -import textwrap -import time -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -HOOK = ROOT / "scripts/ci/merge-compile-guard-hook.sh" -sys.path.insert(0, str(ROOT / "scripts/ci")) -import merge_compile_guard as guard # noqa: E402 - -COMPILE = guard.COMPILE -HEAD = "c" * 40 -SWIFT = ["Packages/macOS/CmuxNext/Sources/A.swift"] - - -def pr(number=7, base="feat-cmux-next", head=HEAD): - return {"number": number, "base": base, "head": head, "state": "open"} - - -def check(name, status="completed", conclusion="success"): - return {"name": name, "status": status, "conclusion": conclusion if status == "completed" else None} - - -class Verdict(unittest.TestCase): - def test_a_swift_pr_merges_only_once_its_compile_passed(self): - self.assertIn("still running", guard.verdict(pr(), SWIFT, [check(COMPILE, "in_progress")])) - self.assertIn("still running", guard.verdict(pr(), SWIFT, [check(COMPILE, "queued")])) - self.assertIn("finished failure", guard.verdict(pr(), SWIFT, [check(COMPILE, conclusion="failure")])) - self.assertIn("finished cancelled", guard.verdict(pr(), SWIFT, [check(COMPILE, conclusion="cancelled")])) - self.assertEqual(guard.verdict(pr(), SWIFT, [check(COMPILE)]), "") - # A rerun that passed after a red counts. - self.assertEqual(guard.verdict(pr(), SWIFT, [check(COMPILE, conclusion="failure"), check(COMPILE)]), "") - - def test_only_a_compile_that_finished_red_refuses(self): - """Everything else is a Hold: the hook says it and the merge goes ahead (HQ's landing gate does the same).""" - for checks in ([], [check(COMPILE, "queued")], [check(COMPILE, "in_progress")], - [check("cmux-next path routing", "in_progress")], [check(COMPILE, conclusion="cancelled")], - [check(guard.FLEET_COMPILE, conclusion="cancelled")]): - with self.subTest(checks=checks): - self.assertIsInstance(guard.verdict(pr(), SWIFT, checks), guard.Hold) - for checks in ([check(COMPILE, conclusion="failure")], [check(COMPILE, conclusion="timed_out")], - [check(guard.FLEET_COMPILE, conclusion="failure")], - [check(COMPILE, conclusion="failure"), check(COMPILE, "queued")]): - with self.subTest(checks=checks): - why = guard.verdict(pr(), SWIFT, checks) - self.assertTrue(why) - self.assertNotIsInstance(why, guard.Hold) - - def test_a_red_fleet_compile_refuses_even_with_a_release_compile_queued(self): - """Codex review of #24265: the red check read only Release attempts, so a failed build-fleet compile next to - a queued Release compile read as a wait and became a warning, and a fleet pass on the head could silence it.""" - fleet = guard.FLEET_COMPILE - for runs in ([check(fleet, conclusion="failure"), check(COMPILE, "queued")], - [check(fleet, conclusion="failure"), check(COMPILE, "in_progress")], - [check(fleet, conclusion="timed_out"), check("cmux-next path routing", "in_progress")]): - with self.subTest(runs=runs): - self.assertFalse(guard.compile_wait(runs)) - for fleet_passed in (False, True): - why = guard.verdict(pr(), SWIFT, runs, fleet_passed=fleet_passed) - self.assertIn(f"{fleet} finished", why) - self.assertNotIsInstance(why, guard.Hold) - # A passing rerun of either compile clears it. - self.assertEqual(guard.verdict(pr(), SWIFT, [check(fleet, conclusion="failure"), check(fleet)]), "") - self.assertEqual(guard.verdict(pr(), SWIFT, [check(fleet, conclusion="failure"), check(COMPILE)]), "") - - def test_a_compile_not_created_yet_waits_while_cmux_next_routes_and_places(self): - for job in guard.ROUTING: - with self.subTest(job=job): - self.assertIn("has not created", guard.verdict(pr(), SWIFT, [check(job, "in_progress")])) - self.assertIn("no checks yet", guard.verdict(pr(), SWIFT, [])) - # cmux-next finished routing without a compile, or never ran on this head. - self.assertEqual(guard.verdict(pr(), SWIFT, [check("cmux-next path routing")]), "") - self.assertEqual(guard.verdict(pr(), SWIFT, [check("web / lint")]), "") - - def test_a_prs_build_fleet_compile_stands_in_for_its_skipped_release_compile(self): - # A same-repository PR compiles on the build fleet; its Mac Release compile runs only with no fleet verdict. - fleet = guard.FLEET_COMPILE - self.assertIn(fleet, guard.ROUTING) - self.assertIn("has not created", guard.verdict(pr(), SWIFT, [check(fleet, "in_progress")])) - self.assertEqual(guard.verdict(pr(), SWIFT, [check(fleet), check(COMPILE, conclusion="skipped")]), "") - for conclusion in ("failure", "timed_out"): - with self.subTest(conclusion=conclusion): - runs = [check(fleet, conclusion=conclusion), check(COMPILE, conclusion="skipped")] - self.assertIn(f"{fleet} finished {conclusion}", guard.verdict(pr(), SWIFT, runs)) - self.assertIn(f"{fleet} finished {conclusion}", guard.verdict(pr(), SWIFT, runs, fleet_passed=True)) - # A run cancelled while the fleet compiled leaves no verdict at all (the Release compile skips on a - # cancelled run): that is no compile, not a pass, and a fleet pass on the exact head stands in. - stopped = [check(fleet, conclusion="cancelled"), check(COMPILE, conclusion="skipped")] - self.assertIn(f"{fleet} was cancelled", guard.verdict(pr(), SWIFT, stopped)) - self.assertTrue(guard.compile_wait(stopped)) - self.assertEqual(guard.verdict(pr(), SWIFT, stopped, fleet_passed=True), "") - # A rerun that passed after the cancel counts. - self.assertEqual(guard.verdict(pr(), SWIFT, stopped + [check(fleet)]), "") - # No fleet verdict: the Mac Release compile that ran instead decides. - self.assertEqual(guard.verdict(pr(), SWIFT, [check(fleet), check(COMPILE)]), "") - self.assertIn("still running", guard.verdict(pr(), SWIFT, [check(fleet), check(COMPILE, "queued")])) - - def test_what_the_compile_does_not_cover_is_not_held(self): - # The router skipped it (iOS, a package outside the graph, a fork). - self.assertEqual(guard.verdict(pr(), ["ios/CmuxiOS/A.swift"], [check(COMPILE, conclusion="skipped")]), "") - self.assertEqual(guard.verdict(pr(), ["webviews/src/a.tsx"], [check(COMPILE, "queued")]), "") - self.assertEqual(guard.verdict(pr(base="main"), SWIFT, [check(COMPILE, "queued")]), "") - - def test_headers_manifests_and_projects_count_as_swift(self): - for path in ("Sources/CCmuxAppFFI/include/ffi.h", "Packages/macOS/CmuxNext/Package.resolved", - "cmux.xcodeproj/project.pbxproj", "Config/Next.xcconfig", "Sources/X/module.modulemap"): - with self.subTest(path=path): - self.assertIn("still running", guard.verdict(pr(), [path], [check(COMPILE, "queued")])) - - -class FleetCompileCheck(unittest.TestCase): - """A passed `cmux-ci compile-check --app` on the exact head stands in for the Release compile. - - #22845's Release compile sat queued over an hour (60 minis offline) after its fleet compile-check - passed. The pass counts only as the `fleet/compile-check` status that fleet-compile-check.yml, run from - feat-cmux-next, posts on the head, and only when that run finished green naming the full head. - """ - - def test_a_verified_fleet_pass_stands_in_for_a_release_compile_still_waiting(self): - for runs in ([check(COMPILE, "queued")], [check(COMPILE, "in_progress")], [], - [check(guard.ROUTING[0], "in_progress")]): - with self.subTest(runs=runs): - self.assertNotEqual(guard.verdict(pr(), SWIFT, runs), "") - self.assertEqual(guard.verdict(pr(), SWIFT, runs, fleet_passed=True), "") - - def test_a_fleet_pass_never_overrides_a_release_compile_that_finished_red(self): - # The fleet compiles on Linux with the pool SDK, not Xcode 26 Release: a red there is the truth. - for conclusion in ("failure", "timed_out", "action_required", "startup_failure"): - with self.subTest(conclusion=conclusion): - runs = [check(COMPILE, conclusion=conclusion)] - self.assertIn(f"finished {conclusion}", guard.verdict(pr(), SWIFT, runs, fleet_passed=True)) - self.assertFalse(guard.compile_wait(runs)) - - def test_a_rerun_queued_after_a_red_compile_is_not_a_wait(self): - # `gh run rerun --failed` on a red Xcode-only error: the fleet pass must not merge it meanwhile. - runs = [check(COMPILE, conclusion="failure"), check(COMPILE, "queued")] - self.assertFalse(guard.compile_wait(runs)) - self.assertIn("still running", guard.verdict(pr(), SWIFT, runs, fleet_passed=True)) - # A red attempt stays red beside a cancelled one, and so does a startup failure. - self.assertFalse(guard.compile_wait([check(COMPILE, conclusion="startup_failure"), check(COMPILE, "queued")])) - self.assertFalse(guard.compile_wait([check(COMPILE, conclusion="failure"), check(COMPILE, conclusion="cancelled"), - check(COMPILE, "queued")])) - - def test_a_cancelled_compile_is_no_verdict_so_a_fleet_pass_stands_in(self): - # #22845: two attempts cancelled, a third queued for hours, a verified fleet pass on the head. - cancelled = check(COMPILE, conclusion="cancelled") - for runs in ([cancelled, cancelled, check(COMPILE, "queued")], [cancelled], - [check(COMPILE, conclusion="skipped"), cancelled]): - with self.subTest(runs=runs): - self.assertTrue(guard.compile_wait(runs)) - self.assertNotEqual(guard.verdict(pr(), SWIFT, runs), "") - self.assertEqual(guard.verdict(pr(), SWIFT, runs, fleet_passed=True), "") - - def test_every_gh_call_shares_one_deadline_under_the_hooks_timeout(self): - self.assertLess(guard.BUDGET, 120) - spent = guard.STARTED - try: - guard.STARTED = guard.time.monotonic() - guard.BUDGET - self.assertIsNone(guard.run(["true"])) - finally: - guard.STARTED = spent - - def test_a_refusal_says_how_to_get_a_fleet_pass(self): - # cmux-ci itself, not a dispatch of the workflow that wraps it: the guard reads the job. - why = guard.verdict(pr(), SWIFT, [check(COMPILE, "queued")]) - self.assertIn(f"cmux-ci compile-check --ref {HEAD} --app", why) - self.assertNotIn("gh workflow run", why) - - def test_a_missing_compile_result_names_the_fleet_compile_check_first(self): - """COMMON.md: the default path is a fleet compile-check on the dev-build workers, not waiting on the Mac - Release compile.""" - for checks in ([], [check(COMPILE, "queued")], [check("cmux-next path routing", "in_progress")]): - why = guard.verdict(pr(), SWIFT, checks) - run = f"cmux-ci compile-check --ref {HEAD} --app" - self.assertIn(run, why) - self.assertIn("fleet dev-build workers", why) - self.assertLess(why.index(run), why.index(COMPILE), why) - - def test_the_fleet_compile_check_refusal_reads_whole_with_several_or_oddly_named_swift_files(self): - """The refusal is built from its parts: a second file (" and more") or a path holding " and " can't split it.""" - base = "Packages/macOS/CmuxNext/Sources/" - for swift, what in (([base + "A.swift", base + "B.swift"], f"({base}A.swift and more)"), - ([base + "Find and Replace.swift"], f"({base}Find and Replace.swift)")): - why = guard.verdict(pr(), swift, []) - self.assertTrue(why.startswith(f"#7 changes Swift {what} and {HEAD[:12]} has no compile result yet."), why) - self.assertTrue(why.endswith(f"({HEAD[:12]} has no checks yet, so no {COMPILE} either.)"), why) - self.assertEqual(why.count("cmux-ci compile-check"), 1, why) - - def test_a_refusal_names_the_cause_of_its_own_read_not_an_earlier_one(self): - guard.run(["false"]) - self.assertTrue(guard.FAILED) - self.assertEqual(guard.run(["true"]), "") - self.assertEqual(guard.FAILED, []) - - def test_the_workflow_posts_the_status_the_guard_reads_on_the_sha_it_was_given(self): - text = (ROOT / ".github/workflows" / guard.FLEET_WORKFLOW).read_text() - self.assertIn(f"run-name: {guard.FLEET_TITLE}" + "${{ inputs.sha }}", text) - self.assertIn(f"CONTEXT: {guard.FLEET_CONTEXT}\n", text) - self.assertIn("compile-check --ref \"$SHA\" --app", text) - self.assertIn("github.ref == 'refs/heads/feat-cmux-next'", text) - self.assertIn("statuses/$SHA", text) - self.assertNotIn("pull_request", text) - - -class AdvisoryTestCompile(unittest.TestCase): - """fleet-test-compile.yml's check is advisory: the guard neither waits for it nor needs it. - - It compiles the test targets a PR reaches on a fleet Mac (#23360, #23163 broke the base's test build - after the app-only fleet/compile-check passed). A red or pending one never holds a merge here, and it - never posts the fleet/compile-check status the guard trusts. - """ - - ADVISORY = "fleet/test-compile (advisory)" - WORKFLOW = ROOT / ".github/workflows/fleet-test-compile.yml" - - def test_a_red_or_pending_advisory_check_never_holds_a_merge(self): - for state, conclusion in (("completed", "failure"), ("queued", None), ("in_progress", None)): - with self.subTest(state=state): - advisory = check(self.ADVISORY, state, conclusion or "success") - self.assertEqual(guard.verdict(pr(), SWIFT, [check(COMPILE), advisory]), "") - self.assertEqual(guard.verdict(pr(), SWIFT, [check(COMPILE, "queued"), advisory], fleet_passed=True), "") - # And it never stands in for the Release compile. - self.assertIn("still running", guard.verdict(pr(), SWIFT, [check(COMPILE, "queued"), advisory])) - - def test_it_is_not_the_fleet_check_the_guard_trusts(self): - self.assertNotEqual(guard.FLEET_WORKFLOW, self.WORKFLOW.name) - text = self.WORKFLOW.read_text() - self.assertIn(f"name: {self.ADVISORY}\n", text) - self.assertNotIn(guard.FLEET_CONTEXT + "\n", text) - self.assertNotIn("statuses", text) - self.assertNotIn(guard.FLEET_TITLE, text) - fleet = (ROOT / ".github/workflows" / guard.FLEET_WORKFLOW).read_text() - self.assertNotIn("compile-tests", fleet) - self.assertNotIn("test-compile", fleet) - - -# #23606 at bf14aa7d: activity-indicators' `cmux-ci compile-check --ref --app` passed (job -# edfa18ea31a796eac433019a, 2026-10-21 19:32Z), the guard still refused the merge, and the lane dispatched -# fleet-compile-check.yml (run 39662744415) to compile the same head again. The controller's record of -# that job and its receipts.json, as `cmux-ci status` and `cmux-ci artifact` gave them (trimmed). -HEAD_23606 = "bf14aa7d4c88f4ff3c7670414a289e561097f4a2" -JOB_23606 = { - "id": "edfa18ea31a796eac433019a", "kind": "cmux-next-swift-check", - "command": f"/Users/Shared/cmux-build-fleet/recipes/cmux-next-swift-check.sh {HEAD_23606} --app", - "repo": "", "ref": HEAD_23606, "labels": ["cmux-next-linux"], "state": "done", - "artifact": "sha256:aa657e2ba1f8f5047e597dcf23b4eb72e06b77d1781de83136ad187a6d461fde", - "submitter": "leoli.4u@gmail.com", "submitter_verified": True, "placement_reason": "linux_pool", - "created_at": "2026-10-21T19:32:16.88696Z", "updated_at": "2026-10-21T19:33:35.356714Z", -} -RECEIPTS_23606 = [{ - "package": "app", "sha": HEAD_23606, "toolchain_id": "swift-6.3.3-cmux+Xcode_26.6/736c824e5bb154f0", - "ok": True, "seconds": 59.1, -}] - - -def fake_cmux_ci(bin_dir: Path, jobs, receipts: dict, log: Path) -> Path: - """A `cmux-ci` that prints `jobs` for `status` and writes receipts[job id] for `artifact JOB OUT`. - jobs None: the controller can't be reached.""" - script = bin_dir / "cmux-ci" - script.write_text(textwrap.dedent(f"""\ - #!{sys.executable} - import json, sys - args = sys.argv[1:] - with open({str(log)!r}, "a") as f: - f.write(json.dumps(["cmux-ci", *args]) + "\\n") - jobs, receipts = json.loads({json.dumps(json.dumps(jobs))}), json.loads({json.dumps(json.dumps(receipts))}) - if args == ["status"] and jobs is not None: - print(json.dumps(jobs)) - sys.exit(0) - if len(args) == 3 and args[0] == "artifact" and args[1] in receipts: - with open(args[2], "w") as f: - json.dump(receipts[args[1]], f) - print("aa657e2b") - sys.exit(0) - sys.exit(1) - """)) - script.chmod(script.stat().st_mode | stat.S_IEXEC) - return script - - -def job(**changes): - return {**JOB_23606, **changes} - - -class CmuxCiCompileCheck(unittest.TestCase): - """A passed `cmux-ci compile-check --ref --app` job is the fleet pass itself: the guard reads it - from the controller, so a lane never dispatches fleet-compile-check.yml to compile the head twice. - - It counts the way the client's own exit 0 does (ci-client.py compile_check): a done - cmux-next-swift-check job of the recipe on the exact head with --app, whose receipts.json has an - `app` receipt that is ok, for that sha. - """ - - def passed(self, jobs, receipts=None, head=HEAD_23606): - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - log = tmp / "calls.log" - client = fake_cmux_ci(tmp, jobs, {JOB_23606["id"]: RECEIPTS_23606} if receipts is None else receipts, log) - saved = os.environ.get("CMUX_CI_CLIENT") - os.environ["CMUX_CI_CLIENT"] = str(client) - try: - return guard.cmux_ci_passed(head) - finally: - if saved is None: - os.environ.pop("CMUX_CI_CLIENT") - else: - os.environ["CMUX_CI_CLIENT"] = saved - - def test_the_23606_job_is_a_pass_on_its_head(self): - self.assertTrue(self.passed([job()])) - - def test_packages_with_the_app_count_as_the_client_builds_them(self): - command = f"/Users/Shared/cmux-build-fleet/recipes/cmux-next-swift-check.sh {HEAD_23606} --package CmuxNext --app" - self.assertTrue(self.passed([job(command=command)])) - - def test_it_counts_only_on_the_exact_head(self): - self.assertFalse(self.passed([job()], head="d" * 40)) - self.assertFalse(self.passed([job()], head=HEAD_23606[:12])) - - def test_anything_but_the_passing_app_check_does_not_count(self): - other = "d" * 40 - cases = { - "another sha": [job(ref=other, command=JOB_23606["command"].replace(HEAD_23606, other))], - "ref and command disagree": [job(ref=other)], - "packages, not the app": [job(command=JOB_23606["command"].replace("--app", "--package CmuxNext"))], - "another recipe": [job(command=f"/tmp/mine.sh {HEAD_23606} --app")], - # The worker runs the command with /bin/sh -c, and `cmux-ci submit` takes any command: a suffix could - # write its own receipts.json. - "shell suffix": [job(command=JOB_23606["command"] + " ; echo forged > artifacts/receipts.json")], - "extra argument": [job(command=JOB_23606["command"] + " --skip")], - "a repo to clone": [job(repo="https://github.com/x/cmux.git")], - "another pool": [job(labels=["cmux-next-linux", "mine"])], - "another kind": [job(kind="custom")], - "failed": [job(state="failed")], - "still running": [job(state="running")], - "cancelled": [job(state="cancelled")], - "unverified submitter": [job(submitter_verified=False)], - "no jobs": [], - "controller unreachable": None, - } - for name, jobs in cases.items(): - with self.subTest(name): - self.assertFalse(self.passed(jobs)) - - def test_the_receipts_decide_not_the_job_state(self): - id_ = JOB_23606["id"] - cases = { - "app did not compile": {id_: [{**RECEIPTS_23606[0], "ok": False}]}, - "receipt for another sha": {id_: [{**RECEIPTS_23606[0], "sha": "d" * 40}]}, - "no app receipt": {id_: [{**RECEIPTS_23606[0], "package": "CmuxNext"}]}, - "no receipts": {id_: []}, - "artifact unreadable": {}, - } - for name, receipts in cases.items(): - with self.subTest(name): - self.assertFalse(self.passed([job()], receipts)) - # The recipe's other receipt shape. - self.assertTrue(self.passed([job()], {id_: {"receipts": RECEIPTS_23606}})) - - def test_a_newer_failed_job_does_not_hide_an_older_pass(self): - failed = job(id="f" * 24, created_at="2026-10-21T19:40:00Z") - self.assertTrue(self.passed([failed, job()], {JOB_23606["id"]: RECEIPTS_23606, - failed["id"]: [{**RECEIPTS_23606[0], "ok": False}]})) - - def test_no_client_is_no_pass(self): - saved = os.environ.get("CMUX_CI_CLIENT") - os.environ["CMUX_CI_CLIENT"] = "/nonexistent/cmux-ci" - try: - self.assertFalse(guard.cmux_ci_passed(HEAD_23606)) - finally: - if saved is None: - os.environ.pop("CMUX_CI_CLIENT") - else: - os.environ["CMUX_CI_CLIENT"] = saved - - -def status(state="success", creator="github-actions[bot]", run=99): - return {"state": state, "creator": creator, "url": f"https://github.com/manaflow-ai/cmux/actions/runs/{run}"} - - -RUN_SHA, TIP = "e" * 40, "f" * 40 # the commit the fleet run's workflow file came from; feat-cmux-next's tip - - -def fleet_run(sha=HEAD, branch="feat-cmux-next", path=None, event="workflow_dispatch", conclusion="success", - head_sha=RUN_SHA): - return {"path": path or f".github/workflows/{guard.FLEET_WORKFLOW}", "head_branch": branch, "event": event, - "status": "completed" if conclusion else "in_progress", "conclusion": conclusion, - "display_title": f"{guard.FLEET_TITLE}{sha}", "head_sha": head_sha} - - -class Targets(unittest.TestCase): - def targets(self, command): - return [(repo, selector) for repo, selector, _ in guard.merge_targets(command, "/tmp")] - - def test_every_merge_form_names_its_pull_request(self): - cmux = "manaflow-ai/cmux" - cases = { - "gh pr merge 22835 --squash": [(None, "22835")], - "gh pr merge 22835 -R manaflow-ai/cmux --squash": [(cmux, "22835")], - "gh pr merge --squash --repo manaflow-ai/cmux '#21480'": [(cmux, "21480")], - # Unquoted, `#21480` is a shell comment: the checkout's branch is what merges. - "gh pr merge --squash --repo manaflow-ai/cmux #21480": [(cmux, None)], - "gh pr merge --repo=manaflow-ai/cmux 8": [(cmux, "8")], - "gh pr merge https://github.com/manaflow-ai/cmux/pull/21487 --auto --squash": [(cmux, "21487")], - "gh pr merge --squash -t 'fix 123' 9": [(None, "9")], - 'gh pr merge -b "it\\"s 99" 7': [(None, "7")], - "gh pr merge --squash": [(None, None)], - # A redirect's fd is not a pull request; the checkout's branch is merged. - "gh pr merge --squash 2>/dev/null": [(None, None)], - "gh pr merge --squash --delete-branch 2>&1": [(None, None)], - "gh pr merge --squash\ngit log -1 4242": [(None, None)], - "gh pr merge my-branch": [(None, "my-branch")], - "gh pr merge 123-fix": [(None, "123-fix")], - 'gh pr merge "$PR" --squash': [(None, guard.UNKNOWN)], - "gh api -X PUT repos/manaflow-ai/cmux/pulls/22914/merge -f merge_method=squash -f sha=abc": - [(cmux, "22914")], - "gh api -X PUT repos/{owner}/{repo}/pulls/5/merge": [(None, "5")], - 'gh api -X PUT "repos/manaflow-ai/cmux/pulls/$N/merge"': [(cmux, guard.UNKNOWN)], - 'gh api graphql -f query="mutation{mergePullRequest(input:{})}"': [(None, guard.UNKNOWN)], - "GH_REPO=manaflow-ai/cmux gh pr merge 5": [(cmux, "5")], - "git log && gh pr merge 3 -R manaflow-ai/cmux --squash": [(cmux, "3")], - # A redirect with no fd of its own leaves the number before it alone. - "gh pr merge 5 >out.txt 2>&1": [(None, "5")], - "gh pr merge 5 > /tmp/log 2>&1": [(None, "5")], - # A comment ends at its newline; the next line still runs. - "echo hi # merge next\ngh pr merge 5 --squash": [(None, "5")], - 'git commit -m "a # b" && gh pr merge 9': [(None, "9")], - "gh pr merge feat#2": [(None, "feat#2")], - # Wrappers and shells run the merge too. - 'bash -c "gh pr merge 5"': [(None, "5")], - "env GH_REPO=manaflow-ai/cmux gh pr merge 5": [(cmux, "5")], - "command gh pr merge 5": [(None, "5")], - "/opt/homebrew/bin/gh pr merge 5": [(None, "5")], - "nice -n 10 gh pr merge 5": [(None, "5")], - "gh pr merge 5 --squash; echo $'it\\'s'": [(None, "5")], - 'echo "unbalanced && gh pr merge 5': [(None, guard.UNKNOWN)], - # Control structures, timeouts and command substitution run the merge too. - "if gh pr checks 5 >/dev/null; then gh pr merge 5 --squash; fi": [(None, "5")], - "for p in 5; do gh pr merge 5 --squash; done": [(None, "5")], - "{ gh pr merge 5 --squash; }": [(None, "5")], - "! gh pr merge 5 --squash": [(None, "5")], - "timeout 60 gh pr merge 5 --squash": [(None, "5")], - "timeout -k 5 60 gh pr merge 5": [(None, "5")], - "echo `gh pr merge 5`": [(None, "5")], - "out=$(gh pr merge 5 --squash 2>&1)": [(None, "5")], - } - for command, want in cases.items(): - with self.subTest(command=command): - self.assertEqual(self.targets(command), want) - - def test_a_cd_before_the_merge_moves_its_checkout(self): - self.assertEqual(guard.merge_targets("cd ../other && gh pr merge 6", "/tmp/a"), [(None, "6", "/tmp/a/../other")]) - - def test_text_that_only_mentions_a_merge_names_nothing(self): - for command in ("git merge origin/feat-cmux-next", "gh pr view 3 --json mergeable", - "gh api repos/manaflow-ai/cmux/pulls/3/update-branch -X PUT", "echo gh pr merged", - 'git grep -n "gh pr merge"', 'rg "gh pr merge N --squash" CLAUDE.md', - 'git commit -m "docs: use gh pr merge 22835 --squash"', - "cat > notes.md <<'EOF'\nthen gh pr merge 77\nEOF\necho done", - "echo 'unbalanced"): - with self.subTest(command=command): - self.assertEqual(self.targets(command), []) - - def test_other_repositories_are_not_guarded(self): - reader = lambda *a: self.fail("read another repo") # noqa: E731 - self.assertEqual(guard.refusal("gh pr merge 662 -R teamleaderleo/leo-workspace --squash", None, reader), "") - - def test_a_merge_in_a_checkout_it_cannot_read_is_a_warning_unless_it_names_the_repo(self): - for command in ('ssh host "cd wt && gh pr merge 5"', 'cd "$WT" && gh pr merge 5'): - with self.subTest(command=command): - self.assertEqual(guard.merge_targets(command, "/tmp")[0][2], guard.REMOTE) - why = guard.refusal(command, "/tmp", lambda *a: self.fail("read")) - self.assertIn("can't read", why) - self.assertIsInstance(why, guard.Hold) - self.assertEqual(guard.refusal('ssh host "gh pr merge 5 -R manaflow-ai/cmux"', "/tmp", lambda *a: ""), "") - # Unquoted, ssh joins the words into the remote command. - self.assertEqual(guard.merge_targets("ssh -p 22 cmux16 gh pr merge 5 -R manaflow-ai/cmux", "/tmp"), - [("manaflow-ai/cmux", "5", guard.REMOTE)]) - - def test_a_merge_whose_checkout_cannot_be_read_is_a_warning(self): - # Out of time (or no git): the checkout's repository is unknown, so the merge can't be judged. - spent = guard.STARTED - try: - guard.STARTED = guard.time.monotonic() - guard.BUDGET - why = guard.refusal("gh pr merge 5 --squash", "/tmp", lambda *a: self.fail("read")) - self.assertIn("can't tell which repository", why) - self.assertIsInstance(why, guard.Hold) - finally: - guard.STARTED = spent - - def test_a_merge_it_cannot_name_is_a_warning(self): - why = guard.refusal('gh pr merge "$PR" -R manaflow-ai/cmux', None, lambda *a: self.fail("read")) - self.assertIn("can't name", why) - self.assertIsInstance(why, guard.Hold) - - def test_a_warning_never_hides_a_red_compile_later_in_the_command(self): - red = "#5 changes Swift (A.swift) and its compile finished failure on cccccccccccc." - why = guard.refusal('gh pr merge "$PR" -R manaflow-ai/cmux; gh pr merge 5 -R manaflow-ai/cmux', None, - lambda *a: red) - self.assertEqual(why, f"merge-compile-guard: {red}") - self.assertNotIsInstance(why, guard.Hold) - held = guard.refusal("gh pr merge 5 -R manaflow-ai/cmux", None, lambda *a: guard.Hold("still running")) - self.assertIsInstance(held, guard.Hold) - - -def fake_gh(bin_dir: Path, answers: dict[str, str], log: Path, throttle: float = 0, sleep: float = 0) -> None: - """A `gh` that prints answers[] and logs its argv. throttle: seconds - it sleeps first unless CMUX_GH_GUARD_DISABLE=1, as the PATH guard ~/.local/lib/cmux-local-build-guards/gh does - for a read its caller repeats within 30 s; sleep: seconds it sleeps whatever the environment (a slow GitHub).""" - script = bin_dir / "gh" - script.write_text(textwrap.dedent(f"""\ - #!{sys.executable} - import json, os, sys, time - if os.environ.get("CMUX_GH_GUARD_DISABLE") != "1": - time.sleep({throttle}) - time.sleep({sleep}) - args = sys.argv[1:] - with open({str(log)!r}, "a") as f: - f.write(json.dumps(args) + "\\n") - answers = {json.dumps(answers)} - for arg in args: - for key, value in answers.items(): - if arg.startswith(key): - print(value) - sys.exit(0) - sys.exit(1) - """)) - script.chmod(script.stat().st_mode | stat.S_IEXEC) - - -class Hook(unittest.TestCase): - @staticmethod - def denial(out) -> str | None: - """The hook's refusal, or None when the command goes ahead (with or without a warning).""" - decision = (out or {}).get("hookSpecificOutput") or {} - return decision.get("permissionDecisionReason") if decision.get("permissionDecision") == "deny" else None - - def warning(self, out) -> str: - """What the hook says about a merge it lets through: shown to the person and added to the agent's context.""" - self.assertIsNotNone(out) - self.assertIsNone(self.denial(out), out) - self.assertNotIn("permissionDecision", out["hookSpecificOutput"]) - self.assertEqual(out["systemMessage"], out["hookSpecificOutput"]["additionalContext"]) - self.assertIn("merging anyway", out["systemMessage"]) - return out["systemMessage"] - - def run_hook(self, command: str, compile_state: tuple[str, str | None] | None, statuses=None, fleet_runs=None, - other_statuses=None, on_base="ahead", head=HEAD, cmux_ci_jobs=(), cmux_ci_receipts=None, - gh_throttle=0, gh_sleep=0, env_extra=None): - """statuses: the head's fleet/compile-check statuses; fleet_runs: {run id: fleet_run()}; other_statuses: - statuses on another sha; cmux_ci_jobs, cmux_ci_receipts: what the controller holds (fake_cmux_ci).""" - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - subprocess.run(["git", "init", "-q", str(tmp)], check=True) - subprocess.run(["git", "-C", str(tmp), "remote", "add", "origin", "https://github.com/manaflow-ai/cmux.git"], - check=True) - bin_dir = tmp / "bin" - bin_dir.mkdir() - log = tmp / "gh.log" - runs = [check("cmux-next path routing")] - if compile_state: - runs.append(check(COMPILE, *compile_state)) - answers = { - "repos/manaflow-ai/cmux/pulls/22835/files": "\n".join(json.dumps(f) for f in SWIFT), - "repos/manaflow-ai/cmux/pulls/22835": json.dumps(pr(22835, head=head)), - "view": json.dumps({"number": 22835}), - f"repos/manaflow-ai/cmux/commits/{head}/check-runs?filter=all": "\n".join(json.dumps(r) for r in runs), - } - if statuses is not None: - answers[f"repos/manaflow-ai/cmux/commits/{HEAD}/statuses"] = "\n".join(json.dumps(s) for s in statuses) - if other_statuses is not None: - answers[f"repos/manaflow-ai/cmux/commits/{'d' * 40}/statuses"] = "\n".join( - json.dumps(s) for s in other_statuses) - for run_id, run in (fleet_runs or {}).items(): - answers[f"repos/manaflow-ai/cmux/actions/runs/{run_id}"] = json.dumps(run) - answers["repos/manaflow-ai/cmux/branches/feat-cmux-next"] = json.dumps(TIP) - if on_base: - answers[f"repos/manaflow-ai/cmux/compare/{RUN_SHA}...{TIP}"] = json.dumps(on_base) - fake_gh(bin_dir, answers, log, throttle=gh_throttle, sleep=gh_sleep) - client = fake_cmux_ci(bin_dir, list(cmux_ci_jobs) if cmux_ci_jobs is not None else None, - cmux_ci_receipts or {JOB_23606["id"]: RECEIPTS_23606}, log) - payload = json.dumps({"tool_name": "Bash", "cwd": str(tmp), "tool_input": {"command": command}}) - env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "CMUX_CI_CLIENT": str(client), - **(env_extra or {})} - env.pop("CMUX_GH_GUARD_DISABLE", None) - done = subprocess.run(["bash", str(HOOK)], input=payload, capture_output=True, text=True, env=env, - timeout=60) - self.assertEqual(done.returncode, 0, done.stderr) - calls = log.read_text().splitlines() if log.exists() else [] - return (json.loads(done.stdout) if done.stdout.strip() else None), calls - - def test_a_merge_with_the_compile_pending_goes_ahead_with_a_warning(self): - out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("in_progress", None)) - why = self.warning(out) - self.assertIn("#22835 changes Swift", why) - self.assertIn("still running", why) - - def test_a_merge_after_a_red_compile_is_refused(self): - out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "failure")) - self.assertIn("#22835 changes Swift", self.denial(out)) - self.assertIn("finished failure", self.denial(out)) - - def test_a_merge_after_a_green_compile_goes_ahead(self): - out, calls = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "success")) - self.assertIsNone(out) - self.assertTrue(any("--paginate" in json.loads(c) for c in calls)) - - def test_a_bare_merge_checks_the_checkouts_branch(self): - """gh refuses `pr view -R` without a selector, so the branch is looked up in the checkout.""" - out, calls = self.run_hook("gh pr merge --squash 2>&1", ("in_progress", None)) - self.assertIn("#22835 changes Swift", self.warning(out)) - view = [json.loads(c) for c in calls if json.loads(c)[:2] == ["pr", "view"]] - self.assertEqual(view, [["pr", "view", "--json", "number"]]) - out, _ = self.run_hook("gh pr merge --squash", ("completed", "success")) - self.assertIsNone(out) - - def test_the_gh_guards_repeat_throttle_does_not_slow_the_guard(self): - """2026-10-21: the hook shares the session's CLAUDE_SESSION_ID, so after a lane's `gh pr view N` the PATH guard - held the hook's identical read up to 30 s and #23611's merges were refused as unreadable. The guard reads once - per merge and never polls: its reads skip the throttle.""" - started = time.monotonic() - out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "success"), gh_throttle=30) - self.assertIsNone(out) - self.assertLess(time.monotonic() - started, 10) - - def test_a_slow_github_lets_the_merge_through_and_says_it_timed_out(self): - """The guard's own read timing out is no evidence against the PR (#23660 and #23371, 2026-10-21).""" - out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "success"), gh_sleep=5, - env_extra={"CMUX_MERGE_GUARD_GH_TIMEOUT": "1"}) - why = self.warning(out) - self.assertIn("could not read #22835 from GitHub", why) - self.assertIn("did not answer within 1 s", why) - self.assertNotIn("rate limit", why) - - def test_an_unreadable_github_lets_the_merge_through(self): - out, _ = self.run_hook("gh pr merge 1 -R manaflow-ai/cmux --squash", None) - self.assertIn("could not read #1 from GitHub", self.warning(out)) - - MERGE = "gh pr merge 22835 -R manaflow-ai/cmux --squash" - - def test_a_fleet_pass_on_the_exact_head_lets_a_queued_compile_merge(self): - out, calls = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], fleet_runs={99: fleet_run()}) - self.assertIsNone(out) - self.assertTrue(any(f"commits/{HEAD}/statuses" in " ".join(json.loads(c)) for c in calls)) - - def test_a_fleet_pass_on_a_different_sha_does_not_count(self): - # The pass was posted on another commit (an older head): the head has no status. - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[], other_statuses=[status()], - fleet_runs={99: fleet_run(sha="d" * 40)}) - self.assertIn("still running", self.warning(out)) - # A status on the head that links a run which compiled another sha. - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], - fleet_runs={99: fleet_run(sha="d" * 40)}) - self.assertIn("still running", self.warning(out)) - # A run that names only a prefix of the head. - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], - fleet_runs={99: fleet_run(sha=HEAD[:12])}) - self.assertIn("still running", self.warning(out)) - - def test_a_self_posted_or_unverified_fleet_status_does_not_count(self): - cases = { - "posted by a person or agent token": ([status(creator="teamleaderleo")], {99: fleet_run()}), - "not success": ([status(state="failure")], {99: fleet_run()}), - "run from another branch": ([status()], {99: fleet_run(branch="my-branch")}), - "another workflow": ([status()], {99: fleet_run(path=".github/workflows/mine.yml")}), - "not a dispatch": ([status()], {99: fleet_run(event="push")}), - # A tag named feat-cmux-next holding a doctored copy of the workflow has head_branch feat-cmux-next. - "workflow from a commit not on the branch": ([status()], {99: fleet_run(head_sha="a" * 40)}), - "run failed": ([status()], {99: fleet_run(conclusion="failure")}), - "run unreadable": ([status(run=98)], {99: fleet_run()}), - "link to another repo's run": ([{**status(), "url": "https://github.com/x/cmux/actions/runs/99"}], - {99: fleet_run()}), - } - for name, (statuses, fleet_runs) in cases.items(): - with self.subTest(name): - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=statuses, fleet_runs=fleet_runs) - self.assertIn("no compile result yet", self.warning(out), name) - - def test_a_run_whose_workflow_commit_left_the_branch_does_not_count(self): - for on_base in ("diverged", "behind", None): - with self.subTest(on_base=on_base): - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], - fleet_runs={99: fleet_run()}, on_base=on_base) - self.assertIn("no compile result yet", self.warning(out)) - - def test_every_attempt_of_the_compile_is_read(self): - out, calls = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], fleet_runs={99: fleet_run()}) - self.assertTrue(any("check-runs?filter=all&" in " ".join(json.loads(c)) for c in calls)) - - def test_a_fleet_pass_does_not_let_a_red_release_compile_merge(self): - out, calls = self.run_hook(self.MERGE, ("completed", "failure"), statuses=[status()], - fleet_runs={99: fleet_run()}) - self.assertIn("finished failure", self.denial(out)) - self.assertFalse(any("statuses" in " ".join(json.loads(c)) for c in calls)) - - def test_a_newer_unverified_status_does_not_hide_a_verified_pass(self): - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[status(state="pending", run=98), status()], - fleet_runs={99: fleet_run()}) - self.assertIsNone(out) - - def test_the_23606_cmux_ci_pass_lets_its_queued_compile_merge_without_a_dispatch(self): - # No fleet/compile-check status: the lane ran cmux-ci itself and never dispatched the workflow. - out, calls = self.run_hook(self.MERGE, ("queued", None), statuses=[], head=HEAD_23606, - cmux_ci_jobs=[job()]) - self.assertIsNone(out) - self.assertIn(["cmux-ci", "status"], [json.loads(c) for c in calls]) - self.assertFalse(any("statuses" in " ".join(json.loads(c)) for c in calls)) # no GitHub read for it - - def test_a_cmux_ci_pass_on_another_head_refuses_and_says_what_to_run(self): - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[], cmux_ci_jobs=[job()]) - why = self.warning(out) - self.assertIn("still running", why) - self.assertIn(f"cmux-ci compile-check --ref {HEAD} --app", why) - - def test_a_cmux_ci_pass_does_not_let_a_red_release_compile_merge(self): - out, calls = self.run_hook(self.MERGE, ("completed", "failure"), head=HEAD_23606, cmux_ci_jobs=[job()]) - self.assertIn("finished failure", self.denial(out)) - self.assertNotIn(["cmux-ci", "status"], [json.loads(c) for c in calls]) - - def test_an_unreachable_controller_falls_back_to_the_fleet_status(self): - out, _ = self.run_hook(self.MERGE, ("queued", None), statuses=[status()], fleet_runs={99: fleet_run()}, - cmux_ci_jobs=None) - self.assertIsNone(out) - - def test_a_green_compile_reads_no_fleet_status(self): - out, calls = self.run_hook(self.MERGE, ("completed", "success"), statuses=[], cmux_ci_jobs=[job()]) - self.assertIsNone(out) - self.assertFalse(any("statuses" in " ".join(json.loads(c)) for c in calls)) - self.assertNotIn(["cmux-ci", "status"], [json.loads(c) for c in calls]) - - def test_other_commands_never_start_python_or_gh(self): - out, calls = self.run_hook("git status && gh pr view 22835", None) - self.assertIsNone(out) - self.assertEqual(calls, []) - - -if __name__ == "__main__": - unittest.main()