Skip to content

Commit 608c710

Browse files
committed
ci(arm-auto-merge): dispatch-triggered Model B wire, no PAT (MCP-1249)
Task B follow-up to MCP-1248. Adds .github/workflows/arm-auto-merge.yml: repository_dispatch (event_type: arm-auto-merge) + workflow_dispatch trigger that runs inside Actions under the built-in GITHUB_TOKEN (github-actions[bot]), re-checks PR head SHA (spec-075 drift guard) + qa-gate=success, posts the approving review (reflecting the Paperclip ACCEPT verdict, counts toward required_approving_review_count) and arms 'gh pr merge --auto --squash'. Closes the 'GitHub sees 0 approvals' gap for code PRs with NO new secret and no --admin. scripts/arm-auto-merge.sh retained as the manual/PAT fallback. Cockpit Gate-3 Approve -> dispatch wiring lives in the Paperclip control-plane (documented in docs/qa-merge-gate.md), out of this repo's lane. actionlint clean.
1 parent 40ed75c commit 608c710

2 files changed

Lines changed: 158 additions & 9 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Arm auto-merge for a code PR — Model B "missing wire" (MCP-1249, follow-up to
2+
# MCP-1248). This is the no-human-credential half of `scripts/arm-auto-merge.sh`.
3+
#
4+
# WHY THIS EXISTS
5+
# `scripts/arm-auto-merge.sh` (Option A) needs a repo-scoped bot PAT / GitHub
6+
# App token minted by a GitHub human/org identity and stored with the agent
7+
# secrets. This workflow (Option B) needs NO new secret: the cockpit fires a
8+
# `repository_dispatch` on Gate-3 Approve using the gh login it already has,
9+
# and the approve+arm happens *inside Actions* under the built-in
10+
# `GITHUB_TOKEN` (the `github-actions[bot]` identity). A `github-actions[bot]`
11+
# approval counts toward `required_approving_review_count` (same identity
12+
# qa-gate-trivial / dependabot-auto-merge already rely on), so this closes the
13+
# "GitHub sees 0 approvals" gap with no PAT and no `gh pr merge --admin`.
14+
#
15+
# INVARIANTS (identical to scripts/arm-auto-merge.sh — keep them in sync)
16+
# - Agents ARM auto-merge; they NEVER bypass a required check. No `--admin`,
17+
# no `bypass_pull_request_allowances`.
18+
# - spec-075: a PASS is valid only while PR head == the blessed SHA. We
19+
# re-verify the live PR head still equals `head_sha` before approving, and
20+
# refuse on drift.
21+
# - `qa-gate` must be `success` at that exact SHA before we approve.
22+
#
23+
# DISPATCH CONTRACT (how the cockpit Gate-3 Approve fires this)
24+
# repository_dispatch:
25+
# event_type: arm-auto-merge
26+
# client_payload: { "pr": <number>, "head_sha": "<40-hex>", "body": "<optional>" }
27+
# e.g. from the cockpit's existing gh login (no new credential):
28+
# gh api repos/${REPO}/dispatches -f event_type=arm-auto-merge \
29+
# -F 'client_payload[pr]=607' -F 'client_payload[head_sha]=fe9fb8e...'
30+
# `workflow_dispatch` below is the manual equivalent for owner/debug use.
31+
#
32+
# See docs/qa-merge-gate.md ("Merging without --admin").
33+
name: Arm auto-merge (Model B)
34+
35+
on:
36+
repository_dispatch:
37+
types: [arm-auto-merge]
38+
workflow_dispatch:
39+
inputs:
40+
pr:
41+
description: PR number to arm
42+
required: true
43+
head_sha:
44+
description: Expected PR head SHA (spec-075 drift guard)
45+
required: true
46+
body:
47+
description: Approval body (optional)
48+
required: false
49+
50+
# contents: write + pull-requests: write are needed to post the approving review
51+
# and arm `gh pr merge --auto`. statuses is read implicitly (commit status read
52+
# needs no extra scope). No `--admin` path is ever taken.
53+
permissions:
54+
contents: write
55+
pull-requests: write
56+
57+
jobs:
58+
arm:
59+
name: arm-auto-merge
60+
runs-on: ubuntu-latest
61+
timeout-minutes: 10
62+
steps:
63+
- name: Resolve inputs (repository_dispatch | workflow_dispatch)
64+
id: in
65+
env:
66+
# repository_dispatch carries client_payload.*; workflow_dispatch
67+
# carries inputs.*. Exactly one set is populated per run.
68+
RD_PR: ${{ github.event.client_payload.pr }}
69+
RD_SHA: ${{ github.event.client_payload.head_sha }}
70+
RD_BODY: ${{ github.event.client_payload.body }}
71+
WD_PR: ${{ github.event.inputs.pr }}
72+
WD_SHA: ${{ github.event.inputs.head_sha }}
73+
WD_BODY: ${{ github.event.inputs.body }}
74+
run: |
75+
PR="${RD_PR:-$WD_PR}"
76+
SHA="${RD_SHA:-$WD_SHA}"
77+
BODY="${RD_BODY:-$WD_BODY}"
78+
if [[ -z "$PR" || -z "$SHA" ]]; then
79+
echo "::error::missing pr and/or head_sha (got pr='$PR' head_sha='$SHA')"
80+
exit 2
81+
fi
82+
if [[ ! "$SHA" =~ ^[0-9a-f]{7,40}$ ]]; then
83+
echo "::error::head_sha is not a hex SHA: '$SHA'"
84+
exit 2
85+
fi
86+
{
87+
echo "pr=$PR"
88+
echo "sha=$SHA"
89+
echo "body=${BODY:-Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.}"
90+
} >> "$GITHUB_OUTPUT"
91+
92+
- name: Verify head SHA + qa-gate, then approve & arm
93+
env:
94+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95+
REPO: ${{ github.repository }}
96+
PR: ${{ steps.in.outputs.pr }}
97+
EXPECTED_SHA: ${{ steps.in.outputs.sha }}
98+
BODY: ${{ steps.in.outputs.body }}
99+
run: |
100+
set -euo pipefail
101+
102+
# 1. spec-075: live PR head must still equal the blessed SHA.
103+
LIVE_SHA="$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)"
104+
# Allow a short (>=7) prefix from the dispatcher to match the full OID.
105+
if [[ "$LIVE_SHA" != "$EXPECTED_SHA" && "${LIVE_SHA#"$EXPECTED_SHA"}" == "$LIVE_SHA" ]]; then
106+
echo "::error::head SHA drifted: blessed=$EXPECTED_SHA live=$LIVE_SHA — re-run QA/review on the new head; refusing to approve."
107+
exit 3
108+
fi
109+
110+
# 2. qa-gate must be success at this exact SHA.
111+
QA_STATE="$(gh api "repos/${REPO}/commits/${LIVE_SHA}/status" \
112+
-q '.statuses[] | select(.context=="qa-gate") | .state' 2>/dev/null | head -1 || true)"
113+
if [[ "$QA_STATE" != "success" ]]; then
114+
echo "::error::qa-gate is '${QA_STATE:-missing}' at ${LIVE_SHA} (need success) — refusing to approve."
115+
exit 4
116+
fi
117+
118+
# 3. Never approve a PR authored by our own bot identity (GitHub would
119+
# reject it anyway; fail loudly so a misroute is visible).
120+
AUTHOR="$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login)"
121+
if [[ "$AUTHOR" == "github-actions[bot]" ]]; then
122+
echo "::error::PR #$PR is authored by github-actions[bot]; cannot self-approve."
123+
exit 5
124+
fi
125+
126+
# 4. Post the approving review (reflects the Paperclip ACCEPT verdict;
127+
# counts toward required_approving_review_count) and arm auto-merge.
128+
gh pr review --approve "$PR" --repo "$REPO" --body "$BODY"
129+
gh pr merge --auto --squash "$PR" --repo "$REPO"
130+
echo "armed auto-merge for PR #$PR at $LIVE_SHA (merges when all required checks are green)"

docs/qa-merge-gate.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,34 @@ The moving parts (all without `--admin`):
101101
|---|---|---|
102102
| Trivial / docs / CI-metadata PRs | Auto-post `qa-gate=success` when the diff touches **no** code-bearing path (`**/*.go`, `go.mod/sum`, `cmd/**`, `internal/**`, `frontend/src/**`, `native/**`); code PRs are left to the real QATester. | `.github/workflows/qa-gate-trivial.yml` |
103103
| Dependabot patch + minor | `dependabot/fetch-metadata``github-actions[bot]` approving review (counts) → `gh pr merge --auto --squash`. Majors still need a human. | `.github/workflows/dependabot-auto-merge.yml` |
104-
| Code PRs (owner + Paperclip) | After Paperclip verdicts = ACCEPT **and** `qa-gate=success` at head SHA, a bot identity posts a GitHub approving review (reflecting the verdict) and arms auto-merge. Gate 3 stays a human **Approve** button; merge fires only when all 11 checks are green. | `scripts/arm-auto-merge.sh` |
105-
106-
`scripts/arm-auto-merge.sh` re-checks the live PR head against the SHA it was
107-
blessed at (refuses on drift) and that `qa-gate` is `success` at that SHA before
108-
approving — so the spec-075 rule is enforced in the merge path, not just the
109-
status. It needs a **repo-scoped fine-grained PAT or GitHub App token**
110-
(Contents RW, Pull requests RW, Commit statuses RW) injected as `GH_TOKEN`,
111-
stored with the agent secrets (`searcher/agents/.env` pattern, gitignored) —
112-
**not** the owner's `--admin`-capable login.
104+
| Code PRs (owner + Paperclip) — **no credential, recommended** | On cockpit Gate-3 Approve the cockpit fires a `repository_dispatch` (`event_type: arm-auto-merge`) using the gh login it already has. The workflow runs *inside Actions* under the built-in `GITHUB_TOKEN` (`github-actions[bot]`), re-checks head SHA + `qa-gate=success`, posts the approving review (reflecting the Paperclip ACCEPT verdict) and arms auto-merge. No new secret, no PAT. | `.github/workflows/arm-auto-merge.yml` |
105+
| Code PRs (owner + Paperclip) — **manual / PAT fallback** | Same verification + approve + arm, run locally with a repo-scoped bot PAT / App token. Use when Actions dispatch isn't available. | `scripts/arm-auto-merge.sh` |
106+
107+
Both paths re-check the live PR head against the SHA they were blessed at
108+
(refuse on drift) and that `qa-gate` is `success` at that SHA before approving —
109+
so the spec-075 rule is enforced in the merge path, not just the status. Gate 3
110+
stays a human **Approve** button; merge fires only when all 11 checks are green.
111+
112+
**Recommended path — `.github/workflows/arm-auto-merge.yml` (Option B, no new
113+
credential).** The cockpit Approve fires:
114+
115+
```bash
116+
gh api repos/${REPO}/dispatches -f event_type=arm-auto-merge \
117+
-F 'client_payload[pr]=<number>' -F 'client_payload[head_sha]=<blessed-sha>'
118+
```
119+
120+
The workflow approves+arms under `github-actions[bot]` — the same identity
121+
`qa-gate-trivial` and `dependabot-auto-merge` already use, whose approval counts
122+
toward `required_approving_review_count`. A `workflow_dispatch` trigger gives the
123+
owner the same action manually for debug. **Cockpit wiring of the Gate-3 Approve
124+
button to this dispatch lives in the Paperclip cockpit (control-plane), not in
125+
this repo** — see MCP-1249.
126+
127+
**Fallback path — `scripts/arm-auto-merge.sh` (Option A).** Needs a
128+
**repo-scoped fine-grained PAT or GitHub App token** (Contents RW, Pull requests
129+
RW, Commit statuses RW) injected as `GH_TOKEN`, stored with the agent secrets
130+
(`searcher/agents/.env` pattern, gitignored) — **not** the owner's
131+
`--admin`-capable login.
113132

114133
**Gate-3 doctrine** (supersedes "agents never merge PRs; a human merges"):
115134
agents may post their review (reflecting the Paperclip verdict) and **arm**

0 commit comments

Comments
 (0)