1# Unified PR labeler — applies size, file-based, title-based, and2# contributor classification labels in a single sequential workflow.3#4# Consolidates pr_labeler_file.yml, pr_labeler_title.yml,5# pr_size_labeler.yml, and PR-handling from tag-external-contributions.yml6# into one workflow to eliminate race conditions from concurrent label7# mutations. tag-external-issues.yml remains active for issue-only8# labeling. Backfill lives in pr_labeler_backfill.yml.9#10# Config and shared logic live in .github/scripts/pr-labeler-config.json11# and .github/scripts/pr-labeler.js — update those when adding partners.12#13# Setup Requirements:14# 1. Create a GitHub App with permissions:15# - Repository: Pull requests (write)16# - Repository: Issues (write)17# - Organization: Members (read)18# 2. Install the app on your organization and this repository19# 3. Add these repository secrets:20# - ORG_MEMBERSHIP_APP_ID: Your app's ID21# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key22#23# The GitHub App token is required to check private organization membership24# and to propagate label events to downstream workflows.2526name: "🏷️ PR Labeler"2728on:29 # Safe since we're not checking out or running the PR's code.30 # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.31 # Doing so would allow attackers to execute arbitrary code in the context of your repository.32 pull_request_target:33 types: [opened, synchronize, reopened, edited]3435permissions:36 contents: read3738concurrency:39 # Separate opened events so external/tier labels are never lost to cancellation40 group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'opened' && 'opened' || 'update' }}41 cancel-in-progress: ${{ github.event.action != 'opened' }}4243jobs:44 label:45 runs-on: ubuntu-latest46 permissions:47 contents: read48 pull-requests: write49 issues: write5051 steps:52 # Checks out the BASE branch (safe for pull_request_target — never53 # the PR head). Needed to load .github/scripts/pr-labeler*.54 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v65556 - name: Generate GitHub App token57 if: github.event.action == 'opened'58 id: app-token59 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v360 with:61 app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}62 private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}6364 - name: Verify App token65 if: github.event.action == 'opened'66 run: |67 if [ -z "${{ steps.app-token.outputs.token }}" ]; then68 echo "::error::GitHub App token generation failed — cannot classify contributor"69 exit 170 fi7172 - name: Check org membership73 if: github.event.action == 'opened'74 id: check-membership75 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.076 with:77 github-token: ${{ steps.app-token.outputs.token }}78 script: |79 const { owner, repo } = context.repo;80 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);8182 const author = context.payload.sender.login;83 const { isExternal } = await h.checkMembership(84 author, context.payload.sender.type,85 );86 core.setOutput('is-external', isExternal ? 'true' : 'false');8788 - name: Apply PR labels89 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.090 env:91 IS_EXTERNAL: ${{ steps.check-membership.outputs.is-external }}92 with:93 github-token: ${{ secrets.GITHUB_TOKEN }}94 script: |95 const { owner, repo } = context.repo;96 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);9798 const pr = context.payload.pull_request;99 if (!pr) return;100 const prNumber = pr.number;101 const action = context.payload.action;102103 const toAdd = new Set();104 const toRemove = new Set();105106 const currentLabels = (await github.paginate(107 github.rest.issues.listLabelsOnIssue,108 { owner, repo, issue_number: prNumber, per_page: 100 },109 )).map(l => l.name ?? '');110111 // ── Size + file labels (skip on 'edited' — files unchanged) ──112 if (action !== 'edited') {113 for (const sl of h.sizeLabels) await h.ensureLabel(sl);114115 const files = await github.paginate(github.rest.pulls.listFiles, {116 owner, repo, pull_number: prNumber, per_page: 100,117 });118119 const { totalChanged, sizeLabel } = h.computeSize(files);120 toAdd.add(sizeLabel);121 for (const sl of h.sizeLabels) {122 if (currentLabels.includes(sl) && sl !== sizeLabel) toRemove.add(sl);123 }124 console.log(`Size: ${totalChanged} changed lines → ${sizeLabel}`);125126 for (const label of h.matchFileLabels(files)) {127 toAdd.add(label);128 }129 }130131 // ── Title-based labels ──132 const { labels: titleLabels, typeLabel } = h.matchTitleLabels(pr.title || '');133 for (const label of titleLabels) toAdd.add(label);134135 // Remove stale type labels only when a type was detected136 if (typeLabel) {137 for (const tl of h.allTypeLabels) {138 if (currentLabels.includes(tl) && !titleLabels.has(tl)) toRemove.add(tl);139 }140 }141142 // ── Internal label (only on open, non-external contributors) ──143 // IS_EXTERNAL is empty string on non-opened events (step didn't144 // run), so this guard is only true for opened + internal.145 if (action === 'opened' && process.env.IS_EXTERNAL === 'false') {146 toAdd.add('internal');147 }148149 // ── Apply changes ──150 // Ensure all labels we're about to add exist (addLabels returns151 // 422 if any label in the batch is missing, which would prevent152 // ALL labels from being applied).153 for (const name of toAdd) {154 await h.ensureLabel(name);155 }156157 for (const name of toRemove) {158 if (toAdd.has(name)) continue;159 try {160 await github.rest.issues.removeLabel({161 owner, repo, issue_number: prNumber, name,162 });163 } catch (e) {164 if (e.status !== 404) throw e;165 }166 }167168 const addList = [...toAdd];169 if (addList.length > 0) {170 await github.rest.issues.addLabels({171 owner, repo, issue_number: prNumber, labels: addList,172 });173 }174175 const removed = [...toRemove].filter(r => !toAdd.has(r));176 console.log(`PR #${prNumber}: +[${addList.join(', ')}] -[${removed.join(', ')}]`);177178 # Apply tier label BEFORE the external label so that179 # "trusted-contributor" is already present when the "external" labeled180 # event fires and triggers require_issue_link.yml.181 - name: Apply contributor tier label182 if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'183 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0184 with:185 github-token: ${{ steps.app-token.outputs.token }}186 script: |187 const { owner, repo } = context.repo;188 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);189190 const pr = context.payload.pull_request;191 await h.applyTierLabel(pr.number, pr.user.login);192193 - name: Add external label194 if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'195 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0196 with:197 # Use App token so the "labeled" event propagates to downstream198 # workflows (e.g. require_issue_link.yml). Events created by the199 # default GITHUB_TOKEN do not trigger additional workflow runs.200 github-token: ${{ steps.app-token.outputs.token }}201 script: |202 const { owner, repo } = context.repo;203 const prNumber = context.payload.pull_request.number;204205 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);206207 await h.ensureLabel('external');208 await github.rest.issues.addLabels({209 owner, repo,210 issue_number: prNumber,211 labels: ['external'],212 });213 console.log(`Added 'external' label to PR #${prNumber}`);
Findings
✓ No findings reported for this file.