Merge pull request #14 from modelcontextprotocol/den/lgtm-status-direct-write

slash-commands: core-only /lgtm, auto-merge, GitHub App flow
This commit is contained in:
Den Delimarsky 2026-03-26 16:28:46 -07:00 • committed by GitHub
commit 71ea84b9b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 174 additions and 255 deletions

View file

@ -9,5 +9,4 @@ GitHub Actions relevant to the management of MCP repositories.
| [`cloudflare-pages-preview/deploy`](cloudflare-pages-preview/README.md) | Deploy a static HTML directory to Cloudflare Pages under a per-PR branch alias, inject noindex headers, and post a sticky PR comment with preview URLs | | [`cloudflare-pages-preview/deploy`](cloudflare-pages-preview/README.md) | Deploy a static HTML directory to Cloudflare Pages under a per-PR branch alias, inject noindex headers, and post a sticky PR comment with preview URLs |
| [`cloudflare-pages-preview/cleanup`](cloudflare-pages-preview/README.md) | Delete Cloudflare Pages deployments for a closed PR's branch alias and update the sticky comment | | [`cloudflare-pages-preview/cleanup`](cloudflare-pages-preview/README.md) | Delete Cloudflare Pages deployments for a closed PR's branch alias and update the sticky comment |
| [`hugo-build`](hugo-build/README.md) | Install Go + Hugo extended and build a Hugo site (preview or production mode) | | [`hugo-build`](hugo-build/README.md) | Install Go + Hugo extended and build a Hugo site (preview or production mode) |
| [`slash-commands`](slash-commands/README.md) | Handle `/lgtm`, `/hold`, and `/stageblog` PR slash commands gated by team membership + CODEOWNERS; invalidate approval on push | | [`slash-commands`](slash-commands/README.md) | Handle `/lgtm` (core-maintainers only: label + approve + auto-merge), `/hold`, and `/stageblog` PR slash commands; invalidate approval on push |
| [`slash-commands/status`](slash-commands/README.md#slash-commandsstatus-sub-action) | Set a pending/success/failure commit status based on approval/hold labels — yellow pending check until `/lgtm`, not red |

View file

@ -8,17 +8,19 @@ CODEOWNERS.
Listens for PR comments and push events, then: Listens for PR comments and push events, then:
- **`/lgtm`** — if the commenter is a core maintainer _or_ a CODEOWNER of the - **`/lgtm`** — **core maintainers only** (`always-allow-teams`). Adds an
PR's changed files, adds an `approved` label and submits a bot **APPROVE** `accepted` label, submits an **APPROVE** review on behalf of the commenter,
review with body "Approved on behalf of @user via `/lgtm`." and **enables auto-merge**. The PR merges automatically once all required
- **`/lgtm cancel`** — removes the `approved` label and dismisses the bot checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization.
review. - **`/lgtm cancel`** — removes the `accepted` label, dismisses the approval,
and disables auto-merge.
- **`/lgtm force`** — same as `/lgtm`, but **only** members of - **`/lgtm force`** — same as `/lgtm`, but **only** members of
`force-allow-teams` (default: core + lead maintainers) may use it, and it `force-allow-teams` (default: core + lead maintainers) may use it, and it
**bypasses the self-approval guard**. Intended as an escape hatch for **bypasses the self-approval guard**. Intended as an escape hatch for
maintainers who need to approve their own PRs. The bot review body notes maintainers who need to approve their own PRs. The review body notes the
the approval came via `/lgtm force`. approval came via `/lgtm force`.
- **`/hold`** — adds a `do-not-merge/hold` label (same authorization rule). - **`/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. - **`/hold cancel`** or **`/unhold`** — removes the hold label.
- **`/stageblog`** — if the PR touches any blog paths (default `blog/**`), - **`/stageblog`** — if the PR touches any blog paths (default `blog/**`),
dispatches a caller-defined `workflow_dispatch` workflow passing the PR dispatches a caller-defined `workflow_dispatch` workflow passing the PR
@ -26,77 +28,82 @@ Listens for PR comments and push events, then:
letting maintainers manually stage blog previews for fork PRs (where the letting maintainers manually stage blog previews for fork PRs (where the
normal `pull_request`-triggered preview workflow can't access secrets). normal `pull_request`-triggered preview workflow can't access secrets).
Disabled unless `stageblog-workflow` is set. Disabled unless `stageblog-workflow` is set.
- **New commits pushed** — removes the `approved` label, dismisses the bot - **New commits pushed** — removes the `accepted` label, dismisses the
review, and posts a brief comment asking for re-approval. 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, 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` 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 their own PR, receive a 👎 reaction with no further noise. Successful commands
receive a 👍 reaction. receive a 👍 reaction.
The action writes a commit status (`slash-commands/approval` by default) after ### Merge gate
each label mutation — this is the merge gate. Mark it as a required status
check in branch protection. The commit status can **only** be set to `success` The merge gate is **GitHub's native required-reviews rule** plus
by an authorized `/lgtm`; manually adding the `approved` label through the **auto-merge**. Configure branch protection to require at least one approving
GitHub UI has no effect on the gate. The caller workflow pairs this with the review; `/lgtm` satisfies that via the App's approval and turns on auto-merge.
`slash-commands/status` sub-action, which sets the status to `pending` on open The PR merges as soon as CI passes. Manually adding the `accepted` label in
and on every push (fresh SHA = fresh approval needed). the GitHub UI has no effect — the gate is the review, not the label.
## Prerequisites ## Prerequisites
- The `approved` and `do-not-merge/hold` labels **must already exist** in the - **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: caller repo (the action does not auto-create them). Suggested colors:
`approved` → `#0e8a16`, `do-not-merge/hold` → `#d93f0b`. `accepted` → `#0e8a16`, `do-not-merge/hold` → `#d93f0b`.
- A CODEOWNERS file at `.github/CODEOWNERS` (or pass a different path via - A CODEOWNERS file at `.github/CODEOWNERS` (or pass a different path via
`codeowners-path`). Without one, only `always-allow-teams` members can act. `codeowners-path`) if you want CODEOWNERS to grant `/hold`/`/stageblog`.
Not required for `/lgtm`.
## Required secrets ## Required secrets
Create this secret in the caller repo: GitHub → repo → **Settings → Secrets The action runs as a **GitHub App** so its approvals satisfy branch
and variables → Actions → New repository secret**. protection's required-reviews rule without depending on the
"Allow GitHub Actions to create and approve pull requests" repo setting.
| Repo secret name | Action input | Value | | Repo secret name | Value |
|---|---|---| |---|---|
| `SLASH_COMMANDS_TOKEN` | `github-token` | Fine-grained PAT (or GitHub App installation token) with **Organization → Members: read** plus **Repository → Contents: read**. If `/stageblog` is enabled, additionally **Repository → Actions: write**. | | `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 |
> **The default `${{ github.token }}` will NOT work** for `github-token` — ### Creating the GitHub App
> team-membership checks (`GET /orgs/{org}/teams/{team}/memberships/{user}`)
> require `read:org` scope, which the automatic `GITHUB_TOKEN` never has. You
> _must_ provide a PAT or App token. Comments, reactions, labels, and reviews
> use a separate `bot-token` (defaults to `GITHUB_TOKEN`) so they show as
> authored by `github-actions[bot]`.
### `SLASH_COMMANDS_TOKEN` Org **Settings → Developer settings → GitHub Apps → New GitHub App**.
Configure:
**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 | | Section | Setting |
|---|---| |---|---|
| Resource owner | `modelcontextprotocol` (or your org) | | Webhook | **Disable** (uncheck "Active") — the App is just an identity |
| Repository access | **Only select repositories** → pick the caller repo | | Repository permissions → Pull requests | **Read & write** |
| Repository permissions → Contents | Read-only | | Repository permissions → Contents | **Read-only** |
| Organization permissions → Members | Read-only | | Repository permissions → Actions | **Read & write** _(only if `/stageblog` is enabled)_ |
| Repository permissions → Actions | Read and write _(only if `stageblog-workflow` is set)_ | | Organization permissions → Members | **Read-only** |
| Where can this GitHub App be installed? | **Only on this account** |
The PAT does not need write permissions — all writes (comments, reactions, After creating:
labels, reviews) use the workflow's `GITHUB_TOKEN`. **Contents** must remain
read-only — the action never writes code.
**Option B — GitHub App installation token:** 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
If your org already uses a bot App, generate an installation token with the > **Why an App and not a PAT?** App installation tokens are short-lived
same scopes and store it as `SLASH_COMMANDS_TOKEN`. The App must be installed > (auto-minted per workflow run via `actions/create-github-app-token`), are
on the caller repo _and_ have org-level **Members: read**. > 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 ## Caller workflow requirements
| | | | | |
|---|---| |---|---|
| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `opened`, `synchronize`) | | **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`) |
| **Permissions** | `pull-requests: write`, `issues: write`, `contents: read`, `statuses: write`. If `/stageblog` is enabled, also `actions: write`. | | **Permissions** | `contents: read` is sufficient — the App token handles all writes. If falling back to `GITHUB_TOKEN` for `bot-token`, add `pull-requests: write`, `issues: 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. | | **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. | | **No checkout** | The action calls GitHub API only. Do not `actions/checkout` PR code. |
@ -109,37 +116,37 @@ on:
issue_comment: issue_comment:
types: [created] types: [created]
pull_request_target: pull_request_target:
types: [opened, synchronize] types: [synchronize]
permissions: permissions:
pull-requests: write
issues: write
contents: read contents: read
statuses: write
jobs: jobs:
handle: handle:
if: >- if: >-
(github.event_name == 'issue_comment' && github.event.issue.pull_request) || (github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
(github.event_name == 'pull_request_target' && github.event.action == 'synchronize') github.event_name == 'pull_request_target'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - uses: modelcontextprotocol/actions/slash-commands@main
with: with:
github-token: ${{ secrets.SLASH_COMMANDS_TOKEN }} github-token: ${{ steps.app-token.outputs.token }}
always-allow-teams: core-maintainers,lead-maintainers bot-token: ${{ steps.app-token.outputs.token }}
# auto-merge-method: squash # default; also: merge, rebase
# stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog # stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog
# Merge-gate commit status. Mark `slash-commands/approval` as a required
# status check in branch protection. This stays pending (yellow) until
# /lgtm adds the approved label — no red ❌ on fresh PRs.
status:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: modelcontextprotocol/actions/slash-commands/status@main
``` ```
**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 ### Enabling `/stageblog` — companion workflow
When `stageblog-workflow` is set, you also need a separate workflow in the When `stageblog-workflow` is set, you also need a separate workflow in the
@ -189,16 +196,17 @@ jobs:
| Input | Required | Default | Description | | Input | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `contents: read` (+ `actions: write` for `/stageblog`). Used for team membership checks, CODEOWNERS fetch, and workflow dispatch. **Default `GITHUB_TOKEN` will not work** for team checks. | | `github-token` | ✅ | — | Token with `read:org` (Organization Members: read) for the team-membership check. Pass the App installation token. **Default `GITHUB_TOKEN` will not work** — it lacks `read:org`. |
| `bot-token` | | `${{ github.token }}` | Token for user-visible side effects (comments, reactions, labels, reviews) — defaults to `GITHUB_TOKEN` so these show as `github-actions[bot]`. Rarely needs overriding. | | `bot-token` | | `${{ github.token }}` | Token for all repo API calls: comments, reactions, labels, reviews, CODEOWNERS fetch, file listing, workflow dispatch, auto-merge. Pass the App installation token so approvals come from the App identity. |
| `approved-label` | | `approved` | Label added by `/lgtm` | | `approved-label` | | `accepted` | Label added by `/lgtm` to mark the PR as accepted |
| `hold-label` | | `do-not-merge/hold` | Label added by `/hold` | | `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 | | `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. | | `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) | | `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 `approved` label + dismiss bot review + comment when new commits are pushed. Set `'false'` to keep approval across pushes. | | `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 a bot APPROVE review alongside the label on `/lgtm`. Set `'false'` to use label only. | | `submit-review` | | `'true'` | Submit an APPROVE review alongside the label on `/lgtm`. Set `'false'` to use label only. |
| `status-context` | | `slash-commands/approval` | Commit-status context written after each label mutation. Must match the `status` sub-action's `status-context`. Mark this as a required status check in branch protection. | | `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-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. | | `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. |
@ -209,42 +217,6 @@ jobs:
| `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` | | `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) | | `actor` | Login of the commenter (empty for non-comment triggers) |
## `slash-commands/status` sub-action
Sets the **initial** `pending` commit status when a PR opens, and forces it
back to `pending` on every push (new SHA = new approval required). Run in a
`status` job on `pull_request_target` events. Mark the `status-context` value
as a required status check in branch protection.
This sub-action **never sets `success`** — that would let anyone with `triage`
permissions bypass the auth gate by adding the `approved` label manually.
Only the main action's `/lgtm` handler (which runs the team/CODEOWNERS check)
writes `success`. The sub-action also cannot react to the main action's label
writes: `GITHUB_TOKEN`-triggered events do not create workflow runs.
**States:**
| Condition | Commit status | Description |
|---|---|---|
| `synchronize` event + `invalidate-on-push: true` | `pending` (🟡) | New commits — re-approve |
| `do-not-merge/hold` present | `failure` (❌) | Blocked by hold |
| otherwise | `pending` (🟡) | Awaiting /lgtm |
**Inputs:**
| Input | Required | Default | Description |
|---|---|---|---|
| `github-token` | | `${{ github.token }}` | Token for the commit-status API. Workflow needs `statuses: write`. |
| `hold-label` | | `do-not-merge/hold` | Must match the main action's `hold-label` |
| `status-context` | | `slash-commands/approval` | Name shown in the merge box. **Must match** the main action's `status-context`. Mark this as a required status check. |
| `invalidate-on-push` | | `'true'` | On `synchronize`, force pending regardless of labels. **Must match** the main action's `invalidate-on-push`. |
**Outputs:**
| Output | Description |
|---|---|
| `state` | The commit status state set: `pending` or `failure` |
## CODEOWNERS pattern support ## CODEOWNERS pattern support
The inline CODEOWNERS parser handles the patterns currently used in MCP repos: The inline CODEOWNERS parser handles the patterns currently used in MCP repos:
@ -275,14 +247,12 @@ Last-match-wins per file, consistent with GitHub's native CODEOWNERS semantics.
`/lgtm force` escape hatch bypasses this, but is gated strictly to `/lgtm force` escape hatch bypasses this, but is gated strictly to
`force-allow-teams` membership (CODEOWNERS does not grant it) and the `force-allow-teams` membership (CODEOWNERS does not grant it) and the
review body records that `force` was used. review body records that `force` was used.
- **Manual label bypass** — the commit status (the actual merge gate) is only - **Manual label bypass** — the merge gate is the App's APPROVE review, which
ever set to `success` by the `/lgtm` handler after the auth check passes. is only submitted by the `/lgtm` handler after the team-membership check
The `status` sub-action sets `pending`/`failure` but never `success`, and the passes. Adding the `accepted` label via the GitHub UI is cosmetic.
caller workflow does not trigger on `labeled` events. Adding the `approved`
label via the GitHub UI is cosmetic.
- **`/stageblog` SHA pinning** — the dispatch passes `pr.head.sha` captured at - **`/stageblog` SHA pinning** — the dispatch passes `pr.head.sha` captured at
the moment the maintainer comments. The companion workflow **must** check 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 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. author cannot push new commits between the maintainer's review and the build.
- **Review provenance** — the bot review body always names the real approver - **Review provenance** — the review body always names the real approver
("on behalf of @login"), even though GitHub shows the review as bot-authored. ("on behalf of @login"), even though GitHub shows the review as App-authored.

View file

@ -1,10 +1,10 @@
name: PR Slash Commands name: PR Slash Commands
description: >- description: >-
Handles /lgtm, /hold, and /stageblog slash commands on PRs. Authorized Handles /lgtm, /hold, and /stageblog slash commands on PRs. Core maintainers
users (core maintainers or CODEOWNERS of the PR's changed files) can approve can approve via /lgtm (adds an accepted label, submits a bot APPROVE review,
via /lgtm (adds a label + bot APPROVE review), block via /hold, or trigger and enables auto-merge). Core maintainers or CODEOWNERS can block via /hold
a blog preview staging workflow via /stageblog. Approval is invalidated when or trigger a blog preview staging workflow via /stageblog. Approval is
new commits are pushed. invalidated when new commits are pushed.
inputs: inputs:
github-token: github-token:
@ -25,9 +25,9 @@ inputs:
required: false required: false
default: ${{ github.token }} default: ${{ github.token }}
approved-label: approved-label:
description: Label added by /lgtm and used by the status job as the merge gate description: Label added by /lgtm to mark the PR as accepted
required: false required: false
default: approved default: accepted
hold-label: hold-label:
description: Label added by /hold to block merging description: Label added by /hold to block merging
required: false required: false
@ -35,7 +35,8 @@ inputs:
always-allow-teams: always-allow-teams:
description: >- description: >-
Comma-separated team slugs (within the repo's org) whose members may Comma-separated team slugs (within the repo's org) whose members may
/lgtm or /hold any PR regardless of CODEOWNERS /lgtm any PR. Only these teams can /lgtm — CODEOWNERS does not grant
/lgtm authorization. Members may also /hold and /stageblog any PR.
required: false required: false
default: core-maintainers default: core-maintainers
force-allow-teams: force-allow-teams:
@ -61,13 +62,18 @@ inputs:
Set to 'false' to rely on the label alone. Set to 'false' to rely on the label alone.
required: false required: false
default: 'true' default: 'true'
status-context: enable-auto-merge:
description: >- description: >-
Commit-status context name written after each label mutation. Must match Enable auto-merge on the PR after a successful /lgtm. The PR will merge
the status sub-action's status-context so both jobs write to the same automatically once all required checks pass. Requires the repo to have
check. Mark this context as a required status check in branch protection. "Allow auto-merge" enabled in Settings. Disabled on /lgtm cancel and on
push invalidation.
required: false required: false
default: slash-commands/approval default: 'true'
auto-merge-method:
description: Merge method for auto-merge — one of squash, merge, rebase
required: false
default: squash
stageblog-workflow: stageblog-workflow:
description: >- description: >-
Workflow file name (e.g. stage-blog.yml) to dispatch when /stageblog is Workflow file name (e.g. stage-blog.yml) to dispatch when /stageblog is
@ -108,7 +114,8 @@ runs:
CODEOWNERS_PATH: ${{ inputs.codeowners-path }} CODEOWNERS_PATH: ${{ inputs.codeowners-path }}
INVALIDATE_ON_PUSH: ${{ inputs.invalidate-on-push }} INVALIDATE_ON_PUSH: ${{ inputs.invalidate-on-push }}
SUBMIT_REVIEW: ${{ inputs.submit-review }} SUBMIT_REVIEW: ${{ inputs.submit-review }}
STATUS_CONTEXT: ${{ inputs.status-context }} ENABLE_AUTO_MERGE: ${{ inputs.enable-auto-merge }}
AUTO_MERGE_METHOD: ${{ inputs.auto-merge-method }}
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
ORG_TOKEN: ${{ inputs.github-token }} ORG_TOKEN: ${{ inputs.github-token }}

View file

@ -13,7 +13,8 @@ module.exports = async ({ github, context, core }) => {
const codeownersPath = process.env.CODEOWNERS_PATH; const codeownersPath = process.env.CODEOWNERS_PATH;
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true'; const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
const submitReview = process.env.SUBMIT_REVIEW === 'true'; const submitReview = process.env.SUBMIT_REVIEW === 'true';
const statusContext = process.env.STATUS_CONTEXT; const autoMerge = process.env.ENABLE_AUTO_MERGE === 'true';
const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase();
const reviewMarker = '<!-- slash-commands-lgtm -->'; const reviewMarker = '<!-- slash-commands-lgtm -->';
// `github` is authed with bot-token (defaults to GITHUB_TOKEN) so comments, // `github` is authed with bot-token (defaults to GITHUB_TOKEN) so comments,
// reactions, labels, and reviews show as github-actions[bot]. The PAT is // reactions, labels, and reviews show as github-actions[bot]. The PAT is
@ -116,14 +117,19 @@ module.exports = async ({ github, context, core }) => {
return ok; return ok;
} }
async function isAuthorized(user, pr) { async function isInAlwaysAllowTeam(user) {
// 1. always-allow teams
for (const team of alwaysAllowTeams) { for (const team of alwaysAllowTeams) {
if (await isTeamMember(user, team)) { if (await isTeamMember(user, team)) {
core.info(`@${user} authorized via always-allow team @${owner}/${team}`); core.info(`@${user} authorized via always-allow team @${owner}/${team}`);
return true; return true;
} }
} }
return false;
}
async function isAuthorized(user, pr) {
// 1. always-allow teams
if (await isInAlwaysAllowTeam(user)) return true;
// 2. CODEOWNERS of the PR's changed files (from BASE ref) // 2. CODEOWNERS of the PR's changed files (from BASE ref)
let content; let content;
try { try {
@ -211,27 +217,48 @@ module.exports = async ({ github, context, core }) => {
} }
} }
} }
// GITHUB_TOKEN-triggered label events do NOT create workflow runs, so the async function enableAutoMerge(prNodeId, prNumber) {
// status sub-action cannot react to our addLabel/removeLabel calls. Set the if (!autoMerge) return;
// commit status directly here. This is also the ONLY path that may set try {
// state=success — the status sub-action deliberately never does, so that a await github.graphql(
// manually-added approved label (triage+ perms suffice) cannot bypass auth. `mutation($id: ID!, $m: PullRequestMergeMethod!) {
async function setCommitStatus(sha, approved, held) { enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: $m}) {
let state, description; pullRequest { autoMergeRequest { enabledAt } }
if (held) { }
state = 'failure'; }`,
description = `Blocked by ${holdLabel} — comment /unhold to clear`; { id: prNodeId, m: autoMergeMethod },
} else if (approved) { );
state = 'success'; core.info(`Auto-merge enabled (${autoMergeMethod})`);
description = `Approved via /lgtm`; } catch (e) {
} else { const msg = e.message || String(e);
state = 'pending'; // enablePullRequestAutoMerge rejects PRs that are already mergeable
description = `Awaiting /lgtm from a maintainer or CODEOWNER`; // ("clean status") because there is nothing to wait for. Merge directly.
if (/clean status/i.test(msg)) {
core.info('PR already mergeable — merging directly');
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber,
merge_method: autoMergeMethod.toLowerCase(),
});
} else {
core.warning(`Could not enable auto-merge: ${msg}`);
}
}
}
async function disableAutoMerge(prNodeId) {
if (!autoMerge) return;
try {
await github.graphql(
`mutation($id: ID!) {
disablePullRequestAutoMerge(input: {pullRequestId: $id}) {
pullRequest { id }
}
}`,
{ id: prNodeId },
);
core.info('Auto-merge disabled');
} catch (e) {
core.info(`Auto-merge disable skipped: ${e.message}`);
} }
await github.rest.repos.createCommitStatus({
owner, repo, sha, state, description, context: statusContext,
});
core.info(`Set commit status ${state} on ${sha.slice(0,7)}: ${description}`);
} }
// --- Dispatch --------------------------------------------------- // --- Dispatch ---------------------------------------------------
@ -245,6 +272,7 @@ module.exports = async ({ github, context, core }) => {
if (!hasApproved) return setResult('noop'); if (!hasApproved) return setResult('noop');
await removeLabel(pr.number, approvedLabel); await removeLabel(pr.number, approvedLabel);
await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.'); await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.');
await disableAutoMerge(pr.node_id);
await github.rest.issues.createComment({ await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, owner, repo, issue_number: pr.number,
body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`, body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`,
@ -275,10 +303,6 @@ module.exports = async ({ github, context, core }) => {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
if (pr.state !== 'open') return setResult('noop', actor); if (pr.state !== 'open') return setResult('noop', actor);
const preLabels = new Set(pr.labels.map(l => l.name));
const hadApproved = preLabels.has(approvedLabel);
const hadHold = preLabels.has(holdLabel);
// /lgtm force — restricted to force-allow-teams. Bypasses the self-approval // /lgtm force — restricted to force-allow-teams. Bypasses the self-approval
// guard and the CODEOWNERS path entirely; only team membership grants this. // guard and the CODEOWNERS path entirely; only team membership grants this.
if (cmd === 'lgtm' && force) { if (cmd === 'lgtm' && force) {
@ -293,7 +317,7 @@ module.exports = async ({ github, context, core }) => {
await addLabel(prNumber, approvedLabel); await addLabel(prNumber, approvedLabel);
if (submitReview) await submitApproval(prNumber, actor, true); if (submitReview) await submitApproval(prNumber, actor, true);
await react(commentId, '+1'); await react(commentId, '+1');
await setCommitStatus(pr.head.sha, true, hadHold); await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-forced', actor); return setResult('lgtm-forced', actor);
} }
@ -303,8 +327,13 @@ module.exports = async ({ github, context, core }) => {
return setResult('self-lgtm-blocked', actor); return setResult('self-lgtm-blocked', actor);
} }
// Auth check (same rule for all commands) // Auth check. /lgtm is restricted to always-allow-teams (core maintainers)
if (!(await isAuthorized(actor, pr))) { // only — CODEOWNERS does not grant it. /hold and /stageblog still accept
// CODEOWNERS of the PR's changed files.
const authorized = cmd === 'lgtm'
? await isInAlwaysAllowTeam(actor)
: await isAuthorized(actor, pr);
if (!authorized) {
await react(commentId, '-1'); await react(commentId, '-1');
return setResult('unauthorized', actor); return setResult('unauthorized', actor);
} }
@ -358,25 +387,23 @@ module.exports = async ({ github, context, core }) => {
await addLabel(prNumber, approvedLabel); await addLabel(prNumber, approvedLabel);
if (submitReview) await submitApproval(prNumber, actor); if (submitReview) await submitApproval(prNumber, actor);
await react(commentId, '+1'); await react(commentId, '+1');
await setCommitStatus(pr.head.sha, true, hadHold); await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-added', actor); return setResult('lgtm-added', actor);
} }
if (cmd === 'lgtm' && cancel) { if (cmd === 'lgtm' && cancel) {
await removeLabel(prNumber, approvedLabel); await removeLabel(prNumber, approvedLabel);
await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`); await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`);
await disableAutoMerge(pr.node_id);
await react(commentId, '+1'); await react(commentId, '+1');
await setCommitStatus(pr.head.sha, false, hadHold);
return setResult('lgtm-removed', actor); return setResult('lgtm-removed', actor);
} }
if ((cmd === 'hold' && !cancel)) { if ((cmd === 'hold' && !cancel)) {
await addLabel(prNumber, holdLabel); await addLabel(prNumber, holdLabel);
await react(commentId, '+1'); await react(commentId, '+1');
await setCommitStatus(pr.head.sha, hadApproved, true);
return setResult('hold-added', actor); return setResult('hold-added', actor);
} }
// /hold cancel or /unhold // /hold cancel or /unhold
await removeLabel(prNumber, holdLabel); await removeLabel(prNumber, holdLabel);
await react(commentId, '+1'); await react(commentId, '+1');
await setCommitStatus(pr.head.sha, hadApproved, false);
return setResult('hold-removed', actor); return setResult('hold-removed', actor);
}; };

View file

@ -1,84 +0,0 @@
name: PR Slash Commands — Merge-Gate Status
description: >-
Sets the initial pending commit status on the PR head SHA at open time and
forces it back to pending on push (new SHA, approval invalidated). Never
sets success — only the main slash-commands action's auth-gated /lgtm
handler may do that, so a manually-added approved label cannot bypass the
merge gate. Sets failure if the hold label is present.
inputs:
github-token:
description: >-
Token for the commit-status API call. The caller workflow must grant
this token `statuses: write`.
required: false
default: ${{ github.token }}
hold-label:
description: Label that blocks merging (set by /hold)
required: false
default: do-not-merge/hold
status-context:
description: >-
The commit-status context name. This is what appears in the PR merge box
and what you mark as a required status check in branch protection.
required: false
default: slash-commands/approval
invalidate-on-push:
description: >-
When 'true', a push (synchronize event) forces the status to pending
regardless of labels. Must match the main slash-commands action's
invalidate-on-push setting.
required: false
default: 'true'
outputs:
state:
description: The commit status state that was set (pending or failure)
value: ${{ steps.set.outputs.state }}
runs:
using: composite
steps:
- name: Set commit status
id: set
uses: actions/github-script@v8
env:
HOLD_LABEL: ${{ inputs.hold-label }}
STATUS_CONTEXT: ${{ inputs.status-context }}
INVALIDATE_ON_PUSH: ${{ inputs.invalidate-on-push }}
with:
github-token: ${{ inputs.github-token }}
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
if (!pr) {
core.setFailed('slash-commands/status must run on a pull_request(_target) event');
return;
}
const sha = pr.head.sha;
const holdLabel = process.env.HOLD_LABEL;
const statusContext = process.env.STATUS_CONTEXT;
const invalidateOnPush = process.env.INVALIDATE_ON_PUSH === 'true';
// Never set success here. The only success writer is the main
// action's /lgtm handler, which is auth-gated. Reading the approved
// label would let anyone with triage+ perms bypass auth by labeling
// manually. This sub-action's job is: pending on open, pending on
// push (new SHA needs fresh approval), failure on hold.
let state, description;
if (invalidateOnPush && context.payload.action === 'synchronize') {
state = 'pending';
description = `New commits pushed — awaiting re-approval via /lgtm`;
} else if (pr.labels.some(l => l.name === holdLabel)) {
state = 'failure';
description = `Blocked by ${holdLabel} — comment /unhold to clear`;
} else {
state = 'pending';
description = `Awaiting /lgtm from a maintainer or CODEOWNER`;
}
await github.rest.repos.createCommitStatus({
owner, repo, sha, state, description, context: statusContext,
});
core.setOutput('state', state);
core.info(`Set status ${state} on ${sha.slice(0,7)}: ${description}`);