diff --git a/.github/scripts/assert_hosted_runners.py b/.github/scripts/assert_hosted_runners.py new file mode 100644 index 0000000..e33952a --- /dev/null +++ b/.github/scripts/assert_hosted_runners.py @@ -0,0 +1,456 @@ +#!/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 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, 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. + +Forms handled, and how each stays fail-closed: + - scalar / inline `[a, b]` / block-sequence `runs-on` -> every label allow-listed. + 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). EVERY other `${{ }}` in a + 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. + 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 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: + 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. + +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 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 +`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 +""" + +from __future__ import annotations + +import glob +import re +import sys + +# 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 +# what makes the indirection verifiable. Every other expression fails closed. +RESOLVABLE_EXPR = re.compile(r"^\$\{\{\s*matrix\.(os|runner)\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: + """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 an allow-listed GitHub-hosted label.""" + return label not in HOSTED_LABELS + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def find_violations(text: str) -> list[tuple[int, str, str]]: + """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) + # `>`/`|` 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 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("./"): + 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: + # 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 ----------------------------- + # 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() or lines[j].lstrip().startswith("#"): + j += 1 + continue + 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 _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, 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 + + +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: + 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 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 labels, runner groups, other ${{{{ }}}} " + f"expressions, flow mappings, generated matrices and remote reusable " + f"workflows fail closed (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 + + +# (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 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"]), + (" 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"]), + (' os: "self-hosted" # quoted + comment\n', ["self-hosted"]), + # --- 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: 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"]), + # matrix include entry whose FIRST key is os/runner (label on the dash line). + (" 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. + (" runs-on: ${{ matrix.platform }}\n", ["${{ matrix.platform }}"]), + (" runs-on: ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]), + (" 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 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", + ["${{ 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 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: private-runners}}}\n", + ["jobs: {build: {runs-on: {group: private-runners}}}"], + ), + ("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}}"], + ), + (' - { "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 this repo's runners. + ( + " 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", [""]), + # A YAML alias resolves to an anchored value the line scanner cannot see; an anchored + # 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. + ( + "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 --- + (" runs-on: ubuntu-latest\n", []), + (" runs-on: macos-latest\n", []), + (" runs-on: windows-latest\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. + (" runs-on: # pick per matrix\n - ubuntu-latest\n", []), + # 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. + ( + " runs-on: ${{ matrix.os }}\n" + " strategy:\n matrix:\n include:\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", []), +] + + +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 + + +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..be52729 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: 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: 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..0f1c4f5 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, 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: 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 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). - 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..aff36b4 --- /dev/null +++ b/.github/workflows/runner-guard.yml @@ -0,0 +1,35 @@ +name: Runner Guard + +# 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 control for that lives in repository and org runner settings, outside this file. + +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..0f93405 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 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 + # 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