Skip to content

ci: make reviewer merge-holds stick against auto-merge re-arming - #4454

Open
dieterolson wants to merge 2 commits into
mainfrom
ci/merge-hold-guard
Open

ci: make reviewer merge-holds stick against auto-merge re-arming#4454
dieterolson wants to merge 2 commits into
mainfrom
ci/merge-hold-guard

Conversation

@dieterolson

Copy link
Copy Markdown
Collaborator

Problem

Fleet automation re-arms GitHub auto-merge on pull requests that a reviewer has deliberately disarmed, which defeats the only lightweight mechanism available for holding a dangerous PR back.

Measured in Gasification_Model on 2026-08-14 (all events attributed to dieterolson, type=User, i.e. the owner's credentials rather than a bot):

PR disarmed re-armed gap
#4692 04:15:08Z 04:15:16Z 8s
#4710 04:15:39Z 04:15:45Z 6s
#4711 04:15:50Z 04:15:56Z 6s
#4709 03:38:35Z 04:02:09Z 24m — closed outright to make the block stick

PR #4709 is why this matters rather than merely annoying: it had auto-merge armed on a diff that deleted 13 files present on main and grew SPEC.md from 5,084 to 60,863 lines.

The re-armer is not a workflow in this repository. No workflow in Gasification_Model, Tools, Tools_Private, Drake_Models, MuJoCo_Models, Controls or Maxwell_Daemon calls gh pr merge --auto or enablePullRequestAutoMerge. The arming comes from agent sessions running under the owner's gh credentials, driven by the fleet-pr-queue automation in Repository_Management/config/codex_fleet_automations.json, whose prompt instructs them to "enable auto-merge for the highest-confidence item". Because those sessions live outside any single repo, a repo-side guard is the only enforcement point that cannot be bypassed by editing an agent prompt.

What this adds

.github/workflows/Merge-Hold-Guard.yml. It only ever removes auto-merge — it never merges, never arms, never pushes.

Hold signals (any one is sufficient):

  1. the do-not-merge or blocked label
  2. the PR is a draft
  3. auto-merge was disabled by a non-bot account more recently than the head commit — a reviewer said no and nobody has pushed since
  4. the diff deletes tracked files with no acknowledgement (deletions-acknowledged label, or a Deletions-Acknowledged: yes line in the PR body)

Signal 3 deliberately ignores bot actors so the guard's own revocations can never manufacture a hold, and a genuine push clears the hold naturally.

Triggers: pull_request_target including the auto_merge_enabled activity type, so a held PR is disarmed seconds after any re-arm; plus a 20,50 * * * * sweep over armed PRs as a backstop, offset off the hour so it does not collide with the fleet pass.

Escalation. A plain revoke cannot win a 6-second re-arm race on its own. After 2 revocations against the same head commit, the guard converts the PR to a draft. This was verified empirically against Tools#4453:

$ gh pr merge 4453 --repo D-sorganization/Tools --squash --auto
GraphQL: Pull Request is still a draft (mergePullRequest)

GitHub refuses to arm auto-merge on a draft, so the hold becomes enforceable at the API level rather than advisory. gh pr ready <n> reverses it deliberately.

The guard also applies do-not-merge to deletion PRs, giving label-aware automation a signal it can honour before ever attempting to arm.

Labels

do-not-merge and deletions-acknowledged did not exist in this repo (gh pr edit --add-label do-not-merge failed with 'do-not-merge' not found). Both have been created, and the workflow re-creates them if missing so it stays drop-in for new repos.

Verification

The detection logic was dry-run against real PRs before this workflow was written. On #4709 it reports:

VERDICT: HELD -> a reviewer disabled auto-merge at 2026-08-14T04:14:21Z,
                 after the head commit (2026-08-13T20:35:02Z);
                 diff deletes 13 tracked file(s) with no acknowledgement

and on #4692/#4710/#4711 it correctly identifies all three as held with auto-merge currently armed. No false positives were found: every open PR in this repo is armed, and the only ones flagged are those the reviewer had disarmed.

YAML parses, all three run: blocks pass bash -n, and the file satisfies this repo's own lint-workflow-files.yml rules (concurrency, cancel-in-progress: true, per-job timeout-minutes, no hosted runners, no protected-branch push).

Companion change

Repository_Management needs the arming side fixed too, so agents stop attempting the arm in the first place rather than relying on the guard to undo it.

Fleet automation re-arms auto-merge on PRs a reviewer disarmed (measured at
6-8s in Gasification_Model), so a manual disarm cannot hold a PR back. Adds a
guard that revokes auto-merge on held PRs, refuses PRs deleting tracked files
without acknowledgement, and converts to draft after repeated re-arms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 374531e051

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/Merge-Hold-Guard.yml Outdated
Comment on lines +243 to +244
elif printf '%s' "$BODY" | grep -qiE '^[[:space:]]*Deletions-Acknowledged:[[:space:]]*(yes|true)'; then
echo "PR #$PR deletes $REMOVED_COUNT file(s) — acknowledged in the PR body."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require reviewer-controlled deletion acknowledgment

When a deletion PR is opened with Deletions-Acknowledged: yes already in its author-editable description, this branch suppresses the deletion hold and the guard never applies do-not-merge. An untrusted PR author can therefore opt their own destructive change out of the safeguard before the first run; require reviewer-controlled state such as the acknowledgment label or an approval from an authorized actor instead.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/Merge-Hold-Guard.yml Outdated
Comment on lines +232 to +234
gh api "repos/$REPO/pulls/$PR/files?per_page=100" --paginate \
--jq '.[] | select(.status == "removed") | .filename' \
> "$REMOVED_FILE" 2>/dev/null || : > "$REMOVED_FILE"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when deletion enumeration fails

When the pull-files API returns a transient error, permission error, or rate-limit response, || : > "$REMOVED_FILE" converts that failure into an empty successful result. An armed deletion PR with no other hold signal is then reported as safe and left armed, potentially merging before the next 30-minute sweep; treat this API failure as an enforcement failure rather than as zero deleted files.

Useful? React with 👍 / 👎.

Comment thread .github/workflows/Merge-Hold-Guard.yml Outdated
Comment on lines +214 to +218
LAST_DISARM="$(grep -v '^Bot ' "$TIMELINE" | cut -f2 | sort | tail -1)"
HEAD_DATE="$(gh api "repos/$REPO/commits/$HEAD_SHA" \
--jq '.commit.committer.date' 2>/dev/null)" || HEAD_DATE=""
if [ -n "$LAST_DISARM" ] && [ -n "$HEAD_DATE" ] && [[ "$LAST_DISARM" > "$HEAD_DATE" ]]; then
add_reason "a reviewer disabled auto-merge at $LAST_DISARM, after the head commit ($HEAD_DATE) — no push has superseded that decision"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use push ordering instead of the commit timestamp

When a contributor pushes or force-pushes an existing commit whose committer date predates the reviewer's disarm, the head SHA changes but this cutoff remains older than the disarm. The workflow therefore concludes that no push superseded the decision and can also count revocations from before the new head, so the documented clearing behavior does not work and the refreshed PR may be converted to draft; track the synchronize/push ordering or associate the disarm with the observed head SHA instead.

Useful? React with 👍 / 👎.

@dieterolson
dieterolson enabled auto-merge (squash) August 14, 2026 08:01
`do-not-automate` is already the fleet-wide convention for "this work must not
be automated" (shared_scripts/agent_identity.DO_NOT_AUTOMATE_LABEL). Honouring
it here keeps one vocabulary instead of a parallel one, and collapses the
per-label checks into a single list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant