Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,9 @@ jobs:
runs-on: [self-hosted, node-b, linux, x64]
steps:
- uses: actions/checkout@v4
- name: Checker tests
run: python3 tests/test_runner_policy.py
- name: Audit this repository's workflows and exception expiry
env:
POLICY_REPO: github-policy
run: bash scripts/audit-workflows.sh .
100 changes: 21 additions & 79 deletions .github/workflows/runner-policy-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,16 @@
# 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 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.
# The checker used to be inlined here, because a private policy repository
# cannot be checked out by a public caller. This repository is public as of
# 2026-08-17, so the caller checks it out and runs scripts/runner_policy.py
# directly. That is now the only copy of the rule -- do not re-inline it.
#
# Exceptions: this gate is strict and reads no exceptions file (it cannot see
# 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.
# Exceptions: this gate reads runner-exceptions.json out of the second
# checkout, so an owner-approved exception no longer means dropping the
# required status check on that repository. Entries are keyed by repo and
# workflow file, optionally narrowed to named jobs, and an expired entry fails
# the gate rather than suppressing anything.
#
# 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
Expand All @@ -44,76 +42,20 @@ jobs:
runner-policy:
runs-on: [self-hosted, node-b, linux, x64]
steps:
- uses: actions/checkout@v5
- name: Check out the calling repository
uses: actions/checkout@v5

- name: Check out the policy
uses: actions/checkout@v5
with:
repository: TheDancingDeveloper-org/github-policy
ref: main
path: .runner-policy

- 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
python3 .runner-policy/scripts/runner_policy.py . \
--repo "${GITHUB_REPOSITORY##*/}" \
--exceptions .runner-policy/runner-exceptions.json
33 changes: 32 additions & 1 deletion runner-exceptions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,35 @@
{
"schema_version": 1,
"exceptions": []
"exceptions": [
{
"repo": "cadastre",
"workflow": "ci.yaml",
"reason": "Public repository, and this repository enforces a stricter rule than the org's in its own test suite: tests/test_release_workflow.py pins SELF_HOSTED_WORKFLOWS to the two tag-gated release workflows and asserts every other job is NOT self-hosted, because pull_request builds untrusted fork code and the self-hosted pool is persistent, runs as root and sits on the tailnet. Moving ci.yaml or publish.yml would require weakening that test. Revisit only alongside ephemeral runners and fork-PR approval set to all outside contributors.",
"expires_on": "2027-02-17"
},
{
"repo": "cadastre",
"workflow": "publish.yml",
"reason": "Same tested invariant as cadastre/ci.yaml. publish.yml triggers on push to main, so it has no fork exposure, but test_self_hosted_workflows_are_all_tag_gated additionally requires that anything self-hosted be reachable only from refs/tags/v*. The tag-gated release-images.yml and release-pypi.yml are self-hosted and stay that way.",
"expires_on": "2027-02-17"
},
{
"repo": "FarmEggs",
"workflow": "ci.yml",
"jobs": [
"mobile"
],
"reason": "flutter build apk --debug needs a JDK and the Android SDK. Neither is on any self-hosted runner and subosito/flutter-action does not install them; verified absent on all three runner containers 2026-08-17. Move when the runner image ships them.",
"expires_on": "2026-11-17"
},
{
"repo": "FarmEggs",
"workflow": "ci.yml",
"jobs": [
"web"
],
"reason": "The browser suite and the M1a acceptance run need google-chrome at the path CHROME_EXECUTABLE/EGGS_WEB_CHROME pin, plus npm for playwright-core. Verified absent on all three runner containers 2026-08-17. Move when the runner image ships them.",
"expires_on": "2026-11-17"
}
]
}
69 changes: 7 additions & 62 deletions scripts/audit-workflows.sh
Original file line number Diff line number Diff line change
@@ -1,67 +1,12 @@
#!/usr/bin/env bash
#
# Thin wrapper kept for the existing call sites. All logic is in
# scripts/runner_policy.py, which the org-wide gate runs directly.

set -euo pipefail

root="${1:-.}"
today="${POLICY_DATE:-$(date -u +%F)}"
exceptions="${RUNNER_EXCEPTION_FILE:-$root/runner-exceptions.json}"
failed=0

command -v jq >/dev/null
jq -e '.schema_version == 1 and (.exceptions | type == "array")' "$exceptions" >/dev/null

while IFS= read -r expiry; do
[[ "$expiry" > "$today" || "$expiry" == "$today" ]] || {
printf 'expired runner exception: %s\n' "$expiry" >&2
failed=1
}
done < <(jq -r '.exceptions[].expires_on' "$exceptions")

while IFS= read -r -d '' workflow; do
if ! python3 - "$workflow" <<'PY'
import re
import sys

path = sys.argv[1]
lines = open(path, encoding='utf-8').read().splitlines()

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

def values(index: int) -> list[str]:
line = lines[index]
base = indent(line)
value = line.split(':', 1)[1].split('#', 1)[0].strip()
if value:
return [part.strip().strip('"\'') for part in value.strip('[]').split(',') if part.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

failed = False
for number, line in enumerate(lines):
if not re.match(r'^\s*runs-on\s*:', line, re.I):
continue
selected = values(number)
if not any(value.lower() == 'self-hosted' for value in selected):
print(f'{path}:{number + 1}: runner selection is not explicitly self-hosted', file=sys.stderr)
failed = True
if any('${{' in value for value in selected):
print(f'{path}:{number + 1}: dynamic runner selection requires explicit review', file=sys.stderr)
failed = True
sys.exit(1 if failed else 0)
PY
then
failed=1
fi
done < <(find "$root" -path '*/.git' -prune -o -path '*/.github/workflows/*.yml' -print0 -o -path '*/.github/workflows/*.yaml' -print0)

exit "$failed"
exec python3 "$(dirname "$0")/runner_policy.py" "$root" \
${POLICY_REPO:+--repo "$POLICY_REPO"} \
--exceptions "${RUNNER_EXCEPTION_FILE:-$root/runner-exceptions.json}" \
${POLICY_DATE:+--today "$POLICY_DATE"}
Loading
Loading