[ci] Re-enable zizmor #16
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |