From 544ea80f96526eb43b67f6c7675eab7e7b4a1fb1 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Mon, 3 Aug 2026 00:58:04 +0000 Subject: [PATCH] feat(policy): reusable runner-policy gate that works on the Team plan The organization ruleset "required workflows" rule cannot enforce anything here. It is a GitHub Enterprise feature and this org is on Team. The REST API accepted the ruleset and reported it active, but no check ever appeared on a pull request and /rulesets/rule-suites returns 403 "Upgrade to GitHub Enterprise". That ruleset was created, observed inert, and deleted. Replace it with a reusable workflow plus a per-repo stub. Reusable workflows run with the caller's context, so actions/checkout fetches the calling repository and the audit applies there. This works on Team because this repository's Actions access is set to `organization`, which permits org-wide calls into a private repository. Drop enforce-runner-policy.yml: as a standalone workflow here it only audited this repository, duplicating audit.yml. The trade-off is honest: this needs one small file per repository and `runner-policy` added to each protected branch's required checks. Without that second step it reports but does not block. Co-Authored-By: Claude Opus 5 (1M context) --- ...-policy.yml => runner-policy-reusable.yml} | 46 ++++++++------- README.md | 59 +++++++++---------- templates/runner-policy-stub.yml | 16 +++++ 3 files changed, 71 insertions(+), 50 deletions(-) rename .github/workflows/{enforce-runner-policy.yml => runner-policy-reusable.yml} (73%) create mode 100644 templates/runner-policy-stub.yml diff --git a/.github/workflows/enforce-runner-policy.yml b/.github/workflows/runner-policy-reusable.yml similarity index 73% rename from .github/workflows/enforce-runner-policy.yml rename to .github/workflows/runner-policy-reusable.yml index 06d7191..ea06d1c 100644 --- a/.github/workflows/enforce-runner-policy.yml +++ b/.github/workflows/runner-policy-reusable.yml @@ -1,35 +1,41 @@ -# Runner policy gate. +# Reusable runner-policy gate. # -# Attached to every repository in the organization through an organization -# ruleset ("workflows" rule), so it runs in the *target* repository's context -# and its check must pass before a pull request can merge. +# Called by a small stub workflow in every repository. Reusable workflows run +# with the CALLER's context, so actions/checkout below checks out the calling +# repository and the audit applies to that repository's workflows. # -# The checker is inlined deliberately. This repository is private, so a target +# Why this rather than an organization ruleset "required workflows" rule: that +# rule is a GitHub Enterprise feature. This organization is on the Team plan. +# The REST API accepts such a ruleset and reports it active, but it never +# executes and the rule-suites endpoint returns 403 "Upgrade to GitHub +# Enterprise" -- a gate that looks real and does nothing. Verified 2026-08-03. +# +# Making this block a merge requires adding `runner-policy` as a required +# status check on the calling repository's protected branch. +# +# The checker is inlined deliberately. This repository is private, so a calling # repository cannot fetch scripts/audit-workflows.sh from it: raw.github- -# usercontent.com returns 404 unauthenticated, and the target repo's -# GITHUB_TOKEN has no read access here either. The ruleset already delivers -# this file from this repository, so the policy still has a single source of -# truth — it is this workflow. Keep the logic below in sync with -# scripts/audit-workflows.sh, which remains the local checker for this repo. +# usercontent.com returns 404 unauthenticated, and the caller's GITHUB_TOKEN +# has no read access here either. Because the caller resolves this workflow +# from this repository, the policy still has one source of truth -- this file. +# Keep the logic below in sync with scripts/audit-workflows.sh, the local +# checker used by audit.yml. # # Exceptions: this gate is strict and reads no exceptions file (it cannot see -# one). A genuine, owner-approved exception is expressed by excluding the -# repository in the ruleset's conditions, or by adding a bypass actor — both -# are visible in the ruleset UI and in the org audit log. +# one). An owner-approved exception means dropping the required status check on +# that repository, and recording the reason and expiry in +# runner-exceptions.json so the daily expiry check still surfaces it. # -# Fail-closed note: this job requests self-hosted labels. A repository not yet +# Fail-closed note: this job requests self-hosted labels. A repository not # added to the `public-node-b` runner group has no runner able to accept it, so -# the check stays queued and the pull request cannot merge. That is intended — +# the check stays queued and the pull request cannot merge. That is intended -- # it forces runner-group membership to be configured rather than letting a # repository quietly fall back to GitHub-hosted runners. -name: Runner policy +name: Runner policy (reusable) on: - pull_request: - push: - branches: [main] - workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/README.md b/README.md index 11b5c06..fdff698 100644 --- a/README.md +++ b/README.md @@ -11,43 +11,42 @@ This repository is the source of truth for organization-wide CI policy. ## How enforcement actually works -There are two workflows, and the distinction matters: - -| Workflow | Scope | Role | +| File | Scope | Role | |---|---|---| -| `enforce-runner-policy.yml` | The repository it runs in | **The gate.** Attached to every repository by an organization ruleset, so it runs in each target repository's context and must pass before a pull request merges. | -| `audit.yml` | This repository only | Self-audit, plus the daily expiry check on `runner-exceptions.json`. | +| `.github/workflows/runner-policy-reusable.yml` | The **calling** repository | **The gate.** Reusable workflow; runs with the caller's context so `actions/checkout` fetches the calling repo. | +| `templates/runner-policy-stub.yml` | — | Copy into each repo as `.github/workflows/runner-policy.yml`. Three lines of real content. | +| `.github/workflows/audit.yml` | This repository only | Self-audit, plus the daily expiry check on `runner-exceptions.json`. | `audit.yml` was previously named "Organization workflow policy audit", which was misleading: it checks out only this repository, so it audited exactly one repository — itself — and stayed green while three repositories in the organization ran on `ubuntu-latest`. Nothing about the checker was wrong; it was never pointed at the organization. Do not re-add organization-wide -ambitions to that file. Per-repository enforcement is the correct mechanism -because it needs no cross-repository token. - -### Fail-closed behaviour - -`enforce-runner-policy.yml` requests self-hosted labels. A repository that has -not been added to the `public-node-b` runner group has no runner able to accept -the job, so the check stays queued and the pull request cannot merge. This is -intentional: it surfaces missing runner-group membership instead of letting a -repository quietly fall back to GitHub-hosted runners, which is exactly how the -2026-08-03 violations arose. - -### Why the gate inlines its checker - -This repository is **private**. A target repository cannot fetch -`scripts/audit-workflows.sh` from it — `raw.githubusercontent.com` returns 404 -unauthenticated, and the target repo's `GITHUB_TOKEN` has no read access here. -The first version of the gate tried exactly that and failed with `curl: (22) -404` on its own pull request. - -So `enforce-runner-policy.yml` carries the checker inline. The ruleset already -delivers that file from this repository, so there is still one source of truth -— it is the workflow. `scripts/audit-workflows.sh` remains the local checker -used by `audit.yml`. **Keep the two in sync**; they implement the same two -rules (explicit `self-hosted`, no dynamic `runs-on`). +ambitions to that file. + +### Why not an organization ruleset + +A ruleset with the `workflows` ("required workflows") rule would attach the +gate to every repository automatically with no per-repo file. **It does not +work on this organization.** That rule is a GitHub Enterprise feature and this +org is on the **Team** plan. The REST API accepts the ruleset and reports it +`active`, but it never executes — no check ever appears on a pull request — and +`/rulesets/rule-suites` returns 403 "Upgrade to GitHub Enterprise". This was +built, observed to do nothing, and deleted on 2026-08-03. Do not rebuild it +without an Enterprise upgrade; a gate that reports active and enforces nothing +is worse than no gate. + +The reusable-workflow approach works on Team because this repository has +Actions access set to `organization`, which lets private-repo workflows be +called org-wide. + +### Rollout, per repository + +1. Copy `templates/runner-policy-stub.yml` to `.github/workflows/runner-policy.yml`. +2. Add `runner-policy` to the branch's required status checks. **Until this + step the gate reports but does not block.** +3. Ensure the repo is in the `public-node-b` runner group, or the job has no + runner and the check never completes. ## Exceptions diff --git a/templates/runner-policy-stub.yml b/templates/runner-policy-stub.yml new file mode 100644 index 0000000..69b6a4c --- /dev/null +++ b/templates/runner-policy-stub.yml @@ -0,0 +1,16 @@ +# Copy this file to .github/workflows/runner-policy.yml in every repository. +# +# It is deliberately tiny: all logic lives in the reusable workflow in +# github-policy, so the rule can be changed in one place. After adding it, make +# `runner-policy` a required status check on the repository's protected branch +# — without that it reports but does not block. +name: Runner policy + +on: + pull_request: + push: + branches: [main] + +jobs: + runner-policy: + uses: TheDancingDeveloper-org/github-policy/.github/workflows/runner-policy-reusable.yml@main