actions/slash-commands/README.md
Den Delimarsky cf4e6c7382
slash-commands: fall back to direct merge when bot approval fails code-owner check
When branch protection enables require_code_owner_review, the GitHub
App's APPROVE review does not satisfy it (Apps cannot be team members).
Auto-merge also does not inherit the App's ruleset-bypass privileges,
so the PR sits in BLOCKED/REVIEW_REQUIRED forever with auto-merge
enabled but never firing.

Detect this by reading reviewDecision back from the
enablePullRequestAutoMerge mutation. If it is still REVIEW_REQUIRED
after we submitted an approval, attempt a direct merge instead — a
direct merge does use the App's bypass.

Both direct-merge paths (this new one and the existing clean-status
fallback) now go through a shared mergeDirect helper that catches
failures and emits an actionable warning pointing at the ruleset
bypass-list setup.

🏠 Remote-Dev: homespace
2026-03-28 01:31:29 +00:00

266 lines
13 KiB
Markdown

# slash-commands
Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and
`/stageblog` slash commands on pull requests, gated by team membership and
CODEOWNERS.
## What it does
Listens for PR comments and push events, then:
- **`/lgtm`** — **core maintainers only** (`always-allow-teams`). Adds an
`accepted` label, submits an **APPROVE** review on behalf of the commenter,
and **enables auto-merge**. The PR merges automatically once all required
checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization.
- **`/lgtm cancel`** — removes the `accepted` label, dismisses the approval,
and disables auto-merge.
- **`/lgtm force`** — same as `/lgtm`, but **only** members of
`force-allow-teams` (default: core + lead maintainers) may use it, and it
**bypasses the self-approval guard**. Intended as an escape hatch for
maintainers who need to approve their own PRs. The review body notes the
approval came via `/lgtm force`.
- **`/hold`** — adds a `do-not-merge/hold` label. Core maintainers _or_
CODEOWNERS of the PR's changed files may use this.
- **`/hold cancel`** or **`/unhold`** — removes the hold label.
- **`/stageblog`** — if the PR touches any blog paths (default `blog/**`),
dispatches a caller-defined `workflow_dispatch` workflow passing the PR
number and the PR's head SHA **at the time of the comment**. Intended for
letting maintainers manually stage blog previews for fork PRs (where the
normal `pull_request`-triggered preview workflow can't access secrets).
Disabled unless `stageblog-workflow` is set.
- **New commits pushed** — removes the `accepted` label, dismisses the
approval, disables auto-merge, and posts a brief comment asking for
re-approval.
Commands are parsed from the **first line** of the comment (case-insensitive,
must match `^/cmd\b`). Unauthorized attempts, or a PR author trying to `/lgtm`
their own PR, receive a 👎 reaction with no further noise. Successful commands
receive a 👍 reaction.
### Merge gate
The merge gate is **GitHub's native required-reviews rule** plus
**auto-merge**. Configure branch protection to require at least one approving
review; `/lgtm` satisfies that via the App's approval and turns on auto-merge.
The PR merges as soon as CI passes. Manually adding the `accepted` label in
the GitHub UI has no effect — the gate is the review, not the label.
> [!IMPORTANT]
> If your branch protection enables **Require review from Code Owners**, the
> App's approval will _not_ satisfy it — GitHub Apps cannot be members of org
> teams, so they cannot be code owners via a team entry. Auto-merge also does
> not inherit the bypass privileges of the App that enabled it. To make
> `/lgtm` work under this rule, **add the GitHub App to the ruleset's bypass
> list** (Settings → Rules → Rulesets → _your ruleset_ → Bypass list → add
> the App with mode _Always_). The action detects this case and falls back to
> a direct merge, which _does_ use the App's bypass.
## Prerequisites
- **A GitHub App** installed on the caller repo with the permissions listed
below. The App's identity is what approves PRs and enables auto-merge.
- **Allow auto-merge** enabled in the caller repo
(Settings → General → Pull Requests → Allow auto-merge).
- The `accepted` and `do-not-merge/hold` labels **must already exist** in the
caller repo (the action does not auto-create them). Suggested colors:
`accepted` → `#0e8a16`, `do-not-merge/hold` → `#d93f0b`.
- A CODEOWNERS file at `.github/CODEOWNERS` (or pass a different path via
`codeowners-path`) if you want CODEOWNERS to grant `/hold`/`/stageblog`.
Not required for `/lgtm`.
## Required secrets
The action runs as a **GitHub App** so its approvals satisfy branch
protection's required-reviews rule without depending on the
"Allow GitHub Actions to create and approve pull requests" repo setting.
| Repo secret name | Value |
|---|---|
| `MCP_COMMANDER_APP_ID` | The App's numeric ID (shown on the App's General settings page) |
| `MCP_COMMANDER_APP_KEY` | The App's private key — full contents of the downloaded `.pem` file, including the `-----BEGIN/END-----` lines |
### Creating the GitHub App
Org **Settings → Developer settings → GitHub Apps → New GitHub App**.
Configure:
| Section | Setting |
|---|---|
| Webhook | **Disable** (uncheck "Active") — the App is just an identity |
| Repository permissions → Pull requests | **Read & write** |
| Repository permissions → Contents | **Read-only** |
| Repository permissions → Actions | **Read & write** _(only if `/stageblog` is enabled)_ |
| Organization permissions → Members | **Read-only** |
| Where can this GitHub App be installed? | **Only on this account** |
After creating:
1. **Install** — left sidebar → Install App → pick the caller repo(s)
2. **App ID** — copy from the top of General settings → save as
`MCP_COMMANDER_APP_ID` secret
3. **Private key** — scroll to bottom → Generate a private key →
copy `.pem` contents → save as `MCP_COMMANDER_APP_KEY` secret
> **Why an App and not a PAT?** App installation tokens are short-lived
> (auto-minted per workflow run via `actions/create-github-app-token`), are
> not tied to any individual's account, and their approvals always count
> toward required reviews. The automatic `GITHUB_TOKEN` lacks `read:org` and
> its approvals are gated by a separate repo setting that may be disabled
> org-wide.
## Caller workflow requirements
| | |
|---|---|
| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`) |
| **Permissions** | `contents: read` is sufficient — the App token handles all writes. |
| **Fork-PR safety** | No special guard needed — `issue_comment` and `pull_request_target` both run in the **base** repo's context with the base workflow definition, so fork authors cannot modify the logic. CODEOWNERS is also fetched from the PR's **base** ref, never the head. |
| **No checkout** | The action calls GitHub API only. Do not `actions/checkout` PR code. |
## Usage
```yaml
name: Slash Commands
on:
issue_comment:
types: [created]
pull_request_target:
types: [synchronize]
permissions:
contents: read
jobs:
handle:
if: >-
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.MCP_COMMANDER_APP_ID }}
private-key: ${{ secrets.MCP_COMMANDER_APP_KEY }}
- uses: modelcontextprotocol/actions/slash-commands@main
with:
github-token: ${{ steps.app-token.outputs.token }}
# auto-merge-method: squash # default; also: merge, rebase
# stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog
```
**Branch protection settings** for this flow: enable _Require a pull request
before merging_ → _Require approvals_ (1), and _Allow auto-merge_ in repo
Settings. The App's APPROVE review satisfies the requirement and auto-merge
handles the rest.
### Enabling `/stageblog` — companion workflow
When `stageblog-workflow` is set, you also need a separate workflow in the
caller repo that receives the dispatch. Example `stage-blog.yml`:
```yaml
name: Stage Blog (fork PRs)
on:
workflow_dispatch:
inputs:
pr_number:
required: true
type: string
head_sha:
required: true
type: string
permissions:
contents: read
pull-requests: write
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.head_sha }} # pinned — do NOT re-fetch the PR head
# ... your blog build steps → output to blog/public ...
- uses: modelcontextprotocol/actions/cloudflare-pages-preview/deploy@main
with:
directory: blog/public
project-name: mcp-blog-preview
api-token: ${{ secrets.CF_PAGES_PREVIEW_API_TOKEN }}
account-id: ${{ secrets.CF_PAGES_PREVIEW_ACCOUNT_ID }}
branch: pr-${{ inputs.pr_number }}
pr-number: ${{ inputs.pr_number }}
commit-sha: ${{ inputs.head_sha }}
comment-title: "Blog Preview (staged via /stageblog)"
comment-marker: "<!-- stage-blog-comment -->"
```
## Inputs
| Input | Required | Default | Description |
|---|---|---|---|
| `github-token` | ✅ | — | GitHub App installation token used for everything: team-membership checks, labels, comments, reactions, APPROVE review, auto-merge, CODEOWNERS fetch, workflow dispatch. Must have Organization Members:read + Repository Pull requests:write + Contents:read (+ Actions:write for `/stageblog`). **Default `GITHUB_TOKEN` will not work.** |
| `approved-label` | | `accepted` | Label added by `/lgtm` to mark the PR as accepted |
| `hold-label` | | `do-not-merge/hold` | Label added by `/hold` |
| `always-allow-teams` | | `core-maintainers` | Comma-separated team slugs (in the repo's org) whose members can `/lgtm` any PR. **Only these teams can `/lgtm`** — CODEOWNERS does not grant it. Members may also `/hold`/`/stageblog` any PR. |
| `force-allow-teams` | | `core-maintainers,lead-maintainers` | Comma-separated team slugs whose members may use `/lgtm force` (bypasses self-approval guard). CODEOWNERS does **not** grant this — only team membership. |
| `codeowners-path` | | `.github/CODEOWNERS` | Path to CODEOWNERS, fetched from the PR's **base** ref (never head — tamper-proof). Grants `/hold` and `/stageblog` only, not `/lgtm`. |
| `invalidate-on-push` | | `'true'` | Remove `accepted` label + dismiss approval + disable auto-merge + comment when new commits are pushed. Set `'false'` to keep approval across pushes. |
| `submit-review` | | `'true'` | Submit an APPROVE review alongside the label on `/lgtm`. Set `'false'` to use label only. |
| `enable-auto-merge` | | `'true'` | Enable auto-merge after `/lgtm` (disabled on `/lgtm cancel` and push invalidation). Requires repo setting "Allow auto-merge". |
| `auto-merge-method` | | `squash` | Merge method for auto-merge: `squash`, `merge`, or `rebase` |
| `stageblog-workflow` | | _(empty)_ | Workflow file name (e.g. `stage-blog.yml`) to dispatch when `/stageblog` is invoked. Empty = command disabled. The workflow must accept `pr_number` and `head_sha` string inputs. |
| `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. |
## Outputs
| Output | Description |
|---|---|
| `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `noop` |
| `actor` | Login of the commenter (empty for non-comment triggers) |
## CODEOWNERS pattern support
The inline CODEOWNERS parser handles the patterns currently used in MCP repos:
| Pattern | Supported | Meaning |
|---|---|---|
| `/path/` | ✅ | Anchored directory prefix (matches anything under `path/`) |
| `/path/to/file.ext` | ✅ | Exact anchored path |
| `*.ext` | ✅ | Any file with extension `.ext` at any depth |
| `/path/**/*.ext` | ✅ | Glob at any depth under `/path/` |
| `**/file` | ✅ | File named `file` at any depth |
| `@user` / `@org/team` | ✅ | Owner: individual user or team (team org must match repo org) |
| `!negation` | ❌ | Not supported (not in use in MCP repos) |
| Escaped spaces (`\ `) | ❌ | Not supported |
Last-match-wins per file, consistent with GitHub's native CODEOWNERS semantics.
## Security notes
- **Comment body injection** — command parsing is a fixed regex on the
first line in JS; comment text is never interpolated into a shell.
- **CODEOWNERS tampering** — fetched from `pr.base.ref`, never the PR head.
An attacker cannot add themselves as CODEOWNER in the PR under review.
- **Workflow modification** — `issue_comment` and `pull_request_target` run
from the default-branch workflow definition. The action does not check out
PR code.
- **Self-approval** — explicitly blocked before the auth check. The
`/lgtm force` escape hatch bypasses this, but is gated strictly to
`force-allow-teams` membership (CODEOWNERS does not grant it) and the
review body records that `force` was used.
- **Manual label bypass** — the merge gate is the App's APPROVE review, which
is only submitted by the `/lgtm` handler after the team-membership check
passes. Adding the `accepted` label via the GitHub UI is cosmetic.
- **`/stageblog` SHA pinning** — the dispatch passes `pr.head.sha` captured at
the moment the maintainer comments. The companion workflow **must** check
out `inputs.head_sha` (not `refs/pull/N/head` or `pr.head.ref`) so a fork
author cannot push new commits between the maintainer's review and the build.
- **Review provenance** — the review body always names the real approver
("on behalf of @login"), even though GitHub shows the review as App-authored.