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
130 changes: 130 additions & 0 deletions .github/workflows/arm-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Arm auto-merge for a code PR — Model B "missing wire" (MCP-1249, follow-up to
# MCP-1248). This is the no-human-credential half of `scripts/arm-auto-merge.sh`.
#
# WHY THIS EXISTS
# `scripts/arm-auto-merge.sh` (Option A) needs a repo-scoped bot PAT / GitHub
# App token minted by a GitHub human/org identity and stored with the agent
# secrets. This workflow (Option B) needs NO new secret: the cockpit fires a
# `repository_dispatch` on Gate-3 Approve using the gh login it already has,
# and the approve+arm happens *inside Actions* under the built-in
# `GITHUB_TOKEN` (the `github-actions[bot]` identity). A `github-actions[bot]`
# approval counts toward `required_approving_review_count` (same identity
# qa-gate-trivial / dependabot-auto-merge already rely on), so this closes the
# "GitHub sees 0 approvals" gap with no PAT and no `gh pr merge --admin`.
#
# INVARIANTS (identical to scripts/arm-auto-merge.sh — keep them in sync)
# - Agents ARM auto-merge; they NEVER bypass a required check. No `--admin`,
# no `bypass_pull_request_allowances`.
# - spec-075: a PASS is valid only while PR head == the blessed SHA. We
# re-verify the live PR head still equals `head_sha` before approving, and
# refuse on drift.
# - `qa-gate` must be `success` at that exact SHA before we approve.
#
# DISPATCH CONTRACT (how the cockpit Gate-3 Approve fires this)
# repository_dispatch:
# event_type: arm-auto-merge
# client_payload: { "pr": <number>, "head_sha": "<40-hex>", "body": "<optional>" }
# e.g. from the cockpit's existing gh login (no new credential):
# gh api repos/${REPO}/dispatches -f event_type=arm-auto-merge \
# -F 'client_payload[pr]=607' -F 'client_payload[head_sha]=fe9fb8e...'
# `workflow_dispatch` below is the manual equivalent for owner/debug use.
#
# See docs/qa-merge-gate.md ("Merging without --admin").
name: Arm auto-merge (Model B)

on:
repository_dispatch:
types: [arm-auto-merge]
workflow_dispatch:
inputs:
pr:
description: PR number to arm
required: true
head_sha:
description: Expected PR head SHA (spec-075 drift guard)
required: true
body:
description: Approval body (optional)
required: false

# contents: write + pull-requests: write are needed to post the approving review
# and arm `gh pr merge --auto`. statuses is read implicitly (commit status read
# needs no extra scope). No `--admin` path is ever taken.
permissions:
contents: write
pull-requests: write

jobs:
arm:
name: arm-auto-merge
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Resolve inputs (repository_dispatch | workflow_dispatch)
id: in
env:
# repository_dispatch carries client_payload.*; workflow_dispatch
# carries inputs.*. Exactly one set is populated per run.
RD_PR: ${{ github.event.client_payload.pr }}
RD_SHA: ${{ github.event.client_payload.head_sha }}
RD_BODY: ${{ github.event.client_payload.body }}
WD_PR: ${{ github.event.inputs.pr }}
WD_SHA: ${{ github.event.inputs.head_sha }}
WD_BODY: ${{ github.event.inputs.body }}
run: |
PR="${RD_PR:-$WD_PR}"
SHA="${RD_SHA:-$WD_SHA}"
BODY="${RD_BODY:-$WD_BODY}"
if [[ -z "$PR" || -z "$SHA" ]]; then
echo "::error::missing pr and/or head_sha (got pr='$PR' head_sha='$SHA')"
exit 2
fi
if [[ ! "$SHA" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "::error::head_sha is not a hex SHA: '$SHA'"
exit 2
fi
{
echo "pr=$PR"
echo "sha=$SHA"
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.}"
} >> "$GITHUB_OUTPUT"

- name: Verify head SHA + qa-gate, then approve & arm
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ steps.in.outputs.pr }}
EXPECTED_SHA: ${{ steps.in.outputs.sha }}
BODY: ${{ steps.in.outputs.body }}
run: |
set -euo pipefail

# 1. spec-075: live PR head must still equal the blessed SHA.
LIVE_SHA="$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid)"
# Allow a short (>=7) prefix from the dispatcher to match the full OID.
if [[ "$LIVE_SHA" != "$EXPECTED_SHA" && "${LIVE_SHA#"$EXPECTED_SHA"}" == "$LIVE_SHA" ]]; then
echo "::error::head SHA drifted: blessed=$EXPECTED_SHA live=$LIVE_SHA — re-run QA/review on the new head; refusing to approve."
exit 3
fi

# 2. qa-gate must be success at this exact SHA.
QA_STATE="$(gh api "repos/${REPO}/commits/${LIVE_SHA}/status" \
-q '.statuses[] | select(.context=="qa-gate") | .state' 2>/dev/null | head -1 || true)"
if [[ "$QA_STATE" != "success" ]]; then
echo "::error::qa-gate is '${QA_STATE:-missing}' at ${LIVE_SHA} (need success) — refusing to approve."
exit 4
fi

# 3. Never approve a PR authored by our own bot identity (GitHub would
# reject it anyway; fail loudly so a misroute is visible).
AUTHOR="$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login)"
if [[ "$AUTHOR" == "github-actions[bot]" ]]; then
echo "::error::PR #$PR is authored by github-actions[bot]; cannot self-approve."
exit 5
fi

# 4. Post the approving review (reflects the Paperclip ACCEPT verdict;
# counts toward required_approving_review_count) and arm auto-merge.
gh pr review --approve "$PR" --repo "$REPO" --body "$BODY"
gh pr merge --auto --squash "$PR" --repo "$REPO"
echo "armed auto-merge for PR #$PR at $LIVE_SHA (merges when all required checks are green)"
61 changes: 61 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Dependabot hands-free merge for patch + minor bumps (MCP-1248, Model B).
#
# Lands dependabot PRs WITHOUT `gh pr merge --admin`:
# 1. fetch-metadata reads the (grouped) update-type — for a grouped PR this is
# the HIGHEST semver bump across the group.
# 2. For patch/minor only, github-actions[bot] posts an approving review. That
# approval is by a *different* identity than the PR author (dependabot[bot])
# and DOES count toward `required_approving_review_count`.
# 3. Auto-merge is armed (squash). GitHub merges only once ALL required checks
# are green — including `qa-gate` (auto-passed for dep PRs that touch no
# code, or QATester-blessed if a dep change does touch code) and the build/
# lint/test matrix. This ARMS the merge; it never bypasses a check.
#
# Major bumps are intentionally excluded — they still require a human review.
#
# NOTE on permissions: workflows triggered by Dependabot get a GITHUB_TOKEN
# whose scope is set by the `permissions:` block below (GitHub grants the
# elevated scope for Dependabot `pull_request` runs only when declared here).
#
# See docs/qa-merge-gate.md ("Merging without --admin").
name: Dependabot auto-merge

on: pull_request

permissions:
contents: write
pull-requests: write

jobs:
auto-merge:
name: dependabot-auto-merge
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Fetch Dependabot metadata
id: meta
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Approve and arm auto-merge (patch + minor only)
if: >-
steps.meta.outputs.update-type == 'version-update:semver-patch' ||
steps.meta.outputs.update-type == 'version-update:semver-minor'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh pr review --approve "$PR_URL" \
--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."
gh pr merge --auto --squash "$PR_URL"

- name: Note major bumps require a human
if: steps.meta.outputs.update-type == 'version-update:semver-major'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh pr comment "$PR_URL" \
--body "⚠️ Major version bump (${{ steps.meta.outputs.dependency-names }}) — auto-merge is gated to patch/minor only. A human must review and merge."
82 changes: 82 additions & 0 deletions .github/workflows/qa-gate-trivial.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# qa-gate auto-pass for non-code PRs (MCP-1248, Model B "merge without --admin").
#
# `qa-gate` is a required branch-protection check, but it is a free-form commit
# status that only the Paperclip QATester posts — and QATester only runs for
# code PRs. So trivial PRs (docs, CI metadata, dependabot config, etc.) never
# get a `qa-gate` status and stay blocked forever unless someone uses
# `gh pr merge --admin`. This workflow closes that gap WITHOUT any bypass:
#
# - If a PR touches NO code-bearing path, post `qa-gate=success` for the PR's
# CURRENT head SHA (using the built-in GITHUB_TOKEN, `statuses: write`).
# - If a PR touches ANY code-bearing path, do nothing — `qa-gate` stays
# pending for the real QATester. This preserves the spec-075 invariant
# (a real PASS is valid only while PR head == qa_head_sha) for code PRs.
#
# REQUIRED-SAFE: this workflow is NOT itself a required check, so its skipped
# `auto-pass` job on a code PR does not block anything. The status it posts
# feeds the existing required `qa-gate` context. The companion required checks
# `swift-test` / `settings-parity` self-satisfy on non-native PRs via the
# required-safe design in native-tests.yml.
#
# Because the status is keyed to `head.sha`, a new push to a trivial PR re-runs
# this workflow (on `synchronize`) and re-blesses the new head — so qa-gate
# stays green across pushes, exactly like QATester's re-bless for code PRs.
#
# See docs/qa-merge-gate.md ("Merging without --admin").
name: QA Gate (trivial auto-pass)

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read

jobs:
classify:
name: classify-trivial
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
# ANY match here => treat the PR as a code PR and leave qa-gate to the
# real QATester. Keep this list BROAD: a false "trivial" classification
# would auto-pass QA for changed product code (the MCP-1214 risk class).
filters: |
code:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'cmd/**'
- 'internal/**'
- 'frontend/src/**'
- 'native/**'

auto-pass:
name: auto-pass-qa-gate
needs: classify
# Only when the diff is provably non-code.
if: needs.classify.outputs.code == 'false'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
statuses: write
steps:
- name: Post qa-gate=success (no code-bearing files changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api --method POST "repos/${REPO}/statuses/${HEAD_SHA}" \
-f state=success \
-f context=qa-gate \
-f description="Auto-passed: no code-bearing files changed (qa-gate-trivial)" \
-f target_url="${RUN_URL}"
echo "qa-gate=success posted for ${HEAD_SHA} (trivial PR)"
66 changes: 63 additions & 3 deletions docs/qa-merge-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Makes feature verification a **required, mechanical** check before a PR can
merge to `main` — closing the class of gap that let MCP-1214 ship (a native
macOS tray bug that Web-UI-only QA never exercised).

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

## Required checks

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

## Merging without `--admin` (Model B — MCP-1248)

Goal: land PRs (owner + Paperclip agents) **without** `gh pr merge --admin`,
while keeping the gate meaningful. The mechanical merge always uses GitHub
auto-merge — agents **arm** the merge, they never bypass a required check.
`enforce_admins` stays `false` purely as an emergency hatch.

> Do **not** use `bypass_pull_request_allowances` for agents — that is a renamed
> `--admin` and breaks the spec-075 head-SHA invariant.

Three load-bearing facts make this work:

- A PR author cannot approve their own PR, and there is no second human. So the
one required approval comes from a **bot/App identity** — a bot approval
**does** count toward `required_approving_review_count`.
- `require_last_push_approval=false` and `dismiss_stale_reviews=false` (no
CODEOWNERS) → a bot approval survives later pushes; no code-owner friction.
- `allow_auto_merge=true` is enabled on the repo, so `gh pr merge --auto` works.

The moving parts (all without `--admin`):

| Path | Mechanism | File |
|---|---|---|
| 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` |
| 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` |
| 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` |
| 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` |

Both paths re-check the live PR head against the SHA they were blessed at
(refuse on drift) and that `qa-gate` is `success` at that SHA before approving —
so the spec-075 rule is enforced in the merge path, not just the status. Gate 3
stays a human **Approve** button; merge fires only when all 11 checks are green.

**Recommended path — `.github/workflows/arm-auto-merge.yml` (Option B, no new
credential).** The cockpit Approve fires:

```bash
gh api repos/${REPO}/dispatches -f event_type=arm-auto-merge \
-F 'client_payload[pr]=<number>' -F 'client_payload[head_sha]=<blessed-sha>'
```

The workflow approves+arms under `github-actions[bot]` — the same identity
`qa-gate-trivial` and `dependabot-auto-merge` already use, whose approval counts
toward `required_approving_review_count`. A `workflow_dispatch` trigger gives the
owner the same action manually for debug. **Cockpit wiring of the Gate-3 Approve
button to this dispatch lives in the Paperclip cockpit (control-plane), not in
this repo** — see MCP-1249.

**Fallback path — `scripts/arm-auto-merge.sh` (Option A).** Needs a
**repo-scoped fine-grained PAT or GitHub App token** (Contents RW, Pull requests
RW, Commit statuses RW) injected as `GH_TOKEN`, stored with the agent secrets
(`searcher/agents/.env` pattern, gitignored) — **not** the owner's
`--admin`-capable login.

**Gate-3 doctrine** (supersedes "agents never merge PRs; a human merges"):
agents may post their review (reflecting the Paperclip verdict) and **arm**
auto-merge; Gate 3 stays a human Approve button; merge fires only when the full
gate is green; **agents never bypass required checks**.

## QATester contract

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