|
| 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)" |
0 commit comments