Files

131 lines
6.6 KiB
YAML

# Repository prerequisite: enable Settings → Actions → General →
# "Allow GitHub Actions to create and approve pull requests".
# The bot approval satisfies the "someone other than the last pusher" rule because
# this workflow's bot never pushes. For SOC 2 four-eyes, this automated control
# inherits the original human review of the commit already landed on protected dev.
#
# Security model (warden SEP-C6H): this runs on pull_request_target, so the
# workflow definition AND scripts/ci/verify-clean-revert.mjs execute from the
# trusted base branch (dev) — never from the PR. PR commits are fetched as git
# data only: the verifier re-applies `git revert` to them as patch content and
# compares tree hashes. A PR that modifies this workflow or the verifier cannot
# change what executes here. actions/checkout is pinned to an immutable commit
# (warden TBZ-HXQ) because this job holds a pull-request write token. The
# same-repo guard below is mandatory under pull_request_target: the write token
# must never run against fork-originated PRs. The approval is created through
# the reviews API with commit_id bound to the verified head; if the head moves
# before approval, it is withheld and the new push re-verifies.
# GitHub offers no atomic compare-and-approve, so the residual read-then-approve
# race is closed server-side by dev's ruleset (warden round 3):
# dismiss_stale_reviews_on_push plus require_last_push_approval dismiss or ignore
# any approval whose head moved afterward. Immediately before approving, the job
# verifies both flags at runtime and withholds approval if either is disabled, so
# the control cannot silently outlive its preconditions.
#
# Scope is limited to single-commit, non-merge reverts whose tree is an exact inverse;
# hand-adjusted reverts (revert conflicts or edits after `git revert`) are deliberately
# rejected and take the normal human-review path.
name: Revert Fast Lane
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [dev]
permissions:
contents: read
pull-requests: write
concurrency:
group: revert-fastlane-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-and-approve:
if: startsWith(github.event.pull_request.title, 'Revert "') && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout trusted base (dev)
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: Fetch PR commits as git data
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: git fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA"
- name: Verify exact clean revert
id: verify
env:
PR_BODY: ${{ github.event.pull_request.body }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
reverted="$(node -e 'const match = (process.env.PR_BODY || "").match(/This reverts commit ([0-9a-f]{40})/i); process.stdout.write(match ? match[1] : "")')"
args=(--base "$BASE_SHA" --head "$HEAD_SHA")
if [[ -n "$reverted" ]]; then
args+=(--reverted "$reverted")
fi
output="$(node scripts/ci/verify-clean-revert.mjs "${args[@]}" 2>&1)"
status=$?
printf '%s\n' "$output"
verdict=""
verified_reverted=""
reason="verification failed"
while IFS= read -r line; do
case "$line" in
verdict=*) verdict="${line#verdict=}" ;;
reverted=*) verified_reverted="${line#reverted=}" ;;
FAIL\ *) reason="${line#FAIL }" ;;
esac
done <<< "$output"
if [[ $status -eq 0 && "$verdict" == "pass" ]]; then
echo "verdict=pass" >> "$GITHUB_OUTPUT"
else
echo "verdict=fail" >> "$GITHUB_OUTPUT"
fi
echo "reverted=$verified_reverted" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
- name: Approve verified revert
if: steps.verify.outputs.verdict == 'pass'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REVERTED_SHA: ${{ steps.verify.outputs.reverted }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# This control's residual read-then-approve race is closed server-side by
# dev's ruleset. Refuse to approve if those guarantees are not confirmed.
rules="$(gh api "repos/$GITHUB_REPOSITORY/rules/branches/dev" --jq '[.[] | select(.type=="pull_request") | .parameters | (.dismiss_stale_reviews_on_push and .require_last_push_approval)] | any' 2>/dev/null || true)"
if [[ "$rules" != "true" ]]; then
printf '## Revert Fast Lane\n\nApproval withheld: could not confirm the dev ruleset still enforces dismiss_stale_reviews_on_push AND require_last_push_approval, which this control depends on to invalidate any approval whose head moves.\n' >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Bind the approval to the verified head. If the head moved since
# verification, withhold — the synchronize event re-runs verification.
current="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.sha)"
if [[ "$current" != "$HEAD_SHA" ]]; then
printf '## Revert Fast Lane\n\nHead moved after verification (%s -> %s); approval withheld. The new push re-runs verification.\n' "$HEAD_SHA" "$current" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
body="Machine-verified at \`$HEAD_SHA\`: this PR's tree is the exact inverse of commit \`$REVERTED_SHA\`, which previously landed on protected \`dev\` via a reviewed PR. Auto-approval per the clean-revert fast-lane control, bound to the verified commit; any further push re-runs verification."
gh api -X POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \
-f commit_id="$HEAD_SHA" \
-f event=APPROVE \
-f body="$body" > /dev/null
printf '## Revert Fast Lane\n\nPASS: %s\n' "$body" >> "$GITHUB_STEP_SUMMARY"
- name: Record ineligible revert
if: steps.verify.outputs.verdict != 'pass'
env:
REASON: ${{ steps.verify.outputs.reason }}
run: |
printf '## Revert Fast Lane\n\nNot eligible for fast lane: %s. Needs normal human review.\n' "$REASON" >> "$GITHUB_STEP_SUMMARY"