diff --git a/slash-commands/README.md b/slash-commands/README.md index 649bb5f..3cc179a 100644 --- a/slash-commands/README.md +++ b/slash-commands/README.md @@ -103,7 +103,7 @@ After creating: | | | |---|---| | **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`) | -| **Permissions** | `contents: read` is sufficient — the App token handles all writes. If falling back to `GITHUB_TOKEN` for `bot-token`, add `pull-requests: write`, `issues: write`. | +| **Permissions** | `contents: read` is sufficient — the App token handles all writes. | | **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. | @@ -137,7 +137,6 @@ jobs: - uses: modelcontextprotocol/actions/slash-commands@main with: github-token: ${{ steps.app-token.outputs.token }} - bot-token: ${{ steps.app-token.outputs.token }} # auto-merge-method: squash # default; also: merge, rebase # stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog ``` @@ -196,8 +195,7 @@ jobs: | Input | Required | Default | Description | |---|---|---|---| -| `github-token` | ✅ | — | Token with `read:org` (Organization Members: read) for the team-membership check. Pass the App installation token. **Default `GITHUB_TOKEN` will not work** — it lacks `read:org`. | -| `bot-token` | | `${{ github.token }}` | Token for all repo API calls: comments, reactions, labels, reviews, CODEOWNERS fetch, file listing, workflow dispatch, auto-merge. Pass the App installation token so approvals come from the App identity. | +| `github-token` | ✅ | — | GitHub App installation token used for everything: team-membership checks, labels, comments, reactions, APPROVE review, auto-merge, CODEOWNERS fetch, workflow dispatch. Must have Organization Members:read + Repository Pull requests:write + Contents:read (+ Actions:write for `/stageblog`). **Default `GITHUB_TOKEN` will not work.** | | `approved-label` | | `accepted` | Label added by `/lgtm` to mark the PR as accepted | | `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` any PR. **Only these teams can `/lgtm`** — CODEOWNERS does not grant it. Members may also `/hold`/`/stageblog` any PR. | diff --git a/slash-commands/action.yml b/slash-commands/action.yml index 4fa714e..6a5b58c 100644 --- a/slash-commands/action.yml +++ b/slash-commands/action.yml @@ -9,21 +9,13 @@ description: >- inputs: github-token: description: >- - Token used for org team-membership checks only. MUST have read:org - scope — the default GITHUB_TOKEN cannot do this. Use a fine-grained PAT - with Organization Members:read, or a GitHub App installation token with - the same. + GitHub App installation token used for all API calls: team-membership + checks, labels, comments, reactions, reviews, auto-merge, CODEOWNERS + fetch, and workflow dispatch. Must have Organization Members:read plus + Repository Pull requests:write and Contents:read (and Actions:write if + /stageblog is enabled). The default GITHUB_TOKEN will NOT work — it + lacks read:org and its approvals may not count toward required reviews. required: true - bot-token: - description: >- - Token used for all repo API calls: comments, reactions, labels, reviews, - CODEOWNERS fetch, listFiles, and workflow dispatch. Defaults to the - automatic GITHUB_TOKEN so user-visible side effects show as authored by - github-actions[bot]. The caller workflow must grant this token the - necessary permissions (pull-requests: write, issues: write, - contents: read; plus actions: write for /stageblog). - required: false - default: ${{ github.token }} approved-label: description: Label added by /lgtm to mark the PR as accepted required: false @@ -118,9 +110,8 @@ runs: AUTO_MERGE_METHOD: ${{ inputs.auto-merge-method }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} - ORG_TOKEN: ${{ inputs.github-token }} with: - github-token: ${{ inputs.bot-token }} + github-token: ${{ inputs.github-token }} script: | const main = require(process.env.GITHUB_ACTION_PATH + '/main.js'); await main({ github, context, core }); diff --git a/slash-commands/main.js b/slash-commands/main.js index 62913d2..8015aad 100644 --- a/slash-commands/main.js +++ b/slash-commands/main.js @@ -16,14 +16,6 @@ module.exports = async ({ github, context, core }) => { const autoMerge = process.env.ENABLE_AUTO_MERGE === 'true'; const autoMergeMethod = process.env.AUTO_MERGE_METHOD.toUpperCase(); const reviewMarker = ''; - // `github` is authed with bot-token (defaults to GITHUB_TOKEN) so comments, - // reactions, labels, and reviews show as github-actions[bot]. The PAT is - // passed via env and used ONLY for the raw-fetch team-membership check - // below — we cannot construct a second Octokit client here because - // @actions/github is bundled into github-script's dist/index.js and not - // resolvable, nor does a per-request `headers.authorization` override work - // (Octokit's auth hook overwrites it). - const orgToken = process.env.ORG_TOKEN; function setResult(result, actor) { core.setOutput('result', result); @@ -87,32 +79,22 @@ module.exports = async ({ github, context, core }) => { async function isTeamMember(user, teamSlug) { const key = `${teamSlug}:${user}`; if (teamMembershipCache.has(key)) return teamMembershipCache.get(key); - const res = await fetch( - `https://api.github.com/orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(user)}`, - { - headers: { - authorization: `Bearer ${orgToken}`, - accept: 'application/vnd.github+json', - 'x-github-api-version': '2022-11-28', - }, - }, - ); - if (res.status === 404) { - teamMembershipCache.set(key, false); - return false; + let ok; + try { + const { data } = await github.rest.teams.getMembershipForUserInOrg({ + org: owner, team_slug: teamSlug, username: user, + }); + ok = data.state === 'active'; + } catch (e) { + if (e.status === 404) { + ok = false; + } else if (e.status === 403) { + core.warning(`Team membership check for @${owner}/${teamSlug} returned 403 — the provided github-token likely lacks Organization Members:read. Treating as not-a-member.`); + ok = false; + } else { + throw e; + } } - if (res.status === 403) { - // Almost always means the token lacks read:org. - core.warning(`Team membership check for @${owner}/${teamSlug} returned 403 — the provided github-token likely lacks read:org (Organization Members:read) scope. Treating as not-a-member.`); - teamMembershipCache.set(key, false); - return false; - } - if (!res.ok) { - const body = await res.text(); - throw new Error(`Team membership check failed: ${res.status} ${body}`); - } - const data = await res.json(); - const ok = data.state === 'active'; teamMembershipCache.set(key, ok); return ok; }