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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ygNtiNcAaqTRnQjHCDkVS
This commit is contained in:
Claude 2026-07-06 17:38:34 +00:00
commit d37cc0a6dc
No known key found for this signature in database
3 changed files with 318 additions and 6 deletions

View file

@ -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 `<!-- slash-commands-dependabot-auto -->`), 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.

View file

@ -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: |

View file

@ -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 = '<!-- slash-commands-lgtm -->';
// 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 = '<!-- slash-commands-dependabot-auto -->';
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');