1# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and2# the PR author to be assigned to that issue. On failure the PR is3# labeled "missing-issue-link", commented on, and closed.4#5# Maintainer override: an org member can reopen the PR or remove6# "missing-issue-link" — both add "bypass-issue-check" and reopen.7#8# Dependency: pr_labeler.yml must apply the "external" label first. This9# workflow does NOT trigger on "opened" (new PRs have no labels yet, so the10# gate would always skip).1112name: Require Issue Link1314on:15 pull_request_target:16 # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.17 # Doing so would allow attackers to execute arbitrary code in the context of your repository.18 types: [edited, reopened, labeled, unlabeled]1920# ──────────────────────────────────────────────────────────────────────────────21# Enforcement gate: set to 'true' to activate the issue link requirement.22# When 'false', the workflow still runs the check logic (useful for dry-run23# visibility) but will NOT label, comment, close, or fail PRs.24# ──────────────────────────────────────────────────────────────────────────────25env:26 ENFORCE_ISSUE_LINK: "true"2728permissions:29 contents: read3031jobs:32 check-issue-link:33 # Run when the "external" label is added, on edit/reopen if already labeled,34 # or when "missing-issue-link" is removed (triggers maintainer override check).35 # Skip entirely when the PR already carries "trusted-contributor" or36 # "bypass-issue-check".37 if: >-38 !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&39 !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&40 (41 (github.event.action == 'labeled' && github.event.label.name == 'external') ||42 (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) ||43 (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external'))44 )45 runs-on: ubuntu-latest46 permissions:47 actions: write48 pull-requests: write4950 steps:51 - name: Check for issue link and assignee52 id: check-link53 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.054 with:55 script: |56 const { owner, repo } = context.repo;57 const prNumber = context.payload.pull_request.number;58 const action = context.payload.action;5960 // ── Helper: ensure a label exists, then add it to the PR ────────61 async function ensureAndAddLabel(labelName, color) {62 try {63 await github.rest.issues.getLabel({ owner, repo, name: labelName });64 } catch (e) {65 if (e.status !== 404) throw e;66 try {67 await github.rest.issues.createLabel({ owner, repo, name: labelName, color });68 } catch (createErr) {69 // 422 = label was created by a concurrent run between our70 // GET and POST — safe to ignore.71 if (createErr.status !== 422) throw createErr;72 }73 }74 await github.rest.issues.addLabels({75 owner, repo, issue_number: prNumber, labels: [labelName],76 });77 }7879 // ── Helper: check if the user who triggered this event (reopened80 // the PR / removed the label) has write+ access on the repo ───81 // Uses the repo collaborator permission endpoint instead of the82 // org membership endpoint. The org endpoint requires the caller83 // to be an org member, which GITHUB_TOKEN (an app installation84 // token) never is — so it always returns 403.85 async function senderIsOrgMember() {86 const sender = context.payload.sender?.login;87 if (!sender) {88 throw new Error('Event has no sender — cannot check permissions');89 }90 try {91 const { data } = await github.rest.repos.getCollaboratorPermissionLevel({92 owner, repo, username: sender,93 });94 const perm = data.permission;95 if (['admin', 'maintain', 'write'].includes(perm)) {96 console.log(`${sender} has ${perm} permission — treating as maintainer`);97 return { isMember: true, login: sender };98 }99 console.log(`${sender} has ${perm} permission — not a maintainer`);100 return { isMember: false, login: sender };101 } catch (e) {102 if (e.status === 404) {103 console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`);104 return { isMember: false, login: sender };105 }106 const status = e.status ?? 'unknown';107 throw new Error(108 `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`,109 );110 }111 }112113 // ── Helper: apply maintainer bypass (shared by both override paths) ──114 async function applyMaintainerBypass(reason) {115 console.log(reason);116117 // Remove missing-issue-link if present118 try {119 await github.rest.issues.removeLabel({120 owner, repo, issue_number: prNumber, name: 'missing-issue-link',121 });122 } catch (e) {123 if (e.status !== 404) throw e;124 }125126 // Reopen before adding bypass label — a failed reopen is more127 // actionable than a closed PR with a bypass label stuck on it.128 if (context.payload.pull_request.state === 'closed') {129 try {130 await github.rest.pulls.update({131 owner, repo, pull_number: prNumber, state: 'open',132 });133 console.log(`Reopened PR #${prNumber}`);134 } catch (e) {135 // 422 if head branch deleted; 403 if permissions insufficient.136 // Bypass labels still apply — maintainer can reopen manually.137 core.warning(138 `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +139 `Bypass labels were applied — a maintainer may need to reopen manually.`,140 );141 }142 }143144 // Add bypass-issue-check so future triggers skip enforcement145 await ensureAndAddLabel('bypass-issue-check', '0e8a16');146147 // Minimize stale enforcement comment (best-effort; must not148 // abort bypass — sync w/ reopen_on_assignment.yml & step below)149 try {150 const marker = '<!-- require-issue-link -->';151 const comments = await github.paginate(152 github.rest.issues.listComments,153 { owner, repo, issue_number: prNumber, per_page: 100 },154 );155 const stale = comments.find(c => c.body && c.body.includes(marker));156 if (stale) {157 await github.graphql(`158 mutation($id: ID!) {159 minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {160 minimizedComment { isMinimized }161 }162 }163 `, { id: stale.node_id });164 console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);165 }166 } catch (e) {167 core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);168 }169170 core.setOutput('has-link', 'true');171 core.setOutput('is-assigned', 'true');172 }173174 // ── Maintainer override: removed "missing-issue-link" label ─────175 if (action === 'unlabeled') {176 const { isMember, login } = await senderIsOrgMember();177 if (isMember) {178 await applyMaintainerBypass(179 `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`,180 );181 return;182 }183 // Non-member removed the label — re-add it defensively and184 // set failure outputs so downstream steps (comment, close) fire.185 // NOTE: addLabels fires a "labeled" event, but the job-level gate186 // only matches labeled events for "external", so no re-trigger.187 console.log(`Non-member ${login} removed missing-issue-link — re-adding`);188 try {189 await ensureAndAddLabel('missing-issue-link', 'b76e79');190 } catch (e) {191 core.warning(192 `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +193 `Downstream step will retry.`,194 );195 }196 core.setOutput('has-link', 'false');197 core.setOutput('is-assigned', 'false');198 return;199 }200201 // ── Maintainer override: reopened PR with "missing-issue-link" ──202 const prLabels = context.payload.pull_request.labels.map(l => l.name);203 if (action === 'reopened' && prLabels.includes('missing-issue-link')) {204 const { isMember, login } = await senderIsOrgMember();205 if (isMember) {206 await applyMaintainerBypass(207 `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`,208 );209 return;210 }211 console.log(`Non-member ${login} reopened PR — proceeding with check`);212 }213214 // ── Fetch live labels (race guard) ──────────────────────────────215 const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({216 owner, repo, issue_number: prNumber,217 });218 const liveNames = liveLabels.map(l => l.name);219 if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {220 console.log('PR has trusted-contributor or bypass-issue-check label — bypassing');221 core.setOutput('has-link', 'true');222 core.setOutput('is-assigned', 'true');223 return;224 }225226 const body = context.payload.pull_request.body || '';227 const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;228 const matches = [...body.matchAll(pattern)];229230 if (matches.length === 0) {231 console.log('No issue link found in PR body');232 core.setOutput('has-link', 'false');233 core.setOutput('is-assigned', 'false');234 return;235 }236237 const issues = matches.map(m => `#${m[1]}`).join(', ');238 console.log(`Found issue link(s): ${issues}`);239 core.setOutput('has-link', 'true');240241 // Check whether the PR author is assigned to at least one linked issue242 const prAuthor = context.payload.pull_request.user.login;243 const MAX_ISSUES = 5;244 const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];245 const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES);246 if (allIssueNumbers.length > MAX_ISSUES) {247 core.warning(248 `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`,249 );250 }251252 let assignedToAny = false;253 for (const num of issueNumbers) {254 try {255 const { data: issue } = await github.rest.issues.get({256 owner, repo, issue_number: num,257 });258 const assignees = issue.assignees.map(a => a.login.toLowerCase());259 if (assignees.includes(prAuthor.toLowerCase())) {260 console.log(`PR author "${prAuthor}" is assigned to #${num}`);261 assignedToAny = true;262 break;263 } else {264 console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);265 }266 } catch (error) {267 if (error.status === 404) {268 console.log(`Issue #${num} not found — skipping`);269 } else {270 // Non-404 errors (rate limit, server error) must not be271 // silently skipped — they could cause false enforcement272 // (closing a legitimate PR whose assignment can't be verified).273 throw new Error(274 `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`,275 );276 }277 }278 }279280 core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');281282 - name: Add missing-issue-link label283 if: >-284 env.ENFORCE_ISSUE_LINK == 'true' &&285 (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')286 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0287 with:288 script: |289 const { owner, repo } = context.repo;290 const prNumber = context.payload.pull_request.number;291 const labelName = 'missing-issue-link';292293 // Ensure the label exists (no checkout/shared helper available)294 try {295 await github.rest.issues.getLabel({ owner, repo, name: labelName });296 } catch (e) {297 if (e.status !== 404) throw e;298 try {299 await github.rest.issues.createLabel({300 owner, repo, name: labelName, color: 'b76e79',301 });302 } catch (createErr) {303 if (createErr.status !== 422) throw createErr;304 }305 }306307 await github.rest.issues.addLabels({308 owner, repo, issue_number: prNumber, labels: [labelName],309 });310311 - name: Remove missing-issue-link label and reopen PR312 if: >-313 env.ENFORCE_ISSUE_LINK == 'true' &&314 steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'315 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0316 with:317 script: |318 const { owner, repo } = context.repo;319 const prNumber = context.payload.pull_request.number;320 try {321 await github.rest.issues.removeLabel({322 owner, repo, issue_number: prNumber, name: 'missing-issue-link',323 });324 } catch (error) {325 if (error.status !== 404) throw error;326 }327328 // Reopen if this workflow previously closed the PR. We check the329 // event payload labels (not live labels) because we already removed330 // missing-issue-link above; the payload still reflects pre-step state.331 const labels = context.payload.pull_request.labels.map(l => l.name);332 if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {333 await github.rest.pulls.update({334 owner,335 repo,336 pull_number: prNumber,337 state: 'open',338 });339 console.log(`Reopened PR #${prNumber}`);340 }341342 // Minimize stale enforcement comment (best-effort;343 // sync w/ applyMaintainerBypass above & reopen_on_assignment.yml)344 try {345 const marker = '<!-- require-issue-link -->';346 const comments = await github.paginate(347 github.rest.issues.listComments,348 { owner, repo, issue_number: prNumber, per_page: 100 },349 );350 const stale = comments.find(c => c.body && c.body.includes(marker));351 if (stale) {352 await github.graphql(`353 mutation($id: ID!) {354 minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {355 minimizedComment { isMinimized }356 }357 }358 `, { id: stale.node_id });359 console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);360 }361 } catch (e) {362 core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);363 }364365 - name: Post comment, close PR, and fail366 if: >-367 env.ENFORCE_ISSUE_LINK == 'true' &&368 (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')369 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0370 with:371 script: |372 const { owner, repo } = context.repo;373 const prNumber = context.payload.pull_request.number;374 const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';375 const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';376 const marker = '<!-- require-issue-link -->';377378 let lines;379 if (!hasLink) {380 lines = [381 marker,382 '**This PR has been automatically closed** because it does not link to an approved issue.',383 '',384 'All external contributions must reference an approved issue or discussion. Please:',385 '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',386 '2. Wait for a maintainer to approve and assign you',387 '3. Add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to your PR description and the PR will be reopened automatically',388 '',389 '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',390 ];391 } else {392 lines = [393 marker,394 '**This PR has been automatically closed** because you are not assigned to the linked issue.',395 '',396 'Opening a PR is **not** an indication that it will be accepted. This process exists so maintainers can confirm a change is aligned with the project direction *before* you invest time implementing it. Please:',397 '1. Comment on the linked issue explaining the approach you would like to take and why — include enough detail for a maintainer to evaluate the design. Do **not** post a drive-by "please assign me" comment with no substance; those will be ignored.',398 '2. Wait for a maintainer to assign you. Once assigned, your PR will be reopened automatically.',399 '',400 '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',401 ];402 }403404 const body = lines.join('\n');405406 // Deduplicate: check for existing comment with the marker407 const comments = await github.paginate(408 github.rest.issues.listComments,409 { owner, repo, issue_number: prNumber, per_page: 100 },410 );411 const existing = comments.find(c => c.body && c.body.includes(marker));412413 if (!existing) {414 await github.rest.issues.createComment({415 owner,416 repo,417 issue_number: prNumber,418 body,419 });420 console.log('Posted requirement comment');421 } else if (existing.body !== body) {422 await github.rest.issues.updateComment({423 owner,424 repo,425 comment_id: existing.id,426 body,427 });428 console.log('Updated existing comment with new message');429 } else {430 console.log('Comment already exists — skipping');431 }432433 // Close the PR434 if (context.payload.pull_request.state === 'open') {435 await github.rest.pulls.update({436 owner,437 repo,438 pull_number: prNumber,439 state: 'closed',440 });441 console.log(`Closed PR #${prNumber}`);442 }443444 // Cancel all other in-progress and queued workflow runs for this PR445 const headSha = context.payload.pull_request.head.sha;446 for (const status of ['in_progress', 'queued']) {447 const runs = await github.paginate(448 github.rest.actions.listWorkflowRunsForRepo,449 { owner, repo, head_sha: headSha, status, per_page: 100 },450 );451 for (const run of runs) {452 if (run.id === context.runId) continue;453 try {454 await github.rest.actions.cancelWorkflowRun({455 owner, repo, run_id: run.id,456 });457 console.log(`Cancelled ${status} run ${run.id} (${run.name})`);458 } catch (err) {459 console.log(`Could not cancel run ${run.id}: ${err.message}`);460 }461 }462 }463464 const reason = !hasLink465 ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'466 : 'PR author must be assigned to the linked issue.';467 core.setFailed(reason);
Findings
✓ No findings reported for this file.