Skip to content

Commit d2309ed

Browse files
authored
ci: merge without --admin (Model B) — qa-gate-trivial + dependabot + arm-auto-merge (MCP-1248, MCP-1249) (#620)
* ci: merge without --admin (Model B) — qa-gate-trivial + dependabot auto-merge + arm script Land PRs through GitHub auto-merge instead of `gh pr merge --admin`, keeping every required check (incl. qa-gate) meaningful. Agents arm auto-merge; they never bypass. - qa-gate-trivial.yml: post qa-gate=success for PRs touching no code-bearing path (dorny/paths-filter, required-safe); code PRs left to the real QATester (preserves spec-075 head-SHA invariant). - dependabot-auto-merge.yml: fetch-metadata -> bot approving review (counts) -> auto-merge --squash, gated to patch+minor; majors get a human. - scripts/arm-auto-merge.sh: after Paperclip ACCEPT + qa-gate green at head SHA, a bot identity approves and arms auto-merge; re-checks head drift + qa-gate before approving; never --admin. - docs/qa-merge-gate.md: Model B doctrine + reframe --admin as emergency-only. Task B credential provisioning (repo-scoped fine-grained PAT / GitHub App) is a follow-up owner action. Refs MCP-1248 * 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 9a74b58 commit d2309ed

5 files changed

Lines changed: 401 additions & 3 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)"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Dependabot hands-free merge for patch + minor bumps (MCP-1248, Model B).
2+
#
3+
# Lands dependabot PRs WITHOUT `gh pr merge --admin`:
4+
# 1. fetch-metadata reads the (grouped) update-type — for a grouped PR this is
5+
# the HIGHEST semver bump across the group.
6+
# 2. For patch/minor only, github-actions[bot] posts an approving review. That
7+
# approval is by a *different* identity than the PR author (dependabot[bot])
8+
# and DOES count toward `required_approving_review_count`.
9+
# 3. Auto-merge is armed (squash). GitHub merges only once ALL required checks
10+
# are green — including `qa-gate` (auto-passed for dep PRs that touch no
11+
# code, or QATester-blessed if a dep change does touch code) and the build/
12+
# lint/test matrix. This ARMS the merge; it never bypasses a check.
13+
#
14+
# Major bumps are intentionally excluded — they still require a human review.
15+
#
16+
# NOTE on permissions: workflows triggered by Dependabot get a GITHUB_TOKEN
17+
# whose scope is set by the `permissions:` block below (GitHub grants the
18+
# elevated scope for Dependabot `pull_request` runs only when declared here).
19+
#
20+
# See docs/qa-merge-gate.md ("Merging without --admin").
21+
name: Dependabot auto-merge
22+
23+
on: pull_request
24+
25+
permissions:
26+
contents: write
27+
pull-requests: write
28+
29+
jobs:
30+
auto-merge:
31+
name: dependabot-auto-merge
32+
if: github.actor == 'dependabot[bot]'
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 10
35+
steps:
36+
- name: Fetch Dependabot metadata
37+
id: meta
38+
uses: dependabot/fetch-metadata@v2
39+
with:
40+
github-token: ${{ secrets.GITHUB_TOKEN }}
41+
42+
- name: Approve and arm auto-merge (patch + minor only)
43+
if: >-
44+
steps.meta.outputs.update-type == 'version-update:semver-patch' ||
45+
steps.meta.outputs.update-type == 'version-update:semver-minor'
46+
env:
47+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
PR_URL: ${{ github.event.pull_request.html_url }}
49+
run: |
50+
gh pr review --approve "$PR_URL" \
51+
--body "Auto-approved by dependabot-auto-merge: ${{ steps.meta.outputs.update-type }} (${{ steps.meta.outputs.dependency-names }}). Merge fires only when all required checks are green."
52+
gh pr merge --auto --squash "$PR_URL"
53+
54+
- name: Note major bumps require a human
55+
if: steps.meta.outputs.update-type == 'version-update:semver-major'
56+
env:
57+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58+
PR_URL: ${{ github.event.pull_request.html_url }}
59+
run: |
60+
gh pr comment "$PR_URL" \
61+
--body "⚠️ Major version bump (${{ steps.meta.outputs.dependency-names }}) — auto-merge is gated to patch/minor only. A human must review and merge."
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# qa-gate auto-pass for non-code PRs (MCP-1248, Model B "merge without --admin").
2+
#
3+
# `qa-gate` is a required branch-protection check, but it is a free-form commit
4+
# status that only the Paperclip QATester posts — and QATester only runs for
5+
# code PRs. So trivial PRs (docs, CI metadata, dependabot config, etc.) never
6+
# get a `qa-gate` status and stay blocked forever unless someone uses
7+
# `gh pr merge --admin`. This workflow closes that gap WITHOUT any bypass:
8+
#
9+
# - If a PR touches NO code-bearing path, post `qa-gate=success` for the PR's
10+
# CURRENT head SHA (using the built-in GITHUB_TOKEN, `statuses: write`).
11+
# - If a PR touches ANY code-bearing path, do nothing — `qa-gate` stays
12+
# pending for the real QATester. This preserves the spec-075 invariant
13+
# (a real PASS is valid only while PR head == qa_head_sha) for code PRs.
14+
#
15+
# REQUIRED-SAFE: this workflow is NOT itself a required check, so its skipped
16+
# `auto-pass` job on a code PR does not block anything. The status it posts
17+
# feeds the existing required `qa-gate` context. The companion required checks
18+
# `swift-test` / `settings-parity` self-satisfy on non-native PRs via the
19+
# required-safe design in native-tests.yml.
20+
#
21+
# Because the status is keyed to `head.sha`, a new push to a trivial PR re-runs
22+
# this workflow (on `synchronize`) and re-blesses the new head — so qa-gate
23+
# stays green across pushes, exactly like QATester's re-bless for code PRs.
24+
#
25+
# See docs/qa-merge-gate.md ("Merging without --admin").
26+
name: QA Gate (trivial auto-pass)
27+
28+
on:
29+
pull_request:
30+
types: [opened, synchronize, reopened]
31+
32+
permissions:
33+
contents: read
34+
35+
jobs:
36+
classify:
37+
name: classify-trivial
38+
runs-on: ubuntu-latest
39+
timeout-minutes: 5
40+
outputs:
41+
code: ${{ steps.filter.outputs.code }}
42+
steps:
43+
- uses: actions/checkout@v4
44+
- uses: dorny/paths-filter@v3
45+
id: filter
46+
with:
47+
# ANY match here => treat the PR as a code PR and leave qa-gate to the
48+
# real QATester. Keep this list BROAD: a false "trivial" classification
49+
# would auto-pass QA for changed product code (the MCP-1214 risk class).
50+
filters: |
51+
code:
52+
- '**/*.go'
53+
- 'go.mod'
54+
- 'go.sum'
55+
- 'cmd/**'
56+
- 'internal/**'
57+
- 'frontend/src/**'
58+
- 'native/**'
59+
60+
auto-pass:
61+
name: auto-pass-qa-gate
62+
needs: classify
63+
# Only when the diff is provably non-code.
64+
if: needs.classify.outputs.code == 'false'
65+
runs-on: ubuntu-latest
66+
timeout-minutes: 5
67+
permissions:
68+
statuses: write
69+
steps:
70+
- name: Post qa-gate=success (no code-bearing files changed)
71+
env:
72+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
74+
REPO: ${{ github.repository }}
75+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
76+
run: |
77+
gh api --method POST "repos/${REPO}/statuses/${HEAD_SHA}" \
78+
-f state=success \
79+
-f context=qa-gate \
80+
-f description="Auto-passed: no code-bearing files changed (qa-gate-trivial)" \
81+
-f target_url="${RUN_URL}"
82+
echo "qa-gate=success posted for ${HEAD_SHA} (trivial PR)"

docs/qa-merge-gate.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ Makes feature verification a **required, mechanical** check before a PR can
44
merge to `main` — closing the class of gap that let MCP-1214 ship (a native
55
macOS tray bug that Web-UI-only QA never exercised).
66

7-
The `gh pr merge --admin` escape hatch is intentionally retained
8-
(`enforce_admins` stays `false`) for genuine emergencies; normal merges must be
9-
green.
7+
Normal merges land **without** `gh pr merge --admin` — through GitHub
8+
auto-merge once every required check is green (see "Merging without --admin"
9+
below). The `--admin` escape hatch is retained (`enforce_admins` stays `false`)
10+
for **genuine emergencies only**; routine use is a smell.
1011

1112
## Required checks
1213

@@ -75,6 +76,65 @@ not blocked on a status that nobody emits yet. Until then, add just `swift-test`
7576
and `settings-parity` — they report on every PR (green/skipped on non-native
7677
PRs) thanks to the required-safe design above, so they will not strand open PRs.
7778

79+
## Merging without `--admin` (Model B — MCP-1248)
80+
81+
Goal: land PRs (owner + Paperclip agents) **without** `gh pr merge --admin`,
82+
while keeping the gate meaningful. The mechanical merge always uses GitHub
83+
auto-merge — agents **arm** the merge, they never bypass a required check.
84+
`enforce_admins` stays `false` purely as an emergency hatch.
85+
86+
> Do **not** use `bypass_pull_request_allowances` for agents — that is a renamed
87+
> `--admin` and breaks the spec-075 head-SHA invariant.
88+
89+
Three load-bearing facts make this work:
90+
91+
- A PR author cannot approve their own PR, and there is no second human. So the
92+
one required approval comes from a **bot/App identity** — a bot approval
93+
**does** count toward `required_approving_review_count`.
94+
- `require_last_push_approval=false` and `dismiss_stale_reviews=false` (no
95+
CODEOWNERS) → a bot approval survives later pushes; no code-owner friction.
96+
- `allow_auto_merge=true` is enabled on the repo, so `gh pr merge --auto` works.
97+
98+
The moving parts (all without `--admin`):
99+
100+
| Path | Mechanism | File |
101+
|---|---|---|
102+
| 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` |
103+
| 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) — **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.
132+
133+
**Gate-3 doctrine** (supersedes "agents never merge PRs; a human merges"):
134+
agents may post their review (reflecting the Paperclip verdict) and **arm**
135+
auto-merge; Gate 3 stays a human Approve button; merge fires only when the full
136+
gate is green; **agents never bypass required checks**.
137+
78138
## QATester contract
79139

80140
The `mcpproxy-qa` skill ("Merge Gate" + "Native macOS Tray Testing" sections)

0 commit comments

Comments
 (0)