Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 102 additions & 61 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@
authorize:
# Runs on self-hosted intel. GitHub-hosted runners are BANNED fleet-wide:
# they bill on private repos, and when the Actions spending limit is reached
# the job does not start AT ALL — it reports `failure` with ZERO steps, which

Check warning on line 21 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

21:81 [line-length] line too long (81 > 80 characters)
# is indistinguishable from this gate rejecting an author. That is exactly
# what happened: this gate had NEVER executed on paiml/infra while showing
# FAILURE on 100% of its PRs, so it was routinely stepped over (paiml/infra#231).

Check warning on line 24 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

24:81 [line-length] line too long (85 > 80 characters)
# An authorization control that cannot start is worse than no control, because

Check warning on line 25 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

25:81 [line-length] line too long (82 > 80 characters)
# it manufactures the appearance of enforcement.
#
# The original rationale for ubuntu-latest was that untrusted PR code must not

Check warning on line 28 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

28:81 [line-length] line too long (82 > 80 characters)
# execute on self-hosted infra. That concern does not apply to THIS job: it has

Check warning on line 29 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

29:81 [line-length] line too long (83 > 80 characters)
# exactly one step, `actions/github-script`, and there is NO `actions/checkout`

Check warning on line 30 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

30:81 [line-length] line too long (83 > 80 characters)
# anywhere in this workflow. No PR-authored code is ever fetched or run here —

Check warning on line 31 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

31:81 [line-length] line too long (82 > 80 characters)
# the job only reads `context.payload` and calls the API. If a checkout step is

Check warning on line 32 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

32:81 [line-length] line too long (83 > 80 characters)
# ever added, this decision must be revisited.
timeout-minutes: 22
runs-on: [self-hosted, gate-light]
Expand All @@ -40,95 +40,136 @@
id: authz
shell: bash
env:
# Everything the decision reads comes from the event payload and is
# passed through env:, never interpolated into the script body.
EVENT_NAME: ${{ github.event_name }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
PR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
run: |
set -uo pipefail

# ==============================================================
# DECISION COMES FROM THE EVENT PAYLOAD ONLY (aprender#5951).
# WHY THIS USES THE RUNNER'S AMBIENT ORG AUTH
#
# Operator: "the fix belongs in the job, not on gx10" and "have
# authorize read author_association from the event". Standing
# rule: no workflow may use credentials that live on a host.
# This step therefore makes NO API call and never invokes `gh`:
# the earlier membership probe depended on the runner's ambient
# `gh` login, and a runner without one (gx10-blackwell) turned
# every PR red with "indeterminate".
# Two different APIs have now been caught giving this gate a
# confidently wrong answer, both for the same underlying reason:
# the default GITHUB_TOKEN cannot see PRIVATE org memberships,
# and every paiml owner's membership is private.
#
# Known limitation, accepted: GitHub reports a PRIVATE org
# member's association as COLLABORATOR rather than MEMBER. That
# is why COLLABORATOR is on the allow-list. The cost is that an
# outside collaborator is indistinguishable from a private
# member; collaborators already hold repo access granted by an
# owner, so this does not widen who can open PRs.
# 1. `orgs.checkMembershipForUser` with GITHUB_TOKEN answers 404
# for a private member -- byte-for-byte identical to "not a
# member". This closed @noahgift's PRs until he was
# allowlisted by name (#12), then closed @alfredodeza's
# paiml/infra#340 twice while `gate` and `validate` were
# green on the same commit.
#
# FAIL-CLOSED: anything not on the allow-list -- including an
# empty, missing or unknown value -- fails. A missing value never
# passes on a PR event.
# Allow-list: OWNER MEMBER COLLABORATOR.
# 2. `author_association` from the payload looked like the fix
# -- the REST API reports MEMBER for those same PRs. It is
# NOT the fix. MEASURED on the live webhook for #340:
# author_association=COLLABORATOR. GitHub downgrades the
# payload's association for private members too, so a real
# org owner is indistinguishable from an outside
# collaborator. That attempt (#54) is what this replaces.
#
# The runner's ambient `gh` auth holds admin:org and therefore
# sees the truth. This is the same decision, for the same reason,
# already taken by lane-liveness.sh (CLAUDE.md: "Uses the
# runner's ambient org gh auth -- never github.token").
#
# Safe here: this job has exactly ONE input it does not control,
# a login string, and there is NO actions/checkout anywhere in
# this workflow. No PR-authored code is fetched or run.
# ==============================================================

# Strip anything that could forge a workflow command before echoing.
clean() { printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '?' | head -c 64; }
ORG=paiml

allow() {
echo "allow=true" >> "$GITHUB_OUTPUT"
echo "reason=$1" >> "$GITHUB_OUTPUT"
echo "${2:-$PR_AUTHOR}: $1 -- ALLOWED"
# Deliberately NOT the workflow token: that token is the blind
# instrument this whole file exists to route around.
unset GITHUB_TOKEN GH_TOKEN

# Returns: member | notmember | indeterminate
# `indeterminate` is a THIRD answer and must never collapse into
# `notmember` -- conflating "they are not a member" with "I could
# not ask" is the exact defect that closed two owners' PRs.
membership() {
local user="$1" resp rc state
resp=$(gh api "orgs/${ORG}/memberships/${user}" 2>&1) && rc=0 || rc=$?

Check warning on line 93 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

93:81 [line-length] line too long (82 > 80 characters)
if [ "$rc" -eq 0 ]; then
state=$(printf '%s' "$resp" | jq -r '.state // "unknown"')
# "pending" = invited, not accepted. Not a member yet.
if [ "$state" = "active" ]; then echo member; else echo notmember; fi

Check warning on line 97 in .github/workflows/pr-gate.yml

View workflow job for this annotation

GitHub Actions / validate

97:81 [line-length] line too long (83 > 80 characters)
return 0
fi
case "$resp" in
*"HTTP 404"*) echo notmember ;;
*) echo indeterminate ;;
esac
}

case "${EVENT_NAME:-}" in
pull_request_target|pull_request) ;;
merge_group|push)
# No PR author exists on these events. They are reachable only
# by someone who already passed this gate on the PR (merge
# queue entry / push to a protected branch need write access),
# and the Close step below acts on a PR that does not exist
# here. Allowing is safe; documented decision, not a default.
allow "no PR author on ${EVENT_NAME:-} event (already gated at PR time)" "${EVENT_NAME:-}"
exit 0 ;;
*)
echo "::error::pr-gate authorize: unsupported event '$(clean "${EVENT_NAME:-}")' -- refusing (fail-closed)"
exit 1 ;;
esac
# --------------------------------------------------------------
# PROBE THE INSTRUMENT BEFORE TRUSTING ITS VERDICT.
# A membership check that cannot see members reports everyone as
# an outsider, and that is precisely how this gate failed twice.
# So it must answer a known member and a known non-member
# correctly, right now, on this runner, before it is allowed to
# judge anybody.
# --------------------------------------------------------------
PROBE_MEMBER=noahgift # org owner, private membership
PROBE_STRANGER=paiml-pr-gate-sentinel-no-such-user

# Explicitly allowlisted EXTERNAL contributors (outside collaborators).
case "${PR_AUTHOR:-}" in
guyernest|spudnic) # guyernest: pmcp lead developer; spudnic: authorized contributor (operator 2026-09-26)
allow "allowlisted external contributor"
probe_pos=$(membership "$PROBE_MEMBER")
probe_neg=$(membership "$PROBE_STRANGER")
echo "instrument probe: ${PROBE_MEMBER}=${probe_pos} (want member), ${PROBE_STRANGER}=${probe_neg} (want notmember)"

if [ "$probe_pos" != "member" ] || [ "$probe_neg" != "notmember" ]; then
echo "::error::pr-gate instrument probe FAILED (${PROBE_MEMBER}=${probe_pos}, ${PROBE_STRANGER}=${probe_neg}) -- refusing to judge anyone with a check that cannot demonstrate both answers"
# Fail RED and leave the PR OPEN. The harm is asymmetric and
# measured: auto-closing a legitimate owner's PR has happened
# repeatedly and is invisible to them, while an unauthorized PR
# left open under a red required check is visible to every
# human on the repo.
exit 1
fi

# --------------------------------------------------------------
# Decision.
# --------------------------------------------------------------
# Explicitly allowlisted EXTERNAL contributors only. Org members
# are authorized structurally, below -- adding one here is how
# this gate hid its own blindness for four months.
case "$PR_AUTHOR" in
guyernest|spudnic) # guyernest: pmcp lead developer; spudnic: authorized contributor (operator 2026-09-26) -- outside collaborators, not org members
echo "allow=true" >> "$GITHUB_OUTPUT"
echo "reason=allowlisted external contributor" >> "$GITHUB_OUTPUT"
echo "$PR_AUTHOR: allowlisted external contributor -- ALLOWED"
exit 0 ;;
esac

# Bots (dependabot, renovate, actions-user) are always allowed.
if [ "${PR_AUTHOR_TYPE:-}" = "Bot" ]; then
allow "bot"
if [ "$PR_AUTHOR_TYPE" = "Bot" ]; then
echo "allow=true" >> "$GITHUB_OUTPUT"
echo "reason=bot" >> "$GITHUB_OUTPUT"
echo "$PR_AUTHOR: bot -- ALLOWED"
exit 0
fi

case "${PR_ASSOCIATION:-}" in
OWNER|MEMBER|COLLABORATOR)
allow "author_association=${PR_ASSOCIATION}"
exit 0 ;;
CONTRIBUTOR|FIRST_TIMER|FIRST_TIME_CONTRIBUTOR|MANNEQUIN|NONE)
author_state=$(membership "$PR_AUTHOR")
echo "author=${PR_AUTHOR} type=${PR_AUTHOR_TYPE} membership=${author_state}"

case "$author_state" in
member)
echo "allow=true" >> "$GITHUB_OUTPUT"
echo "reason=active ${ORG} org member" >> "$GITHUB_OUTPUT"
echo "$PR_AUTHOR: active ${ORG} org member -- ALLOWED" ;;
notmember)
echo "allow=false" >> "$GITHUB_OUTPUT"
echo "reason=author_association=${PR_ASSOCIATION} is not OWNER/MEMBER/COLLABORATOR and author is not allowlisted" >> "$GITHUB_OUTPUT"
echo "::error::pr-gate: author_association=${PR_ASSOCIATION} for $(clean "${PR_AUTHOR:-}") -- REJECTED"
exit 1 ;;
echo "reason=not an active ${ORG} org member and not allowlisted" >> "$GITHUB_OUTPUT"
echo "$PR_AUTHOR: not a member -- REJECTED" ;;
*)
# Empty, missing or unknown: could not establish who this is.
# Fail red, leave the PR open for a human (no allow=false, so
# the Close step does not run).
echo "::error::pr-gate: author_association='$(clean "${PR_ASSOCIATION:-}")' is empty or unknown -- refusing (fail-closed)"
# Could not ask. Not a verdict. Never close on this.
echo "::error::could not determine ${ORG} membership for ${PR_AUTHOR} -- leaving the PR open for a human"
exit 1 ;;
esac

- name: Close unauthorized PR
if: ${{ !cancelled() && steps.authz.outputs.allow == 'false' }}
if: steps.authz.outputs.allow != 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
REASON: ${{ steps.authz.outputs.reason }}
Expand Down
64 changes: 0 additions & 64 deletions tests/pr-gate-authorize-test.sh

This file was deleted.