From 858bb897690a112dfc4d3fb43c5e54cb0f461db8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 2 Sep 2026 15:56:43 -0400 Subject: [PATCH] ci(#4196): exempt dependabot[bot] from issue-link, title, and unsolicited-PR gates (#4203) Dependabot has no mechanism to link a PR it opens to a repo issue -- its alerts live in the Security tab, not as issues -- so require-issue-link, pr-title-validator, and auto-close-unsolicited-prs all rejected its PRs by design (confirmed live on #4193: auto-closed for "no pre-approved issue", then flagged again by the title gate on reopen). Exempt by authenticated author login (github.event.pull_request.user.login / context.payload.pull_request.user.login), which GitHub attributes and a crafted title or branch name cannot forge -- scoped narrowly to dependabot[bot] only, no other author gets this treatment. Co-authored-by: sim --- .../workflows/auto-close-unsolicited-prs.yml | 1 + .github/workflows/pr-title-validator.yml | 11 ++++++++++ .github/workflows/require-issue-link.yml | 1 + scripts/require-issue-link-policy.cjs | 17 +++++++++++++++- tests/require-issue-link-policy.test.cjs | 20 +++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-close-unsolicited-prs.yml b/.github/workflows/auto-close-unsolicited-prs.yml index 856261316..46f80d396 100644 --- a/.github/workflows/auto-close-unsolicited-prs.yml +++ b/.github/workflows/auto-close-unsolicited-prs.yml @@ -39,6 +39,7 @@ jobs: # are evaluated. if: >- github.event.pull_request.draft == false && + github.event.pull_request.user.login != 'dependabot[bot]' && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) == false runs-on: ubuntu-latest timeout-minutes: 2 diff --git a/.github/workflows/pr-title-validator.yml b/.github/workflows/pr-title-validator.yml index d89cb2045..92a7da896 100644 --- a/.github/workflows/pr-title-validator.yml +++ b/.github/workflows/pr-title-validator.yml @@ -94,6 +94,17 @@ jobs: const matcherPath = `${process.env.GITHUB_WORKSPACE}/scripts/release-notes/conventional-title.cjs`; const pr = context.payload.pull_request; + + // #4196: Dependabot PR titles (e.g. "chore(deps): bump ...") + // never carry `(#)` — there's no issue to link (its + // alerts live in the Security tab, not as repo issues). Exempt + // by author login, not title/branch shape, since that field is + // authenticated by GitHub and not forgeable. + if (pr.user && pr.user.login === 'dependabot[bot]') { + core.info('PR opened by dependabot[bot] — skipping title convention check.'); + return; + } + const title = pr.title || ''; const warnOnly = process.env.WARN_ONLY === 'true'; diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index a23c3d12c..27262bc16 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -103,6 +103,7 @@ jobs: PR_BODY: ${{ github.event.pull_request.body }} HEAD_REF: ${{ github.head_ref }} SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} CHANGED_FILES: ${{ steps.changed_files.outputs.files }} CHANGED_FILES_TOTAL: ${{ github.event.pull_request.changed_files }} run: | diff --git a/scripts/require-issue-link-policy.cjs b/scripts/require-issue-link-policy.cjs index ca283aafb..9bb705ddb 100644 --- a/scripts/require-issue-link-policy.cjs +++ b/scripts/require-issue-link-policy.cjs @@ -30,6 +30,7 @@ const { runMain } = require('./lib/cli-exit.cjs'); const ISSUE_LINK_REASON = Object.freeze({ OK_CLOSING_KEYWORD: 'ok_closing_keyword', OK_BACKMERGE_EXEMPT: 'ok_backmerge_exempt', + OK_DEPENDABOT_EXEMPT: 'ok_dependabot_exempt', OK_FOLLOWUP_REFERENCE: 'ok_followup_reference', FAIL_NO_ISSUE_REFERENCE: 'fail_no_issue_reference', FAIL_REFERENCE_NEEDS_CLOSING: 'fail_reference_needs_closing', @@ -41,6 +42,14 @@ const ISSUE_LINK_REASON = Object.freeze({ // ONLY when combined with `sameRepo === true` below (see header comment). const BACKMERGE_BRANCH_PREFIX = 'chore/backmerge-main-to-next-'; +// #4196: Dependabot has no mechanism to link a PR it opens to a repo issue — +// its alerts live in the Security tab, not as issues, so there is nothing +// for it to reference. `pr.user.login` is authenticated by GitHub (not +// forgeable by a crafted title/branch), so this is safe without a sameRepo +// conjunct: no external actor can make GitHub report this login for a PR +// they opened. +const DEPENDABOT_LOGIN = 'dependabot[bot]'; + // A follow-up-only PR (references an issue without closing it) is only // allowed to skip the closing keyword when every changed file is a test or // doc file — i.e. it cannot be the PR that actually implements the fix. @@ -122,7 +131,11 @@ function allPathsAreTestsOrDocs(changedFiles) { }); } -function evaluateIssueLink({ prBody, headRef, sameRepo, changedFiles, changedFilesTotal }) { +function evaluateIssueLink({ prBody, headRef, sameRepo, authorLogin, changedFiles, changedFilesTotal }) { + if (authorLogin === DEPENDABOT_LOGIN) { + return { ok: true, reason: ISSUE_LINK_REASON.OK_DEPENDABOT_EXEMPT }; + } + if (hasClosingKeyword(prBody)) { return { ok: true, reason: ISSUE_LINK_REASON.OK_CLOSING_KEYWORD }; } @@ -159,6 +172,7 @@ function main() { prBody: process.env.PR_BODY || '', headRef: process.env.HEAD_REF || '', sameRepo: process.env.SAME_REPO === 'true', + authorLogin: process.env.PR_AUTHOR_LOGIN || '', changedFiles, changedFilesTotal, }); @@ -179,6 +193,7 @@ if (require.main === module) runMain(main); module.exports = { ISSUE_LINK_REASON, BACKMERGE_BRANCH_PREFIX, + DEPENDABOT_LOGIN, EXEMPT_PATH_PREFIXES, EXCLUDED_ROOT_DOCS, CLOSING_KEYWORD_REGEX, diff --git a/tests/require-issue-link-policy.test.cjs b/tests/require-issue-link-policy.test.cjs index b3c062082..ac76a45ae 100644 --- a/tests/require-issue-link-policy.test.cjs +++ b/tests/require-issue-link-policy.test.cjs @@ -184,6 +184,26 @@ describe('evaluateIssueLink', () => { assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE); }); + // #4196: Dependabot has no mechanism to link a PR it opens to a repo + // issue (its alerts live in the Security tab, not as issues) — exempt by + // authenticated author login, with no reference and no source-file + // restriction, mirroring the backmerge exemption's structure above. + test('#4196: dependabot[bot] author is exempt with no reference at all, even on a source diff', () => { + const result = evaluateIssueLink(forkPr({ + prBody: '', authorLogin: 'dependabot[bot]', changedFiles: ['src/init.cts'], changedFilesTotal: 1, + })); + assert.strictEqual(result.reason, ISSUE_LINK_REASON.OK_DEPENDABOT_EXEMPT); + assert.strictEqual(result.ok, true); + }); + + test('#4196 anti-forgery: an author login that merely CONTAINS "dependabot" is NOT exempt', () => { + const lookalikes = ['dependabot', 'Dependabot[bot]', 'not-dependabot[bot]', 'dependabot[bot] ']; + for (const authorLogin of lookalikes) { + const result = evaluateIssueLink(forkPr({ prBody: '', authorLogin, changedFiles: ['src/init.cts'], changedFilesTotal: 1 })); + assert.strictEqual(result.reason, ISSUE_LINK_REASON.FAIL_NO_ISSUE_REFERENCE, `authorLogin: ${JSON.stringify(authorLogin)}`); + } + }); + // 16. hasClosingKeyword corpus parity — expected values come from the // shipped shell grep this regex replaces: // grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'