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
16 changes: 14 additions & 2 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
name: Organization workflow policy audit
# Self-audit for this repository only.
#
# Renamed 2026-08-03. This workflow was previously called "Organization
# workflow policy audit", but it checks out only this repository and runs the
# audit against `.`, so it has never inspected any other repository. Its own
# workflows are compliant, so it reported green while three repositories in the
# organization ran on GitHub-hosted runners.
#
# Organization-wide enforcement is `enforce-runner-policy.yml`, attached to
# every repository through an organization ruleset so it runs in each target
# repository's own context. Keep this file scoped to this repository.

name: Policy repository self-audit

on:
pull_request:
Expand All @@ -16,5 +28,5 @@ jobs:
runs-on: [self-hosted, node-b, linux, x64]
steps:
- uses: actions/checkout@v4
- name: Audit workflow runner policy and exceptions
- name: Audit this repository's workflows and exception expiry
run: bash scripts/audit-workflows.sh .
113 changes: 113 additions & 0 deletions .github/workflows/enforce-runner-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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.
#
# The checker is inlined deliberately. This repository is private, so a target
# 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.
#
# 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.
#
# Fail-closed note: this job requests self-hosted labels. A repository not yet
# 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 —
# it forces runner-group membership to be configured rather than letting a
# repository quietly fall back to GitHub-hosted runners.

name: Runner policy

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
runner-policy:
runs-on: [self-hosted, node-b, linux, x64]
steps:
- uses: actions/checkout@v5

- name: Audit runner selection
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import pathlib
import re
import sys

def indent(line: str) -> int:
return len(line) - len(line.lstrip(' '))

def values(lines: list[str], index: int) -> list[str]:
line = lines[index]
base = indent(line)
value = line.split(':', 1)[1].split('#', 1)[0].strip()
if value:
return [p.strip().strip('"\'') for p in value.strip('[]').split(',') if p.strip()]
result = []
for child in lines[index + 1:]:
stripped = child.strip()
if not stripped or stripped.startswith('#'):
continue
if indent(child) <= base:
break
match = re.match(r'^-\s*([^#]+)', stripped)
if match:
result.append(match.group(1).strip().strip('"\''))
return result

# Assembled at runtime on purpose. A literal dollar-brace-brace in
# this file would be parsed as a GitHub Actions expression before the
# script ever runs, which breaks the whole workflow -- the first
# version of this file failed with an unresolvable workflow name for
# exactly that reason.
EXPR = '$' + '{' + '{'

failed = False
workflows = sorted(
p for pattern in ('.github/workflows/*.yml', '.github/workflows/*.yaml')
for p in pathlib.Path('.').glob(pattern)
)
if not workflows:
print('no workflows found; nothing to audit')

for path in workflows:
lines = path.read_text(encoding='utf-8').splitlines()
for number, line in enumerate(lines):
if not re.match(r'^\s*runs-on\s*:', line, re.I):
continue
selected = values(lines, number)
if not any(v.lower() == 'self-hosted' for v in selected):
print(f'{path}:{number + 1}: runner selection is not explicitly '
f'self-hosted -> {selected}', file=sys.stderr)
failed = True
if any(EXPR in v for v in selected):
print(f'{path}:{number + 1}: dynamic runner selection requires '
f'explicit review', file=sys.stderr)
failed = True

if failed:
print('', file=sys.stderr)
print('Organization policy: every job must select a self-hosted runner '
'explicitly, e.g.', file=sys.stderr)
print(' runs-on: [self-hosted, node-b, linux, x64]', file=sys.stderr)
print(' runs-on: [self-hosted, node-b, linux, x64, docker, publish] '
'# needs Docker', file=sys.stderr)
sys.exit(1)

print(f'ok: {len(workflows)} workflow file(s) audited, all self-hosted')
PY
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,72 @@ This repository is the source of truth for organization-wide CI policy.
- Public-hosted runners require an owner-approved, time-bounded exception in
`runner-exceptions.json`.
- Workflow and policy changes require owner review through `CODEOWNERS`.
- `scripts/audit-workflows.sh` is the shared enforcement entry point.
- `scripts/audit-workflows.sh` is the shared checker used by both workflows
below.

## How enforcement actually works

There are two workflows, and the distinction matters:

| Workflow | 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`. |

`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`).

## Exceptions

`runner-exceptions.json` is consumed by `audit.yml` only, which fails on any
entry whose `expires_on` has passed so exceptions cannot rot silently:

```json
{ "schema_version": 1,
"exceptions": [
{ "repo": "example", "workflow": "ci.yml", "reason": "...",
"expires_on": "2026-09-30" } ] }
```

The org-wide gate cannot read that file (see above), so it is strict. A
genuine, owner-approved exception is expressed in the ruleset itself — exclude
the repository in the ruleset conditions, or add a bypass actor. Both are
visible in the ruleset UI and the organization audit log, which is a stronger
record than a JSON entry. Record the reason and expiry in
`runner-exceptions.json` as well so the daily expiry check still surfaces it.

## Related

- `~/Working/docs/CI-RUNNER-GATES.md` — the human-facing gate document:
approved labels, new-repository checklist, and known gaps.

The destination organization repository must be created with Actions disabled,
then protected rules and the self-hosted runner group must be configured before
Expand Down
Loading