# slash-commands Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and `/stageblog` slash commands on pull requests, gated by team membership and CODEOWNERS. Optionally auto-approves narrowly-scoped Dependabot PRs (`dependabot-auto-approve`). ## 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. Optionally removes staging labels (`remove-labels-on-accept`) and moves the PR to an _Accepted_ status in a configured org Project (`project-number`). - **`/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. **Exception:** if the `accepted` label was most recently applied manually by a member of `always-allow-teams` or `force-allow-teams` (rather than by the bot via `/lgtm`), it is treated as a sticky governance decision and left in place — nothing is removed or dismissed. 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. ### Dependabot auto-approve (opt-in) When `dependabot-auto-approve: dev-patch-minor` is set, the action evaluates Dependabot PRs on `pull_request_target` (`opened`/`reopened`) and on every `check_suite` completion for a `dependabot/` branch, and — when the PR passes **all** of the gates below — adds the `accepted` label, submits a bot APPROVE review (marker ``), and enables auto-merge, exactly like `/lgtm`. Any gate failure exits **without approving** (fail closed) and logs the reason to the action log only: 1. **Real Dependabot** — PR author login is `dependabot[bot]` **and** the user id is `49699333` (the Dependabot app's stable id). A lookalike account can never match. 2. **Same-repo head** — the head branch lives in the base repo (never a fork) and starts with `dependabot/`. 3. **Trusted commits** — every commit on the PR has a **verified signature** and is authored by `dependabot[bot]`. 4. **File allow-list** — the changed files are a subset of `dependabot-allowed-files` (exact paths; default `package.json,package-lock.json`). 5. **Dependency scope** — Dependabot's structured commit-message metadata (`updated-dependencies:` block) must show **every** updated dependency as `dependency-type: direct:development` with `update-type: version-update:semver-patch` or `…semver-minor`. Missing or unparseable metadata never approves. (The metadata is trustworthy at this point because gate 3 proved the commits are dependabot-authored and signed.) 6. **CI green** — every check run on the head SHA is completed with conclusion `success`/`neutral`/`skipped`, at least one **non-skipped** check exists, and the combined commit status (if any statuses exist) is `success`. Pending checks exit silently — the next `check_suite` completion re-evaluates. This gate lives **in the action** so approval itself is withheld until CI passes, even when branch protection has no required status checks. 7. **PR state** — open, not a draft, no merge conflicts, and not carrying the hold label. Push invalidation applies to these approvals too: any push to the PR (human or Dependabot rebase) dismisses the auto-approval, removes the label, and disables auto-merge. If **Dependabot** pushed (e.g. a rebase), the PR is re-evaluated when its checks complete and can be re-approved. If a **human** pushed, gate 3 fails permanently for that PR — every subsequent evaluation sees a non-dependabot commit — so the PR can only proceed via a maintainer's `/lgtm`. That one-way trapdoor is intentional. Enabling the mode requires two extra caller-workflow triggers — see [Caller workflow requirements](#caller-workflow-requirements). ### 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`). With `dependabot-auto-approve` enabled, also `pull_request_target` types `opened`, `reopened` + `check_suite` (types: `completed`) — gate the `check_suite` job on `startsWith(github.event.check_suite.head_branch, 'dependabot/')` to avoid a run per suite repo-wide. | | **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 # dependabot-auto-approve: dev-patch-minor # uncomment to enable — also add # pull_request_target types [opened, reopened] and check_suite [completed] # triggers above (see Caller workflow requirements) ``` **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: "" ``` ## 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. Skipped if the label was most recently applied manually by an `always-allow-teams` or `force-allow-teams` member (not the bot via `/lgtm`). Set `'false'` to keep approval across pushes unconditionally. | | `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` | | `remove-labels-on-accept` | | _(empty)_ | Comma-separated label names to remove when `/lgtm` accepts (e.g. `in-review,draft,proposal`). Missing labels are ignored. | | `project-number` | | _(empty)_ | Org-level Project (V2) number to update on accept. Empty = disabled. Adds the PR to the project if not already present. Requires the App to have **Organization Projects: write**. | | `project-status-field` | | `Status` | Name of the single-select field to set on accept. | | `project-accepted-option` | | `Accepted` | Name of the option to set in the status field on accept. | | `project-gate-label` | | _(empty)_ | If set, only update the project status when the PR has this label (e.g. `SEP`). Empty = update for every accepted PR. Label removal via `remove-labels-on-accept` is not gated — it always runs. | | `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. | | `dependabot-auto-approve` | | _(empty)_ | Dependabot auto-approve policy. Empty = disabled. `dev-patch-minor` = approve + label + auto-merge Dependabot PRs that pass **all** gates (see [Dependabot auto-approve](#dependabot-auto-approve-opt-in)). Any other value disables the mode (fail closed). Requires the extra caller triggers listed above. | | `dependabot-allowed-files` | | `package.json,package-lock.json` | Comma-separated **exact** file paths a Dependabot PR may touch to qualify for auto-approve. Any other changed file blocks auto-approval. | ## Outputs | Output | Description | |---|---| | `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `invalidate-skipped`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `dependabot-approved`, `dependabot-skipped`, `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. - **Dependabot auto-approve** — off by default; every gate fails closed. The author check pins the Dependabot app's numeric user id, not just the login; fork heads are rejected outright; commit trust requires verified signatures on dependabot-authored commits; the file allow-list is exact-path; missing update metadata never approves; and approval waits for green CI inside the action, so it holds even without required status checks in branch protection. A human push onto a dependabot branch permanently disqualifies the PR from auto-approval (gate 3) — only `/lgtm` can accept it after that.