131 lines
6.6 KiB
YAML
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"
|