actions/runner-isolated — no pull_request-triggered job may run on a self-hosted runner
#58
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:docs
scope:guards
scope:labels
scope:release-flow
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/ceremony#58
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #56.
ready— blocked by nothing. Independent of the sibling child (the cross-repo doctrine); either may land first.Context
Accepted from discussion #55, decision D5 in #56 — read the epic first.
The hole, stated once: a
pull_requestworkflow 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, the 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 — not the write-token toggle, not the secrets toggle, both of which are correctly off (#16's ruling); they protect credentials, not the runner.Nothing is wrong today, and this is not an incident report. Verified against
heavy-duty/incubatoratmain:runs-ondeploy.ymlpush[self-hosted, ci-runner]pr-checks.ymlpull_requestubuntu-latestbuild-images.ymlpushubuntu-latestpr-checks.yml's own header already says unreviewed branch code "must never reach the self-hosted deploy runner". That is the rule; today it is a sentence in a file, kept true by whoever remembers it. This issue moves it into CI — the same movedrill-recordedmade after three releases shipped through a documented-but-unenforced gate (see drill-recorded.sh's header: "the ONE time it was caught is the time somebody happened to look, and that is not a gate, it is luck with good manners").All line references pinned at
2f0d3c6.Spec
A fifth guard in the existing family, same shape as the four beside it (
actions/changelog-armed,changelog-monotonic,drill-recorded,docs-sync).1. The files
actions/runner-isolated/action.yml— composite, oneshell: bashstep running the script from$GITHUB_ACTION_PATH, exactly like docs-sync's action.yml.actions/runner-isolated/runner-isolated.sh—#!/usr/bin/env bash,set -euo pipefail, the whole decision. Workflows gather facts; scripts decide (CONTRIBUTING, code conventions).test/runner-isolated.test.sh— fixtures per the test plan. No registration step: test/run.sh globstest/*.test.sh.Name rationale, so nobody renames it in review: the family names the asserted property, not the failure —
changelog-armed,drill-recorded, sorunner-isolated.2. The rule
Scan
$WORKFLOWS_DIR(input, default.github/workflows) in the caller's checked-out tree, every*.ymland*.yaml. A file fails when both hold:pull_requestorpull_request_target; andruns-onandself-hosted.Exit non-zero listing every offending file and the matching
runs-online. Exit 0 with a one-line count when clean. A missing workflows directory is a pass, not an error — most consumer repos in the family have one, but a guard that fails on absence is a guard nobody adopts.3. The granularity decision — file-level, deliberately
The precise rule is "no job reachable from a
pull_requesttrigger runs on a self-hosted runner", and it is not implementable in mawk-compatible bash without a YAML parser. So the guard is coarse: a file that has a PR trigger and a self-hostedruns-onanywhere in it fails, even if they belong to different jobs.This is the right trade, and the script's header comment must say why:
pr-checks.ymlfromdeploy.yml, which is the shape the guard is asking for.4. Detecting the trigger block
The
on:key takes three shapes, and all three must be caught:Implementation, mawk-safe: read the file line by line; the trigger block starts at a line matching
^on:(or^"on":/^'on':— YAML 1.1 parses bareonas a boolean, so some repos quote it, and a guard that missed the quoted form would silently pass the file it most needs to read) and ends at the next line that starts a top-level key (^[^[:space:]#]) . Inside that span, plus the^on:line itself, look forpull_request(which also matchespull_request_target— intended). No\xescapes anywhere (mawk, per CONTRIBUTING).Skip comment lines — first non-space character
#— in both halves of the rule. A workflow that merely mentionsself-hostedin a comment is not the bug, and a guard that cried wolf on prose would be turned off within a week.5. Known limits — write them in the action description, not just here
workflow_callis not treated as PR-reachable in v1. A called workflow inherits its caller's event, so apull_requestcaller plus a self-hosted callee is a real path this guard does not see. It is deliberately out of v1: followinguses:across files is the YAML-parsing problem again, and the family has no such caller today (ceremony's ownrelease-exercise.ymlisubuntu-latest). Name it in the description as a known gap so the next reader does not mistake silence for coverage.runs-on: { group: … }) are not inspected — a group can contain self-hosted runners without the string appearing. Same reasoning, same treatment: named, not hidden.6. Wiring — ceremony eats what it serves
Add
- uses: ./actions/runner-isolatedto theself-guardsjob in.github/workflows/ci.yml, beside the other three. That job exists for exactly this ("this repo eats exactly what it serves", issue #11), and ceremony's own tree is a genuine non-vacuous case: it has six workflows, PR triggers onci.yml, and zero self-hosted runners — so a green run is a real pass, and a regression here would be caught on the PR that introduced it.7. Consumer documentation
One entry in
docs/CONSUMERS.mdwherever the CI guard steps are listed, matching the existing entries' form (theuses:line at the pin, one sentence on what it asserts, one on when it fires). Say explicitly that a repo with no self-hosted runner still wants it — the guard's value is the day somebody adds one.Adoption in incubator is not in this issue: #16 is claimed and in flight, and widening a live bootstrap changeset costs review attention it needs elsewhere. It belongs on #16's follow-ups line, and this issue's PR should say so.
8. Changelog
One line under
## Unreleased, inserted above the## 0.1.0heading — never typed over it:Tasks
actions/runner-isolated/runner-isolated.sh— the scan, the two-condition rule, the exit codes, and a header comment carrying the why (the threat, the file-level trade, the two known limits).actions/runner-isolated/action.yml— composite wrapper,workflows-dirinput defaulting to.github/workflows, description naming theworkflow_calland runner-group gaps.test/runner-isolated.test.sh— the fixture matrix below..github/workflows/ci.yml— the guard joins theself-guardsjob.docs/CONSUMERS.md— the consumer-facing entry.CHANGELOG.md— one line under## Unreleased, inserted above## 0.1.0.bash test/run.shgreen.Acceptance criteria
pull_requesttrigger and a self-hostedruns-on, in all threeon:shapes (block, inline list, scalar) and for the quoted"on":key.pull_request_target+ self-hosted.push-only workflow with a self-hostedruns-on— incubator'sdeploy.ymlshape, which must stay legal.pull_requestworkflow onubuntu-latest— incubator'spr-checks.ymlshape.self-hostedappears only in a comment, and when the workflows directory does not exist.runs-online; success prints a one-line count of files scanned.workflow_callreachability, runner groups) — a consumer must not read silence as coverage.ci.yml'sself-guardsjob runs the guard against ceremony's real tree, green.\xescapes and no gawk-only constructs;bash test/run.sh, shellcheck and actionlint all green.Test plan
Fixtures in
test/runner-isolated.test.sh, each a temp dir holding a workflows directory. Cases that must fail the guard (non-zero exit) are the point of the file:on:block withpull_request:+ job on[self-hosted, ci-runner]on: [push, pull_request]inline +runs-on: self-hostedon: pull_requestscalar + self-hosted"on":quoted key, block form, + self-hostedpull_request_target+ self-hostedubuntu-latestcheck and a self-hosted jobpush-only + self-hosted (incubator'sdeploy.yml)pull_request+ubuntu-latest(incubator'spr-checks.yml)pull_request+# runs-on: self-hostedin a comment onlyschedule+ self-hosted, no PR triggerPlus: run the script against ceremony's own
.github/workflowsand assert a pass (the same treeself-guardswill guard), so a future workflow change that breaks the guard's parsing shows up as a unit-test failure and not only as a red CI job.Dependencies
Part of #56. Blocked by nothing. Blocks nothing in this repo; incubator's adoption of the guard rides on #16's follow-ups, not on this issue.
Claiming — starting now. Branch
build/58-runner-isolated; draft PR follows with the worklog. Plan follows the spec as written: script + action + fixture-matrix test, wired intoself-guards, CONSUMERS.md entry, changelog line, red-run evidence on a scratch branch.