Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0530329
fix(security): gate Dependabot approve/merge on check success and com…
don-petry Oct 28, 2026
f0ae8f3
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
9fb2e5e
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
31953be
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
d9090ab
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
d9270d2
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
0cd6921
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
29f81b8
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 28, 2026
4f1bc38
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 29, 2026
1af3161
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 29, 2026
c661c72
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 29, 2026
ac9bb71
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 30, 2026
659bbfc
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 30, 2026
ccea012
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 31, 2026
30b4fad
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Oct 31, 2026
59b9cfe
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 1, 2026
48d7069
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 1, 2026
b3ffe3e
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 2, 2026
9be6619
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 2, 2026
2178179
fix(security): withdraw Dependabot approval and auto-merge when the h…
don-petry Nov 2, 2026
64d692a
fix(security): withdraw automation when a Dependabot update becomes i…
don-petry Nov 2, 2026
e70c253
test(dependabot): fail extraction of a missing run block; correct the…
don-petry Nov 2, 2026
6150ffb
docs(dependabot): record the accepted auto-merge window (#1400)
don-petry Nov 2, 2026
738f4f2
fix(dependabot): do not hold merges on org agent jobs
don-petry Nov 2, 2026
c526db3
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 2, 2026
3fa4ad2
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
e661fc0
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
b436fb8
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
d076999
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
9ace830
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
6015ebb
fix(dependabot): withdraw automation when metadata fails; count only …
don-petry Nov 3, 2026
537a374
Merge branch 'main' of https://github.com/combsmith/.github into sec-…
don-petry Nov 3, 2026
b64c289
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
539990a
fix(dependabot): count a required check only as a GitHub Actions chec…
don-petry Nov 3, 2026
fd7b407
test(dependabot): pin that forged unsigned Dependabot-attributed comm…
don-petry Nov 3, 2026
60cd8bb
fix(dependabot): bind required checks to the ruleset's app; scope bot…
don-petry Nov 3, 2026
4362311
fix(dependabot): do not request workflows permission the App lacks (#…
don-petry Nov 3, 2026
6a0f6c9
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 3, 2026
30de8af
Merge remote-tracking branch 'origin/fix/sec-dependabot-merge-gating'…
don-petry Nov 3, 2026
831dcc7
fix(dependabot): read the merge gate with the App token, not GITHUB_T…
don-petry Nov 4, 2026
17b4155
Merge branch 'main' into fix/sec-dependabot-merge-gating
don-petry Nov 4, 2026
15804e0
Merge remote-tracking branch 'origin/fix/sec-dependabot-merge-gating'…
don-petry Nov 4, 2026
cc8abca
style(dependabot): wrap the failed-token error under the 200-column l…
don-petry Nov 4, 2026
64497f8
test(dependabot): make the token-split assertions fail when they shou…
don-petry Nov 4, 2026
5cc84ac
fix(dependabot): collapse superseded check runs only when unambiguous…
don-petry Nov 4, 2026
a775709
test(dependabot): pin that forged GITHUB_TOKEN commits are refused (#…
don-petry Nov 4, 2026
1f54664
docs(dependabot): match the caller stub example to the template; note…
don-petry Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/dependabot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dependabot gate scripts

Decision logic for the org-level Dependabot reusables
(`.github/workflows/dependabot-automerge-reusable.yml` and
`.github/workflows/dependabot-rebase-reusable.yml`). Both workflows feed the
same privileged sink — a GitHub App that approves Dependabot PRs and merges
them with ruleset bypass — so the gates that protect it live here, unit-tested
with bats (`test/workflows/dependabot/`), instead of inline in YAML.

Each reusable checks this repo out at its own commit (`job.workflow_sha`) into
`.dependabot-tooling/` and sources both libs, so the gates always match the
channel the caller pinned.

## `lib/gates.sh` — pure decisions

No network calls. Each gate prints a one-line reason and returns `0` (pass) or
`1` (block). Malformed or missing input always blocks.

| Function | Passes only when |
|----------|------------------|
| `dependabot_checks_gate PR_JSON REQUIRED_JSON [SHA]` | Agent jobs (`DEPENDABOT_AGENT_JOB_RE`, GitHub Actions only, never a required check) are not counted. Every other check run on the head commit `COMPLETED` as `success`, `neutral` or `skipped`; every commit status is `success`; at least one check reported; every check the base branch's rules require is present as a check run from the required app: the app the rule's `integration_id` names, or GitHub Actions when it names none (a commit status, or a run from any other app or with an unreadable app, does not count); the rollup is for the head commit (and the head is `SHA` when given). Failure, cancelled, timed_out, action_required, stale, startup_failure, pending, missing, or more than one page of checks all block (#1399). A check run superseded on the same commit (same app, workflow, event and name, lower databaseId) is not counted, but only when that whole key can be read and the newest run is unique and `success`; otherwise every run of the check counts, so any failure among them blocks. |
| `dependabot_provenance_gate PR_JSON COMMITS_JSON [APP_LOGIN] [SHA]` | The full commit list was read, at least one commit is Dependabot's, and every commit has a verified signature and is either authored by `dependabot[bot]` and committed by `web-flow` or `dependabot[bot]`, or a merge commit by `APP_LOGIN` committed by `web-flow` (the App's update-branch merges). Anything else — e.g. a commit someone else pushed to the branch — blocks (#1400). Returns `2`, not `1`, when the reads cannot be trusted (no head, no commits, a commit list that does not match the PR, malformed input), so a push between two reads is never taken as a foreign commit. |
| `dependabot_required_contexts RULES_JSON` | Prints the unique required checks from `GET /repos/{r}/rules/branches/{b}` as `{context, integration_id}` objects, with `integration_id` null when the rule names no app (returns `1` on a non-array). |

## `lib/gh-io.sh` — GitHub I/O

Reads state through `gh`, applies the gates, and performs the writes. Any read
failure fails closed.

| Function | Does |
|----------|------|
| `dependabot_merge_gate REPO PR APP_LOGIN` | Auto-merge requested + `MERGEABLE` + checks gate + provenance gate, all bound to one head SHA. Sets `DEPENDABOT_GATE_SHA` / `DEPENDABOT_GATE_REASON`. Used by the merger before every direct merge. |
| `dependabot_provenance_check REPO PR APP_LOGIN [SHA]` | Provenance gate on the live PR. Returns `0` pass, `1` block (sets `DEPENDABOT_GATE_STALE=true` when the head moved past `SHA`), `2` state unreadable. Reads the head again after the commit list and returns `2` if it moved in between. |
| `dependabot_merge_pr REPO PR SHA` | Squash-merges with `sha=SHA`, so GitHub rejects the merge if the head moved after the gate ran. |
| `dependabot_approve_and_automerge REPO PR SHA APP_LOGIN` | Approves with `commit_id=SHA`, enables auto-merge with `--match-head-commit SHA`, then re-reads the PR. If the head moved to a verified commit, it dismisses the approval of `SHA`; if the new head fails the gate or the PR cannot be read, it revokes everything. |
| `dependabot_dismiss_app_approvals REPO PR APP_LOGIN REASON [KEEP_SHA]` | Dismisses `APP_LOGIN`'s approvals except those of `KEEP_SHA`. Returns `1` if one may have survived. |
| `dependabot_revoke_automation REPO PR APP_LOGIN REASON` | Disables auto-merge and dismisses all of `APP_LOGIN`'s approvals. Returns `1` unless the PR reads back with auto-merge off and every dismissal succeeded, and the workflow then fails. |
| `dependabot_withdraw_app_automation REPO PR APP_LOGIN REASON` | For an update that became ineligible: dismisses `APP_LOGIN`'s approvals and disables auto-merge only if `APP_LOGIN` enabled it. |

**What this cannot make atomic.** Native auto-merge has no head-bound form that
survives a push: a writer's push does not disable it, and `--match-head-commit`
applies only when it is enabled. The re-read and the `synchronize` run together
withdraw that state on every interleaving, but not in the same instant, so a
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
foreign push has a short window before its run revokes. The merger is not
exposed to it: it re-runs both gates and merges with `sha=`. If the `synchronize`
run does not run at all, or fails because the provenance read returned `2`
(the PR state could not be read, so there is no verdict to act on), the state
stays until something re-evaluates the PR; a failed read fails the run, so it
shows up as a red check. The
required approval of the last push should block a merge in that window, but this
has not been tested end to end. The maintainer accepted this risk on 2026-11-02
(see `standards/dependabot-policy.md`).
249 changes: 249 additions & 0 deletions .github/scripts/dependabot/lib/gates.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Merge / approval gates for the Dependabot reusables (issues #1399, #1400).
#
# Pure decisions: no network calls, no sleeps. Each gate takes the JSON the
# I/O glue (lib/gh-io.sh) fetched, prints a one-line reason, and returns
# 0 pass — safe to approve / merge the PR at the verified head SHA
# 1 block — skip the PR (fail closed); the reason says why
# Malformed or missing input always blocks.
#
# Tested by test/workflows/dependabot/gates.bats.
# Contract: see .github/scripts/dependabot/README.md

# Logins GitHub attributes to genuine Dependabot commits. Dependabot commits are
# authored by dependabot[bot] and committed by GitHub (`web-flow`) with a
# GitHub-verified signature.
DEPENDABOT_LOGIN="dependabot[bot]"

# Org agent jobs that run on every PR but do not test the change. A cancelled or
# failed agent job says nothing about a dependency update, so it does not hold
# the merge. Exact names and prefixes only, and only from GitHub Actions, so a
# similarly named external check still counts. A check the base branch's rules
# require is never exempt.
# dev-lead / *, pr-auto-review / * caller jobs of those reusables
# pr-review / review the agent-ingress caller job
# review / review the pre-ingress caller job
DEPENDABOT_AGENT_JOB_RE='^(dev-lead / .+|pr-auto-review / .+|pr-review / review|review / review)$'

# dependabot_checks_gate PR_JSON REQUIRED_JSON [EXPECTED_SHA]
# PR_JSON GraphQL `pullRequest` object (or the whole `{data:{repository:
# {pullRequest:…}}}` response) with headRefOid and
# commits(last:1){nodes{commit{oid statusCheckRollup{contexts{
# pageInfo{hasNextPage} nodes{CheckRun{databaseId name status
# conclusion checkSuite{app{slug} workflowRun{event workflow{
# databaseId}}}} StatusContext{context state}}}}}}}.
# REQUIRED_JSON JSON array of the checks the base branch's rules require:
# {context, integration_id} objects as dependabot_required_contexts
# prints them, or plain names (no integration_id). `[]` when none.
# EXPECTED_SHA optional — block unless the PR head is this commit.
#
# A check run superseded on the same commit — an earlier run of the same check
# from the same app, workflow and event, e.g. one cancelled by a concurrency
# group or replaced by a re-run — is not counted when the newest (highest
# databaseId) run is unique and succeeded. Otherwise every run of the check
# counts: runs without a databaseId, or whose app, workflow or event cannot
# be read, are never collapsed, nor are runs whose newest was skipped,
# neutral, failing or pending, or shares its databaseId.
# Agent jobs (DEPENDABOT_AGENT_JOB_RE) are not counted. Passes only when every
# other check run on the head commit COMPLETED as success, neutral or skipped,
# every commit status is success, at least one such check was reported, and
# every required check is present as a check run from the required app: the
# app the rule's integration_id names, or GitHub Actions when it names none.
# A commit status, or a run from any other app or with an unreadable app,
# does not count. Anything else — failure, cancelled,
# timed_out, action_required, stale, startup_failure, pending, missing, or more
# contexts than one page — blocks.
dependabot_checks_gate() {
local pr_json="$1" required_json="${2:-[]}" expected="${3:-}" out verdict reason

out=$(jq -r --argjson required "$required_json" --arg expected "$expected" \
--arg agent_re "$DEPENDABOT_AGENT_JOB_RE" '
(if has("data") then .data.repository.pullRequest else . end) as $pr
| ($pr.headRefOid // "") as $head
| ($pr.commits.nodes[0].commit // {}) as $commit
| ($commit.statusCheckRollup.contexts // null) as $ctx
| def run_key:
[(.checkSuite.app.slug? // null), (.checkSuite.workflowRun.workflow.databaseId? // null),
(.checkSuite.workflowRun.event? // null), (.name // null)];
def collapsible: .__typename == "CheckRun" and ((.databaseId | type) == "number")
and (run_key | (.[0] | type) == "string" and .[0] != ""
and (.[1] | type) == "number"
and (.[2] | type) == "string" and .[2] != ""
and (.[3] | type) == "string" and .[3] != "");
def latest_runs:
# Older runs of a check are dropped only when the newest run of the
# same (app, workflow, event, name) is unambiguous and succeeded. A run
# whose key cannot be read fully, a newest run that was skipped,
# neutral, failing or pending, or a tie on the newest databaseId keeps
# every run of the group, so any failure among them still holds.
([.[] | select(collapsible)] | group_by(run_key)
| map((map(.databaseId) | max) as $top
| [.[] | select(.databaseId == $top)] as $newest
| if ($newest | length) == 1
and (($newest[0].status // "" | ascii_upcase) == "COMPLETED")
and (($newest[0].conclusion // "" | ascii_upcase) == "SUCCESS")
then $newest else . end)
| add // []) as $kept
| [.[] | select(collapsible | not)] + $kept;
($ctx.nodes // [] | latest_runs) as $nodes
| def name_of: if .__typename == "StatusContext" then (.context // "")
else (.name // "") end;
def ok: if .__typename == "CheckRun" then
((.status // "" | ascii_upcase) == "COMPLETED")
and ((.conclusion // "" | ascii_upcase)
| IN("SUCCESS", "NEUTRAL", "SKIPPED"))
elif .__typename == "StatusContext" then
((.state // "" | ascii_upcase) == "SUCCESS")
else false end;
def outcome: if .__typename == "StatusContext" then (.state // "MISSING")
elif ((.status // "" | ascii_upcase) != "COMPLETED") then (.status // "MISSING")
else (.conclusion // "MISSING") end | ascii_downcase;
def agent_job: .__typename == "CheckRun"
and (.checkSuite.app.slug? // "") == "github-actions"
and (name_of | test($agent_re))
and (name_of as $n | [$required[] | if type == "object" then .context else . end] | index($n) | not);
# A required check counts only as a CheckRun from the app the rule names
# (integration_id), or from GitHub Actions when it names none. A commit
# status, or a run from any other app or with an unreadable app, with the
# same name does not satisfy it.
def satisfies($r):
.__typename == "CheckRun" and name_of == $r.context
and (if ($r.integration_id | type) == "number"
then (.checkSuite.app.databaseId? // null) == $r.integration_id
else (.checkSuite.app.slug? // "") == "github-actions" end);
([$required[] | if type == "object" then . else {context: ., integration_id: null} end]) as $req
| ([$req[] | . as $r | select([$nodes[] | satisfies($r)] | any | not) | .context]) as $missing
| ([$nodes[] | select(agent_job | not)]) as $counted
| (($nodes | length) - ($counted | length)) as $exempt
| ([$counted[] | select(ok | not) | "\(name_of) (\(outcome))"]) as $bad
Comment thread
don-petry marked this conversation as resolved.
Resolved
| if $head == "" then "block\tPR head commit unknown"
elif ($expected != "" and $head != $expected) then
"block\thead moved to \($head[0:7]) (expected \($expected[0:7]))"
elif ($commit.oid // "") != $head then
"block\tcheck rollup is not for the head commit \($head[0:7])"
elif $ctx == null or ($counted | length) == 0 then
"block\tno checks reported on \($head[0:7])"
elif ($ctx.pageInfo.hasNextPage // false) then
"block\tmore checks than fit in one page on \($head[0:7]) — cannot verify them all"
elif ($bad | length) > 0 then
"block\t\($bad | length) check(s) not successful on \($head[0:7]): \($bad | join(", "))"
elif ($missing | length) > 0 then
"block\trequired check(s) not reported by the required app on \($head[0:7]): \($missing | join(", "))"
else
"pass\tall \($counted | length) check(s) succeeded on \($head[0:7])\(if $exempt > 0 then " (\($exempt) agent job(s) not counted)" else "" end)"
end
' <<<"$pr_json" 2>/dev/null) || out=""

verdict="${out%%$'\t'*}"
reason="${out#*$'\t'}"
if [[ "$verdict" == "pass" ]]; then
echo "$reason"
return 0
fi
if [[ "$verdict" == "block" ]]; then
echo "$reason"
else
echo "could not evaluate checks (malformed input)"
fi
return 1
}

# dependabot_provenance_gate PR_JSON COMMITS_JSON [TRUSTED_MERGE_LOGIN] [EXPECTED_SHA]
# PR_JSON REST `GET /repos/{r}/pulls/{n}` object (head.sha, commits).
# COMMITS_JSON REST `GET /repos/{r}/pulls/{n}/commits` — a flat array, or the
# array of pages `gh api --paginate --slurp` produces.
# TRUSTED_MERGE_LOGIN login of the merge App (e.g. `my-app[bot]`); merge
# commits it created through the update-branch API are allowed.
# Empty — only Dependabot commits are allowed.
# EXPECTED_SHA optional — block unless the PR head is this commit.
#
# Passes only when the full commit list was read (its length equals the PR's
# commit count and contains the head), at least one commit is Dependabot's,
# and EVERY commit is either
# - authored by dependabot[bot], committed by web-flow or dependabot[bot],
# with a GitHub-verified signature; or
# - a merge commit (2+ parents) authored by TRUSTED_MERGE_LOGIN, committed
# by web-flow, with a GitHub-verified signature (update-branch).
# Any other commit — e.g. one pushed to the branch by someone else — blocks.
#
# Returns 0 pass, 1 block (a verdict about the commits), 2 block (the reads
# cannot be trusted: no head, no commits, a commit list that does not match
# the PR — a push between the two reads — or malformed input). Callers must
# not treat 2 as evidence of a foreign commit.
dependabot_provenance_gate() {
local pr_json="$1" commits_json="$2" trusted="${3:-}" expected="${4:-}" out verdict reason

# Both documents go through stdin: a long commit list can exceed the
# per-argument size limit of --argjson.
out=$({ printf '%s\n' "$pr_json"; printf '%s\n' "$commits_json"; } | jq -rs \
--arg trusted "$trusted" --arg expected "$expected" --arg bot "$DEPENDABOT_LOGIN" '
if length != 2 then error("expected two documents") else . end
| .[0] as $pr | .[1] as $pages
| ($pr.head.sha // "") as $head
| ($pr.commits // -1) as $count
| ([$pages | if type == "array" then flatten[] else empty end
| select(type == "object")]) as $commits
| def verified: (.commit.verification.verified // false) == true;
def author: (.author.login // "");
def committer: (.committer.login // "");
def from_dependabot:
author == $bot and (committer | IN("web-flow", $bot)) and verified;
def trusted_merge:
$trusted != "" and author == $trusted and committer == "web-flow"
and ((.parents // []) | length) >= 2 and verified;
([$commits[] | select((from_dependabot or trusted_merge) | not)
| "\(.sha // "?" | .[0:7]) (author \(author | if . == "" then "unknown" else . end), committer \(committer | if . == "" then "unknown" else . end)\(if verified then "" else ", unverified" end))"]) as $foreign
| if $head == "" then "unreadable\tPR head commit unknown"
elif ($expected != "" and $head != $expected) then
"block\thead moved to \($head[0:7]) (expected \($expected[0:7]))"
elif ($commits | length) == 0 then "unreadable\tno commits read"
elif ($commits | length) != $count then
"unreadable\tread \($commits | length) of \($count) commits — cannot verify them all"
elif ([$commits[] | .sha] | index($head)) == null then
"unreadable\thead \($head[0:7]) not in the commit list"
elif ($foreign | length) > 0 then
"block\t\($foreign | length) commit(s) not from Dependabot: \($foreign | join(", "))"
elif ([$commits[] | select(from_dependabot)] | length) == 0 then
"block\tno Dependabot commit on the PR"
else
"pass\tall \($commits | length) commit(s) from Dependabot\(if $trusted != "" then " or \($trusted) branch updates" else "" end) (head \($head[0:7]))"
end
' 2>/dev/null) || out=""

verdict="${out%%$'\t'*}"
reason="${out#*$'\t'}"
if [[ "$verdict" == "pass" ]]; then
echo "$reason"
return 0
fi
if [[ "$verdict" == "block" ]]; then
echo "$reason"
return 1
fi
if [[ "$verdict" == "unreadable" ]]; then
echo "$reason"
else
echo "could not evaluate commit provenance (malformed input)"
fi
return 2
}

# dependabot_required_contexts RULES_JSON
# RULES_JSON REST `GET /repos/{r}/rules/branches/{b}` — a flat array of
# rules or the array of pages `--paginate --slurp` produces.
# Prints a JSON array of the unique required checks as {context,
# integration_id} (null when the rule names no app). Returns 1 (printing
# nothing) when the input is not an array.
dependabot_required_contexts() {
local rules_json="$1" out
out=$(jq -c '
if type != "array" then error("not an array") else . end
| [flatten[] | select(type == "object" and .type == "required_status_checks")
| (.parameters.required_status_checks // [])[]
| select((.context | type) == "string" and .context != "")
| {context, integration_id: (if (.integration_id | type) == "number" then .integration_id else null end)}]
| unique
' <<<"$rules_json" 2>/dev/null) || return 1
[[ -n "$out" ]] || return 1
echo "$out"
}
Loading
Loading