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
Original file line number Diff line number Diff line change
@@ -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
Expand Down
59 changes: 29 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions templates/runner-policy-stub.yml
Original file line number Diff line number Diff line change
@@ -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
Loading