diff --git a/.github/workflows/pr-draft-gate.yml b/.github/workflows/pr-draft-gate.yml new file mode 100644 index 0000000..6d1cf2b --- /dev/null +++ b/.github/workflows/pr-draft-gate.yml @@ -0,0 +1,231 @@ +# TGWAB PR draft gate—DEV-STANDARDS §4, the three-hand gate. +# +# Place at: .github/workflows/pr-draft-gate.yml +# Required status-check context: pr-draft-gate (the JOB name, not the workflow name) +# +# WHAT THIS CATCHES, and why the convention did not. +# +# The gate is: Sr Dev opens a DRAFT, DevOps marks it ready, Michal merges. Its +# whole design argument is that draft is a *state* and therefore cannot be +# skipped by forgetting. That argument is false at the one moment it matters. +# `gh pr create` opens a PR ready, GitHub offers no way to force draft-on-open, +# and a PR opened ready is merge-eligible the second it exists—the DevOps hand +# is not pending, it is gone, and nothing anywhere says so. +# +# Measured 2026-09-06 over 221 merged PRs in 12 repos, all merged on or after the +# 2026-08-30 ruling: 21 never entered draft. Six of those merged strictly after +# the ruling day—`resizewizard-api` #42 and #43, `uploadwizard-app` #71, +# `TGWAB/tgwab-account` #29, and `TGWAB/changelogs` #8 and #9. The convention +# holds ~90% of the time on habit alone, which is exactly the failure shape this +# estate keeps cataloguing: a control that works until the once someone is +# concentrating. +# +# HOW IT DECIDES, and why it reads history rather than state. +# +# `pull_request.draft` is the CURRENT state, and a status check is keyed to a +# commit: a later run overwrites an earlier one on the same SHA. A gate built on +# the current flag is therefore launderable—open ready, convert to draft, convert +# back, and the failure is overwritten by a pass. So this reads the PR's +# state-change events instead, which are append-only: +# +# GitHub emits `ready_for_review` ONLY on draft -> ready, and `convert_to_draft` +# ONLY on ready -> draft. The FIRST such event therefore says how the PR was +# OPENED, and nothing done afterwards can change it. +# +# first is ready_for_review -> opened as a draft PASS +# first is convert_to_draft -> opened ready, drafted later FAIL +# no events, still a draft -> opened as a draft PASS +# no events, not a draft -> opened ready, never drafted FAIL +# +# WHAT THIS DOES NOT PROVE, stated here because a control nobody has bounded is +# the thing this gate was already failing at. +# +# It does NOT prove DevOps reviewed anything. Every hand in the three-hand +# gate—Sr Dev, DevOps, and the owner—acts through one GitHub credential, so +# `ready_for_review.actor` equals the author on every PR in the estate and an +# `actor != author` rule would refuse nothing. This check asserts only that the +# PR passed through the state in which review was supposed to happen. That is +# strictly less than the gate's stated design, and the job says so in its own +# output on every run so the next reader cannot over-read it. +# +# The escape hatch is the `gate-skipped` label, and the label is deliberately a +# confession rather than a permission: a gate with no declared way out gets +# bypassed by deleting the gate, and a PR that must be labelled "skipped" to +# merge leaves a greppable mark where a forgotten draft leaves none. +# +# THE LABEL MUST EXIST IN THE REPO BEFORE THE GATE IS MADE REQUIRED. `gh pr edit +# --add-label` fails on a label that has not been created, so vendoring this +# workflow without the label ships a gate whose only declared exit does not open. +# Create it alongside the workflow: +# +# gh label create gate-skipped -R / --color D93F0B \ +# --description "This PR did not pass through the three-hand draft gate (DS §4)." + +# +# `on: pull_request` (not `pull_request_target`) is deliberate: this reads event +# metadata and the PR's own event list, never repository code, and needs no write +# scope. There is no action to pin—the job is `gh api` and two shell steps. + +name: pr-draft-gate + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled, unlabeled] + +permissions: {} + +concurrency: + group: pr-draft-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-draft-gate: # job name == the required status-check context + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # BOTH are required, measured rather than assumed: with `issues: read` + # alone, /issues/N/events returns 403 "Resource not accessible by + # integration" on a pull request. No write scope anywhere. + issues: read + pull-requests: read + steps: + - name: Read this PR's state-change history + env: + GH_TOKEN: ${{ github.token }} + # Quoted into the environment rather than interpolated into the script: + # `${{ }}` inside `run` is textual substitution straight into the shell. + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_ACTION: ${{ github.event.action }} + run: | + set -euo pipefail + + # The webhook can beat the REST API to consistency. That only matters + # on `ready_for_review`, where an empty list would read as "opened + # ready" and fail a PR that did everything right—a false red on the + # NORMAL path, which is the one failure that would teach people to + # ignore this check. Retry only in that case, and only briefly. + for attempt in 1 2 3 4 5; do + gh api "repos/${REPO}/issues/${PR_NUMBER}/events" --paginate \ + --jq '[.[] | {event, actor: .actor.login, created_at}]' > events.json + n=$(jq 'length' events.json) + if [ "$EVENT_ACTION" != "ready_for_review" ] || [ "$n" -gt 0 ]; then + break + fi + echo "events list empty on a ready_for_review event; retry ${attempt}/5" + sleep 3 + done + + echo "State-change events on this PR:" + jq -r '.[] | " \(.created_at) \(.event) actor=\(.actor)"' events.json + jq -e 'length > 0' events.json >/dev/null || echo " (none)" + + - name: The PR MUST have been opened as a draft + env: + EVENTS: events.json + IS_DRAFT: ${{ github.event.pull_request.draft }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # --- decision:begin --- + # Everything between these markers is a pure function of $EVENTS, + # $IS_DRAFT, $LABELS and $PR_NUMBER. In tgwab-standards, + # scripts/check-draft-gate.test.sh extracts this block from + # .github/workflows/pr-draft-gate.yml — the file that actually runs, + # not a copy of it — and executes it against synthetic histories. + # Guard 0 in that same suite separately holds that file byte-identical + # to templates/pr-draft-gate.yml, and it is guard 0, not the extraction, + # that makes the run evidence about the template this repo ships. + # + # That script does NOT travel with this file, and must not: a gate + # that depended on a script vendored alongside it could be disabled by + # not vendoring the script. So in every repo BUT tgwab-standards this + # block is verified by being byte-identical to the template, which is + # a claim about a file nothing here can check. scripts/check-gate-drift.sh + # checks it, centrally, and is the only thing that does. + set -euo pipefail + + # Exact match on a comma-delimited list, so a label named + # "not-gate-skipped" cannot satisfy the check by substring. + case ",${LABELS}," in + *,gate-skipped,*) + echo "::notice title=Draft gate deliberately skipped::PR #${PR_NUMBER} carries 'gate-skipped'. It did not pass through the three-hand gate, and this label is the only record that it did not." + echo "SKIPPED by label: the three-hand gate was not enforced on this PR." + exit 0 + ;; + esac + + # Both spellings are matched on purpose. The webhook activity type is + # `converted_to_draft`; the issue-event is `convert_to_draft`. Matching + # both means a rename on either surface makes this gate FAIL a + # laundered PR rather than silently pass one. + first=$(jq -r ' + [ .[] | select(.event == "ready_for_review" + or .event == "convert_to_draft" + or .event == "converted_to_draft") ] | .[0].event // "none" + ' "$EVENTS") + + # Reported, never gated on—see the header. With one credential this is + # always the author, and a rule keyed to it would refuse nothing. + ready_actor=$(jq -r ' + [ .[] | select(.event == "ready_for_review") ] | .[-1].actor // "—" + ' "$EVENTS") + + case "$first" in + ready_for_review) + verdict=pass + reason="opened as a draft; marked ready for review by ${ready_actor}" + ;; + convert_to_draft|converted_to_draft) + verdict=fail + reason="opened READY and converted to draft afterwards" + ;; + none) + if [ "$IS_DRAFT" = "true" ]; then + verdict=pass + reason="opened as a draft and still a draft" + else + verdict=fail + reason="opened READY and has never been a draft" + fi + ;; + *) + # An event name this script does not know is not a pass. Failing + # closed here is the difference between a gate and a decoration. + verdict=fail + reason="unrecognised state-change event '${first}'" + ;; + esac + + if [ "$verdict" = pass ]; then + echo "PR #${PR_NUMBER}: ${reason}." + echo "::notice title=Draft state only::This proves PR #${PR_NUMBER} passed through draft, the state in which DevOps review is supposed to happen. It does NOT prove anyone reviewed it: every agent acts through one GitHub credential, so 'marked ready by ${ready_actor}' is not evidence of a second hand (DS §4)." + exit 0 + fi + + echo "::error title=PR did not pass through the draft gate::PR #${PR_NUMBER} was ${reason}." + cat <