Skip to content

[ci] Re-enable zizmor #16

[ci] Re-enable zizmor

[ci] Re-enable zizmor #16

Workflow file for this run

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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
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)))' > /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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
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)))' > /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