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:
parent
5cfe4d9921
commit
f931dcd1bf
3 changed files with 118 additions and 19 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
77
slash-commands/status/action.yml
Normal file
77
slash-commands/status/action.yml
Normal 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}`);
|
||||
Loading…
Reference in a new issue