slash-commands: remove commit-status gate, document GitHub App flow

The merge gate is now solely the App's APPROVE review satisfying branch
protection's required-reviews rule, plus auto-merge. The commit-status
mechanism is removed entirely:

- delete slash-commands/status sub-action
- drop status-context input and setCommitStatus() from main.js
- drop the now-unused hadApproved/hadHold label snapshot

README rewritten to document the GitHub App setup (MCP_COMMANDER_APP_ID
+ MCP_COMMANDER_APP_KEY secrets, actions/create-github-app-token to mint
the installation token) as the primary flow. The App token is passed as
both github-token (team checks) and bot-token (approval/auto-merge) so
approvals come from the App identity and always count toward required
reviews, independent of the "Allow GitHub Actions to approve" repo
setting.
This commit is contained in:
Den Delimarsky 2026-03-26 23:21:16 +00:00
commit 63db292169
No known key found for this signature in database
5 changed files with 69 additions and 239 deletions

View file

@ -10,4 +10,3 @@ GitHub Actions relevant to the management of MCP repositories.
| [`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` (core-maintainers only: label + approve + auto-merge), `/hold`, and `/stageblog` PR slash commands; 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) | _Optional:_ set a pending/failure commit status for callers using status-check-based gating instead of required-reviews + auto-merge |

View file

@ -9,16 +9,16 @@ CODEOWNERS.
Listens for PR comments and push events, then: Listens for PR comments and push events, then:
- **`/lgtm`** — **core maintainers only** (`always-allow-teams`). Adds an - **`/lgtm`** — **core maintainers only** (`always-allow-teams`). Adds an
`accepted` label, submits a bot **APPROVE** review on behalf of the team, `accepted` label, submits an **APPROVE** review on behalf of the commenter,
and **enables auto-merge**. The PR merges automatically once all required and **enables auto-merge**. The PR merges automatically once all required
checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization. checks pass. CODEOWNERS does _not_ grant `/lgtm` authorization.
- **`/lgtm cancel`** — removes the `accepted` label, dismisses the bot review, - **`/lgtm cancel`** — removes the `accepted` label, dismisses the approval,
and disables auto-merge. 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. Core maintainers _or_ - **`/hold`** — adds a `do-not-merge/hold` label. Core maintainers _or_
CODEOWNERS of the PR's changed files may use this. 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.
@ -28,8 +28,8 @@ 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 `accepted` label, dismisses the bot - **New commits pushed** — removes the `accepted` label, dismisses the
review, disables auto-merge, and posts a brief comment asking for approval, disables auto-merge, and posts a brief comment asking for
re-approval. 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,
@ -39,75 +39,71 @@ receive a 👍 reaction.
### Merge gate ### Merge gate
The primary merge gate is **GitHub's native required-reviews rule** plus The merge gate is **GitHub's native required-reviews rule** plus
**auto-merge**: configure branch protection to require at least one approving **auto-merge**. Configure branch protection to require at least one approving
review, and enable "Allow auto-merge" in repo Settings. `/lgtm` then satisfies review; `/lgtm` satisfies that via the App's approval and turns on auto-merge.
the review requirement and turns on auto-merge; the PR merges as soon as CI The PR merges as soon as CI passes. Manually adding the `accepted` label in
passes. Manually adding the `accepted` label in the GitHub UI has no effect — the GitHub UI has no effect — the gate is the review, not the label.
the gate is the review, not the label.
An optional commit status (`slash-commands/approval`) is also written for
callers who prefer status-check-based gating — set `status-context: ''` to
disable it. See the [`slash-commands/status` sub-action](#slash-commandsstatus-sub-action)
if you want to use the status-check flow instead of auto-merge.
## Prerequisites ## Prerequisites
- **Allow auto-merge** must be enabled in the caller repo - **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). (Settings → General → Pull Requests → Allow auto-merge).
- The `accepted` and `do-not-merge/hold` labels **must already exist** in the - 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:
`accepted` → `#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. |
@ -120,31 +116,35 @@ 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 }}
bot-token: ${{ steps.app-token.outputs.token }}
# auto-merge-method: squash # default; also: merge, rebase # 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
``` ```
**Branch protection settings** for this flow: enable _Require a pull request **Branch protection settings** for this flow: enable _Require a pull request
before merging_ → _Require approvals_ (1), and _Allow auto-merge_ in repo before merging_ → _Require approvals_ (1), and _Allow auto-merge_ in repo
Settings. The bot's APPROVE review satisfies the requirement and auto-merge Settings. The App's APPROVE review satisfies the requirement and auto-merge
handles the rest. handles the rest.
### Enabling `/stageblog` — companion workflow ### Enabling `/stageblog` — companion workflow
@ -196,18 +196,17 @@ jobs:
| Input | Required | Default | Description | | Input | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| `github-token` | ✅ | — | PAT or App token with `read:org` (Organization Members: read). Used **only** for the team-membership check. **Default `GITHUB_TOKEN` will not work** — it lacks `read:org`. | | `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 all repo API calls: comments, reactions, labels, reviews, CODEOWNERS fetch, file listing, workflow dispatch, auto-merge. Defaults to `GITHUB_TOKEN` so user-visible actions show as `github-actions[bot]`. The caller workflow must grant it the needed permissions. | | `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` | | `accepted` | Label added by `/lgtm` to mark the PR as accepted | | `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` any PR. **Only these teams can `/lgtm`** — CODEOWNERS does not grant it. Members may also `/hold`/`/stageblog` any PR. | | `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). Grants `/hold` and `/stageblog` only, not `/lgtm`. | | `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 bot review + disable auto-merge + 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. |
| `enable-auto-merge` | | `'true'` | Enable auto-merge after `/lgtm` (disabled on `/lgtm cancel` and push invalidation). Requires repo setting "Allow auto-merge". | | `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` | | `auto-merge-method` | | `squash` | Merge method for auto-merge: `squash`, `merge`, or `rebase` |
| `status-context` | | `slash-commands/approval` | Optional commit-status context. Set to `''` to disable status writes (recommended when using auto-merge + required reviews as the gate). |
| `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. |
@ -218,47 +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
> **Optional** — only needed if you're using the commit-status merge gate
> instead of (or alongside) the required-reviews + auto-merge flow. If your
> branch protection uses required reviews, you can skip this sub-action and
> set `status-context: ''` on the main 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:
@ -289,13 +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 merge gate is the bot's APPROVE review (or, - **Manual label bypass** — the merge gate is the App's APPROVE review, which
in the status-check flow, the commit status), both of which are only written is only submitted by the `/lgtm` handler after the team-membership check
by the `/lgtm` handler after the auth check passes. Adding the `accepted` passes. Adding the `accepted` label via the GitHub UI is cosmetic.
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

@ -74,14 +74,6 @@ inputs:
description: Merge method for auto-merge — one of squash, merge, rebase description: Merge method for auto-merge — one of squash, merge, rebase
required: false required: false
default: squash default: squash
status-context:
description: >-
Commit-status context name written after each label mutation. Set to
empty string to disable status writes (recommended when using
enable-auto-merge + required reviews as the merge gate instead). If set,
must match the status sub-action's status-context.
required: false
default: slash-commands/approval
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
@ -124,7 +116,6 @@ runs:
SUBMIT_REVIEW: ${{ inputs.submit-review }} SUBMIT_REVIEW: ${{ inputs.submit-review }}
ENABLE_AUTO_MERGE: ${{ inputs.enable-auto-merge }} ENABLE_AUTO_MERGE: ${{ inputs.enable-auto-merge }}
AUTO_MERGE_METHOD: ${{ inputs.auto-merge-method }} AUTO_MERGE_METHOD: ${{ inputs.auto-merge-method }}
STATUS_CONTEXT: ${{ inputs.status-context }}
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,6 @@ 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 autoMerge = process.env.ENABLE_AUTO_MERGE === 'true';
const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase(); const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase();
const reviewMarker = '<!-- slash-commands-lgtm -->'; const reviewMarker = '<!-- slash-commands-lgtm -->';
@ -218,29 +217,6 @@ module.exports = async ({ github, context, core }) => {
} }
} }
} }
// GITHUB_TOKEN-triggered label events do NOT create workflow runs, so the
// status sub-action cannot react to our addLabel/removeLabel calls. Set the
// commit status directly here. This is also the ONLY path that may set
// state=success — the status sub-action deliberately never does, so that a
// manually-added approved label (triage+ perms suffice) cannot bypass auth.
async function setCommitStatus(sha, approved, held) {
if (!statusContext) return;
let state, description;
if (held) {
state = 'failure';
description = `Blocked by ${holdLabel} — comment /unhold to clear`;
} else if (approved) {
state = 'success';
description = `Approved via /lgtm`;
} else {
state = 'pending';
description = `Awaiting /lgtm from a maintainer or CODEOWNER`;
}
await github.rest.repos.createCommitStatus({
owner, repo, sha, state, description, context: statusContext,
});
core.info(`Set commit status ${state} on ${sha.slice(0,7)}: ${description}`);
}
async function enableAutoMerge(prNodeId, prNumber) { async function enableAutoMerge(prNodeId, prNumber) {
if (!autoMerge) return; if (!autoMerge) return;
try { try {
@ -327,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) {
@ -345,7 +317,6 @@ 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); await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-forced', actor); return setResult('lgtm-forced', actor);
} }
@ -416,7 +387,6 @@ 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); await enableAutoMerge(pr.node_id, prNumber);
return setResult('lgtm-added', actor); return setResult('lgtm-added', actor);
} }
@ -425,18 +395,15 @@ module.exports = async ({ github, context, core }) => {
await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`); await dismissBotApprovals(prNumber, `@${actor} cancelled approval via /lgtm cancel.`);
await disableAutoMerge(pr.node_id); 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}`);