diff --git a/README.md b/README.md index 972cbff..8d45f42 100644 --- a/README.md +++ b/README.md @@ -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/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 | diff --git a/slash-commands/README.md b/slash-commands/README.md index 1e10107..80b3628 100644 --- a/slash-commands/README.md +++ b/slash-commands/README.md @@ -1,7 +1,8 @@ # slash-commands -Reusable composite action that implements Prow-style `/lgtm` and `/hold` slash -commands on pull requests, gated by team membership and CODEOWNERS. +Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and +`/stageblog` slash commands on pull requests, gated by team membership and +CODEOWNERS. ## What it does @@ -14,6 +15,12 @@ Listens for PR comments and push events, then: review. - **`/hold`** — adds a `do-not-merge/hold` label (same authorization rule). - **`/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 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 | |---|---|---| -| `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 > (`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 → Issues | Read and write | | 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** 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`) | -| **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. | | **No checkout** | The action calls GitHub API only. Do not `actions/checkout` PR code. | @@ -109,6 +117,7 @@ jobs: with: github-token: ${{ secrets.SLASH_COMMANDS_TOKEN }} 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. status: @@ -131,6 +140,70 @@ jobs: 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 | 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) | | `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. | +| `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 | 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) | ## 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 PR code. - **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 ("on behalf of @login"), even though GitHub shows the review as bot-authored. diff --git a/slash-commands/action.yml b/slash-commands/action.yml index 1c14dbc..3202c31 100644 --- a/slash-commands/action.yml +++ b/slash-commands/action.yml @@ -1,9 +1,10 @@ name: PR Slash Commands description: >- - Handles /lgtm and /hold slash commands on PRs. Authorized users - (core maintainers or CODEOWNERS of the PR's changed files) can approve via - /lgtm (adds a label + bot APPROVE review) or block via /hold. Approval is - invalidated when new commits are pushed. + Handles /lgtm, /hold, and /stageblog slash commands on PRs. Authorized + users (core maintainers or CODEOWNERS of the PR's changed files) can approve + via /lgtm (adds a label + bot APPROVE review), block via /hold, or trigger + a blog preview staging workflow via /stageblog. Approval is invalidated when + new commits are pushed. inputs: github-token: @@ -43,12 +44,27 @@ inputs: Set to 'false' to rely on the label alone. required: false 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: result: description: >- 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 }} actor: description: Login of the user who triggered the action (commenter) @@ -67,6 +83,8 @@ runs: CODEOWNERS_PATH: ${{ inputs.codeowners-path }} INVALIDATE_ON_PUSH: ${{ inputs.invalidate-on-push }} SUBMIT_REVIEW: ${{ inputs.submit-review }} + STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} + STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} with: github-token: ${{ inputs.github-token }} script: | diff --git a/slash-commands/main.js b/slash-commands/main.js index aa9570e..19a1ac0 100644 --- a/slash-commands/main.js +++ b/slash-commands/main.js @@ -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 })`. // 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 actor = context.payload.comment.user.login; 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); const cmd = m[1].toLowerCase(); @@ -240,6 +240,46 @@ module.exports = async ({ github, context, core }) => { 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 if (cmd === 'lgtm' && !cancel) { await addLabel(prNumber, approvedLabel);