Post comments/reactions/labels/reviews as github-actions[bot]

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.
This commit is contained in:
Den Delimarsky 2026-03-11 20:41:44 +00:00
commit cad4102f07
No known key found for this signature in database
3 changed files with 43 additions and 23 deletions

View file

@ -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 |

View file

@ -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: |

View file

@ -13,6 +13,13 @@ module.exports = async ({ github, context, core }) => {
const submitReview = process.env.SUBMIT_REVIEW === 'true';
const reviewMarker = '<!-- slash-commands-lgtm -->';
// 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.`,
});