Add slash-commands/status sub-action for pending-state merge gate

The previous status-job pattern (inline shell that exits 1 when 'approved'
is missing) shows a red failed check on every fresh PR. GitHub Actions jobs
only have pass/fail — there's no way to express 'pending' via exit code.

The new sub-action uses the commit-status API to set state=pending until
the approved label appears. Hold label produces state=failure. The status
job itself now always succeeds; the commit status it creates is what branch
protection enforces.

Caller workflow swaps the 20-line jq+exit block for a one-line uses: call,
adds statuses:write to permissions, and marks 'slash-commands/approval'
as a required status check (replacing the job-based check).
This commit is contained in:
Den Delimarsky 2026-03-11 22:56:18 +00:00
commit f931dcd1bf
No known key found for this signature in database
3 changed files with 118 additions and 19 deletions

View file

@ -10,3 +10,4 @@ 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 |
| [`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/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

@ -29,9 +29,10 @@ 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.
The caller workflow pairs this with the `slash-commands/status` sub-action,
which sets a commit status that stays **pending** until `approved` is present
and goes **failure** when `do-not-merge/hold` is present — making the labels
an actual merge gate via branch protection without a red ❌ on every fresh PR.
## Prerequisites
@ -87,7 +88,7 @@ on the caller repo _and_ have org-level **Members: read**.
| | |
|---|---|
| **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`. |
| **Permissions** | `pull-requests: write`, `issues: write`, `contents: read`, `statuses: write`. 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. |
@ -106,6 +107,7 @@ permissions:
pull-requests: write
issues: write
contents: read
statuses: write
jobs:
handle:
@ -120,25 +122,14 @@ jobs:
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.
# 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:
- 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"
- uses: modelcontextprotocol/actions/slash-commands/status@main
```
### Enabling `/stageblog` — companion workflow
@ -208,6 +199,36 @@ jobs:
| `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) |
## `slash-commands/status` sub-action
Sets a commit status on the PR head SHA derived from the approval/hold labels.
Use in a `status` job that runs on every `pull_request_target` event. The job
itself always succeeds — the commit status it creates is what branch protection
enforces. Make the `status-context` value a required status check.
**States:**
| Labels | Commit status | Description |
|---|---|---|
| `do-not-merge/hold` present | `failure` (❌) | Blocked by hold |
| `approved` present, no hold | `success` (✅) | Approved via /lgtm |
| neither | `pending` (🟡) | Awaiting /lgtm |
**Inputs:**
| Input | Required | Default | Description |
|---|---|---|---|
| `github-token` | | `${{ github.token }}` | Token for the commit-status API. Workflow needs `statuses: write`. |
| `approved-label` | | `approved` | Must match the main action's `approved-label` |
| `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. Mark this as a required status check. |
**Outputs:**
| Output | Description |
|---|---|
| `state` | The commit status state set: `pending`, `success`, or `failure` |
## CODEOWNERS pattern support
The inline CODEOWNERS parser handles the patterns currently used in MCP repos:

View file

@ -0,0 +1,77 @@
name: PR Slash Commands — Merge-Gate Status
description: >-
Sets a commit status on the PR head SHA based on approval/hold labels.
Stays `pending` until the approved label is present, goes `failure` when the
hold label is present, and `success` when approved-and-not-held. Use this
instead of an exit-code-based status job so PRs show a yellow pending check
(not a red failure) while awaiting /lgtm.
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 }}
approved-label:
description: Label that marks the PR as approved (set by /lgtm)
required: false
default: approved
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
outputs:
state:
description: The commit status state that was set (pending, success, failure)
value: ${{ steps.set.outputs.state }}
runs:
using: composite
steps:
- name: Set commit status
id: set
uses: actions/github-script@v8
env:
APPROVED_LABEL: ${{ inputs.approved-label }}
HOLD_LABEL: ${{ inputs.hold-label }}
STATUS_CONTEXT: ${{ inputs.status-context }}
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 labels = pr.labels.map(l => l.name);
const approvedLabel = process.env.APPROVED_LABEL;
const holdLabel = process.env.HOLD_LABEL;
const statusContext = process.env.STATUS_CONTEXT;
let state, description;
if (labels.includes(holdLabel)) {
state = 'failure';
description = `Blocked by ${holdLabel} — comment /unhold to clear`;
} else if (labels.includes(approvedLabel)) {
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.setOutput('state', state);
core.info(`Set status ${state} on ${sha.slice(0,7)}: ${description}`);