Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/ci/merge-freeze.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ freeze.
GitHub couldn't be read, which counts as frozen.
- `.github/workflows/merge-freeze.yml` shows a `merge freeze` check on each pull request into
`main` and `feat-cmux-next`. Every hour it also closes lapsed freeze issues with a comment.
- At merge time, gh-merge-green should run the same check. Until it does, it still reads the old
`WINDOW` file.
- At merge time, `scripts/gh-merge-green` runs the same check on every base branch and refuses
on exit 1 (frozen) or any other failure (freeze state unknown). `--override` doesn't lift a
freeze. The old `--freeze-token` and `--window-token` flags print a warning and are ignored.

`python3 scripts/ci/merge_freeze.py list --repo manaflow-ai/cmux` prints every open freeze and
whether it's active, lapsed or malformed.
Expand Down
211 changes: 35 additions & 176 deletions scripts/gh-merge-green
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash
# gh-merge-green REPO#PR [--main-fix] [--override "reason"] [--trust-fork-head SHA] [--freeze-token TOKEN] [--window-token TOKEN]... [gh pr merge flags...]
# gh-merge-green REPO#PR [--main-fix] [--override "reason"] [--trust-fork-head SHA] [gh pr merge flags...]
# gh-merge-green --revert REPO#PR: open a revert PR for a merged PR and land it (scripts/ci/revert_pr.py).
# Merges only when the PR head's required-in-practice checks are green.
# main has no required status checks, so `gh pr merge --auto` merges at once; agents use this instead.
Expand Down Expand Up @@ -111,20 +111,20 @@ fi
# path posts the required audit comment before merging a red-main fix-forward.
fix=0
override_reason=''
freeze_tokens=()
trust_fork_head=''
window_tokens=()
args=()
while [ "$#" -gt 0 ]; do
case "$1" in
--main-fix)
fix=1
;;
--freeze-token)
--freeze-token|--window-token)
# Retired with the WINDOW file on cmux-lawrence: freezes are merge-freeze
# issues now (docs/ci/merge-freeze.md). Old callers still parse.
[ "$#" -ge 2 ] || refuse \
'not green: --freeze-token requires the token from the FREEZE line' \
'pass --freeze-token followed by the token=... value you hold'
freeze_tokens+=("$2")
"not green: $1 requires a value" \
"drop $1; freezes are merge-freeze issues now (docs/ci/merge-freeze.md)"
echo "warning: $1 is retired and ignored; a freeze's owner lets a PR through with the freeze-ok label (docs/ci/merge-freeze.md)" >&2
shift
;;
--trust-fork-head)
Expand All @@ -137,13 +137,6 @@ while [ "$#" -gt 0 ]; do
trust_fork_head="$2"
shift
;;
--window-token)
[ "$#" -ge 2 ] || refuse \
'not green: --window-token requires the token of the WINDOW area you hold' \
'pass --window-token followed by the token: value of your [AREA] in the WINDOW'
window_tokens+=("$2")
shift
;;
--override)
[ "$#" -ge 2 ] || refuse \
'not green: --override requires a reason' \
Expand Down Expand Up @@ -760,121 +753,38 @@ if grep -qE '^\+(<<<<<<<|>>>>>>>|=======$)' <<<"$patches"; then
"not green: conflict markers are present in $ref" \
'resolve the conflict markers, push the repaired head, and rerun the checks'
fi
# A feat-cmux-next merge stops at a frozen path. The coordinator's WINDOW
# (cmux-lawrence:~/agent-mailbox/WINDOW) names each freeze as
# `FREEZE: <path prefix>... token=<token>`; only the token holder
# (--freeze-token) merges into it, and --override never lifts it. A WINDOW
# that cannot be read refuses: the coordinator merges while freezes are unknown.
window=''
if [ "$base" = feat-cmux-next ] || [ "${#freeze_tokens[@]}" -gt 0 ] || [ "${#window_tokens[@]}" -gt 0 ]; then
if [ -n "${GH_MERGE_GREEN_WINDOW_FILE:-}" ]; then
window=$(cat -- "$GH_MERGE_GREEN_WINDOW_FILE" 2>/dev/null) || window=''
elif [ -n "${GH_MERGE_GREEN_TEST:-}" ]; then
window=''
else
window=$(timeout 30 ssh -o BatchMode=yes -o ConnectTimeout=10 cmux-lawrence 'cat ~/agent-mailbox/WINDOW' 2>/dev/null) || window=''
fi
# A merge stops at a frozen path: an open merge-freeze issue names the paths,
# an owner and an expiry, and only that owner's freeze-ok label on the PR lets
# it through (docs/ci/merge-freeze.md). --override never lifts a freeze, and a
# freeze state that cannot be read refuses. merge_freeze.py lists the changed
# files itself, with both ends of a rename, and refuses a list GitHub cut short.
if ! changed=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].filename' 2>/dev/null); then
if ! changed=$(graphql_changed_files); then
refuse "not merged: cannot list changed files for $ref" 'restore GitHub API access or GraphQL access, then retry'
fi
echo "note: used GraphQL fallback to inspect changed files for $ref" >&2
fi
if [ "$base" = feat-cmux-next ]; then
if [ -z "$window" ]; then
freeze_args=(check --repo "$repo" --pr "$num" --base "$base")
# Tests hand the checker recorded issues and events. GH_MERGE_GREEN_TEST left set
# in a real shell without both fixtures refuses, rather than reading as no freezes.
if [ -n "${GH_MERGE_GREEN_TEST:-}" ]; then
if [ -z "${GH_MERGE_GREEN_FREEZE_ISSUES:-}" ] || [ -z "${GH_MERGE_GREEN_FREEZE_EVENTS:-}" ]; then
refuse \
"not merged: cannot read the coordinator's WINDOW, so active freezes on $ref are unknown" \
'check with the coordinator; they merge this PR while the WINDOW is unreadable'
fi
if ! changed=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].filename' 2>/dev/null); then
if ! changed=$(graphql_changed_files); then
refuse "not merged: cannot list changed files for $ref to check freezes" 'restore GitHub API access or GraphQL access, then retry'
fi
echo "note: used GraphQL fallback to inspect changed files for $ref" >&2
fi
freeze_token_csv=''
if [ "${#freeze_tokens[@]}" -gt 0 ]; then freeze_token_csv=$(IFS=,; printf '%s' "${freeze_tokens[*]}"); fi
if ! frozen=$(WINDOW="$window" FREEZE_TOKEN="$freeze_token_csv" python3 -c '
import os, sys
tokens = {value for value in os.environ["FREEZE_TOKEN"].split(",") if value}
freezes = []
for line in os.environ["WINDOW"].splitlines():
if not line.startswith("FREEZE:"): continue
words = line[len("FREEZE:"):].split()
held = {value for word in words if word.startswith("token=") for value in word.split("=", 1)[1].split(",")}
if held & tokens: continue
freezes += [word for word in words if "=" not in word]
for path in sys.stdin.read().split():
for prefix in freezes:
if path == prefix or path.startswith(prefix if prefix.endswith("/") else prefix + "/"):
print(f"{path} (FREEZE: {prefix})")
' <<<"$changed"); then
refuse \
"not merged: cannot check $ref against the WINDOW freezes" \
'check with the coordinator before merging'
fi
if [ -n "$frozen" ]; then
frozen_summary=$(printf '%s\n' "$frozen" | tr '\n' ';' | sed 's/;$//')
refuse \
"not merged: $ref touches frozen paths: ${frozen_summary//;/; }" \
'wait for the freeze to lift, or send the PR to the coordinator; only the token holder merges with --freeze-token'
"not merged: freeze state unknown for $ref (GH_MERGE_GREEN_TEST is set without GH_MERGE_GREEN_FREEZE_ISSUES and GH_MERGE_GREEN_FREEZE_EVENTS)" \
'unset GH_MERGE_GREEN_TEST for a real merge; tests set both freeze fixtures'
fi
freeze_args+=(--files /dev/stdin --issues-file "$GH_MERGE_GREEN_FREEZE_ISSUES" --events-file "$GH_MERGE_GREEN_FREEZE_EVENTS")
fi
# A merge under a WINDOW token releases it: each token must be current (an
# [AREA] section's `token:` or a hold line's `token=`), and right after the
# merge a RELEASE <AREA> mail with the landed SHA goes to
# inbox/lawrence-coordinator (Lawrence's coordinator missed a manual one).
releases=''
# The real mailbox is reachable only from a real run: under test
# (GH_MERGE_GREEN_TEST) or with a local WINDOW file, the RELEASE mail needs
# GH_MERGE_GREEN_MAILBOX_DIR, so a test can never mail a fake RELEASE that
# frees a real token.
mailbox_is_local() {
[ -n "${GH_MERGE_GREEN_MAILBOX_DIR:-}" ] && return 0
[ -z "${GH_MERGE_GREEN_TEST:-}" ] && [ -z "${GH_MERGE_GREEN_WINDOW_FILE:-}" ] && return 0
return 1
}
if [ "${#freeze_tokens[@]}" -gt 0 ] || [ "${#window_tokens[@]}" -gt 0 ]; then
if ! mailbox_is_local; then
refuse \
"not merged: a test or local-WINDOW run must set GH_MERGE_GREEN_MAILBOX_DIR before a token merge of $ref" \
'set GH_MERGE_GREEN_MAILBOX_DIR to a temp mailbox; only a real run mails the coordinator'
fi
if [ -z "$window" ]; then
refuse \
"not merged: cannot read the coordinator's WINDOW to check the tokens for $ref" \
'check with the coordinator; they merge this PR while the WINDOW is unreadable'
fi
token_args=()
if [ "${#freeze_tokens[@]}" -gt 0 ]; then token_args+=("${freeze_tokens[@]}"); fi
if [ "${#window_tokens[@]}" -gt 0 ]; then token_args+=("${window_tokens[@]}"); fi
if ! releases=$(WINDOW="$window" python3 -c '
import re, os, sys
areas, holds, frozen = {}, {}, set()
area = None
for line in os.environ["WINDOW"].splitlines():
header = re.match(r"\[([A-Za-z0-9_-]+)\]", line)
if header: area = header.group(1); continue
held = re.match(r"token:\s*(\S+)", line)
if area and held and held.group(1) != "none": areas[held.group(1)] = area
if not line.strip(): area = None
for group in re.findall(r"token=(\S+)", line):
for token in group.split(","):
if line.startswith("FREEZE:"): frozen.add(token)
else: holds.setdefault(token, line.split()[0])
missing = []
seen = set()
for token in sys.argv[1:]:
if token in seen: continue
seen.add(token)
name = areas.get(token) or holds.get(token) or ("FREEZE" if token in frozen else None)
if name is None: missing.append(token)
else: print(f"{name}\t{token}")
if missing:
print(" ".join(missing), file=sys.stderr); sys.exit(3)
' "${token_args[@]}" 2>"${TMPDIR:-/tmp}/gh-merge-green-tokens.$$" ); then
stale=$(cat "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$")
rm -f "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$"
refuse \
"not merged: ${stale:-a token} is not a current WINDOW token" \
'merge only while you hold the token; ask the coordinator for the current one'
fi
rm -f "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$"
freeze_status=0
frozen=$(python3 "$script_dir/ci/merge_freeze.py" "${freeze_args[@]}" <<<"$changed") || freeze_status=$?
if [ "$freeze_status" -eq 1 ]; then
refuse \
"not merged: $ref touches frozen paths: $(printf '%s\n' "$frozen" | paste -sd ';' - | sed 's/;/; /g')" \
"wait for the freeze to lift, or ask its owner to add the freeze-ok label to $ref"
elif [ "$freeze_status" -ne 0 ]; then
refuse \
"not merged: freeze state unknown for $ref (merge_freeze.py check exited $freeze_status)" \
"restore GitHub API access, then retry; python3 scripts/ci/merge_freeze.py list --repo $repo shows the open freezes"
fi
# The merged head passes the checks a red lint job can hide (#18326 grew a god
# type while an unrelated lint red masked the god-file step): the Swift god-file
Expand Down Expand Up @@ -1099,54 +1009,3 @@ if ! gh pr merge "$num" --repo "$repo" --match-head-commit "$sha" "$@" 2>"$merge
fi
fi
rm -f "$merge_error_file"
[ -n "$releases" ] || exit 0
landed=''
read_landed_sha() {
local error_file view raw
error_file=$(mktemp "${TMPDIR:-/tmp}/gh-merge-green-graphql.XXXXXX")
if view=$(gh pr view "$num" --repo "$repo" --json state,mergeCommit 2>"$error_file"); then
rm -f "$error_file"
jq -r 'select(.state == "MERGED") | .mergeCommit.oid // empty' <<<"$view"
return 0
fi
if graphql_quota_error "$error_file"; then
if raw=$(gh api "repos/$repo/pulls/$num" 2>"$error_file"); then
rm -f "$error_file"
jq -r 'select((.state // "") | ascii_upcase == "MERGED") | .merge_commit.sha // empty' <<<"$raw"
return 0
fi
fi
rm -f "$error_file"
return 1
}
for _ in 1 2 3 4 5 6 7 8 9 10; do
landed=$(read_landed_sha 2>/dev/null || true)
[ -n "$landed" ] && break
sleep 3
done
sender="${GH_MERGE_GREEN_SENDER:-}"
[ -n "$sender" ] || { [ -n "${TMUX_PANE:-}" ] && sender=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null); }
sender="${sender:-$(hostname -s)}"
mail_failed=0
while IFS=$'\t' read -r area token; do
subject="RELEASE $area: $ref landed ${landed:-<merge commit not reported yet>}"
subject="${subject/$repo#/#}"
body=$(printf 'from: %s/gh-merge-green\nto: lawrence-coordinator\nre: none\npriority: normal\nsubject: %s\n\nRELEASE %s. #%s landed as %s under token %s (sent by gh-merge-green right after the merge).\n' \
"$sender" "$subject" "$area" "$num" "${landed:-an unreported merge commit}" "$token")
name="$(date -u +%Y%m%dT%H%M%SZ)-$sender-release-$(tr '[:upper:]' '[:lower:]' <<<"$area")-$num.md"
if [ -n "${GH_MERGE_GREEN_MAILBOX_DIR:-}" ]; then
dir="$GH_MERGE_GREEN_MAILBOX_DIR/inbox/lawrence-coordinator"
printf '%s\n' "$body" > "$dir/$name.tmp" && mv "$dir/$name.tmp" "$dir/$name" || mail_failed=1
elif ! mailbox_is_local; then
mail_failed=1
else
printf '%s\n' "$body" | timeout 30 ssh -o BatchMode=yes -o ConnectTimeout=10 cmux-lawrence \
"f=~/agent-mailbox/inbox/lawrence-coordinator/$name; cat > \"\$f.tmp\" && mv \"\$f.tmp\" \"\$f\"" || mail_failed=1
fi
if [ "$mail_failed" -eq 1 ]; then
refusal_reported=1
printf 'merged %s, but the RELEASE %s mail failed. Send it by hand to inbox/lawrence-coordinator:\n%s\n' "$ref" "$area" "$body" >&2
exit 1
fi
printf 'mailed RELEASE %s for %s (%s) to inbox/lawrence-coordinator\n' "$area" "$ref" "${landed:-merge commit pending}" >&2
done <<<"$releases"
Loading