ci: use GitHub App for community label org membership check (#24149)

Supersedes #23343.

## Problem

`author_association` on `pull_request_target` events is unreliable:

- Returns `CONTRIBUTOR` instead of `MEMBER` when both apply
([actions/github-script#643](https://github.com/actions/github-script/issues/643)).
- Returns `NONE` for members with private org visibility
([community#18690](https://github.com/orgs/community/discussions/18690)).

This causes org members to incorrectly receive the `community` label.

## Approach

Replace the `author_association` check with an explicit
`orgs.checkMembershipForUser()` API call, which reliably detects both
public and private org members.

Uses a dedicated **GitHub App** via `actions/create-github-app-token`
instead of a PAT. The App only needs **Organization > Members: Read**
permission. Installation tokens are short-lived (1 hour) and
auto-rotated — no long-lived secrets to worry about.

### Setup required

A repo/org admin needs to:
1. Create a GitHub App with only **Organization > Members: Read**
permission.
2. Install it on the `coder` org.
3. Store the App ID as a repository variable: `ORG_MEMBERSHIP_APP_ID`.
4. Store the App's private key as a repository secret:
`ORG_MEMBERSHIP_APP_PRIVATE_KEY`.

> [!NOTE]
> Generated by Coder Agents

---------

Co-authored-by: Jakub Domeracki <jakub@coder.com>
This commit is contained in:
Garrett Delfosse
2026-04-13 10:18:16 -04:00
committed by GitHub
co-authored by Jakub Domeracki
parent b78eba9f9d
commit 079dc48ba0
+37 -8
View File
@@ -30,16 +30,28 @@ jobs:
if: >-
${{
github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
github.event.pull_request.author_association != 'MEMBER' &&
github.event.pull_request.author_association != 'COLLABORATOR' &&
github.event.pull_request.author_association != 'OWNER'
github.event.action == 'opened'
}}
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ vars.ORG_MEMBERSHIP_APP_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
- name: Add community label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
with:
# Default GITHUB_TOKEN handles label writes via the
# `github` object (needs pull-requests: write). The App
# token is scoped to members: read only and used via a
# separate Octokit client for the membership check.
script: |
const { Octokit } = require("@octokit/rest")
const orgClient = new Octokit({ auth: process.env.APP_TOKEN })
const params = {
issue_number: context.issue.number,
owner: context.repo.owner,
@@ -52,10 +64,27 @@ jobs:
return
}
console.log(
'Adding "community" label for author association "%s".',
context.payload.pull_request.author_association,
)
// author_association can be unreliable: it returns
// CONTRIBUTOR instead of MEMBER when both apply, and
// returns NONE for members with private org visibility.
// Use the org membership API as the source of truth.
// See: https://github.com/actions/github-script/issues/643
const author = context.payload.pull_request.user.login
try {
await orgClient.orgs.checkMembershipForUser({
org: context.repo.owner,
username: author,
})
console.log('Author "%s" is an org member, skipping.', author)
return
} catch (error) {
if (error.status !== 404 && error.status !== 302) {
throw error
}
}
console.log('Adding "community" label for author "%s".', author)
// Uses the default GITHUB_TOKEN via the `github` object.
await github.rest.issues.addLabels({
...params,
labels: ["community"],