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:
commit
cf69d8ce9e
6 changed files with 542 additions and 4 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
32
actions/runner-isolated/action.yml
Normal file
32
actions/runner-isolated/action.yml
Normal 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"
|
||||||
185
actions/runner-isolated/runner-isolated.sh
Normal file
185
actions/runner-isolated/runner-isolated.sh
Normal 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"
|
||||||
|
|
@ -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 —
|
||||||
|
|
|
||||||
308
test/runner-isolated.test.sh
Normal file
308
test/runner-isolated.test.sh
Normal 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
|
||||||
Loading…
Reference in a new issue