diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index eeb5da9..d80cc37 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -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: @@ -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 . diff --git a/.github/workflows/enforce-runner-policy.yml b/.github/workflows/enforce-runner-policy.yml new file mode 100644 index 0000000..06d7191 --- /dev/null +++ b/.github/workflows/enforce-runner-policy.yml @@ -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 diff --git a/README.md b/README.md index 5ea446e..11b5c06 100644 --- a/README.md +++ b/README.md @@ -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