Merge pull request #2 from modelcontextprotocol/den/stageblog-command
Add /stageblog slash command for fork-PR blog preview dispatch
This commit is contained in:
commit
9fccbf5d69
4 changed files with 150 additions and 13 deletions
|
|
@ -8,4 +8,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 |
|
||||||
| [`slash-commands`](slash-commands/README.md) | Handle `/lgtm` and `/hold` PR slash commands gated by team membership + CODEOWNERS; invalidate approval on push |
|
| [`slash-commands`](slash-commands/README.md) | Handle `/lgtm`, `/hold`, and `/stageblog` PR slash commands gated by team membership + CODEOWNERS; invalidate approval on push |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# slash-commands
|
# slash-commands
|
||||||
|
|
||||||
Reusable composite action that implements Prow-style `/lgtm` and `/hold` slash
|
Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and
|
||||||
commands on pull requests, gated by team membership and CODEOWNERS.
|
`/stageblog` slash commands on pull requests, gated by team membership and
|
||||||
|
CODEOWNERS.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|
@ -14,6 +15,12 @@ Listens for PR comments and push events, then:
|
||||||
review.
|
review.
|
||||||
- **`/hold`** — adds a `do-not-merge/hold` label (same authorization rule).
|
- **`/hold`** — adds a `do-not-merge/hold` label (same authorization rule).
|
||||||
- **`/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/**`),
|
||||||
|
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
|
- **New commits pushed** — removes the `approved` label, dismisses the bot
|
||||||
review, and posts a brief comment asking for re-approval.
|
review, and posts a brief comment asking for re-approval.
|
||||||
|
|
||||||
|
|
@ -41,7 +48,7 @@ and variables → Actions → New repository secret**.
|
||||||
|
|
||||||
| Repo secret name | Action input | Value |
|
| 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** |
|
| `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
|
> **The default `${{ github.token }}` will NOT work.** Team-membership checks
|
||||||
> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope,
|
> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope,
|
||||||
|
|
@ -63,6 +70,7 @@ tokens → Generate new token**. Configure:
|
||||||
| Repository permissions → Pull requests | Read and write |
|
| Repository permissions → Pull requests | Read and write |
|
||||||
| Repository permissions → Issues | Read and write |
|
| Repository permissions → Issues | Read and write |
|
||||||
| Organization permissions → Members | Read-only |
|
| 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**
|
Do not grant any write permissions beyond Pull requests + Issues. **Contents**
|
||||||
must remain read-only — the action never writes code.
|
must remain read-only — the action never writes code.
|
||||||
|
|
@ -78,7 +86,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`) |
|
| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`, `opened`, `reopened`, `labeled`, `unlabeled`) |
|
||||||
| **Permissions** | `pull-requests: write`, `issues: write`, `contents: read` |
|
| **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. |
|
| **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,6 +117,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.SLASH_COMMANDS_TOKEN }}
|
github-token: ${{ secrets.SLASH_COMMANDS_TOKEN }}
|
||||||
always-allow-teams: core-maintainers,lead-maintainers
|
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 status check — make this a required check in branch protection.
|
||||||
status:
|
status:
|
||||||
|
|
@ -131,6 +140,70 @@ jobs:
|
||||||
echo "approved and not held"
|
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
|
## Inputs
|
||||||
|
|
||||||
| Input | Required | Default | Description |
|
| Input | Required | Default | Description |
|
||||||
|
|
@ -142,12 +215,14 @@ jobs:
|
||||||
| `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) |
|
||||||
| `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 `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. |
|
| `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
|
## Outputs
|
||||||
|
|
||||||
| Output | Description |
|
| Output | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `result` | One of: `lgtm-added`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `unauthorized`, `self-lgtm-blocked`, `noop` |
|
| `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) |
|
| `actor` | Login of the commenter (empty for non-comment triggers) |
|
||||||
|
|
||||||
## CODEOWNERS pattern support
|
## CODEOWNERS pattern support
|
||||||
|
|
@ -177,5 +252,9 @@ Last-match-wins per file, consistent with GitHub's native CODEOWNERS semantics.
|
||||||
from the default-branch workflow definition. The action does not check out
|
from the default-branch workflow definition. The action does not check out
|
||||||
PR code.
|
PR code.
|
||||||
- **Self-approval** — explicitly blocked before the auth check.
|
- **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
|
- **Review provenance** — the bot 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 bot-authored.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
name: PR Slash Commands
|
name: PR Slash Commands
|
||||||
description: >-
|
description: >-
|
||||||
Handles /lgtm and /hold slash commands on PRs. Authorized users
|
Handles /lgtm, /hold, and /stageblog slash commands on PRs. Authorized
|
||||||
(core maintainers or CODEOWNERS of the PR's changed files) can approve via
|
users (core maintainers or CODEOWNERS of the PR's changed files) can approve
|
||||||
/lgtm (adds a label + bot APPROVE review) or block via /hold. Approval is
|
via /lgtm (adds a label + bot APPROVE review), block via /hold, or trigger
|
||||||
invalidated when new commits are pushed.
|
a blog preview staging workflow via /stageblog. Approval is invalidated when
|
||||||
|
new commits are pushed.
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
github-token:
|
github-token:
|
||||||
|
|
@ -43,12 +44,27 @@ 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'
|
||||||
|
stageblog-workflow:
|
||||||
|
description: >-
|
||||||
|
Workflow file name (e.g. stage-blog.yml) to dispatch when /stageblog is
|
||||||
|
invoked. Leave empty to disable the command. The workflow must accept
|
||||||
|
inputs 'pr_number' and 'head_sha'.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
stageblog-paths:
|
||||||
|
description: >-
|
||||||
|
Comma-separated glob patterns (CODEOWNERS-style: **, *, ?, leading /,
|
||||||
|
trailing /) matched against the PR's changed files. /stageblog is refused
|
||||||
|
if no file matches.
|
||||||
|
required: false
|
||||||
|
default: 'blog/**'
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
result:
|
result:
|
||||||
description: >-
|
description: >-
|
||||||
One of: lgtm-added, lgtm-removed, hold-added, hold-removed, invalidated,
|
One of: lgtm-added, lgtm-removed, hold-added, hold-removed, invalidated,
|
||||||
unauthorized, self-lgtm-blocked, noop
|
unauthorized, self-lgtm-blocked, stageblog-dispatched, stageblog-not-blog,
|
||||||
|
stageblog-disabled, noop
|
||||||
value: ${{ steps.handle.outputs.result }}
|
value: ${{ steps.handle.outputs.result }}
|
||||||
actor:
|
actor:
|
||||||
description: Login of the user who triggered the action (commenter)
|
description: Login of the user who triggered the action (commenter)
|
||||||
|
|
@ -67,6 +83,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 }}
|
||||||
|
STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }}
|
||||||
|
STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ inputs.github-token }}
|
github-token: ${{ inputs.github-token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Handler for /lgtm and /hold slash commands. Called by actions/github-script
|
// Handler for /lgtm, /hold, and /stageblog slash commands. Called by actions/github-script
|
||||||
// via `require(github.action_path + '/main.js')({ github, context, core })`.
|
// via `require(github.action_path + '/main.js')({ github, context, core })`.
|
||||||
// See action.yml for input wiring via env vars.
|
// See action.yml for input wiring via env vars.
|
||||||
|
|
||||||
|
|
@ -218,7 +218,7 @@ module.exports = async ({ github, context, core }) => {
|
||||||
const commentId = context.payload.comment.id;
|
const commentId = context.payload.comment.id;
|
||||||
const actor = context.payload.comment.user.login;
|
const actor = context.payload.comment.user.login;
|
||||||
const firstLine = commentBody.split('\n')[0].trim();
|
const firstLine = commentBody.split('\n')[0].trim();
|
||||||
const m = firstLine.match(/^\/(lgtm|hold|unhold)\b\s*(cancel)?\s*$/i);
|
const m = firstLine.match(/^\/(lgtm|hold|unhold|stageblog)\b\s*(cancel)?\s*$/i);
|
||||||
if (!m) return setResult('noop', actor);
|
if (!m) return setResult('noop', actor);
|
||||||
|
|
||||||
const cmd = m[1].toLowerCase();
|
const cmd = m[1].toLowerCase();
|
||||||
|
|
@ -240,6 +240,46 @@ module.exports = async ({ github, context, core }) => {
|
||||||
return setResult('unauthorized', actor);
|
return setResult('unauthorized', actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /stageblog — dispatch a caller-defined workflow with pinned head SHA
|
||||||
|
if (cmd === 'stageblog') {
|
||||||
|
const stageblogWorkflow = process.env.STAGEBLOG_WORKFLOW;
|
||||||
|
if (!stageblogWorkflow) {
|
||||||
|
await react(commentId, 'confused');
|
||||||
|
return setResult('stageblog-disabled', actor);
|
||||||
|
}
|
||||||
|
// cancel is meaningless here — just ignore silently
|
||||||
|
if (cancel) return setResult('noop', actor);
|
||||||
|
|
||||||
|
// Gate: PR must touch blog paths
|
||||||
|
const blogRegexes = process.env.STAGEBLOG_PATHS
|
||||||
|
.split(',').map(s => s.trim()).filter(Boolean).map(patternToRegex);
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner, repo, pull_number: prNumber, per_page: 100,
|
||||||
|
});
|
||||||
|
const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename)));
|
||||||
|
if (!touchesBlog) {
|
||||||
|
await react(commentId, '-1');
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number: prNumber,
|
||||||
|
body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`,
|
||||||
|
});
|
||||||
|
return setResult('stageblog-not-blog', actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch — pin head SHA at time of comment
|
||||||
|
const headSha = pr.head.sha;
|
||||||
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
|
owner, repo, workflow_id: stageblogWorkflow, ref: pr.base.ref,
|
||||||
|
inputs: { pr_number: String(prNumber), head_sha: headSha },
|
||||||
|
});
|
||||||
|
await react(commentId, 'rocket');
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number: prNumber,
|
||||||
|
body: `Blog staging triggered by @${actor} for \`${headSha.slice(0, 7)}\`. Watch the [**${stageblogWorkflow}** workflow](../actions/workflows/${stageblogWorkflow}) for the preview link.`,
|
||||||
|
});
|
||||||
|
return setResult('stageblog-dispatched', actor);
|
||||||
|
}
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
if (cmd === 'lgtm' && !cancel) {
|
if (cmd === 'lgtm' && !cancel) {
|
||||||
await addLabel(prNumber, approvedLabel);
|
await addLabel(prNumber, approvedLabel);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue