-
Notifications
You must be signed in to change notification settings - Fork 148
272 lines (244 loc) · 12.1 KB
/
auto-approve.yml
File metadata and controls
272 lines (244 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
name: Auto-Approve Lifecycle
# We support auto-approving PRs as configured by `.github/auto-approvers.json`.
# A naive implementation would approve PRs based on their content before they've
# been added to the merge queue, but this is subject to TOCTOU issues that are
# difficult to mitigate:
# - A user can always update a PR after it's been approved (can be mitigated
# by having updates dismiss existing reviews)
# - A PR can be updated twice, with the first update triggering the Action run
# and the second update taking place before the Action's approval has taken
# place (harder to mitigate)
#
# To avoid these issues, we split the approval into two phases. The PR itself is
# approved using a simple, optimistic approach that is good enough in the vast
# majority of cases and provides good UX. However, this phase is not trusted.
# A second phase which performs the *real* security enforcement occurs once the
# PR is in the merge queue and thus cannot be modified.
on:
# Trigger 1: A best-effort optimistic pass which auto-approves PRs.
pull_request_target: # zizmor: ignore[dangerous-triggers] (Best-effort; not used for security)
types: [opened, synchronize, reopened]
# Trigger 2: The real security enforcement, which runs in the merge queue and
# thus avoids TOCTOU issues.
merge_group:
types: [checks_requested]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
# Goal: Quickly evaluate the PR and submit an approval to satisfy branch
# protection rules so the user can click "Add to Merge Queue".
optimistic-approve:
name: Optimistic Approve
# NOTE: Configured via GitHub repo settings to only be required for PRs.
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
permissions:
pull-requests: write # Required to submit the review
contents: read
steps:
- name: Checkout trusted base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.base_ref }}
persist-credentials: false
- name: Fetch Changed Files
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -eo pipefail
API_ENDPOINT="repos/$REPOSITORY/pulls/$PR_NUMBER/files"
gh api "$API_ENDPOINT" --paginate --jq 'map([.filename, .previous_filename] | map(select(. != null)))' | jq -s 'add' > /tmp/changed_files.json
- name: Optimistic Evaluation
id: evaluation
env:
ACTOR: ${{ github.actor }}
TOTAL_PR_FILES: ${{ github.event.pull_request.changed_files }}
run: |
set +e
python3 ci/validate_auto_approvers.py \
--expected-count "$TOTAL_PR_FILES" \
--contributors "$ACTOR" \
--changed-files /tmp/changed_files.json
EXIT_CODE=$?
set -eo pipefail
# Exit code 0 means auto-approval is granted.
if [ $EXIT_CODE -eq 0 ]; then
echo "is_auto_approvable=true" >> "$GITHUB_OUTPUT"
echo "✅ PR is auto-approvable."
# Exit code 1 means the PR content is valid but does not fall under
# any auto-approval rules.
elif [ $EXIT_CODE -eq 1 ]; then
echo "is_auto_approvable=false" >> "$GITHUB_OUTPUT"
echo "ℹ️ PR is not auto-approvable; skipping bot approval."
# Any other exit code indicates a technical error (e.g., config
# corruption, API failure). These must fail the job to ensure we
# notice when the system is broken.
else
echo "::error::❌ Validation encountered a technical error: $EXIT_CODE"
exit $EXIT_CODE
fi
- name: Approve PR Atomically
if: steps.evaluation.outputs.is_auto_approvable == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --method POST \
-H "Accept: application/vnd.github+json" \
"/repos/$REPOSITORY/pulls/$PR_NUMBER/reviews" \
-f commit_id="$HEAD_SHA" \
-f event="APPROVE" \
-f body="🤖 **Optimistically Approved:** Changes appear scoped. Final strict verification will occur in the Merge Queue."
# Goal: Act as the final, immutable security boundary. Validates the lowest
# common denominator of all contributors in the queue. Runs in a context in
# which the contents cannot be modified, and so there's no risk of TOCTOU
# issues, which there are when approving the PR before it's in the merge
# queue.
strict-queue-gatekeeper:
name: Strict Queue Gatekeeper
# NOTE: Configured via GitHub repo settings to only be required in the merge
# queue.
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
permissions:
pull-requests: read # Required to query the PR endpoints via `gh api`
contents: read # Explicitly drop write access for maximum security
steps:
- name: Checkout trusted base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.merge_group.base_ref }}
persist-credentials: false
- name: Check if auto-approval is intended
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eo pipefail
# Extract PR number from the merge group branch name.
# Example: refs/heads/gh-readonly-queue/main/pr-123-SHA
PR_NUMBER=$(echo "${GITHUB_REF}" | sed -n 's/.*\/pr-\([0-9]*\)-.*/\1/p')
if [ -z "$PR_NUMBER" ]; then
echo "::error::❌ Could not extract PR number from branch name: ${GITHUB_REF}"
exit 1
fi
# We distinguish between 'Auto-Approved' PRs and 'Manual' PRs by
# checking the PR's review history. If the `github-actions[bot]` has
# EVER approved this PR, we consider it an 'Auto-Approved' PR and
# enforce the strict validation boundary.
#
# Why check the entire history instead of just the current state?
# This prevents an attacker from 'hiding' the fact that a PR was
# auto-approved by dismissing the bot's review before it enters the
# merge queue. By checking the history, we ensure that if a PR ever
# took the 'Auto-Approved' path, it remains subject to the strictest
# levels of scrutiny.
#
# If no bot approval is found, we assume the PR was manually
# reviewed by a human. In that case, the human reviewer is the
# security boundary, and we can safely skip the bot's validation.
PR_NUMBER=$(echo "${GITHUB_REF}" | sed -n 's/.*\/pr-\([0-9]*\)-.*/\1/p')
IS_AUTO_PR=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \
--jq 'any(.[]; .user.login == "github-actions[bot]" and .state == "APPROVED")')
# Use `!= "false"` instead of `= "true"` so that we fail closed if
# truth is ever encoded as any string other than `"true"`.
if [ "$IS_AUTO_PR" != "false" ]; then
echo "is_auto_pr=true" >> "$GITHUB_OUTPUT"
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "🤖 Detected historical bot approval; enforcing strict validation."
else
echo "is_auto_pr=false" >> "$GITHUB_OUTPUT"
echo "👤 No bot approval detected; assuming manual review path."
fi
- name: Extract PR Context
if: steps.gate.outputs.is_auto_pr != 'false'
id: context
env:
PR_NUMBER: ${{ steps.gate.outputs.pr_number }}
run: |
set -eo pipefail
# PR_NUMBER is retrieved from step env.
echo "Processing PR #$PR_NUMBER"
- name: Fetch PR Metadata
if: steps.gate.outputs.is_auto_pr != 'false'
id: metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.gate.outputs.pr_number }}
run: |
set -eo pipefail
TOTAL_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.changed_files')
echo "total_pr_files=$TOTAL_FILES" >> "$GITHUB_OUTPUT"
# This is obviously a ridiculously low limit but:
# - Any higher and it's *theoretically possible* that we could
# introduce a vulnerability if GitHub truncates its output, leading
# to us skipping commits and thus permitting PRs through the merge
# queue which should have been rejected
# - In practice, we always produce single-commit PRs, so this will
# rarely get in our way
TOTAL_COMMITS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.commits')
if [ "$TOTAL_COMMITS" -gt 1 ]; then
echo "::error::❌ PR contains $TOTAL_COMMITS commits. The GitHub API may truncate results at >1 commits, preventing safe identity validation. Manual review required."
exit 1
fi
- name: Fetch PR Commits
if: steps.gate.outputs.is_auto_pr != 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.gate.outputs.pr_number }}
run: |
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \
--paginate \
--jq '[.[] | {author: .author.login, committer: .committer.login}]' > /tmp/commits.json
- name: Fetch Changed Files
if: steps.gate.outputs.is_auto_pr != 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.gate.outputs.pr_number }}
run: |
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--paginate \
--jq 'map([.filename, .previous_filename] | map(select(. != null)))' | jq -s 'add' > /tmp/changed_files.json
- name: Strict Identity & File Validation
if: steps.gate.outputs.is_auto_pr != 'false'
env:
EXPECTED_COUNT: ${{ steps.metadata.outputs.total_pr_files }}
run: |
set -eo pipefail
# Ensure no commits are unlinked to a GitHub account
NULL_COUNT=$(jq '[.[] | select(.author == null or .committer == null)] | length' /tmp/commits.json)
if [ "$NULL_COUNT" -gt 0 ]; then
echo "::error::❌ A commit belongs to an email not linked to a GitHub account."
exit 1
fi
# Extract unique authors and committers into a space-separated string
CONTRIBUTORS=$(jq -r '.[] | .author, .committer' /tmp/commits.json | sort -u)
# Pass the unquoted $CONTRIBUTORS variable to python so argparse
# correctly receives each name as a separate item in the list.
python3 ci/validate_auto_approvers.py \
--expected-count "$EXPECTED_COUNT" \
--contributors $CONTRIBUTORS \
--changed-files /tmp/changed_files.json
# Used to signal to branch protections that all other jobs have succeeded.
all-jobs-succeed:
# WARNING: This name is load-bearing! It's how GitHub's settings UI configures which jobs
# to block on. DO NOT change this name without updating the settings UI to match.
name: All checks succeeded (auto-approve.yml)
# On failure, we run and unconditionally exit with a failing status code.
# On success, this job is skipped. Jobs skipped using `if:` are considered
# to have succeeded:
#
# https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks#handling-skipped-but-required-checks
if: failure()
runs-on: ubuntu-latest
needs: [optimistic-approve, strict-queue-gatekeeper]
steps:
- name: Mark the job as failed
run: exit 1