Merge pull request #60 from claude-bot-andresmgsl/build/58-runner-isolated

feat(guards): actions/runner-isolated — no pull_request-triggered job on a self-hosted runner
This commit is contained in:
Daniel Marin 2026-07-23 13:24:41 +01:00 committed by GitHub
commit cf69d8ce9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 542 additions and 4 deletions

View file

@ -52,8 +52,9 @@ jobs:
uses: ./.github/workflows/release-exercise.yml uses: ./.github/workflows/release-exercise.yml
# The self-guards (issue #11): this repo eats exactly what it serves. The # The self-guards (issue #11): this repo eats exactly what it serves. The
# three guard actions run against the REAL tree — VERSION, CHANGELOG.md, # guard actions run against the REAL tree — VERSION, CHANGELOG.md,
# drills/ — through the same `uses:` steps every consumer's CI carries. # drills/, .github/workflows/ — through the same `uses:` steps every
# consumer's CI carries.
# These steps are also the composite-action wiring proof (issue #5's # These steps are also the composite-action wiring proof (issue #5's
# acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the # acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the
# relative lib sourcing) that action-exercise carried with scratch files # relative lib sourcing) that action-exercise carried with scratch files
@ -73,6 +74,7 @@ jobs:
- uses: ./actions/changelog-armed - uses: ./actions/changelog-armed
- uses: ./actions/changelog-monotonic - uses: ./actions/changelog-monotonic
- uses: ./actions/drill-recorded - uses: ./actions/drill-recorded
- uses: ./actions/runner-isolated
# Exercises changelog-monotonic the way a consumer does, against a # Exercises changelog-monotonic the way a consumer does, against a
# CONSTRUCTED history. The self-guards job above runs the same action on # CONSTRUCTED history. The self-guards job above runs the same action on

View file

@ -9,6 +9,7 @@ so entries say what changed, cite the issue, and stop.
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61). - `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51). - `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57). - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).
- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58).
## 0.1.0 — 2026-07-22 ## 0.1.0 — 2026-07-22

View file

@ -0,0 +1,32 @@
name: Runner isolated
description: >-
Assert that no `pull_request`- (or `pull_request_target`-) triggered
workflow names a self-hosted runner (#58). A pull_request workflow runs
the PR branch's code — from a fork, unreviewed code — and a self-hosted
runner executes it on our own hardware, inside our own network; the
fork-PR write-token and secrets toggles protect credentials, not the
runner. The rule is file-level, deliberately: a file whose trigger block
names pull_request and which names self-hosted anywhere fails, even
across jobs — the fix is to split the workflow. Known gaps, so silence
is never read as coverage: workflow_call reachability is not followed
(a pull_request caller plus a self-hosted callee goes unseen), and
indirection is not resolved (runner groups, matrix or expression values
for runs-on). A missing workflows directory is a pass. The caller must
have checked out its own repository first: the guard reads the
consumer's tree at the workspace.
inputs:
workflows-dir:
description: >-
Directory scanned for `*.yml`/`*.yaml` workflow files. A missing
directory passes — a guard that fails on absence is a guard nobody
adopts.
required: false
default: .github/workflows
runs:
using: composite
steps:
- name: runner isolated
shell: bash
env:
WORKFLOWS_DIR: ${{ inputs.workflows-dir }}
run: bash "$GITHUB_ACTION_PATH/runner-isolated.sh"

View file

@ -0,0 +1,185 @@
#!/usr/bin/env bash
set -euo pipefail
# runner-isolated.sh [<workflows-dir>] — assert that no `pull_request`-
# triggered workflow in the tree names a self-hosted runner (#58; epic #56
# decision D5).
#
# The threat, stated once: a `pull_request` workflow runs code from the
# PR's branch. When that branch comes from a fork and the repo's fork-PR
# settings do not require approval, that code is UNREVIEWED. Point such a
# job at a self-hosted runner and unreviewed code executes on our own
# hardware, inside our own network. Nothing else in the fleet's setup
# gates that path — the write-token and secrets toggles (correctly off,
# #16's ruling) protect credentials, not the runner.
#
# Nothing was wrong the day this was written: incubator's deploy.yml is
# push-triggered and self-hosted (legal), its pr-checks.yml is
# PR-triggered and hosted, and the rule lived as a sentence in
# pr-checks.yml's header, kept true by whoever remembered it. This guard
# is that sentence moved into CI — the same move drill-recorded made
# after three releases shipped through a documented-but-unenforced gate
# (its header: "that is not a gate, it is luck with good manners").
#
# THE RULE IS FILE-LEVEL, DELIBERATELY. The precise rule — no JOB
# reachable from a pull_request trigger runs self-hosted — needs a YAML
# parser, and a second parser in bash is a new class of guard bug bought
# in exchange for permitting a file shape we do not want. So: a file
# FAILS when its trigger block names pull_request (which also matches
# pull_request_target — intended) AND it names a self-hosted runner
# anywhere, even in a different job. The false positive has a clean,
# safer fix — SPLIT THE WORKFLOW; incubator already keeps pr-checks.yml
# apart from deploy.yml, which is the shape this guard asks for. False
# NEGATIVES are what a security guard must not have, and file-level
# granularity has none for the modelled threat: it can only be stricter
# than the precise rule, never laxer.
#
# Self-hosted detection covers two shapes, because the same-line rule
# alone ("runs-on and self-hosted on one line") would pass the
# block-sequence form — a false negative, the one defect this guard is
# not allowed to have:
#
# runs-on: [self-hosted, ci-runner] # same line: caught
# runs-on: # block sequence: the bare
# - self-hosted # key opens a window over
# - ci-runner # its `- …` list items
#
# KNOWN LIMITS, named in action.yml's description too so a consumer
# never reads silence as coverage:
# - workflow_call is not treated as PR-reachable in v1: a pull_request
# caller plus a self-hosted callee is a real path this guard does not
# see. Following `uses:` across files is the YAML-parsing problem
# again, and the family has no such caller today (ceremony's own
# release-exercise.yml is ubuntu-latest).
# - indirection is not resolved: a runner group (`runs-on: {group: …}`)
# or a matrix/expression value can reach self-hosted hardware without
# the string appearing on any line this guard reads.
#
# Comments are skipped on BOTH halves of the rule: a workflow that merely
# mentions self-hosted in prose is not the bug (incubator's pr-checks.yml
# header is exactly that prose), and a guard that cried wolf on comments
# would be turned off within a week. A missing workflows directory is a
# PASS, not an error — most repos in the family have one, but a guard
# that fails on absence is a guard nobody adopts.
#
# A file of its own (not inlined in action.yml) so
# test/runner-isolated.test.sh can drive it against constructed trees —
# the same discipline as the four guards beside it.
workflows_dir="${1:-${WORKFLOWS_DIR:-.github/workflows}}"
if [ ! -d "$workflows_dir" ]; then
echo "runner-isolated: no workflows directory at '$workflows_dir' — nothing to scan"
exit 0
fi
shopt -s nullglob
files=("$workflows_dir"/*.yml "$workflows_dir"/*.yaml)
if [ "${#files[@]}" -eq 0 ]; then
echo "runner-isolated: 0 workflow files under '$workflows_dir' — nothing to scan"
exit 0
fi
offenders=0
for file in "${files[@]}"; do
pr_triggered=0
in_on=0
in_runs_on=0
hits=()
lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
# A blank line ends nothing: it is not a top-level key, and a YAML
# sequence may legally continue past one.
case "$line" in
*[![:space:]]*) ;;
*) continue ;;
esac
stripped="${line#"${line%%[![:space:]]*}"}"
# Comment lines are invisible to both halves of the rule.
case "$stripped" in
'#'*) continue ;;
esac
# A line starting a top-level key opens or closes the trigger block.
# YAML 1.1 parses bare `on` as a boolean, so some repos quote the
# key — a guard that missed '"on":' would silently pass the file it
# most needs to read.
case "$line" in
[![:space:]]*)
in_runs_on=0
case "$line" in
'on:'*| '"on":'* | "'on':"*) in_on=1 ;;
*) in_on=0 ;;
esac
;;
esac
# Half one: the trigger. Checked on the `on:` line itself (scalar and
# inline-list shapes) and on every line of its block.
if [ "$in_on" -eq 1 ]; then
case "$line" in
*pull_request*) pr_triggered=1 ;;
esac
fi
# Half two: the runner. The window a bare `runs-on:` key opened over
# its list items closes at the first line that is not a `- …` item.
if [ "$in_runs_on" -eq 1 ]; then
case "$stripped" in
'-'*)
case "$line" in
*self-hosted*) hits+=("$lineno: $line") ;;
esac
;;
*) in_runs_on=0 ;;
esac
fi
case "$line" in
*runs-on*)
case "$line" in
*self-hosted*) hits+=("$lineno: $line") ;;
esac
case "$stripped" in
'runs-on:' | 'runs-on:'[[:space:]]*)
rest="${stripped#runs-on:}"
rest="${rest#"${rest%%[![:space:]]*}"}"
case "$rest" in
'' | '#'*) in_runs_on=1 ;;
esac
;;
esac
;;
esac
done <"$file"
if [ "$pr_triggered" -eq 1 ] && [ "${#hits[@]}" -gt 0 ]; then
offenders=$((offenders + 1))
{
echo "runner-isolated: $file is pull_request-triggered and names a self-hosted runner:"
printf ' %s\n' "${hits[@]}"
} >&2
fi
done
if [ "$offenders" -gt 0 ]; then
cat >&2 <<EOF
runner-isolated: $offenders offending workflow file(s). A pull_request
workflow runs the PR branch's code; from a fork that code is unreviewed,
and a self-hosted runner would execute it on our own hardware, inside
our own network. The unblock is to SPLIT THE WORKFLOW: PR-triggered
checks in one file on hosted runners, self-hosted work behind
push/dispatch triggers in another — the shape incubator's pr-checks.yml
and deploy.yml already have. The rule is file-level on purpose (this
script's header): a file mixing the two is one editing mistake away
from being the real bug.
EOF
exit 1
fi
echo "runner-isolated: ${#files[@]} workflow file(s) scanned under '$workflows_dir' — no pull_request-triggered work on a self-hosted runner"

View file

@ -70,6 +70,7 @@ the machinery at all:
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag> - uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
``` ```
`changelog-armed` and `drill-recorded` take `changelog-armed` and `drill-recorded` take
@ -77,6 +78,15 @@ the machinery at all:
inputs and defaults are in its `action.yml` inputs and defaults are in its `action.yml`
([actions/](../actions/)). Adopting the agent team flow adds the ([actions/](../actions/)). Adopting the agent team flow adds the
`docs-sync` step ([below](#adopting-the-agent-team-flow)). `docs-sync` step ([below](#adopting-the-agent-team-flow)).
`runner-isolated` asserts that no `pull_request`-triggered workflow
names a self-hosted runner — a PR workflow runs the branch's code, and
unreviewed fork code must never execute on your own hardware
([#58](https://github.com/heavy-duty/ceremony/issues/58)). It fires on
the PR that first mixes a PR trigger and a self-hosted `runs-on` in
one file; the unblock is splitting the workflow. A repo with **no**
self-hosted runner still wants it: the guard's value is the day
somebody adds one.
6. **Labels automation** (optional but recommended): the caller from 6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf` [Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
@ -323,9 +333,9 @@ Bumping the pin re-syncs the mirror in the same PR —
[releases page](https://github.com/heavy-duty/ceremony/releases) is [releases page](https://github.com/heavy-duty/ceremony/releases) is
that section, verbatim). One bump PR updates **every** ceremony `uses:` that section, verbatim). One bump PR updates **every** ceremony `uses:`
reference in the repo to the new tag — the workflow callers *and* each reference in the repo to the new tag — the workflow callers *and* each
guard step; a release-only setup already has four (the guard step; a release-only setup already has five (the
[release caller](#release-workflow) plus the [release caller](#release-workflow) plus the
[three CI guards](#bootstrap-a-new-repo)), and changing only one line [four CI guards](#bootstrap-a-new-repo)), and changing only one line
leaves the consumer split across ceremony versions, which the same-tag leaves the consumer split across ceremony versions, which the same-tag
rule above forbids. A repo that has adopted the agent team flow rule above forbids. A repo that has adopted the agent team flow
additionally bumps the mirror in the same PR — additionally bumps the mirror in the same PR —

View file

@ -0,0 +1,308 @@
#!/usr/bin/env bash
# Contract tests for actions/runner-isolated (issue #58). Constructed
# fixture trees — a dir holding a workflows directory, not git repos —
# the same discipline as the guards beside it. set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
SCRIPT="$ROOT/actions/runner-isolated/runner-isolated.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# The guard reads the consumer's tree at its working directory, so every
# case runs from inside a constructed fixture tree.
in_tree() {
local dir="$1"
shift
(cd "$TMP/$dir" && bash "$SCRIPT" "$@")
}
# wf <tree> <file> — write .github/workflows/<file> in the tree from stdin.
wf() {
mkdir -p "$TMP/$1/.github/workflows"
cat >"$TMP/$1/.github/workflows/$2"
}
# --- the rows that must FAIL: they are the point of this file ----------------
# 1: block-form trigger + inline-list runner — incubator's deploy.yml
# shape with the one edit that would make it the bug.
wf block-list a.yml <<'YAML'
name: deploy checks
on:
pull_request:
types: [opened]
jobs:
deploy:
runs-on: [self-hosted, ci-runner]
steps:
- run: echo deploy
YAML
check "on: block + runs-on inline list fails" 1 "self-hosted" \
in_tree block-list
check "failure names the offending file" 1 "a.yml" in_tree block-list
check "failure names the offending runs-on line" 1 "runs-on: [self-hosted, ci-runner]" \
in_tree block-list
# 2: inline-list trigger + scalar runner.
wf inline-trigger a.yml <<'YAML'
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: self-hosted
steps:
- run: echo build
YAML
check "on: inline list + runs-on scalar fails" 1 "self-hosted" \
in_tree inline-trigger
# 3: scalar trigger + self-hosted — and a .yaml extension, so the second
# glob leg is load-bearing in at least one case.
wf scalar-trigger a.yaml <<'YAML'
name: ci
on: pull_request
jobs:
build:
runs-on: self-hosted
steps:
- run: echo build
YAML
check "on: scalar + self-hosted fails, .yaml extension scanned" 1 "a.yaml" \
in_tree scalar-trigger
# 4: the quoted key — YAML 1.1 parses bare `on` as a boolean, so some
# repos quote it; a guard that missed this form would silently pass the
# file it most needs to read.
wf quoted-on a.yml <<'YAML'
name: ci
"on":
pull_request:
jobs:
build:
runs-on: self-hosted
steps:
- run: echo build
YAML
check "quoted \"on\": key + self-hosted fails" 1 "self-hosted" \
in_tree quoted-on
# 5: pull_request_target is the same threat with a scarier token.
wf target a.yml <<'YAML'
name: ci
on:
pull_request_target:
jobs:
build:
runs-on: self-hosted
steps:
- run: echo build
YAML
check "pull_request_target + self-hosted fails" 1 "self-hosted" \
in_tree target
# 6: two jobs in one file — a PR-triggered hosted check AND a self-hosted
# job. Pins the file-level decision (#58 §3): a later "fix" to job
# granularity must be a deliberate change, not a silent one.
wf mixed-jobs a.yml <<'YAML'
name: ci
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo check
deploy:
runs-on: [self-hosted, ci-runner]
steps:
- run: echo deploy
YAML
check "file-level rule: hosted PR check + self-hosted job in one file fails" 1 \
"self-hosted" in_tree mixed-jobs
# The block-sequence runner — the same-line rule alone would pass this
# file, and a false negative is the one defect this guard is not allowed
# to have (the script header's why).
wf block-seq a.yml <<'YAML'
name: ci
on:
pull_request:
jobs:
deploy:
runs-on:
- self-hosted
- ci-runner
steps:
- run: echo deploy
YAML
check "block-sequence runs-on: - self-hosted fails" 1 "self-hosted" \
in_tree block-seq
# 11: multiple offenders across two files — BOTH named, not just the first.
wf two-files a.yml <<'YAML'
name: one
on: pull_request
jobs:
a:
runs-on: self-hosted
steps:
- run: echo a
YAML
wf two-files b.yml <<'YAML'
name: two
on: pull_request
jobs:
b:
runs-on: [self-hosted, other]
steps:
- run: echo b
YAML
check "two offending files: the first is named" 1 "a.yml" in_tree two-files
check "two offending files: the second is named too" 1 "b.yml" in_tree two-files
# --- the rows that must PASS: the legal shapes stay legal --------------------
# 7: push-only + self-hosted — incubator's deploy.yml, which must stay
# legal.
wf push-deploy deploy.yml <<'YAML'
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: [self-hosted, ci-runner]
steps:
- run: echo deploy
YAML
check "push-only + self-hosted passes (deploy.yml's shape)" 0 "1 workflow file" \
in_tree push-deploy
# The block-sequence window with no PR trigger: the window logic must not
# widen the rule past its two conditions.
wf push-block-seq deploy.yml <<'YAML'
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on:
- self-hosted
steps:
- run: echo deploy
YAML
check "push-only + block-sequence self-hosted passes" 0 "1 workflow file" \
in_tree push-block-seq
# 8: PR trigger on a hosted runner — incubator's pr-checks.yml.
wf pr-hosted checks.yml <<'YAML'
name: checks
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo check
YAML
check "pull_request + ubuntu-latest passes (pr-checks.yml's shape)" 0 \
"1 workflow file" in_tree pr-hosted
# 9: self-hosted in a comment only — prose is not the bug; incubator's
# pr-checks.yml header is exactly this prose.
wf comment-only checks.yml <<'YAML'
name: checks
# Unreviewed branch code must never reach the self-hosted deploy runner.
on:
pull_request:
jobs:
check:
# not: runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- run: echo check
YAML
check "self-hosted in comments only passes" 0 "1 workflow file" \
in_tree comment-only
# 10: an empty workflows dir, and no workflows dir at all — both pass; a
# guard that fails on absence is a guard nobody adopts.
mkdir -p "$TMP/empty-dir/.github/workflows"
check "empty workflows dir passes" 0 "0 workflow files" in_tree empty-dir
mkdir -p "$TMP/no-dir"
check "missing workflows dir passes" 0 "no workflows directory" in_tree no-dir
# 12: a schedule trigger with self-hosted and no PR trigger — the runner
# half alone is not the offence.
wf scheduled nightly.yml <<'YAML'
name: nightly
on:
schedule:
- cron: '0 3 * * *'
jobs:
sweep:
runs-on: [self-hosted, ci-runner]
steps:
- run: echo sweep
YAML
check "schedule + self-hosted, no PR trigger, passes" 0 "1 workflow file" \
in_tree scheduled
# The trigger-block span: pull_request below the on: block (here, a step
# name in jobs:) is not a trigger. Guards the block-end detection.
wf pr-elsewhere build.yml <<'YAML'
name: build
on:
push:
branches: [main]
jobs:
build:
runs-on: [self-hosted, ci-runner]
steps:
- name: mention pull_request in a step name
run: echo build
YAML
check "pull_request outside the on: block is not a trigger" 0 \
"1 workflow file" in_tree pr-elsewhere
# --- a non-default workflows dir, as arg and as the action's env var ---------
mkdir -p "$TMP/alt-dir/ci-flows"
cat >"$TMP/alt-dir/ci-flows/a.yml" <<'YAML'
name: ci
on: pull_request
jobs:
a:
runs-on: self-hosted
steps:
- run: echo a
YAML
check "a non-default workflows dir is honored as an argument" 1 "ci-flows/a.yml" \
in_tree alt-dir ci-flows
# A non-default dir proves the env var is honored, not the default —
# the same wiring proof drill-recorded's suite carries.
env_tree() {
(cd "$TMP/alt-dir" && WORKFLOWS_DIR=ci-flows bash "$SCRIPT")
}
check "the env var drives the script the way action.yml does" 1 \
"ci-flows/a.yml" env_tree
# --- ceremony's own tree — the same tree self-guards runs against ------------
# A future workflow change that breaks this guard's parsing should show
# up as a unit-test failure, not only as a red CI job.
own_tree() {
(cd "$ROOT" && bash "$SCRIPT")
}
check "ceremony's own .github/workflows passes" 0 "no pull_request-triggered work" \
own_tree
summary