From cad4102f07bb2354a8a9450dab2447067a92e61c Mon Sep 17 00:00:00 2001 From: Den Delimarsky Date: Wed, 11 Mar 2026 20:41:44 +0000 Subject: [PATCH] Post comments/reactions/labels/reviews as github-actions[bot] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the action used the PAT (SLASH_COMMANDS_TOKEN) for everything, so comments and reviews showed as authored by whoever owns the PAT. This is confusing — the PAT exists only because team-membership checks need read:org, which GITHUB_TOKEN can never have. New bot-token input (default ${{ github.token }}) is used for all user-visible side effects. The PAT is now scoped to what actually needs it: team membership, CODEOWNERS fetch, listFiles, pulls.get, and workflow dispatch. Side benefit: the PAT's required scopes shrink. It no longer needs Pull requests:write or Issues:write — just Contents:read + Members:read (+ Actions:write for /stageblog). README updated to reflect this. --- slash-commands/README.md | 22 ++++++++++++---------- slash-commands/action.yml | 19 +++++++++++++++---- slash-commands/main.js | 25 ++++++++++++++++--------- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/slash-commands/README.md b/slash-commands/README.md index b0aba74..63aa6ad 100644 --- a/slash-commands/README.md +++ b/slash-commands/README.md @@ -48,12 +48,14 @@ 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**. If `/stageblog` is enabled, additionally **Repository → Actions: write**. | +| `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**. | -> **The default `${{ github.token }}` will NOT work.** Team-membership checks -> (`GET /orgs/{org}/teams/{team}/memberships/{user}`) require `read:org` scope, -> which the automatic `GITHUB_TOKEN` never has — even with a `permissions:` -> block. You _must_ provide a PAT or App token. +> **The default `${{ github.token }}` will NOT work** for `github-token` — +> 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` @@ -67,13 +69,12 @@ tokens → Generate new token**. Configure: | Resource owner | `modelcontextprotocol` (or your org) | | Repository access | **Only select repositories** → pick the caller repo | | Repository permissions → Contents | Read-only | -| 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. +The PAT does not need write permissions — all writes (comments, reactions, +labels, reviews) use the workflow's `GITHUB_TOKEN`. **Contents** must remain +read-only — the action never writes code. **Option B — GitHub App installation token:** @@ -189,7 +190,8 @@ jobs: | Input | Required | Default | Description | |---|---|---|---| -| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `pull-requests: write` + `issues: write` + `contents: read`. **Default `GITHUB_TOKEN` will not work** for team checks. | +| `github-token` | ✅ | — | PAT or App token with `read:org` + repo `contents: read` (+ `actions: write` for `/stageblog`). Used for team membership checks, CODEOWNERS fetch, and workflow dispatch. **Default `GITHUB_TOKEN` will not work** for team checks. | +| `bot-token` | | `${{ github.token }}` | Token for user-visible side effects (comments, reactions, labels, reviews) — defaults to `GITHUB_TOKEN` so these show as `github-actions[bot]`. Rarely needs overriding. | | `approved-label` | | `approved` | Label added by `/lgtm` | | `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` or `/hold` any PR regardless of CODEOWNERS | diff --git a/slash-commands/action.yml b/slash-commands/action.yml index 3202c31..12653d8 100644 --- a/slash-commands/action.yml +++ b/slash-commands/action.yml @@ -9,11 +9,21 @@ description: >- inputs: github-token: description: >- - Token for API calls. MUST have read:org scope to check team membership — - the default GITHUB_TOKEN cannot do this. Use a fine-grained PAT with - Organization Members:read + Repository Contents:read + Pull requests:write - + Issues:write, or a GitHub App installation token with equivalent perms. + Token for org team-membership checks and workflow dispatch. MUST have + read:org scope — the default GITHUB_TOKEN cannot do this. Use a + fine-grained PAT with Organization Members:read (+ Actions:write if + using /stageblog), or a GitHub App installation token with equivalent + perms. required: true + bot-token: + description: >- + Token used for user-visible side effects (comments, reactions, labels, + reviews). Defaults to the automatic GITHUB_TOKEN so these show as + authored by github-actions[bot]. The caller workflow must grant this + token the necessary repo permissions (pull-requests: write, + issues: write). + required: false + default: ${{ github.token }} approved-label: description: Label added by /lgtm and used by the status job as the merge gate required: false @@ -85,6 +95,7 @@ runs: SUBMIT_REVIEW: ${{ inputs.submit-review }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} + BOT_TOKEN: ${{ inputs.bot-token }} with: github-token: ${{ inputs.github-token }} script: | diff --git a/slash-commands/main.js b/slash-commands/main.js index 2b66f77..44cb9fe 100644 --- a/slash-commands/main.js +++ b/slash-commands/main.js @@ -13,6 +13,13 @@ module.exports = async ({ github, context, core }) => { const submitReview = process.env.SUBMIT_REVIEW === 'true'; const reviewMarker = ''; + // Separate client for user-visible side effects (comments, reactions, + // labels, reviews). Uses github.token so everything shows as authored by + // github-actions[bot] instead of the PAT's identity. The PAT (`github`) + // is still used for team-membership checks (needs read:org), CODEOWNERS + // fetch, listFiles, pulls.get, and workflow dispatch. + const bot = require('@actions/github').getOctokit(process.env.BOT_TOKEN); + function setResult(result, actor) { core.setOutput('result', result); core.setOutput('actor', actor || ''); @@ -150,13 +157,13 @@ module.exports = async ({ github, context, core }) => { // --- Label & review helpers ------------------------------------- async function addLabel(prNumber, label) { - await github.rest.issues.addLabels({ + await bot.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [label], }); } async function removeLabel(prNumber, label) { try { - await github.rest.issues.removeLabel({ + await bot.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label, }); } catch (e) { @@ -164,25 +171,25 @@ module.exports = async ({ github, context, core }) => { } } async function react(commentId, content) { - await github.rest.reactions.createForIssueComment({ + await bot.rest.reactions.createForIssueComment({ owner, repo, comment_id: commentId, content, }); } async function submitApproval(prNumber, onBehalfOf) { - await github.rest.pulls.createReview({ + await bot.rest.pulls.createReview({ owner, repo, pull_number: prNumber, event: 'APPROVE', body: `${reviewMarker}\nApproved on behalf of @${onBehalfOf} via \`/lgtm\`.`, }); } async function dismissBotApprovals(prNumber, reason) { - const reviews = await github.paginate(github.rest.pulls.listReviews, { + const reviews = await bot.paginate(bot.rest.pulls.listReviews, { owner, repo, pull_number: prNumber, per_page: 100, }); for (const r of reviews) { if (r.state !== 'APPROVED') continue; if (!r.body || !r.body.includes(reviewMarker)) continue; try { - await github.rest.pulls.dismissReview({ + await bot.rest.pulls.dismissReview({ owner, repo, pull_number: prNumber, review_id: r.id, message: reason, }); @@ -203,7 +210,7 @@ module.exports = async ({ github, context, core }) => { if (!hasApproved) return setResult('noop'); await removeLabel(pr.number, approvedLabel); await dismissBotApprovals(pr.number, 'New commits pushed — approval invalidated.'); - await github.rest.issues.createComment({ + await bot.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `New commits were pushed — removed the \`${approvedLabel}\` label. Re-approve with \`/lgtm\`.`, }); @@ -263,7 +270,7 @@ module.exports = async ({ github, context, core }) => { const touchesBlog = files.some(f => blogRegexes.some(r => r.test(f.filename))); if (!touchesBlog) { await react(commentId, '-1'); - await github.rest.issues.createComment({ + await bot.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: `\`/stageblog\` refused — this PR does not touch any blog paths (\`${process.env.STAGEBLOG_PATHS}\`).`, }); @@ -277,7 +284,7 @@ module.exports = async ({ github, context, core }) => { inputs: { pr_number: String(prNumber), head_sha: headSha }, }); await react(commentId, 'rocket'); - await github.rest.issues.createComment({ + await bot.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.`, });