Skip to content

Commit 85e70b5

Browse files
authored
Automate Dependabot approvals (#2176)
* Automate Dependabot approvals * Fix Dependabot security update detection * Strengthen Dependabot workflow guards * Format Dependabot workflow tests * Pin Dependabot workflow step ordering * Restrict major security auto-approval * Make CodeQL a required merge gate
1 parent 8689bf8 commit 85e70b5

4 files changed

Lines changed: 130 additions & 17 deletions

File tree

.github/workflows/codeql-quality.yml

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ name: CodeQL Code Quality
1818
# - javascript scans the JS/TS family (.js/.mjs/.cjs/.ts/.tsx/.jsx) across the
1919
# whole tree — first-party src/, the Astro site's .mjs config,
2020
# and test JS; the same PATHS_IGNORE drops the vendored fixture
21-
# tree. (CodeQL does not extract .astro component scripts, but
22-
# .astro is in the path triggers below so site/ edits still run
23-
# the gate.)
21+
# tree. (CodeQL does not extract .astro component scripts.)
2422
#
2523
# Why a custom workflow instead of GitHub's Code Quality page:
2624
# - GitHub Code Quality (Preview) is gated to Team/Enterprise Cloud org plans;
@@ -37,17 +35,6 @@ permissions:
3735
on:
3836
pull_request:
3937
branches: [ master ]
40-
paths:
41-
- '**.py'
42-
- '**.js'
43-
- '**.mjs'
44-
- '**.cjs'
45-
- '**.jsx'
46-
- '**.ts'
47-
- '**.tsx'
48-
- '**.astro'
49-
- '.github/workflows/codeql-quality.yml'
50-
- 'scripts/codeql_quality_gate.py'
5138
push:
5239
branches: [ master ]
5340
paths:
@@ -128,3 +115,12 @@ jobs:
128115

129116
- name: Gate on findings
130117
run: python scripts/codeql_quality_gate.py quality.sarif
118+
119+
code-quality-gate:
120+
name: CodeQL Gate
121+
if: ${{ always() }}
122+
needs: code-quality
123+
runs-on: ubuntu-latest
124+
steps:
125+
- name: Require every CodeQL analysis to pass
126+
run: test "${{ needs.code-quality.result }}" = "success"

.github/workflows/dependabot-auto-merge.yml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,35 @@ permissions:
88

99
jobs:
1010
dependabot:
11-
if: github.actor == 'dependabot[bot]'
11+
if: >-
12+
github.actor == 'dependabot[bot]' &&
13+
github.event.pull_request.user.login == 'dependabot[bot]' &&
14+
github.event.pull_request.head.repo.full_name == github.repository
1215
runs-on: ubuntu-latest
1316
steps:
1417
- name: Fetch Dependabot metadata
1518
id: metadata
1619
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3
1720
with:
18-
github-token: "${{ secrets.GITHUB_TOKEN }}"
21+
github-token: "${{ secrets.DEPENDABOT_APPROVAL_TOKEN }}"
22+
alert-lookup: true
23+
24+
- name: Approve eligible Dependabot PR
25+
if: ${{ (steps.metadata.outputs.alert-state == 'OPEN' && !contains(steps.metadata.outputs.dependency-names, ',')) || contains(fromJson('["version-update:semver-minor","version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
26+
run: |
27+
gh api --method POST \
28+
"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \
29+
-f event=APPROVE \
30+
-f commit_id="$HEAD_SHA" \
31+
-f body="Automated approval: verified Dependabot update; required checks remain authoritative."
32+
env:
33+
# This must be a Dependabot secret owned by a maintainers-team member.
34+
GH_TOKEN: ${{ secrets.DEPENDABOT_APPROVAL_TOKEN }}
35+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
36+
PR_NUMBER: ${{ github.event.pull_request.number }}
1937

2038
- name: Enable auto-merge for Dependabot PRs
21-
if: ${{ steps.metadata.outputs.update-type == 'security-update' || contains(fromJson('["version-update:semver-minor","version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
39+
if: ${{ (steps.metadata.outputs.alert-state == 'OPEN' && !contains(steps.metadata.outputs.dependency-names, ',')) || contains(fromJson('["version-update:semver-minor","version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
2240
run: gh pr merge --auto --squash "$PR_URL"
2341
env:
2442
PR_URL: ${{ github.event.pull_request.html_url }}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Guard the always-reported CodeQL merge gate."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import yaml
9+
10+
_REPO_ROOT = Path(__file__).resolve().parents[3]
11+
_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "codeql-quality.yml"
12+
13+
14+
def _workflow() -> dict[str, Any]:
15+
return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8"))
16+
17+
18+
def test_pull_requests_always_emit_codeql_gate() -> None:
19+
workflow = _workflow()
20+
pull_request = workflow[True]["pull_request"]
21+
gate = workflow["jobs"]["code-quality-gate"]
22+
23+
assert "paths" not in pull_request
24+
assert gate["name"] == "CodeQL Gate"
25+
assert gate["needs"] == "code-quality"
26+
assert gate["if"] == "${{ always() }}"
27+
assert gate["steps"][0]["run"] == (
28+
'test "${{ needs.code-quality.result }}" = "success"'
29+
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Guard the security-sensitive wiring of Dependabot auto-merge."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import yaml
9+
10+
_REPO_ROOT = Path(__file__).resolve().parents[3]
11+
_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "dependabot-auto-merge.yml"
12+
13+
14+
def _workflow() -> dict[str, Any]:
15+
return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8"))
16+
17+
18+
def _normalize(value: str) -> str:
19+
return " ".join(value.split())
20+
21+
22+
def test_metadata_fetches_security_alert_state_with_approval_token() -> None:
23+
job = _workflow()["jobs"]["dependabot"]
24+
steps = job["steps"]
25+
metadata = next(step for step in steps if step.get("id") == "metadata")
26+
27+
assert _normalize(job["if"]) == _normalize(
28+
"github.actor == 'dependabot[bot]' && "
29+
"github.event.pull_request.user.login == 'dependabot[bot]' && "
30+
"github.event.pull_request.head.repo.full_name == github.repository"
31+
)
32+
assert metadata["with"]["alert-lookup"] is True
33+
assert metadata["with"]["github-token"] == (
34+
"${{ secrets.DEPENDABOT_APPROVAL_TOKEN }}"
35+
)
36+
37+
38+
def test_approval_is_bound_to_the_current_head_and_dedicated_token() -> None:
39+
steps = _workflow()["jobs"]["dependabot"]["steps"]
40+
approval = next(
41+
step for step in steps if step["name"] == "Approve eligible Dependabot PR"
42+
)
43+
44+
assert '-f commit_id="$HEAD_SHA"' in approval["run"]
45+
assert approval["env"]["HEAD_SHA"] == "${{ github.event.pull_request.head.sha }}"
46+
assert approval["env"]["GH_TOKEN"] == ("${{ secrets.DEPENDABOT_APPROVAL_TOKEN }}")
47+
48+
49+
def test_approval_and_auto_merge_share_security_aware_eligibility() -> None:
50+
steps = _workflow()["jobs"]["dependabot"]["steps"]
51+
metadata = next(step for step in steps if step.get("id") == "metadata")
52+
approval = next(
53+
step for step in steps if step["name"] == "Approve eligible Dependabot PR"
54+
)
55+
auto_merge = next(
56+
step for step in steps if step["name"] == "Enable auto-merge for Dependabot PRs"
57+
)
58+
59+
assert steps.index(metadata) < steps.index(approval)
60+
assert steps.index(metadata) < steps.index(auto_merge)
61+
expected = (
62+
"${{ (steps.metadata.outputs.alert-state == 'OPEN' && "
63+
"!contains(steps.metadata.outputs.dependency-names, ',')) || "
64+
'contains(fromJson(\'["version-update:semver-minor",'
65+
'"version-update:semver-patch"]\'), '
66+
"steps.metadata.outputs.update-type) }}"
67+
)
68+
assert {_normalize(approval["if"]), _normalize(auto_merge["if"])} == {
69+
_normalize(expected)
70+
}

0 commit comments

Comments
 (0)