From 4ac4e64426eee52baac05e442021f5c202e237b4 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 13 Sep 2026 22:23:13 +1000 Subject: [PATCH 1/4] ci(core): move all workflows to ubuntu-latest (LAB-3501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move every job in cachekit-core off the self-hosted ARC pool (cachekit/cachekit-lean) to GitHub-hosted ubuntu-latest — LAB-1161 stage 1 (option a+c). cachekit-core is public/forkable and its workflows ran on the privileged homelab ARC pool; ADR-0002 §5 always said PR lanes should be hosted, and the workflows had drifted. Slower-but-isolated hosted CI is the accepted trade (Ray's a+c+b ruling, 2026-09-13). The server-side fork control (runner group cachekit-private) is LAB-1161 stage 2, staged behind this. - ci.yml: collapse the redundant matrix `runner:` key into `os:`; security job hosted - codeql.yml, security.yml, release.yml (release-please), attestation-check: all jobs hosted - security.yml deep-fuzz: timeout-minutes 540 -> 360; fuzz_seconds dispatch input clamped (default 8h -> 5h, hard cap 18000s) so no job can exceed GitHub's 6h cap - drop pod-specific CARGO_BUILD_JOBS OOM caps (a no-op on 4-core/16GB hosted runners) - correct comments that claimed jobs run self-hosted or that ubuntu-latest was chosen to avoid a per-job pool option that no longer exists repo-wide Add a fail-closed drift guard (option a): runner-guard.yml runs assert_hosted_runners.py on push + pull_request. It is an allow-list — any runs-on that cannot be proven to resolve to a hosted label (a pool label, a runner group, or an unresolvable ${{ }} expression) fails CI. This is drift protection for maintainers, NOT a fork-PR control (a fork runs its own copy of the workflow); the server-side control is stage 2. --- .github/scripts/assert_hosted_runners.py | 240 +++++++++++++++++++++++ .github/workflows/attestation-check.yml | 13 +- .github/workflows/ci.yml | 18 +- .github/workflows/codeql.yml | 8 +- .github/workflows/release.yml | 20 +- .github/workflows/runner-guard.yml | 35 ++++ .github/workflows/security.yml | 56 +++--- .gitignore | 4 + 8 files changed, 330 insertions(+), 64 deletions(-) create mode 100644 .github/scripts/assert_hosted_runners.py create mode 100644 .github/workflows/runner-guard.yml diff --git a/.github/scripts/assert_hosted_runners.py b/.github/scripts/assert_hosted_runners.py new file mode 100644 index 0000000..69004f7 --- /dev/null +++ b/.github/scripts/assert_hosted_runners.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Fail if any workflow job could run on a non-GitHub-hosted runner. + +This is an ALLOW-LIST that FAILS CLOSED: every job's `runs-on` must resolve to a +known GitHub-hosted label family (`ubuntu-*`, `macos-*`, `windows-*`). Anything +the scanner cannot *prove* is hosted — `cachekit`, `self-hosted`, a future pool +label nobody has invented yet, a runner-group object, or a `${{ }}` expression it +can't resolve — is a violation. A deny-list of today's bad names would fail open +the day someone adds a new one, or reformats the drift into a shape the deny-list +doesn't grep (Alaya 999a8af5, selecta PR #72 lesson). + +Forms handled, and how each stays fail-closed: + - scalar / inline `[a, b]` / block-sequence `runs-on` -> every label allow-listed. + - matrix indirection `runs-on: ${{ matrix.os }}` / `${{ matrix.runner }}` -> allowed + ONLY because every matrix `os:` / `runner:` value in the file is scanned directly + (a non-hosted value anywhere fails the whole file). ANY OTHER expression + (`${{ matrix.platform }}`, `${{ vars.RUNNER }}`, `${{ env.POOL }}`) is a violation: + the scanner does not resolve it, so it will not bless it. + - object form `runs-on: { group:, labels: }` -> `group:` is rejected outright (this + repo uses standard hosted labels, never a runner group; a hosted larger-runner + group would be an explicit future decision that must extend this allow-list), and + every `labels:` entry is allow-listed. + - matrix `include` entries whose first key is `- os:` / `- runner:` (label on the + dash line) are scanned like any other `os:` / `runner:` value. + - a comment after `runs-on:` (`runs-on: # note`) is not mistaken for a label. + +SCOPE / what this is NOT. This runs inside the workflow, so it only protects against +*maintainer drift on a trusted branch*: a fork PR runs the fork's own copy of this +file and can simply delete the guard, so it is not a fork-PR control. The server-side +control (runner group `cachekit-private`, allows_public_repositories=false) is +LAB-1161 stage 2. + +Deliberately dependency-free (stdlib only): it must behave identically on a hosted +runner and a laptop, with no PyYAML — the ubuntu-latest image does not ship it, and a +`pip install` in a merge-gating security check adds network flakiness. A focused, +fail-closed line scanner with a self-test that locks every case is the right trade. + +Run: python3 .github/scripts/assert_hosted_runners.py +Test: python3 .github/scripts/assert_hosted_runners.py --selftest +""" + +from __future__ import annotations + +import glob +import re +import sys + +# GitHub-hosted label families. Versioned and ARM variants are allowed +# (ubuntu-24.04, macos-14, ubuntu-24.04-arm, windows-2022, *-latest, …). +HOSTED = re.compile(r"^(ubuntu|macos|windows)-[a-z0-9._-]+$") + +# The two matrix keys that runs-on is permitted to indirect through; their values +# are scanned directly, so `runs-on: ${{ matrix.os }}` is verifiable. Any other +# expression is not resolvable by this scanner and therefore fails closed. +RESOLVABLE_EXPR = re.compile(r"^\$\{\{\s*matrix\.(os|runner)\s*\}\}$") + +# A runner-label-bearing line: `runs-on:` (job key) or a matrix `os:` / `runner:` +# value, optionally on a `- ` sequence-item dash (matrix include entries). +KEY = re.compile(r"^(?P\s*)(?:-\s+)?(?Pruns-on|os|runner):\s*(?P.*?)\s*$") +SEQ_ITEM = re.compile(r"^(?P\s*)-\s*(?P.+?)\s*$") +MAP_ITEM = re.compile(r"^(?P\s*)(?Pgroup|labels):\s*(?P.*?)\s*$") + + +def _strip_comment(text: str) -> str: + """Remove a `# …` comment (whole-line or trailing) and surrounding space.""" + return re.sub(r"(?:^|\s)#.*$", "", text).strip() + + +def _unquote(token: str) -> str: + token = token.strip() + if len(token) >= 2 and token[0] in "\"'" and token[-1] == token[0]: + token = token[1:-1] + return token.strip() + + +def _labels_from_inline(value: str) -> list[str]: + """Label tokens from an inline scalar or `[a, b]` list (comments stripped).""" + value = _strip_comment(value) + if value.startswith("[") and value.endswith("]"): + return [_unquote(t) for t in value[1:-1].split(",") if _unquote(t)] + token = _unquote(value) + return [token] if token else [] + + +def _bad_label(label: str) -> bool: + """True if this concrete label is not a known hosted label.""" + return HOSTED.match(label) is None + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def find_violations(text: str) -> list[tuple[int, str, str]]: + """Return (line_number, key, offending_value) for every non-hosted target.""" + lines = text.splitlines() + out: list[tuple[int, str, str]] = [] + i = 0 + while i < len(lines): + m = KEY.match(lines[i]) + if not m: + i += 1 + continue + key, raw = m.group("key"), m.group("value") + value = _strip_comment(raw) + + # --- inline value present ----------------------------------------- + if value: + if value.startswith("${{"): + # Expressions are only meaningful (and only occur) on runs-on. + if key == "runs-on" and not RESOLVABLE_EXPR.match(value): + out.append((i + 1, "runs-on (unresolvable expression)", value)) + # matrix.os / matrix.runner: verified via the os/runner scan below. + else: + for label in _labels_from_inline(value): + if _bad_label(label): + out.append((i + 1, key, label)) + i += 1 + continue + + # --- empty inline value: a block follows on deeper-indented lines -- + key_indent = _indent(m.group(0)) + j = i + 1 + while j < len(lines): + if not lines[j].strip(): + j += 1 + continue + if _indent(lines[j]) <= key_indent: + break + seq = SEQ_ITEM.match(lines[j]) + if seq: + label = _unquote(_strip_comment(seq.group("value"))) + if label and not label.startswith("${{") and _bad_label(label): + out.append((j + 1, key, label)) + j += 1 + continue + # object form: `runs-on:` followed by `group:` / `labels:` + mp = MAP_ITEM.match(lines[j]) + if mp and key == "runs-on": + sub, sval = mp.group("key"), _strip_comment(mp.group("value")) + if sub == "group": + # Any runner-group target fails closed (see module docstring). + if sval and not sval.startswith("${{"): + out.append((j + 1, "runs-on.group", _unquote(sval))) + elif sval: # labels: [ ... ] inline + for label in _labels_from_inline(sval): + if _bad_label(label): + out.append((j + 1, "runs-on.labels", label)) + # labels with an empty inline value → its block items are picked + # up by SEQ_ITEM on the following iterations of this same loop. + j += 1 + i = j + return out + + +def main() -> int: + files = sorted( + glob.glob(".github/workflows/*.yml") + glob.glob(".github/workflows/*.yaml") + ) + if not files: + print("::error::no workflow files found under .github/workflows/", file=sys.stderr) + return 1 + failed = False + for path in files: + with open(path, encoding="utf-8") as fh: + for lineno, key, value in find_violations(fh.read()): + print( + f"::error file={path},line={lineno}::runner target '{value}' on " + f"'{key}' is not a provably GitHub-hosted runner. Every job must " + f"run on ubuntu-*/macos-*/windows-*; self-hosted pools, runner " + f"groups, and unresolvable ${{{{ }}}} runner expressions are " + f"forbidden in this repo (LAB-1161 / LAB-3501)." + ) + failed = True + if failed: + return 1 + print(f"OK: all runner targets across {len(files)} workflow file(s) are GitHub-hosted.") + return 0 + + +def _selftest() -> int: + def v(text): + return [val for _, _, val in find_violations(text)] + + # --- MUST FAIL: direct pool labels and future names ------------------- + assert v(" runs-on: cachekit\n") == ["cachekit"] + assert v(" runs-on: cachekit-lean\n") == ["cachekit-lean"] + assert v(" runs-on: self-hosted\n") == ["self-hosted"] + assert v(" runs-on: cachekit-turbo\n") == ["cachekit-turbo"] + assert v(" runs-on: [self-hosted, linux, x64]\n") == ["self-hosted", "linux", "x64"] + assert v(" runs-on:\n - self-hosted\n - linux\n") == ["self-hosted", "linux"] + assert v(" runner: cachekit\n") == ["cachekit"] + assert v(' os: "self-hosted" # quoted + comment\n') == ["self-hosted"] + + # --- MUST FAIL: the fail-open forms the expert panel found ------------ + # runner-group object form (LAB-1161 stage 2's own mechanism). + assert v(" runs-on:\n group: cachekit-private\n") == ["cachekit-private"] + assert v(" runs-on:\n group: cachekit-private\n labels: [self-hosted]\n") == [ + "cachekit-private", + "self-hosted", + ] + # A hosted-*sounding* group name is still rejected: groups are not used here. + assert v(" runs-on:\n group: ubuntu-big\n") == ["ubuntu-big"] + # matrix include entry whose FIRST key is os/runner (label on the dash line). + assert v(" include:\n - os: cachekit\n rust: stable\n") == ["cachekit"] + assert v(" - runner: self-hosted\n") == ["self-hosted"] + # indirection through a key the scanner does not resolve → fail closed. + assert v(" runs-on: ${{ matrix.platform }}\n") == ["${{ matrix.platform }}"] + assert v(" runs-on: ${{ vars.RUNNER }}\n") == ["${{ vars.RUNNER }}"] + assert v(" runs-on: ${{ env.POOL }}\n") == ["${{ env.POOL }}"] + + # --- MUST PASS: hosted labels, variants, and verifiable indirection --- + assert v(" runs-on: ubuntu-latest\n") == [] + assert v(" runs-on: macos-latest\n") == [] + assert v(" runs-on: windows-latest\n") == [] + assert v(" runs-on: ubuntu-24.04\n") == [] + assert v(" runs-on: ubuntu-24.04-arm\n") == [] + assert v(" runs-on: [ubuntu-latest]\n") == [] + assert v(" runs-on: ${{ matrix.os }}\n") == [] + assert v(" runs-on: ${{ matrix.runner }}\n") == [] + assert v(" os:\n - ubuntu-latest\n - macos-latest\n") == [] + # A comment after runs-on before a block list must not be read as a label. + assert v(" runs-on: # pick per matrix\n - ubuntu-latest\n") == [] + # A comment line that merely mentions a pool name must not trip the scanner. + assert v(" # runs-on: cachekit was the old value\n runs-on: ubuntu-latest\n") == [] + # runs-on via matrix.os, with the matrix defining only hosted os values. + assert v( + " runs-on: ${{ matrix.os }}\n" + " strategy:\n matrix:\n include:\n" + " - os: ubuntu-latest\n - os: macos-latest\n" + ) == [] + + print("selftest OK") + return 0 + + +if __name__ == "__main__": + if "--selftest" in sys.argv[1:]: + sys.exit(_selftest()) + sys.exit(main()) diff --git a/.github/workflows/attestation-check.yml b/.github/workflows/attestation-check.yml index 2bf734b..b3dd3fa 100644 --- a/.github/workflows/attestation-check.yml +++ b/.github/workflows/attestation-check.yml @@ -14,12 +14,13 @@ concurrency: jobs: verify: name: Verify Latest Release Attestations - # ubuntu-latest, NOT the self-hosted `cachekit` runner: the ARC pods have no `gh` - # binary (LAB-899) and unreliable Sigstore (Fulcio/Rekor) egress — the same reason - # release.yml's publish job is hosted. `gh attestation verify` is the only tool - # that does Sigstore bundle verification, so moving this job back to ARC silently - # re-breaks it (LAB-984: every `gh` call exited 127 into `|| echo ""`, and the job - # reported green while verifying nothing for weeks). + # Must stay GitHub-hosted: this job needs `gh` (preinstalled on ubuntu-latest) + # and reliable Sigstore (Fulcio/Rekor) egress. `gh attestation verify` is the + # only tool that does Sigstore bundle verification. The whole repo is on + # ubuntu-latest (LAB-3501) and runner-guard.yml keeps it there, but the point + # is load-bearing here specifically: LAB-984 showed that when this ran on an + # ARC pod with no `gh` binary, every `gh` call exited 127 into `|| echo ""` and + # the job reported green while verifying nothing for weeks. runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7fb7fe..4f903d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,43 +8,31 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - # cargo's available_parallelism() reads the node's 32 threads, not the pod's - # cgroup CPU quota, so a cold (cache-miss) build fans out ~22-way and can - # OOM-kill the linker in the 5Gi cachekit runner — the same failure that hit - # cachekit-rs (#25/#26). Cap parallel jobs so peak RSS stays under the cap. - CARGO_BUILD_JOBS: "4" - jobs: test: name: ${{ matrix.rust }} / ${{ matrix.os }} - runs-on: ${{ matrix.runner }} + runs-on: ${{ matrix.os }} # Fail fast instead of hanging ~10min to GitHub's heartbeat if a runner wedges. timeout-minutes: 20 continue-on-error: ${{ matrix.rust == 'beta' }} strategy: fail-fast: false matrix: - # Full OS matrix for stable only; MSRV and beta on self-hosted only + # Full OS matrix for stable only; MSRV and beta on Linux only. include: # MSRV - ensures we don't use newer Rust features - rust: "1.85" os: ubuntu-latest - runner: cachekit # Stable - primary target, all platforms - rust: stable os: ubuntu-latest - runner: cachekit - rust: stable os: macos-latest - runner: macos-latest - rust: stable os: windows-latest - runner: windows-latest # Beta - early warning (allowed to fail) - rust: beta os: ubuntu-latest - runner: cachekit steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -76,7 +64,7 @@ jobs: run: cargo test --features ffi security: - runs-on: cachekit + runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e0b0837..0d33262 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,16 +13,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - # cargo's available_parallelism() reads the node's 32 threads, not the pod's - # cgroup CPU quota, so the cold release build below can OOM-kill the linker in - # the 5Gi cachekit runner (cachekit-rs #25/#26). Cap parallel jobs to fit. - CARGO_BUILD_JOBS: "4" - jobs: analyze: name: Analyze - runs-on: cachekit + runs-on: ubuntu-latest timeout-minutes: 30 permissions: actions: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bfff20..90d1c83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,14 @@ concurrency: cancel-in-progress: false jobs: + # GitHub-hosted: this job inherits contents+PR write and mints an App + # installation token. The self-hosted `cachekit` pool shares a writable + # hostPath build cache (/cache) across all pods and every cachekit-io repo, + # and job-level `permissions:` does not isolate that filesystem — untrusted + # build-script/proc-macro code can poison the cache a later credentialed + # job runs against (LAB-1040). release-please needs no warm cache anyway. release-please: - runs-on: cachekit + runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} @@ -41,8 +47,9 @@ jobs: # release PR here. outputs.pr is set whenever the release PR was created # OR updated, so this re-runs on every push to main while a release PR is # open — adding an already-present assignee is a no-op, so that's safe. - # github-script, NOT `gh`: the self-hosted cachekit runner has no gh CLI - # (LAB-899). outputs.pr is passed raw via env and parsed in JS — never + # github-script, NOT `gh`: chosen when this job ran on the self-hosted + # cachekit runner, which has no gh CLI (LAB-899); it works identically on + # a hosted runner. outputs.pr is passed raw via env and parsed in JS — never # through template-position fromJson, which is evaluated even when if: is # false and crashes on '' for no-release pushes (LAB-865). - name: Assign release PR to 27Bslash6 @@ -103,10 +110,9 @@ jobs: subject-path: target/package/*.crate - name: Install cargo-sbom - # --force is required: the self-hosted runner's CARGO_HOME (/cache/cargo) is a - # persistent volume, so the binary survives between runs and a plain install - # exits 101 ("binary `cargo-sbom` already exists"). --force reinstalls the - # --locked pinned version idempotently. + # --force kept from when this job ran self-hosted with a persistent + # CARGO_HOME (/cache/cargo), where a plain install exits 101 ("binary + # `cargo-sbom` already exists"). Harmless no-op cost on a hosted runner. run: cargo install cargo-sbom --locked --force - name: Generate SBOM diff --git a/.github/workflows/runner-guard.yml b/.github/workflows/runner-guard.yml new file mode 100644 index 0000000..33cecfd --- /dev/null +++ b/.github/workflows/runner-guard.yml @@ -0,0 +1,35 @@ +name: Runner Guard + +# Drift protection (LAB-1161 stage 1 / LAB-3501): every job in this repo must run +# on a GitHub-hosted runner. This job fails the workflow if any `runs-on` or matrix +# `os:`/`runner:` value in .github/workflows/ is not a hosted label — an allow-list, +# so a NEW self-hosted pool label nobody has named yet still fails (Alaya 999a8af5). +# +# This is protection against MAINTAINER drift on trusted branches only. It is NOT a +# fork-PR control: a fork runs its own copy of this file and can delete the guard. +# The server-side control is runner group `cachekit-private` (LAB-1161 stage 2). + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + assert-hosted-runners: + name: Assert all runners are GitHub-hosted + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Self-test the guard, then scan every workflow + run: | + python3 .github/scripts/assert_hosted_runners.py --selftest + python3 .github/scripts/assert_hosted_runners.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f79815c..ae10235 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,12 +7,13 @@ on: branches: [main] schedule: # Saturday 11:07 UTC = Sat 21:07 AEST / 22:07 AEDT (Sydney night, year-round). - # Weekly cadence: deep fuzz at 1h/target × 16 targets is ~16 runner-hours, which - # drains in a few hours overnight instead of monopolising the shared ARC pool for - # ~32h. The full 8h/target run is opt-in via workflow_dispatch (run_deep_fuzz). - # PR-time coverage (cargo audit/deny, Cargo Vet, Quick Fuzz, CodeQL) catches - # regressions promptly; deep fuzz is for finding bugs, not gating merges. - # Off-minute (:07) avoids the cron pile-up that GitHub schedules at :00. + # Weekly cadence: deep fuzz at 1h/target × 16 targets. Each matrix target is a + # separate GitHub-hosted job, so they run in parallel (bounded by GitHub's + # concurrency limit) rather than serialising on one pool — and hosted minutes + # are free for public repos. The longer opt-in run is via workflow_dispatch + # (run_deep_fuzz). PR-time coverage (cargo audit/deny, Cargo Vet, Quick Fuzz, + # CodeQL) catches regressions promptly; deep fuzz is for finding bugs, not + # gating merges. Off-minute (:07) avoids the cron pile-up GitHub schedules at :00. - cron: '7 11 * * 6' # Deliberately no `release:` trigger. It existed only for an SBOM job that # attached a release asset, and that can never work here: immutable releases @@ -29,13 +30,13 @@ on: workflow_dispatch: inputs: run_deep_fuzz: - description: "Run the full deep-fuzz matrix (heavy — occupies the ARC pool)" + description: "Run the full deep-fuzz matrix (heavy — one hosted job per target)" type: boolean default: false fuzz_seconds: - description: "Seconds per target for an on-demand deep fuzz (default 8h)" + description: "Seconds per target for an on-demand deep fuzz (default 5h; hard cap 18000s to fit GitHub's 6h job limit)" type: string - default: "28800" + default: "18000" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -44,16 +45,11 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - # Cap cargo parallelism so cold builds (incl. ASAN fuzz-target builds) don't - # OOM-kill the linker in the 5Gi cachekit runner — see cachekit-rs #25/#26. - # NOTE: -j bounds parallel codegen, not ASAN's absolute footprint; it's a - # strong mitigation for the fuzz builds, a full fix for normal cargo builds. - CARGO_BUILD_JOBS: "4" jobs: fast-security: name: Fast Security Checks - runs-on: cachekit + runs-on: ubuntu-latest if: github.event_name == 'push' || github.event_name == 'pull_request' steps: - name: Checkout code @@ -98,7 +94,7 @@ jobs: quick-fuzz: name: Quick Fuzz (Corpus Only) - runs-on: cachekit + runs-on: ubuntu-latest if: github.event_name == 'push' || github.event_name == 'pull_request' strategy: fail-fast: false @@ -160,13 +156,13 @@ jobs: deep-fuzz: name: Deep Fuzzing - runs-on: cachekit - # Scheduled weekly run is light (1h/target) so it can't monopolise the shared - # ARC pool. The full 8h/target run is opt-in via workflow_dispatch (run_deep_fuzz). + runs-on: ubuntu-latest + # Scheduled weekly run is light (1h/target). The longer on-demand run is opt-in + # via workflow_dispatch (run_deep_fuzz) and clamped to fit the hosted 6h cap. if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.run_deep_fuzz) - # 540min cap accommodates the on-demand 8h path; the inner timeout governs the - # actual scheduled (1h) vs dispatched (configurable) duration. - timeout-minutes: 540 + # 360min = GitHub's hosted job cap. The inner timeout governs the actual + # scheduled (1h) vs dispatched (<=5h, clamped below) duration. + timeout-minutes: 360 strategy: fail-fast: false matrix: @@ -214,18 +210,20 @@ jobs: run: cargo install cargo-fuzz - name: Run deep fuzz - # Scheduled runs use 1h/target (keeps the ARC pool free for PR CI); a manual - # workflow_dispatch can request the full 8h (or any duration) via fuzz_seconds. + # Scheduled runs use 1h/target; a manual workflow_dispatch can request a + # longer duration (up to the 5h clamp below) via fuzz_seconds. env: FUZZ_SECONDS: ${{ (github.event_name == 'workflow_dispatch' && inputs.fuzz_seconds) || '3600' }} run: | # Validate the dispatch-supplied duration before it reaches shell arithmetic - # and the fuzzer. Must be a positive integer and within the 540min job cap. + # and the fuzzer. Must be a positive integer within the hosted 6h job cap. + # Ceiling is 18000s (5h): +180s hang slack (below) still leaves headroom + # under timeout-minutes 360 (21600s), so a dispatched job cannot exceed 6h. case "$FUZZ_SECONDS" in ''|*[!0-9]*) echo "::error::fuzz_seconds must be a positive integer (got '$FUZZ_SECONDS')"; exit 1 ;; esac - if [ "$FUZZ_SECONDS" -lt 1 ] || [ "$FUZZ_SECONDS" -gt 32400 ]; then - echo "::error::fuzz_seconds must be 1..32400 (<= 540min job cap), got $FUZZ_SECONDS"; exit 1 + if [ "$FUZZ_SECONDS" -lt 1 ] || [ "$FUZZ_SECONDS" -gt 18000 ]; then + echo "::error::fuzz_seconds must be 1..18000 (<= 6h hosted job cap), got $FUZZ_SECONDS"; exit 1 fi cd fuzz # Build first - fail fast on compile errors @@ -249,7 +247,7 @@ jobs: kani: name: Kani Formal Verification - runs-on: cachekit + runs-on: ubuntu-latest if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' permissions: contents: read # least-privilege: the job only checks out and verifies @@ -293,7 +291,7 @@ jobs: cargo-vet: name: Cargo Vet (Supply Chain) - runs-on: cachekit + runs-on: ubuntu-latest if: github.event_name == 'schedule' || github.event_name == 'pull_request' steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index a191e5a..d9d3180 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Rust build artifacts /target/ +# Python bytecode from .github/scripts/ (the runner-guard drift check) +__pycache__/ +*.py[cod] + # Generated files (keep .gitkeep but ignore generated header) include/cachekit.h From dd3a5a4ecd0f778208d2733a4dae5e97d772ef14 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 14 Sep 2026 01:55:58 +1000 Subject: [PATCH 2/4] ci(core): make the runner guard reject every shape it cannot verify (LAB-3516) The guard is an allow-list that must fail closed, but it had four places where a shape it could not evaluate was *skipped* instead of rejected: - `${{ }}` as an os:/runner: matrix value, as a block-list entry, or as runs-on.group was ignored. With runs-on: ${{ matrix.os }} blessed, an `os: ${{ vars.POOL }}` laundered a self-hosted pool through the one indirection the scanner trusts (Kody critical, CodeRabbit major). - A generated or flow-form matrix (`matrix: ${{ fromJSON(...) }}`, `matrix: {os: [...]}`) hid the os: values that earn that blessing. - Inside a runs-on:/os:/runner: block, any line that was not `- item` or group:/labels: was silently skipped: prettier's own `[self-hosted, ...]` continuation, a same-indent k8s-style list, a column-0 commented-out entry ending the block early, a plain-scalar continuation. - Flow mappings anywhere (`- { os: cachekit, rust: stable }` is the standard Rust cross-compile include idiom), quoted keys, a space before the colon, a YAML anchor before the brace, and remote reusable-workflow `uses:` were invisible to the line-anchored regexes. Now exactly one expression is blessed (the complete scalar runs-on: ${{ matrix.os|runner }}, quotes optional); every other unknown shape falls through to the allow-list check or is rejected as unscannable. The top-level on: block is skipped whole so a workflow_dispatch input named os/runner is not misread, and flow-mapping detection is anchored to YAML value positions so JS object literals in github-script steps and jq programs in run: are not mistaken for one. Also from the review: - The selftest used `assert`, which `python3 -O` strips: a sabotaged _bad_label still printed "selftest OK". It is now a (input, expected) table plus a loop; the sabotage now exits 1 under -O. 67 cases lock every shape above, pass and fail. - open() is wrapped so an unreadable workflow file emits a `::error file=` annotation and fails the scan instead of a traceback. - The annotation no longer calls a matrix a "runner target"; it names the field and the rule that rejected it. - print() is kept deliberately: stdout is the GitHub Actions workflow-command protocol and a logging formatter would corrupt the `::error` lines (Kody print-to-logging suggestion rejected on that basis). Still a maintainer-drift control, not a fork-PR control; PyYAML is not documented on the ubuntu-24.04 image so the stdlib-only design stands. --- .github/scripts/assert_hosted_runners.py | 353 ++++++++++++++++++----- 1 file changed, 273 insertions(+), 80 deletions(-) diff --git a/.github/scripts/assert_hosted_runners.py b/.github/scripts/assert_hosted_runners.py index 69004f7..632fd27 100644 --- a/.github/scripts/assert_hosted_runners.py +++ b/.github/scripts/assert_hosted_runners.py @@ -11,15 +11,45 @@ Forms handled, and how each stays fail-closed: - scalar / inline `[a, b]` / block-sequence `runs-on` -> every label allow-listed. - - matrix indirection `runs-on: ${{ matrix.os }}` / `${{ matrix.runner }}` -> allowed + A block list may sit at the key's own indent (k8s style) or deeper; comment lines + inside it are skipped, not treated as its end. Keys may be quoted or carry a space + before the colon (`"runs-on" :`) — the same key to YAML, so the same key here. + - matrix indirection: the ONE blessed expression is a complete scalar + `runs-on: ${{ matrix.os }}` or `${{ matrix.runner }}` (quotes optional), allowed ONLY because every matrix `os:` / `runner:` value in the file is scanned directly - (a non-hosted value anywhere fails the whole file). ANY OTHER expression - (`${{ matrix.platform }}`, `${{ vars.RUNNER }}`, `${{ env.POOL }}`) is a violation: - the scanner does not resolve it, so it will not bless it. - - object form `runs-on: { group:, labels: }` -> `group:` is rejected outright (this - repo uses standard hosted labels, never a runner group; a hosted larger-runner - group would be an explicit future decision that must extend this allow-list), and - every `labels:` entry is allow-listed. + (a non-hosted value anywhere fails the whole file). EVERY other `${{ }}` in a + runner-target position is a violation — e.g. `os: ${{ vars.POOL }}` would launder + a self-hosted pool through the blessed indirection. + - `matrix:` / `include:` must be static block mappings. A generated matrix + (`matrix: ${{ fromJSON(…) }}`) or a flow mapping (`matrix: {os: […]}`) hides its + `os:` values from the scanner, so any inline value on those keys is a violation. + Accepted false positive: this fires on ANY `matrix:`/`include:` key with an inline + value, e.g. an action input named `include:` — cheaper than tracking `strategy:` + scope; write such inputs in block form. + - flow mappings: a `{ … }` where YAML would put one (`- {`, `key: {`, a bare `{`) + that carries `runs-on:` / `os:` / `runner:` — or a reusable-workflow `uses:` — at + any depth (`- { os: cachekit, rust: stable }`, `strategy: { matrix: { os: […] } }`, + `jobs: { build: { runs-on: … } }`) is rejected outright: a line scanner cannot see + inside a flow mapping, so it does not pretend to. A YAML anchor or tag before the + brace (`- &x { os: … }`) does not hide it. A single-line JS object literal inside a + `script: |` step or a jq program in `run:` does not start a YAML value, so it is + not mistaken for one; a multi-line literal with `os:` on its own line does trip + the scanner — accepted, rename the property or keep the literal on one line. + - object form `runs-on: { group:, labels: }` -> `group:` is rejected outright, + whatever its value (this repo uses standard hosted labels, never a runner group; a + hosted larger-runner group would be an explicit future decision that must extend + this allow-list), and every `labels:` entry is allow-listed. + - inside any `runs-on:` / `os:` / `runner:` block, a line that is not a `- item` (or, + for runs-on, `group:` / `labels:`) is a shape this scanner cannot verify — a + plain-scalar or `[…]` continuation line, a nested mapping — and is a violation. + Write the value inline instead. + - a job-level `uses:` of a REMOTE reusable workflow runs that workflow's jobs on this + repo's runner pool with a `runs-on` this scanner cannot see -> violation, as is a + `uses:` whose value is not inline (`uses: >-`). Local callees + (`./.github/workflows/…`) are scanned like any other file. + - the top-level `on:` block (triggers, `workflow_dispatch` inputs) is skipped whole: + nothing under it selects a runner, and an input named `os:` / `runner:` would + otherwise be misread as a matrix key. - matrix `include` entries whose first key is `- os:` / `- runner:` (label on the dash line) are scanned like any other `os:` / `runner:` value. - a comment after `runs-on:` (`runs-on: # note`) is not mistaken for a label. @@ -28,13 +58,17 @@ *maintainer drift on a trusted branch*: a fork PR runs the fork's own copy of this file and can simply delete the guard, so it is not a fork-PR control. The server-side control (runner group `cachekit-private`, allows_public_repositories=false) is -LAB-1161 stage 2. +LAB-1161 stage 2. Nor does it defend its own workflow (`runner-guard.yml`) against an +`if: false` — that is branch protection's job (required status check + CODEOWNERS). Deliberately dependency-free (stdlib only): it must behave identically on a hosted runner and a laptop, with no PyYAML — the ubuntu-latest image does not ship it, and a `pip install` in a merge-gating security check adds network flakiness. A focused, fail-closed line scanner with a self-test that locks every case is the right trade. +Output is GitHub Actions workflow commands (`::error file=…::`), which the runner +parses off the raw output stream — that is why this script prints rather than logs. + Run: python3 .github/scripts/assert_hosted_runners.py Test: python3 .github/scripts/assert_hosted_runners.py --selftest """ @@ -49,16 +83,31 @@ # (ubuntu-24.04, macos-14, ubuntu-24.04-arm, windows-2022, *-latest, …). HOSTED = re.compile(r"^(ubuntu|macos|windows)-[a-z0-9._-]+$") -# The two matrix keys that runs-on is permitted to indirect through; their values -# are scanned directly, so `runs-on: ${{ matrix.os }}` is verifiable. Any other -# expression is not resolvable by this scanner and therefore fails closed. +# The ONE expression this scanner blesses, and only as the complete scalar value of +# `runs-on`: the two matrix keys whose values are scanned directly below, which is +# what makes the indirection verifiable. Every other expression fails closed. RESOLVABLE_EXPR = re.compile(r"^\$\{\{\s*matrix\.(os|runner)\s*\}\}$") -# A runner-label-bearing line: `runs-on:` (job key) or a matrix `os:` / `runner:` -# value, optionally on a `- ` sequence-item dash (matrix include entries). -KEY = re.compile(r"^(?P\s*)(?:-\s+)?(?Pruns-on|os|runner):\s*(?P.*?)\s*$") +# Lines this scanner acts on: a runner-label-bearing key (`runs-on:`, matrix `os:` / +# `runner:`, optionally on a `- ` dash), the `matrix:` / `include:` key whose block +# those values must live in, and `uses:` (reusable workflows). Quoted keys and a +# space before the colon are the same key to YAML. +KEY = re.compile( + r"^(?P\s*)(?:-\s+)?(?P[\"']?)(?Pruns-on|os|runner|matrix|include|uses)(?P=q)" + r"\s*:\s*(?P.*?)\s*$" +) SEQ_ITEM = re.compile(r"^(?P\s*)-\s*(?P.+?)\s*$") MAP_ITEM = re.compile(r"^(?P\s*)(?Pgroup|labels):\s*(?P.*?)\s*$") +# The top-level trigger block; nothing under it can select a runner. +ON_BLOCK = re.compile(r"^[\"']?on[\"']?\s*:\s*(?:#.*)?$") +# A flow mapping where YAML would put one (`- {`, `key: {`, bare `{`) — not a JS +# object literal in `script: |` nor a jq program in `run:` … +FLOW_START = re.compile(r"^\s*(?:-\s+)?(?:[\"']?[\w.-]+[\"']?\s*:\s*)?(?:[&!]\S+\s+)?\{") +# … that carries a runner-target key, or a reusable-workflow `uses:`, at any depth. +FLOW_KEY = re.compile( + r"[{,]\s*[\"']?(?:runs-on|os|runner)[\"']?\s*:" + r"|[{,]\s*[\"']?uses[\"']?\s*:\s*[^,}\s]*\.github/workflows/" +) def _strip_comment(text: str) -> str: @@ -92,45 +141,74 @@ def _indent(line: str) -> int: def find_violations(text: str) -> list[tuple[int, str, str]]: - """Return (line_number, key, offending_value) for every non-hosted target.""" + """Return (line_number, what, offending_value) for every non-hosted target. + + `what` is the field name, with a parenthesised reason when the field's shape, + not its label, is the problem (e.g. `matrix (not a static block)`). + """ lines = text.splitlines() out: list[tuple[int, str, str]] = [] i = 0 while i < len(lines): + if ON_BLOCK.match(lines[i]): + i += 1 + while i < len(lines) and (not lines[i].strip() or lines[i][0] in " \t#"): + i += 1 + continue m = KEY.match(lines[i]) if not m: + stripped = _strip_comment(lines[i]) + if FLOW_START.match(stripped) and FLOW_KEY.search(stripped): + out.append((i + 1, "flow mapping (unscannable)", stripped)) i += 1 continue key, raw = m.group("key"), m.group("value") value = _strip_comment(raw) + if key == "uses": + v = _unquote(value) + if not v or v[0] in ">|": + out.append((i + 1, "uses (unverifiable form)", v)) + elif ".github/workflows/" in v and not v.startswith("./"): + out.append((i + 1, "uses (remote reusable workflow)", v)) + i += 1 + continue + + # An inline value here hides os:/runner: from the scan → blessing unearned. + if key in ("matrix", "include"): + if value: + out.append((i + 1, f"{key} (not a static block)", value)) + i += 1 + continue + # --- inline value present ----------------------------------------- if value: - if value.startswith("${{"): - # Expressions are only meaningful (and only occur) on runs-on. - if key == "runs-on" and not RESOLVABLE_EXPR.match(value): - out.append((i + 1, "runs-on (unresolvable expression)", value)) - # matrix.os / matrix.runner: verified via the os/runner scan below. - else: + # Only the blessed runs-on scalar (RESOLVABLE_EXPR) escapes _bad_label. + if not (key == "runs-on" and RESOLVABLE_EXPR.match(_unquote(value))): for label in _labels_from_inline(value): if _bad_label(label): out.append((i + 1, key, label)) i += 1 continue - # --- empty inline value: a block follows on deeper-indented lines -- + # --- empty inline value: a block follows ----------------------------- + # Items may sit deeper OR at the key's own indent (k8s style) — unless the + # key was itself a `- ` item, when a same-indent dash is a sibling, not ours. key_indent = _indent(m.group(0)) + on_dash = m.group(0).lstrip().startswith("-") j = i + 1 while j < len(lines): - if not lines[j].strip(): + if not lines[j].strip() or lines[j].lstrip().startswith("#"): j += 1 continue - if _indent(lines[j]) <= key_indent: + ind = _indent(lines[j]) + if ind < key_indent or (ind == key_indent and (on_dash or not SEQ_ITEM.match(lines[j]))): break seq = SEQ_ITEM.match(lines[j]) if seq: + # An expression as a list entry is unresolvable → _bad_label rejects it. label = _unquote(_strip_comment(seq.group("value"))) - if label and not label.startswith("${{") and _bad_label(label): + if label and _bad_label(label): out.append((j + 1, key, label)) j += 1 continue @@ -139,15 +217,19 @@ def find_violations(text: str) -> list[tuple[int, str, str]]: if mp and key == "runs-on": sub, sval = mp.group("key"), _strip_comment(mp.group("value")) if sub == "group": - # Any runner-group target fails closed (see module docstring). - if sval and not sval.startswith("${{"): - out.append((j + 1, "runs-on.group", _unquote(sval))) + # Any runner-group target fails closed, expression or not + # (see module docstring). + out.append((j + 1, "runs-on.group", _unquote(sval))) elif sval: # labels: [ ... ] inline for label in _labels_from_inline(sval): if _bad_label(label): out.append((j + 1, "runs-on.labels", label)) # labels with an empty inline value → its block items are picked # up by SEQ_ITEM on the following iterations of this same loop. + else: + # Not a `- item`, not runs-on group:/labels: → a shape this scanner + # cannot verify (scalar or `[…]` continuation, nested mapping). + out.append((j + 1, f"{key} (unrecognised block form)", _strip_comment(lines[j]))) j += 1 i = j return out @@ -162,75 +244,186 @@ def main() -> int: return 1 failed = False for path in files: - with open(path, encoding="utf-8") as fh: - for lineno, key, value in find_violations(fh.read()): - print( - f"::error file={path},line={lineno}::runner target '{value}' on " - f"'{key}' is not a provably GitHub-hosted runner. Every job must " - f"run on ubuntu-*/macos-*/windows-*; self-hosted pools, runner " - f"groups, and unresolvable ${{{{ }}}} runner expressions are " - f"forbidden in this repo (LAB-1161 / LAB-3501)." - ) - failed = True + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except (OSError, UnicodeDecodeError) as exc: + # Unreadable is unverifiable: fail closed, but keep scanning the rest. + print(f"::error file={path}::cannot read workflow file: {exc}", file=sys.stderr) + failed = True + continue + for lineno, what, value in find_violations(text): + print( + f"::error file={path},line={lineno}::{what}: '{value}' is not provably " + f"GitHub-hosted. Every job must run on ubuntu-*/macos-*/windows-* as a " + f"plain scalar, `[a, b]`, block list, or `${{{{ matrix.os }}}}` over a " + f"static block matrix; self-hosted pools, runner groups, other ${{{{ }}}} " + f"expressions, flow mappings, generated matrices and remote reusable " + f"workflows fail closed (LAB-1161 / LAB-3501; see the docstring of " + f".github/scripts/assert_hosted_runners.py)." + ) + failed = True if failed: return 1 print(f"OK: all runner targets across {len(files)} workflow file(s) are GitHub-hosted.") return 0 -def _selftest() -> int: - def v(text): - return [val for _, _, val in find_violations(text)] - +# (workflow snippet, expected offending values). Table-driven rather than `assert` +# so the self-test cannot be silently neutered by `python3 -O` / PYTHONOPTIMIZE. +_CASES: list[tuple[str, list[str]]] = [ # --- MUST FAIL: direct pool labels and future names ------------------- - assert v(" runs-on: cachekit\n") == ["cachekit"] - assert v(" runs-on: cachekit-lean\n") == ["cachekit-lean"] - assert v(" runs-on: self-hosted\n") == ["self-hosted"] - assert v(" runs-on: cachekit-turbo\n") == ["cachekit-turbo"] - assert v(" runs-on: [self-hosted, linux, x64]\n") == ["self-hosted", "linux", "x64"] - assert v(" runs-on:\n - self-hosted\n - linux\n") == ["self-hosted", "linux"] - assert v(" runner: cachekit\n") == ["cachekit"] - assert v(' os: "self-hosted" # quoted + comment\n') == ["self-hosted"] - + (" runs-on: cachekit\n", ["cachekit"]), + (" runs-on: cachekit-lean\n", ["cachekit-lean"]), + (" runs-on: self-hosted\n", ["self-hosted"]), + (" runs-on: cachekit-turbo\n", ["cachekit-turbo"]), + (" runs-on: [self-hosted, linux, x64]\n", ["self-hosted", "linux", "x64"]), + (" runs-on:\n - self-hosted\n - linux\n", ["self-hosted", "linux"]), + (" runner: cachekit\n", ["cachekit"]), + (' os: "self-hosted" # quoted + comment\n', ["self-hosted"]), # --- MUST FAIL: the fail-open forms the expert panel found ------------ # runner-group object form (LAB-1161 stage 2's own mechanism). - assert v(" runs-on:\n group: cachekit-private\n") == ["cachekit-private"] - assert v(" runs-on:\n group: cachekit-private\n labels: [self-hosted]\n") == [ - "cachekit-private", - "self-hosted", - ] + (" runs-on:\n group: cachekit-private\n", ["cachekit-private"]), + ( + " runs-on:\n group: cachekit-private\n labels: [self-hosted]\n", + ["cachekit-private", "self-hosted"], + ), # A hosted-*sounding* group name is still rejected: groups are not used here. - assert v(" runs-on:\n group: ubuntu-big\n") == ["ubuntu-big"] + (" runs-on:\n group: ubuntu-big\n", ["ubuntu-big"]), # matrix include entry whose FIRST key is os/runner (label on the dash line). - assert v(" include:\n - os: cachekit\n rust: stable\n") == ["cachekit"] - assert v(" - runner: self-hosted\n") == ["self-hosted"] + (" include:\n - os: cachekit\n rust: stable\n", ["cachekit"]), + (" - runner: self-hosted\n", ["self-hosted"]), # indirection through a key the scanner does not resolve → fail closed. - assert v(" runs-on: ${{ matrix.platform }}\n") == ["${{ matrix.platform }}"] - assert v(" runs-on: ${{ vars.RUNNER }}\n") == ["${{ vars.RUNNER }}"] - assert v(" runs-on: ${{ env.POOL }}\n") == ["${{ env.POOL }}"] - + (" runs-on: ${{ matrix.platform }}\n", ["${{ matrix.platform }}"]), + (" runs-on: ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]), + (" runs-on: ${{ env.POOL }}\n", ["${{ env.POOL }}"]), + # --- MUST FAIL: expressions anywhere but the one blessed runs-on scalar -- + # (Kody critical / CodeRabbit on PR #76.) An expression as the os:/runner: + # VALUE would launder a self-hosted pool through `runs-on: ${{ matrix.os }}`. + (" os: ${{ vars.POOL }}\n", ["${{ vars.POOL }}"]), + ( + " runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n" + " os: ${{ fromJSON(inputs.oses) }}\n", + ["${{ fromJSON(inputs.oses) }}"], + ), + # …as a block-sequence entry, as runs-on.group, or list-wrapped. + (" runs-on:\n - ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]), + (" runs-on:\n group: ${{ vars.GROUP }}\n", ["${{ vars.GROUP }}"]), + (" runs-on: [${{ matrix.os }}]\n", ["${{ matrix.os }}"]), + # A generated or flow-form matrix hides its os: values → the blessing is unearned. + ( + " runs-on: ${{ matrix.os }}\n strategy:\n" + " matrix: ${{ fromJSON(needs.gen.outputs.matrix) }}\n", + ["${{ fromJSON(needs.gen.outputs.matrix) }}"], + ), + (" include: ${{ fromJSON(inputs.include) }}\n", ["${{ fromJSON(inputs.include) }}"]), + (" matrix: {os: [cachekit]}\n", ["{os: [cachekit]}"]), + # --- MUST FAIL: shapes the panel review of PR #76 found skipped ---------- + # Flow mappings anywhere: the standard Rust cross-compile include idiom, a + # compact strategy, a whole job in flow form (scalar, group object, alias, uses). + ( + " include:\n - { os: cachekit, rust: stable }\n", + ["- { os: cachekit, rust: stable }"], + ), + ( + " strategy: { fail-fast: false, matrix: { os: [ubuntu-latest, cachekit] } }\n", + ["strategy: { fail-fast: false, matrix: { os: [ubuntu-latest, cachekit] } }"], + ), + ("jobs: {build: {runs-on: cachekit}}\n", ["jobs: {build: {runs-on: cachekit}}"]), + ( + "jobs: {build: {runs-on: {group: cachekit-private}}}\n", + ["jobs: {build: {runs-on: {group: cachekit-private}}}"], + ), + ("jobs: {build: {runs-on: *pool}}\n", ["jobs: {build: {runs-on: *pool}}"]), + ( + "jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}\n", + ["jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}"], + ), + (' - { "os": cachekit, rust: stable }\n', ['- { "os": cachekit, rust: stable }']), + (" - &x { os: cachekit }\n", ["- &x { os: cachekit }"]), # anchor before the brace + # Quoted key / space before the colon: the same key to YAML. + (' "runs-on": cachekit\n', ["cachekit"]), + (" runs-on : cachekit\n", ["cachekit"]), + ( + " runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os : [cachekit]\n", + ["cachekit"], + ), + # Block list at the key's own indent (k8s style) — was read as end-of-block. + (" runs-on:\n - self-hosted\n", ["self-hosted"]), + ( + " runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os:\n - cachekit\n", + ["cachekit"], + ), + # A column-0 commented-out entry must not end the block early. + (" runs-on:\n# - ubuntu-latest\n - self-hosted\n", ["self-hosted"]), + # Continuation lines the scanner cannot verify (prettier emits the `[…]` one). + (" runs-on:\n cachekit\n", ["cachekit"]), + (" runs-on:\n [self-hosted, linux]\n", ["[self-hosted, linux]"]), + (" runs-on:\n labels:\n [self-hosted]\n", ["[self-hosted]"]), + # Remote reusable workflow: its runs-on is invisible here but runs on our pool. + ( + " uses: cachekit-io/tooling/.github/workflows/ci.yml@main\n", + ["cachekit-io/tooling/.github/workflows/ci.yml@main"], + ), + (" uses: >-\n org/repo/.github/workflows/ci.yml@main\n", [">-"]), + (" uses:\n org/repo/.github/workflows/ci.yml@main\n", [""]), + # The on: block is skipped, but jobs after it are still scanned. + ( + "on:\n workflow_dispatch:\n inputs:\n os:\n type: choice\n" + " options: [ubuntu-latest]\njobs:\n b:\n runs-on: cachekit\n", + ["cachekit"], + ), # --- MUST PASS: hosted labels, variants, and verifiable indirection --- - assert v(" runs-on: ubuntu-latest\n") == [] - assert v(" runs-on: macos-latest\n") == [] - assert v(" runs-on: windows-latest\n") == [] - assert v(" runs-on: ubuntu-24.04\n") == [] - assert v(" runs-on: ubuntu-24.04-arm\n") == [] - assert v(" runs-on: [ubuntu-latest]\n") == [] - assert v(" runs-on: ${{ matrix.os }}\n") == [] - assert v(" runs-on: ${{ matrix.runner }}\n") == [] - assert v(" os:\n - ubuntu-latest\n - macos-latest\n") == [] + (" runs-on: ubuntu-latest\n", []), + (" runs-on: macos-latest\n", []), + (" runs-on: windows-latest\n", []), + (" runs-on: ubuntu-24.04\n", []), + (" runs-on: ubuntu-24.04-arm\n", []), + (" runs-on: [ubuntu-latest]\n", []), + (" runs-on: ${{ matrix.os }}\n", []), + (" runs-on: ${{ matrix.runner }}\n", []), + (' runs-on: "${{ matrix.os }}"\n', []), # quotes are transparent, as for labels + (" os:\n - ubuntu-latest\n - macos-latest\n", []), + (" runs-on:\n - ubuntu-latest\n", []), # k8s-style same-indent list + (" runs-on:\n labels:\n - ubuntu-latest\n", []), # object form, block labels # A comment after runs-on before a block list must not be read as a label. - assert v(" runs-on: # pick per matrix\n - ubuntu-latest\n") == [] + (" runs-on: # pick per matrix\n - ubuntu-latest\n", []), # A comment line that merely mentions a pool name must not trip the scanner. - assert v(" # runs-on: cachekit was the old value\n runs-on: ubuntu-latest\n") == [] - # runs-on via matrix.os, with the matrix defining only hosted os values. - assert v( + (" # runs-on: cachekit was the old value\n runs-on: ubuntu-latest\n", []), + # runs-on via matrix.os, with a static block matrix defining only hosted values. + ( " runs-on: ${{ matrix.os }}\n" " strategy:\n matrix:\n include:\n" - " - os: ubuntu-latest\n - os: macos-latest\n" - ) == [] + " - os: ubuntu-latest\n - os: macos-latest\n", + [], + ), + # Local reusable workflow (quoted or not) and ordinary step actions are not remote callees. + (" uses: ./.github/workflows/ci.yml\n", []), + (' uses: "./.github/workflows/ci.yml"\n', []), + (" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6\n", []), + # A workflow_dispatch input named os/runner lives under on:, not in a matrix. + ( + '"on":\n workflow_dispatch:\n inputs:\n runner:\n description: x\n' + " required: false\n", + [], + ), + # JS object literals in github-script and jq programs in run: are not YAML flow mappings. + (" script: |\n const payload = { os: process.platform };\n", []), + (" core.setOutput('meta', JSON.stringify({ runner: process.env.RUNNER_NAME }));\n", []), + (" - run: jq -n --arg os \"$RUNNER_OS\" '{os: $os, runner: .r}'\n", []), +] - print("selftest OK") + +def _selftest() -> int: + failed = False + for text, expected in _CASES: + got = [val for _, _, val in find_violations(text)] + if got != expected: + print(f"::error::selftest {text!r}: expected {expected}, got {got}", file=sys.stderr) + failed = True + if failed: + return 1 + print(f"selftest OK ({len(_CASES)} cases)") return 0 From 28b27c66fef268b962699749ab7c90b741d81b78 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 14 Sep 2026 04:12:17 +1000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20allow-list=20hosted=20labels,=20reject=20uses=20ali?= =?UTF-8?q?ases=20(LAB-3516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the (ubuntu|macos|windows)-.* family regex with a finite HOSTED_LABELS allow-list ({ubuntu,macos,windows}-latest): the family pattern accepted a custom ubuntu-private label that GitHub Actions routes to a self-hosted runner, laundering it through this hosted-only fail-closed guard. Version pins are now a deliberate opt-in that must extend the set. Also reject YAML aliases (uses: *remote) as an unverifiable form — an anchored remote reusable workflow would otherwise run on our pool undetected. Selftest extended with the negative cases (ubuntu-private, windows-cachekit, ubuntu-lates, pinned versions, uses alias). CodeRabbit-Resolved: assert_hosted_runners.py:84:Use an explicit allow-list CodeRabbit-Resolved: assert_hosted_runners.py:173:Reject unresolved aliases in uses --- .github/scripts/assert_hosted_runners.py | 47 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/.github/scripts/assert_hosted_runners.py b/.github/scripts/assert_hosted_runners.py index 632fd27..1432b81 100644 --- a/.github/scripts/assert_hosted_runners.py +++ b/.github/scripts/assert_hosted_runners.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Fail if any workflow job could run on a non-GitHub-hosted runner. -This is an ALLOW-LIST that FAILS CLOSED: every job's `runs-on` must resolve to a -known GitHub-hosted label family (`ubuntu-*`, `macos-*`, `windows-*`). Anything -the scanner cannot *prove* is hosted — `cachekit`, `self-hosted`, a future pool +This is an ALLOW-LIST that FAILS CLOSED: every job's `runs-on` must resolve to an +allow-listed GitHub-hosted label (`ubuntu-latest`, `macos-latest`, `windows-latest`; +see HOSTED_LABELS). Anything the scanner cannot *prove* is hosted — `cachekit`, +`self-hosted`, a custom `ubuntu-private` label on a self-hosted runner, a future pool label nobody has invented yet, a runner-group object, or a `${{ }}` expression it can't resolve — is a violation. A deny-list of today's bad names would fail open the day someone adds a new one, or reformats the drift into a shape the deny-list @@ -79,9 +80,15 @@ import re import sys -# GitHub-hosted label families. Versioned and ARM variants are allowed -# (ubuntu-24.04, macos-14, ubuntu-24.04-arm, windows-2022, *-latest, …). -HOSTED = re.compile(r"^(ubuntu|macos|windows)-[a-z0-9._-]+$") +# GitHub-hosted runner labels this repository permits — a finite, source-controlled +# allow-list, NOT an `(ubuntu|macos|windows)-.*` family pattern. A family pattern fails +# OPEN: it accepts a custom label such as `ubuntu-private` (or a misspelling like +# `ubuntu-lates`) which GitHub Actions will happily route to a *self-hosted* runner +# registered under that label, laundering it through this hosted-only guard (CodeRabbit, +# PR #76). Pinning a specific image version (`ubuntu-24.04`, `macos-14`, `windows-2022`) +# is a deliberate future decision that must extend this set explicitly — exactly as a +# hosted larger-runner group would (see the `runs-on.group` handling below). +HOSTED_LABELS = frozenset({"ubuntu-latest", "macos-latest", "windows-latest"}) # The ONE expression this scanner blesses, and only as the complete scalar value of # `runs-on`: the two matrix keys whose values are scanned directly below, which is @@ -132,8 +139,8 @@ def _labels_from_inline(value: str) -> list[str]: def _bad_label(label: str) -> bool: - """True if this concrete label is not a known hosted label.""" - return HOSTED.match(label) is None + """True if this concrete label is not an allow-listed GitHub-hosted label.""" + return label not in HOSTED_LABELS def _indent(line: str) -> int: @@ -167,7 +174,11 @@ def find_violations(text: str) -> list[tuple[int, str, str]]: if key == "uses": v = _unquote(value) - if not v or v[0] in ">|": + # `>`/`|` are block scalars whose body is on later lines; `*` is a YAML + # alias that resolves to an anchored value this line scanner cannot see — + # `uses: *remote` would run an anchored remote reusable workflow on this + # repo's runner pool undetected (CodeRabbit, PR #76). All fail closed. + if not v or v[0] in ">|*": out.append((i + 1, "uses (unverifiable form)", v)) elif ".github/workflows/" in v and not v.startswith("./"): out.append((i + 1, "uses (remote reusable workflow)", v)) @@ -255,7 +266,8 @@ def main() -> int: for lineno, what, value in find_violations(text): print( f"::error file={path},line={lineno}::{what}: '{value}' is not provably " - f"GitHub-hosted. Every job must run on ubuntu-*/macos-*/windows-* as a " + f"GitHub-hosted. Every job must run on an allow-listed hosted label " + f"(ubuntu-latest/macos-latest/windows-latest) as a " f"plain scalar, `[a, b]`, block list, or `${{{{ matrix.os }}}}` over a " f"static block matrix; self-hosted pools, runner groups, other ${{{{ }}}} " f"expressions, flow mappings, generated matrices and remote reusable " @@ -277,6 +289,15 @@ def main() -> int: (" runs-on: cachekit-lean\n", ["cachekit-lean"]), (" runs-on: self-hosted\n", ["self-hosted"]), (" runs-on: cachekit-turbo\n", ["cachekit-turbo"]), + # A custom label under a hosted-sounding family: the family-regex fail-open the + # allow-list closes (CodeRabbit, PR #76). ubuntu-private / windows-cachekit route to + # a self-hosted runner registered under that label; ubuntu-lates is a typo that must + # not silently pass; a pinned version is a deliberate opt-in this repo has not made. + (" runs-on: ubuntu-private\n", ["ubuntu-private"]), + (" runs-on: windows-cachekit\n", ["windows-cachekit"]), + (" runs-on: ubuntu-lates\n", ["ubuntu-lates"]), + (" runs-on: ubuntu-24.04\n", ["ubuntu-24.04"]), + (" runs-on: ubuntu-24.04-arm\n", ["ubuntu-24.04-arm"]), (" runs-on: [self-hosted, linux, x64]\n", ["self-hosted", "linux", "x64"]), (" runs-on:\n - self-hosted\n - linux\n", ["self-hosted", "linux"]), (" runner: cachekit\n", ["cachekit"]), @@ -367,6 +388,10 @@ def main() -> int: ), (" uses: >-\n org/repo/.github/workflows/ci.yml@main\n", [">-"]), (" uses:\n org/repo/.github/workflows/ci.yml@main\n", [""]), + # A YAML alias resolves to an anchored value the line scanner cannot see; an anchored + # remote reusable workflow through `uses: *remote` would run on our pool undetected + # (CodeRabbit, PR #76). Rejected as an unverifiable form. + (" uses: *remote\n", ["*remote"]), # The on: block is skipped, but jobs after it are still scanned. ( "on:\n workflow_dispatch:\n inputs:\n os:\n type: choice\n" @@ -377,8 +402,6 @@ def main() -> int: (" runs-on: ubuntu-latest\n", []), (" runs-on: macos-latest\n", []), (" runs-on: windows-latest\n", []), - (" runs-on: ubuntu-24.04\n", []), - (" runs-on: ubuntu-24.04-arm\n", []), (" runs-on: [ubuntu-latest]\n", []), (" runs-on: ${{ matrix.os }}\n", []), (" runs-on: ${{ matrix.runner }}\n", []), From a67172e8bb1ebb6858cc2d3c62dc2cde86261a43 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 14 Sep 2026 14:48:06 +1000 Subject: [PATCH 4/4] ci(core): keep runner-guard comments to what a public repo should say (LAB-3501) Comment- and string-only. The guard's docstring, selftest fixture names and workflow comments named org runner infrastructure and settings that do not belong in a public repository; the public reason is simply that a public, forkable repo runs only on GitHub-hosted runners. No behaviour change: selftest (71 cases, also under -O) and the workflow scan pass unchanged. --- .github/scripts/assert_hosted_runners.py | 56 ++++++++++++------------ .github/workflows/attestation-check.yml | 4 +- .github/workflows/release.yml | 14 +++--- .github/workflows/runner-guard.yml | 12 ++--- .github/workflows/security.yml | 2 +- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/scripts/assert_hosted_runners.py b/.github/scripts/assert_hosted_runners.py index 1432b81..e33952a 100644 --- a/.github/scripts/assert_hosted_runners.py +++ b/.github/scripts/assert_hosted_runners.py @@ -4,11 +4,11 @@ This is an ALLOW-LIST that FAILS CLOSED: every job's `runs-on` must resolve to an allow-listed GitHub-hosted label (`ubuntu-latest`, `macos-latest`, `windows-latest`; see HOSTED_LABELS). Anything the scanner cannot *prove* is hosted — `cachekit`, -`self-hosted`, a custom `ubuntu-private` label on a self-hosted runner, a future pool +`self-hosted`, a custom `ubuntu-private` label on a self-hosted runner, any future label nobody has invented yet, a runner-group object, or a `${{ }}` expression it can't resolve — is a violation. A deny-list of today's bad names would fail open the day someone adds a new one, or reformats the drift into a shape the deny-list -doesn't grep (Alaya 999a8af5, selecta PR #72 lesson). +doesn't grep. Forms handled, and how each stays fail-closed: - scalar / inline `[a, b]` / block-sequence `runs-on` -> every label allow-listed. @@ -19,8 +19,8 @@ `runs-on: ${{ matrix.os }}` or `${{ matrix.runner }}` (quotes optional), allowed ONLY because every matrix `os:` / `runner:` value in the file is scanned directly (a non-hosted value anywhere fails the whole file). EVERY other `${{ }}` in a - runner-target position is a violation — e.g. `os: ${{ vars.POOL }}` would launder - a self-hosted pool through the blessed indirection. + runner-target position is a violation — e.g. `os: ${{ vars.TARGET }}` would launder + a self-hosted label through the blessed indirection. - `matrix:` / `include:` must be static block mappings. A generated matrix (`matrix: ${{ fromJSON(…) }}`) or a flow mapping (`matrix: {os: […]}`) hides its `os:` values from the scanner, so any inline value on those keys is a violation. @@ -45,7 +45,7 @@ plain-scalar or `[…]` continuation line, a nested mapping — and is a violation. Write the value inline instead. - a job-level `uses:` of a REMOTE reusable workflow runs that workflow's jobs on this - repo's runner pool with a `runs-on` this scanner cannot see -> violation, as is a + repo's runners with a `runs-on` this scanner cannot see -> violation, as is a `uses:` whose value is not inline (`uses: >-`). Local callees (`./.github/workflows/…`) are scanned like any other file. - the top-level `on:` block (triggers, `workflow_dispatch` inputs) is skipped whole: @@ -57,10 +57,10 @@ SCOPE / what this is NOT. This runs inside the workflow, so it only protects against *maintainer drift on a trusted branch*: a fork PR runs the fork's own copy of this -file and can simply delete the guard, so it is not a fork-PR control. The server-side -control (runner group `cachekit-private`, allows_public_repositories=false) is -LAB-1161 stage 2. Nor does it defend its own workflow (`runner-guard.yml`) against an -`if: false` — that is branch protection's job (required status check + CODEOWNERS). +file and can simply delete the guard, so it is not a fork-PR control; the control for +that lives in repository and org runner settings, outside this file. Its own workflow +(`runner-guard.yml`) is kept honest by branch protection (required status check + +CODEOWNERS), not by this script. Deliberately dependency-free (stdlib only): it must behave identically on a hosted runner and a laptop, with no PyYAML — the ubuntu-latest image does not ship it, and a @@ -177,7 +177,7 @@ def find_violations(text: str) -> list[tuple[int, str, str]]: # `>`/`|` are block scalars whose body is on later lines; `*` is a YAML # alias that resolves to an anchored value this line scanner cannot see — # `uses: *remote` would run an anchored remote reusable workflow on this - # repo's runner pool undetected (CodeRabbit, PR #76). All fail closed. + # repo's runners undetected (CodeRabbit review). All fail closed. if not v or v[0] in ">|*": out.append((i + 1, "uses (unverifiable form)", v)) elif ".github/workflows/" in v and not v.startswith("./"): @@ -269,9 +269,9 @@ def main() -> int: f"GitHub-hosted. Every job must run on an allow-listed hosted label " f"(ubuntu-latest/macos-latest/windows-latest) as a " f"plain scalar, `[a, b]`, block list, or `${{{{ matrix.os }}}}` over a " - f"static block matrix; self-hosted pools, runner groups, other ${{{{ }}}} " + f"static block matrix; self-hosted labels, runner groups, other ${{{{ }}}} " f"expressions, flow mappings, generated matrices and remote reusable " - f"workflows fail closed (LAB-1161 / LAB-3501; see the docstring of " + f"workflows fail closed (see the docstring of " f".github/scripts/assert_hosted_runners.py)." ) failed = True @@ -284,7 +284,7 @@ def main() -> int: # (workflow snippet, expected offending values). Table-driven rather than `assert` # so the self-test cannot be silently neutered by `python3 -O` / PYTHONOPTIMIZE. _CASES: list[tuple[str, list[str]]] = [ - # --- MUST FAIL: direct pool labels and future names ------------------- + # --- MUST FAIL: direct self-hosted labels and future names ------------------- (" runs-on: cachekit\n", ["cachekit"]), (" runs-on: cachekit-lean\n", ["cachekit-lean"]), (" runs-on: self-hosted\n", ["self-hosted"]), @@ -302,12 +302,12 @@ def main() -> int: (" runs-on:\n - self-hosted\n - linux\n", ["self-hosted", "linux"]), (" runner: cachekit\n", ["cachekit"]), (' os: "self-hosted" # quoted + comment\n', ["self-hosted"]), - # --- MUST FAIL: the fail-open forms the expert panel found ------------ - # runner-group object form (LAB-1161 stage 2's own mechanism). - (" runs-on:\n group: cachekit-private\n", ["cachekit-private"]), + # --- MUST FAIL: fail-open forms found in review ----------------------- + # runner-group object form. + (" runs-on:\n group: private-runners\n", ["private-runners"]), ( - " runs-on:\n group: cachekit-private\n labels: [self-hosted]\n", - ["cachekit-private", "self-hosted"], + " runs-on:\n group: private-runners\n labels: [self-hosted]\n", + ["private-runners", "self-hosted"], ), # A hosted-*sounding* group name is still rejected: groups are not used here. (" runs-on:\n group: ubuntu-big\n", ["ubuntu-big"]), @@ -317,11 +317,11 @@ def main() -> int: # indirection through a key the scanner does not resolve → fail closed. (" runs-on: ${{ matrix.platform }}\n", ["${{ matrix.platform }}"]), (" runs-on: ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]), - (" runs-on: ${{ env.POOL }}\n", ["${{ env.POOL }}"]), + (" runs-on: ${{ env.TARGET }}\n", ["${{ env.TARGET }}"]), # --- MUST FAIL: expressions anywhere but the one blessed runs-on scalar -- # (Kody critical / CodeRabbit on PR #76.) An expression as the os:/runner: - # VALUE would launder a self-hosted pool through `runs-on: ${{ matrix.os }}`. - (" os: ${{ vars.POOL }}\n", ["${{ vars.POOL }}"]), + # VALUE would launder a self-hosted label through `runs-on: ${{ matrix.os }}`. + (" os: ${{ vars.TARGET }}\n", ["${{ vars.TARGET }}"]), ( " runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n" " os: ${{ fromJSON(inputs.oses) }}\n", @@ -339,7 +339,7 @@ def main() -> int: ), (" include: ${{ fromJSON(inputs.include) }}\n", ["${{ fromJSON(inputs.include) }}"]), (" matrix: {os: [cachekit]}\n", ["{os: [cachekit]}"]), - # --- MUST FAIL: shapes the panel review of PR #76 found skipped ---------- + # --- MUST FAIL: shapes review of PR #76 found skipped -------------------- # Flow mappings anywhere: the standard Rust cross-compile include idiom, a # compact strategy, a whole job in flow form (scalar, group object, alias, uses). ( @@ -352,10 +352,10 @@ def main() -> int: ), ("jobs: {build: {runs-on: cachekit}}\n", ["jobs: {build: {runs-on: cachekit}}"]), ( - "jobs: {build: {runs-on: {group: cachekit-private}}}\n", - ["jobs: {build: {runs-on: {group: cachekit-private}}}"], + "jobs: {build: {runs-on: {group: private-runners}}}\n", + ["jobs: {build: {runs-on: {group: private-runners}}}"], ), - ("jobs: {build: {runs-on: *pool}}\n", ["jobs: {build: {runs-on: *pool}}"]), + ("jobs: {build: {runs-on: *shared}}\n", ["jobs: {build: {runs-on: *shared}}"]), ( "jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}\n", ["jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}"], @@ -381,7 +381,7 @@ def main() -> int: (" runs-on:\n cachekit\n", ["cachekit"]), (" runs-on:\n [self-hosted, linux]\n", ["[self-hosted, linux]"]), (" runs-on:\n labels:\n [self-hosted]\n", ["[self-hosted]"]), - # Remote reusable workflow: its runs-on is invisible here but runs on our pool. + # Remote reusable workflow: its runs-on is invisible here but runs on this repo's runners. ( " uses: cachekit-io/tooling/.github/workflows/ci.yml@main\n", ["cachekit-io/tooling/.github/workflows/ci.yml@main"], @@ -389,7 +389,7 @@ def main() -> int: (" uses: >-\n org/repo/.github/workflows/ci.yml@main\n", [">-"]), (" uses:\n org/repo/.github/workflows/ci.yml@main\n", [""]), # A YAML alias resolves to an anchored value the line scanner cannot see; an anchored - # remote reusable workflow through `uses: *remote` would run on our pool undetected + # remote reusable workflow through `uses: *remote` would run on this repo's runners undetected # (CodeRabbit, PR #76). Rejected as an unverifiable form. (" uses: *remote\n", ["*remote"]), # The on: block is skipped, but jobs after it are still scanned. @@ -411,7 +411,7 @@ def main() -> int: (" runs-on:\n labels:\n - ubuntu-latest\n", []), # object form, block labels # A comment after runs-on before a block list must not be read as a label. (" runs-on: # pick per matrix\n - ubuntu-latest\n", []), - # A comment line that merely mentions a pool name must not trip the scanner. + # A comment line that merely mentions a self-hosted label must not trip the scanner. (" # runs-on: cachekit was the old value\n runs-on: ubuntu-latest\n", []), # runs-on via matrix.os, with a static block matrix defining only hosted values. ( diff --git a/.github/workflows/attestation-check.yml b/.github/workflows/attestation-check.yml index b3dd3fa..be52729 100644 --- a/.github/workflows/attestation-check.yml +++ b/.github/workflows/attestation-check.yml @@ -18,8 +18,8 @@ jobs: # and reliable Sigstore (Fulcio/Rekor) egress. `gh attestation verify` is the # only tool that does Sigstore bundle verification. The whole repo is on # ubuntu-latest (LAB-3501) and runner-guard.yml keeps it there, but the point - # is load-bearing here specifically: LAB-984 showed that when this ran on an - # ARC pod with no `gh` binary, every `gh` call exited 127 into `|| echo ""` and + # is load-bearing here specifically: when this once ran on a self-hosted runner + # with no `gh` binary, every `gh` call exited 127 into `|| echo ""` and # the job reported green while verifying nothing for weeks. runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90d1c83..0f1c4f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,11 @@ concurrency: jobs: # GitHub-hosted: this job inherits contents+PR write and mints an App - # installation token. The self-hosted `cachekit` pool shares a writable - # hostPath build cache (/cache) across all pods and every cachekit-io repo, - # and job-level `permissions:` does not isolate that filesystem — untrusted - # build-script/proc-macro code can poison the cache a later credentialed - # job runs against (LAB-1040). release-please needs no warm cache anyway. + # installation token, so it must not share a writable build cache with other + # jobs — job-level `permissions:` does not isolate a filesystem, and untrusted + # build-script/proc-macro code could poison a cache a later credentialed job + # runs against. A fresh hosted VM has no shared cache to poison, and + # release-please needs no warm cache anyway. release-please: runs-on: ubuntu-latest outputs: @@ -47,8 +47,8 @@ jobs: # release PR here. outputs.pr is set whenever the release PR was created # OR updated, so this re-runs on every push to main while a release PR is # open — adding an already-present assignee is a no-op, so that's safe. - # github-script, NOT `gh`: chosen when this job ran on the self-hosted - # cachekit runner, which has no gh CLI (LAB-899); it works identically on + # github-script, NOT `gh`: chosen when this job ran on a self-hosted + # runner without the gh CLI; it works identically on # a hosted runner. outputs.pr is passed raw via env and parsed in JS — never # through template-position fromJson, which is evaluated even when if: is # false and crashes on '' for no-release pushes (LAB-865). diff --git a/.github/workflows/runner-guard.yml b/.github/workflows/runner-guard.yml index 33cecfd..aff36b4 100644 --- a/.github/workflows/runner-guard.yml +++ b/.github/workflows/runner-guard.yml @@ -1,13 +1,13 @@ name: Runner Guard -# Drift protection (LAB-1161 stage 1 / LAB-3501): every job in this repo must run -# on a GitHub-hosted runner. This job fails the workflow if any `runs-on` or matrix -# `os:`/`runner:` value in .github/workflows/ is not a hosted label — an allow-list, -# so a NEW self-hosted pool label nobody has named yet still fails (Alaya 999a8af5). +# Drift protection (LAB-3501): every job in this repo must run on a GitHub-hosted +# runner. This job fails the workflow if any `runs-on` or matrix `os:`/`runner:` +# value in .github/workflows/ is not a hosted label — an allow-list, so a NEW +# self-hosted label nobody has named yet still fails. # # This is protection against MAINTAINER drift on trusted branches only. It is NOT a -# fork-PR control: a fork runs its own copy of this file and can delete the guard. -# The server-side control is runner group `cachekit-private` (LAB-1161 stage 2). +# fork-PR control: a fork runs its own copy of this file and can delete the guard; +# the control for that lives in repository and org runner settings, outside this file. on: push: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ae10235..0f93405 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -9,7 +9,7 @@ on: # Saturday 11:07 UTC = Sat 21:07 AEST / 22:07 AEDT (Sydney night, year-round). # Weekly cadence: deep fuzz at 1h/target × 16 targets. Each matrix target is a # separate GitHub-hosted job, so they run in parallel (bounded by GitHub's - # concurrency limit) rather than serialising on one pool — and hosted minutes + # concurrency limit) rather than serialising on shared runners — and hosted minutes # are free for public repos. The longer opt-in run is via workflow_dispatch # (run_deep_fuzz). PR-time coverage (cargo audit/deny, Cargo Vet, Quick Fuzz, # CodeQL) catches regressions promptly; deep fuzz is for finding bugs, not