actions/slash-commands/README.md
Den Delimarsky 5bc398b196
Add /stageblog slash command to dispatch blog preview workflow
Extends slash-commands with a new /stageblog command that lets authorized
maintainers trigger a workflow_dispatch for staging blog previews from fork
PRs, where the normal pull_request-triggered preview cannot access secrets.

- Captures pr.head.sha at comment time and passes it to the dispatched
  workflow so fork authors cannot push new commits between review and build
- Disabled unless stageblog-workflow input is set
- Refuses with stageblog-not-blog result if no changed file matches
  stageblog-paths (default blog/**)
- Documents companion workflow pattern, additional PAT scope (Actions:
  write), and SHA-pinning safety requirement
2026-03-11 20:12:52 +00:00

260 lines
12 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`** — if the commenter is a core maintainer _or_ a CODEOWNER of the
PR's changed files, adds an `approved` label and submits a bot **APPROVE**
review with body "Approved on behalf of @user via `/lgtm`."
- **`/lgtm cancel`** — removes the `approved` label and dismisses the bot
review.
- **`/hold`** — adds a `do-not-merge/hold` label (same authorization rule).
- **`/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 `approved` label, dismisses the bot
review, 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.
The caller workflow adds a separate `status` job that reports a required check
which passes only when `approved` is present and `do-not-merge/hold` is absent
— making the labels an actual merge gate via branch protection.
## Prerequisites
- The `approved` and `do-not-merge/hold` labels **must already exist** in the
caller repo (the action does not auto-create them). Suggested colors:
`approved` → `#0e8a16`, `do-not-merge/hold` → `#d93f0b`.
- A CODEOWNERS file at `.github/CODEOWNERS` (or pass a different path via
`codeowners-path`). Without one, only `always-allow-teams` members can act.
## Required secrets
Create this secret in the caller repo: GitHub → repo → **Settings → Secrets
and variables → Actions → New repository secret**.
| Repo secret name | Action input | Value |
|---|---|---|
| `SLASH_COMMANDS_TOKEN` | `github-token` | Fine-grained PAT (or GitHub App installation token) with **Organization → Members: read** plus **Repository → Contents: read, Pull requests: write, Issues: write**. If `/stageblog` is enabled, additionally **Repository → Actions: write**. |
> **The default `${{ github.token }}` will NOT work.** Team-membership checks
> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope,
> which the automatic `GITHUB_TOKEN` never has — even with a `permissions:`
> block. You _must_ provide a PAT or App token.
### `SLASH_COMMANDS_TOKEN`
**Option A — fine-grained PAT (recommended for single-repo setups):**
GitHub → **Settings → Developer settings → Personal access tokens → Fine-grained
tokens → Generate new token**. Configure:
| Section | Setting |
|---|---|
| Resource owner | `modelcontextprotocol` (or your org) |
| Repository access | **Only select repositories** → pick the caller repo |
| Repository permissions → Contents | Read-only |
| Repository permissions → Pull requests | Read and write |
| Repository permissions → Issues | Read and write |
| Organization permissions → Members | Read-only |
| Repository permissions → Actions | Read and write _(only if `stageblog-workflow` is set)_ |
Do not grant any write permissions beyond Pull requests + Issues. **Contents**
must remain read-only — the action never writes code.
**Option B — GitHub App installation token:**
If your org already uses a bot App, generate an installation token with the
same scopes and store it as `SLASH_COMMANDS_TOKEN`. The App must be installed
on the caller repo _and_ have org-level **Members: read**.
## Caller workflow requirements
| | |
|---|---|
| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`, `opened`, `reopened`, `labeled`, `unlabeled`) |
| **Permissions** | `pull-requests: write`, `issues: write`, `contents: read`. If `/stageblog` is enabled, also `actions: write`. |
| **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, opened, reopened, labeled, unlabeled]
permissions:
pull-requests: write
issues: write
contents: read
jobs:
handle:
if: >-
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
(github.event_name == 'pull_request_target' && github.event.action == 'synchronize')
runs-on: ubuntu-latest
steps:
- uses: modelcontextprotocol/actions/slash-commands@main
with:
github-token: ${{ secrets.SLASH_COMMANDS_TOKEN }}
always-allow-teams: core-maintainers,lead-maintainers
# stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog
# Merge-gate status check — make this a required check in branch protection.
status:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Require approval, block on hold
env:
LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: |
echo "Labels: $LABELS"
if ! jq -e 'contains(["approved"])' <<<"$LABELS" > /dev/null; then
echo "::error::Missing 'approved' label — comment /lgtm"
exit 1
fi
if jq -e 'contains(["do-not-merge/hold"])' <<<"$LABELS" > /dev/null; then
echo "::error::'do-not-merge/hold' present — resolve, then /unhold"
exit 1
fi
echo "approved and not held"
```
### 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 ...
- id: deploy
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 }}
# NOTE: cloudflare-pages-preview/deploy reads the PR number from
# context.payload.pull_request, which is undefined on workflow_dispatch.
# Post the preview comment yourself:
- uses: actions/github-script@v8
env:
PR_NUMBER: ${{ inputs.pr_number }}
PREVIEW_URL: ${{ steps.deploy.outputs.url }}
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(process.env.PR_NUMBER),
body: `Blog preview: ${process.env.PREVIEW_URL}`,
});
```
> **Known limitation:** `cloudflare-pages-preview/deploy` currently assumes
> `context.payload.pull_request` exists for its sticky-comment step, which is
> not the case on `workflow_dispatch`. Until the deploy action accepts an
> explicit `pr-number` input, post the preview comment from the caller
> workflow as shown above. (Fixing deploy is a follow-up.)
## Inputs
| Input | Required | Default | Description |
|---|---|---|---|
| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `pull-requests: write` + `issues: write` + `contents: read`. **Default `GITHUB_TOKEN` will not work** for team checks. |
| `approved-label` | | `approved` | Label added by `/lgtm` |
| `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` or `/hold` any PR regardless of CODEOWNERS |
| `codeowners-path` | | `.github/CODEOWNERS` | Path to CODEOWNERS, fetched from the PR's **base** ref (never head — tamper-proof) |
| `invalidate-on-push` | | `'true'` | Remove `approved` label + dismiss bot review + comment when new commits are pushed. Set `'false'` to keep approval across pushes. |
| `submit-review` | | `'true'` | Submit a bot APPROVE review alongside the label on `/lgtm`. Set `'false'` to use label only. |
| `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-removed`, `hold-added`, `hold-removed`, `invalidated`, `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.
- **`/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 bot review body always names the real approver
("on behalf of @login"), even though GitHub shows the review as bot-authored.