From d37cc0a6dc3d2863fa5548c64c5c90436bb33804 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 17:38:34 +0000 Subject: [PATCH] slash-commands: add hardened Dependabot auto-approve mode Opt-in via new 'dependabot-auto-approve' input (mode 'dev-patch-minor'). When enabled, Dependabot PRs are evaluated on pull_request_target opened/reopened and on check_suite completed for dependabot/ branches, and approved + labeled + auto-merged only when ALL gates hold: 1. Author is the real dependabot[bot] app (login AND user id 49699333) 2. Same-repo head on a dependabot/ branch (never forks) 3. Every commit signature-verified and dependabot-authored 4. Changed files a subset of 'dependabot-allowed-files' (exact paths) 5. Every updated dependency direct:development with semver-patch/minor update per Dependabot's commit metadata (missing metadata = no approve) 6. CI green on head SHA: all check runs success/neutral/skipped with at least one non-skipped check, combined status success if any exist 7. PR open, non-draft, conflict-free, not on hold Every gate fails closed, logging the reason to the action log only. Approvals use a distinct marker (slash-commands-dependabot-auto) that push invalidation now dismisses alongside /lgtm approvals; after a human push, gate 3 fails permanently so only /lgtm can accept the PR. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_019ygNtiNcAaqTRnQjHCDkVS --- slash-commands/README.md | 68 +++++++++++- slash-commands/action.yml | 30 ++++- slash-commands/main.js | 226 +++++++++++++++++++++++++++++++++++++- 3 files changed, 318 insertions(+), 6 deletions(-) diff --git a/slash-commands/README.md b/slash-commands/README.md index 4f84314..2b6cf97 100644 --- a/slash-commands/README.md +++ b/slash-commands/README.md @@ -2,7 +2,8 @@ Reusable composite action that implements Prow-style `/lgtm`, `/hold`, and `/stageblog` slash commands on pull requests, gated by team membership and -CODEOWNERS. +CODEOWNERS. Optionally auto-approves narrowly-scoped Dependabot PRs +(`dependabot-auto-approve`). ## What it does @@ -42,6 +43,54 @@ must match `^/cmd\b`). Unauthorized attempts, or a PR author trying to `/lgtm` their own PR, receive a πŸ‘Ž reaction with no further noise. Successful commands receive a πŸ‘ reaction. +### Dependabot auto-approve (opt-in) + +When `dependabot-auto-approve: dev-patch-minor` is set, the action evaluates +Dependabot PRs on `pull_request_target` (`opened`/`reopened`) and on every +`check_suite` completion for a `dependabot/` branch, and β€” when the PR passes +**all** of the gates below β€” adds the `accepted` label, submits a bot APPROVE +review (marker ``), and enables +auto-merge, exactly like `/lgtm`. Any gate failure exits **without approving** +(fail closed) and logs the reason to the action log only: + +1. **Real Dependabot** β€” PR author login is `dependabot[bot]` **and** the + user id is `49699333` (the Dependabot app's stable id). A lookalike + account can never match. +2. **Same-repo head** β€” the head branch lives in the base repo (never a + fork) and starts with `dependabot/`. +3. **Trusted commits** β€” every commit on the PR has a **verified signature** + and is authored by `dependabot[bot]`. +4. **File allow-list** β€” the changed files are a subset of + `dependabot-allowed-files` (exact paths; default + `package.json,package-lock.json`). +5. **Dependency scope** β€” Dependabot's structured commit-message metadata + (`updated-dependencies:` block) must show **every** updated dependency as + `dependency-type: direct:development` with + `update-type: version-update:semver-patch` or `…semver-minor`. Missing or + unparseable metadata never approves. (The metadata is trustworthy at this + point because gate 3 proved the commits are dependabot-authored and + signed.) +6. **CI green** β€” every check run on the head SHA is completed with + conclusion `success`/`neutral`/`skipped`, at least one **non-skipped** + check exists, and the combined commit status (if any statuses exist) is + `success`. Pending checks exit silently β€” the next `check_suite` + completion re-evaluates. This gate lives **in the action** so approval + itself is withheld until CI passes, even when branch protection has no + required status checks. +7. **PR state** β€” open, not a draft, no merge conflicts, and not carrying + the hold label. + +Push invalidation applies to these approvals too: any push to the PR (human +or Dependabot rebase) dismisses the auto-approval, removes the label, and +disables auto-merge. If **Dependabot** pushed (e.g. a rebase), the PR is +re-evaluated when its checks complete and can be re-approved. If a **human** +pushed, gate 3 fails permanently for that PR β€” every subsequent evaluation +sees a non-dependabot commit β€” so the PR can only proceed via a maintainer's +`/lgtm`. That one-way trapdoor is intentional. + +Enabling the mode requires two extra caller-workflow triggers β€” see +[Caller workflow requirements](#caller-workflow-requirements). + ### Merge gate The merge gate is **GitHub's native required-reviews rule** plus @@ -117,7 +166,7 @@ After creating: | | | |---|---| -| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`) | +| **Triggers** | `issue_comment` (types: `created`) + `pull_request_target` (types: `synchronize`). With `dependabot-auto-approve` enabled, also `pull_request_target` types `opened`, `reopened` + `check_suite` (types: `completed`) β€” gate the `check_suite` job on `startsWith(github.event.check_suite.head_branch, 'dependabot/')` to avoid a run per suite repo-wide. | | **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. | @@ -154,6 +203,9 @@ jobs: github-token: ${{ steps.app-token.outputs.token }} # auto-merge-method: squash # default; also: merge, rebase # stageblog-workflow: stage-blog.yml # uncomment to enable /stageblog + # dependabot-auto-approve: dev-patch-minor # uncomment to enable β€” also add + # pull_request_target types [opened, reopened] and check_suite [completed] + # triggers above (see Caller workflow requirements) ``` **Branch protection settings** for this flow: enable _Require a pull request @@ -227,12 +279,14 @@ jobs: | `project-gate-label` | | _(empty)_ | If set, only update the project status when the PR has this label (e.g. `SEP`). Empty = update for every accepted PR. Label removal via `remove-labels-on-accept` is not gated β€” it always runs. | | `stageblog-workflow` | | _(empty)_ | Workflow file name (e.g. `stage-blog.yml`) to dispatch when `/stageblog` is invoked. Empty = command disabled. The workflow must accept `pr_number` and `head_sha` string inputs. | | `stageblog-paths` | | `blog/**` | Comma-separated CODEOWNERS-style glob patterns. `/stageblog` is refused if no changed file matches. | +| `dependabot-auto-approve` | | _(empty)_ | Dependabot auto-approve policy. Empty = disabled. `dev-patch-minor` = approve + label + auto-merge Dependabot PRs that pass **all** gates (see [Dependabot auto-approve](#dependabot-auto-approve-opt-in)). Any other value disables the mode (fail closed). Requires the extra caller triggers listed above. | +| `dependabot-allowed-files` | | `package.json,package-lock.json` | Comma-separated **exact** file paths a Dependabot PR may touch to qualify for auto-approve. Any other changed file blocks auto-approval. | ## Outputs | Output | Description | |---|---| -| `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `invalidate-skipped`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `noop` | +| `result` | One of: `lgtm-added`, `lgtm-forced`, `lgtm-removed`, `hold-added`, `hold-removed`, `invalidated`, `invalidate-skipped`, `unauthorized`, `force-unauthorized`, `self-lgtm-blocked`, `stageblog-dispatched`, `stageblog-not-blog`, `stageblog-disabled`, `dependabot-approved`, `dependabot-skipped`, `noop` | | `actor` | Login of the commenter (empty for non-comment triggers) | ## CODEOWNERS pattern support @@ -274,3 +328,11 @@ Last-match-wins per file, consistent with GitHub's native CODEOWNERS semantics. author cannot push new commits between the maintainer's review and the build. - **Review provenance** β€” the review body always names the real approver ("on behalf of @login"), even though GitHub shows the review as App-authored. +- **Dependabot auto-approve** β€” off by default; every gate fails closed. The + author check pins the Dependabot app's numeric user id, not just the login; + fork heads are rejected outright; commit trust requires verified signatures + on dependabot-authored commits; the file allow-list is exact-path; missing + update metadata never approves; and approval waits for green CI inside the + action, so it holds even without required status checks in branch + protection. A human push onto a dependabot branch permanently disqualifies + the PR from auto-approval (gate 3) β€” only `/lgtm` can accept it after that. diff --git a/slash-commands/action.yml b/slash-commands/action.yml index 1f76e02..2acd70e 100644 --- a/slash-commands/action.yml +++ b/slash-commands/action.yml @@ -4,7 +4,8 @@ description: >- can approve via /lgtm (adds an accepted label, submits a bot APPROVE review, and enables auto-merge). Core maintainers or CODEOWNERS can block via /hold or trigger a blog preview staging workflow via /stageblog. Approval is - invalidated when new commits are pushed. + invalidated when new commits are pushed. Optionally auto-approves + narrowly-scoped Dependabot PRs (dependabot-auto-approve). inputs: github-token: @@ -110,6 +111,29 @@ inputs: if no file matches. required: false default: 'blog/**' + dependabot-auto-approve: + description: >- + Opt-in Dependabot auto-approve policy. Empty (default) = disabled. + 'dev-patch-minor' approves + labels + enables auto-merge on Dependabot + PRs where ALL of these hold: author is the real dependabot[bot] app + (login and user id), same-repo dependabot/ head branch, every commit is + signature-verified and dependabot-authored, changed files are a subset + of dependabot-allowed-files, every updated dependency is + direct:development with a semver-patch/minor update (per Dependabot's + commit-message metadata; missing metadata never approves), all check + runs on the head SHA are green with at least one non-skipped check, and + the PR is open, non-draft, conflict-free, and not on hold. Requires the + caller workflow to also trigger on pull_request_target + [opened, reopened] and check_suite [completed]. Any other value + disables the mode (fail closed). + required: false + default: '' + dependabot-allowed-files: + description: >- + Comma-separated exact file paths a Dependabot PR may touch to qualify + for auto-approve. A PR changing any other file is never auto-approved. + required: false + default: 'package.json,package-lock.json' outputs: result: @@ -117,7 +141,7 @@ outputs: One of: lgtm-added, lgtm-forced, lgtm-removed, hold-added, hold-removed, invalidated, invalidate-skipped, unauthorized, force-unauthorized, self-lgtm-blocked, stageblog-dispatched, stageblog-not-blog, - stageblog-disabled, noop + stageblog-disabled, dependabot-approved, dependabot-skipped, noop value: ${{ steps.handle.outputs.result }} actor: description: Login of the user who triggered the action (commenter) @@ -146,6 +170,8 @@ runs: PROJECT_GATE_LABEL: ${{ inputs.project-gate-label }} STAGEBLOG_WORKFLOW: ${{ inputs.stageblog-workflow }} STAGEBLOG_PATHS: ${{ inputs.stageblog-paths }} + DEPENDABOT_AUTO_APPROVE: ${{ inputs.dependabot-auto-approve }} + DEPENDABOT_ALLOWED_FILES: ${{ inputs.dependabot-allowed-files }} with: github-token: ${{ inputs.github-token }} script: | diff --git a/slash-commands/main.js b/slash-commands/main.js index fbbfc42..3271ac5 100644 --- a/slash-commands/main.js +++ b/slash-commands/main.js @@ -21,7 +21,15 @@ module.exports = async ({ github, context, core }) => { const projectStatusField = process.env.PROJECT_STATUS_FIELD; const projectAcceptedOption = process.env.PROJECT_ACCEPTED_OPTION; const projectGateLabel = process.env.PROJECT_GATE_LABEL; + const dependabotAutoApprove = process.env.DEPENDABOT_AUTO_APPROVE.trim(); + const dependabotAllowedFiles = process.env.DEPENDABOT_ALLOWED_FILES + .split(',').map(s => s.trim()).filter(Boolean); const reviewMarker = ''; + // Marker for reviews submitted by the dependabot auto-approve mode. Kept + // distinct from the /lgtm marker so logs/audits can tell them apart; push + // invalidation dismisses reviews carrying either marker. + const dependabotMarker = ''; + const botReviewMarkers = [reviewMarker, dependabotMarker]; function setResult(result, actor) { core.setOutput('result', result); @@ -205,7 +213,7 @@ module.exports = async ({ github, context, core }) => { }); for (const r of reviews) { if (r.state !== 'APPROVED') continue; - if (!r.body || !r.body.includes(reviewMarker)) continue; + if (!r.body || !botReviewMarkers.some(mk => r.body.includes(mk))) continue; try { await github.rest.pulls.dismissReview({ owner, repo, pull_number: prNumber, review_id: r.id, @@ -338,6 +346,185 @@ module.exports = async ({ github, context, core }) => { } } + // --- Dependabot auto-approve ------------------------------------- + // Opt-in via `dependabot-auto-approve`. Approves, labels, and enables + // auto-merge on Dependabot PRs β€” but ONLY when every gate below holds. + // Any gate failure exits without approving (fail closed), logging why. + + // The real Dependabot app's login and stable numeric user id. Checking the + // id means a lookalike account (e.g. a renamed user squatting a similar + // login) can never match. + const DEPENDABOT_LOGIN = 'dependabot[bot]'; + const DEPENDABOT_USER_ID = 49699333; + + // Parse Dependabot's structured metadata block from a commit message: + // updated-dependencies: + // - dependency-name: foo + // dependency-type: direct:development + // update-type: version-update:semver-patch + // Returns one entry per updated dependency; fields are undefined when the + // corresponding line is absent. Empty array = no metadata block found. + function parseDependabotMetadata(message) { + const entries = []; + let inBlock = false; + let current = null; + for (const raw of message.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!inBlock) { + if (/^updated-dependencies:\s*$/.test(line)) inBlock = true; + continue; + } + const name = line.match(/^-\s+dependency-name:\s*["']?(.+?)["']?\s*$/); + if (name) { + current = { name: name[1] }; + entries.push(current); + continue; + } + // Indented `key: value` lines belong to the current entry. Record the + // two keys we gate on; tolerate others (e.g. dependency-group). + const field = current && line.match(/^\s+([a-z-]+):\s*["']?(.+?)["']?\s*$/); + if (field) { + if (field[1] === 'dependency-type') current.dependencyType = field[2]; + if (field[1] === 'update-type') current.updateType = field[2]; + continue; + } + // Anything else (the `...` terminator, blank line, trailer) ends the block. + inBlock = false; + current = null; + } + return entries; + } + + // Evaluate one PR against the auto-approve policy. Returns true only if it + // was approved on this call. + async function handleDependabotAutoApprove(prNumber) { + const skip = (reason) => { + core.info(`dependabot-auto-approve: PR #${prNumber} not approved β€” ${reason}`); + return false; + }; + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + + // PR must be open, non-draft, without merge conflicts, and not held. + if (pr.state !== 'open') return skip('PR is not open'); + if (pr.draft) return skip('PR is a draft'); + if (pr.mergeable_state === 'dirty') return skip('PR has merge conflicts'); + if (pr.labels.some(l => l.name === holdLabel)) return skip(`PR has the '${holdLabel}' label`); + + // 1. Author must be the real Dependabot app β€” login AND user id. + if (pr.user.login !== DEPENDABOT_LOGIN || pr.user.id !== DEPENDABOT_USER_ID) { + return skip(`author is @${pr.user.login} (id ${pr.user.id}), not ${DEPENDABOT_LOGIN} (id ${DEPENDABOT_USER_ID})`); + } + + // 2. Same-repo head on a dependabot/ branch β€” never fork PRs. + if (pr.head.repo?.full_name !== pr.base.repo.full_name) { + return skip('head repo is not the base repo'); + } + if (!pr.head.ref.startsWith('dependabot/')) { + return skip(`head ref '${pr.head.ref}' is not a dependabot/ branch`); + } + + // 3. Every commit must carry a verified signature and be authored by + // dependabot. Once a human pushes to the branch this gate fails for the + // rest of the PR's life β€” by design (fail closed); push invalidation + // dismisses the old approval and a maintainer must /lgtm from then on. + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + if (!commits.length) return skip('PR has no commits'); + for (const c of commits) { + if (c.commit?.verification?.verified !== true) { + return skip(`commit ${c.sha.slice(0, 7)} does not have a verified signature`); + } + if (c.author?.login !== DEPENDABOT_LOGIN) { + return skip(`commit ${c.sha.slice(0, 7)} authored by @${c.author?.login || '(unknown)'}, not ${DEPENDABOT_LOGIN}`); + } + } + + // 4. Changed files must be a subset of the allow-list (exact paths). + // Renames are checked on both sides. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + for (const f of files) { + if (!dependabotAllowedFiles.includes(f.filename)) { + return skip(`changed file '${f.filename}' is not in dependabot-allowed-files`); + } + if (f.previous_filename && !dependabotAllowedFiles.includes(f.previous_filename)) { + return skip(`renamed file '${f.previous_filename}' is not in dependabot-allowed-files`); + } + } + + // 5. Scope: under 'dev-patch-minor', every updated dependency (from the + // structured metadata in the commit messages β€” trustworthy here because + // gate 3 proved the messages are dependabot-authored and signed) must be + // direct:development with a semver-patch or semver-minor update. Missing + // or unparseable metadata never approves. + const deps = []; + for (const c of commits) deps.push(...parseDependabotMetadata(c.commit.message)); + if (!deps.length) return skip('no updated-dependencies metadata found in commit messages'); + for (const d of deps) { + if (d.dependencyType !== 'direct:development') { + return skip(`'${d.name}' has dependency-type '${d.dependencyType || '(missing)'}' β€” mode dev-patch-minor requires direct:development`); + } + if (!['version-update:semver-patch', 'version-update:semver-minor'].includes(d.updateType)) { + return skip(`'${d.name}' has update-type '${d.updateType || '(missing)'}' β€” mode dev-patch-minor requires semver-patch or semver-minor`); + } + } + + // 6. CI must be green on the current head SHA: every check run completed + // with a passing conclusion, at least one non-skipped check exists (no + // approval without CI evidence), and the combined commit status β€” if any + // statuses exist β€” is success. Pending checks exit silently; the next + // check_suite completion re-evaluates. This gate lives here rather than + // in branch protection so approval itself is withheld until CI passes. + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref: pr.head.sha, per_page: 100, + }); + const passing = ['success', 'neutral', 'skipped']; + for (const run of checkRuns) { + if (run.status !== 'completed') { + return skip(`check '${run.name}' is still ${run.status}`); + } + if (!passing.includes(run.conclusion)) { + return skip(`check '${run.name}' concluded '${run.conclusion}'`); + } + } + if (!checkRuns.some(r => r.conclusion !== 'skipped')) { + return skip('no non-skipped check runs on the head SHA'); + } + const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ + owner, repo, ref: pr.head.sha, + }); + if (combined.total_count > 0 && combined.state !== 'success') { + return skip(`combined commit status is '${combined.state}'`); + } + + // Idempotency: concurrent check_suite completions can race β€” skip if an + // active (non-dismissed) auto-approval already exists. + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + if (reviews.some(r => r.state === 'APPROVED' && r.body?.includes(dependabotMarker))) { + return skip('already auto-approved'); + } + + // All gates passed β€” mirror the /lgtm accept path with the distinct + // marker, so push invalidation dismisses this approval too. + await addLabel(prNumber, approvedLabel); + await onAccept(pr); + await github.rest.pulls.createReview({ + owner, repo, pull_number: prNumber, event: 'APPROVE', + body: `${dependabotMarker}\nAutomatically approved under the \`${dependabotAutoApprove}\` Dependabot policy: ` + + 'all commits are dependabot-authored with verified signatures, changed files are within ' + + `\`${dependabotAllowedFiles.join(', ')}\`, every update is \`direct:development\` ` + + 'semver-patch/minor, and CI is green.', + }); + await enableAutoMerge(pr.node_id, prNumber); + core.info(`dependabot-auto-approve: PR #${prNumber} approved`); + return true; + } + // --- Dispatch --------------------------------------------------- const ev = context.eventName; @@ -370,6 +557,43 @@ module.exports = async ({ github, context, core }) => { return setResult('invalidated'); } + // Dependabot auto-approve β€” evaluated when a PR opens/reopens and whenever + // a check suite completes on a dependabot branch (the pass that usually + // approves, since CI is rarely finished at open time). + const isDependabotEvent = + (ev === 'pull_request_target' && ['opened', 'reopened'].includes(context.payload.action)) || + (ev === 'check_suite' && context.payload.action === 'completed'); + if (isDependabotEvent) { + if (!dependabotAutoApprove) return setResult('noop'); + if (dependabotAutoApprove !== 'dev-patch-minor') { + core.warning(`Unknown dependabot-auto-approve mode '${dependabotAutoApprove}' β€” auto-approve disabled (fail closed).`); + return setResult('noop'); + } + const prNumbers = []; + if (ev === 'check_suite') { + const suite = context.payload.check_suite; + const branch = suite.head_branch || ''; + // Only same-repo dependabot/ branches can ever pass the gates β€” skip + // the API round-trips entirely for everything else. + if (!branch.startsWith('dependabot/')) return setResult('noop'); + for (const p of suite.pull_requests || []) prNumbers.push(p.number); + if (!prNumbers.length) { + // pull_requests can be empty on some deliveries β€” resolve by branch. + const { data: prs } = await github.rest.pulls.list({ + owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 10, + }); + for (const p of prs) prNumbers.push(p.number); + } + } else { + prNumbers.push(context.payload.pull_request.number); + } + let approvedCount = 0; + for (const n of prNumbers) { + if (await handleDependabotAutoApprove(n)) approvedCount++; + } + return setResult(approvedCount ? 'dependabot-approved' : 'dependabot-skipped'); + } + // Comment commands if (ev !== 'issue_comment') return setResult('noop'); if (!context.payload.issue.pull_request) return setResult('noop');