diff --git a/.github/scripts/fork-ci-routing.sh b/.github/scripts/fork-ci-routing.sh new file mode 100755 index 000000000000..7c94e727a5aa --- /dev/null +++ b/.github/scripts/fork-ci-routing.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Fork CI routing guard. +# +# Decide whether a CI run may execute on the fork's owner-admitted self-hosted +# validation capacity, and fail closed with an exact reason when it may not. +# This is the single source of truth for the routing policy documented in +# `docs/internals/fork-ci.md`. `.github/workflows/ci.yml` runs it before any +# checkout or install, and `.github/scripts/fork-ci-routing.test.py` exercises +# it with bounded fixtures. +# +# Inputs (environment): +# EVENT_NAME github.event_name +# HEAD_REPO github.event.pull_request.head.repo.full_name ("" otherwise) +# REPOSITORY github.repository +# AUTHORIZED vars.T3CODE_AUTHORIZED_RUNNERS (comma-separated labels) +# T3CODE_LINUX_RUNNER vars.T3CODE_LINUX_RUNNER +# T3CODE_MACOS_X64_RUNNER vars.T3CODE_MACOS_X64_RUNNER +# REQUIRED_ROLES space-separated roles this run needs (default: linux) +# +# Output and exit codes: +# 0 ADMITTED every required role is declared and authorized +# 2 CAPACITY_NOT_CONFIGURED a required runner variable or the authorized list +# is unset or empty +# 3 UNTRUSTED_FORK an external or unidentified pull request; never +# routed onto self-hosted capacity +# 4 RUNNER_NOT_AUTHORIZED a declared label is absent from the authorized list +# 5 UNTRUSTED_CONTEXT an unknown event or an unidentified repository; +# nothing can be trusted, so refuse +# +# The workflow does not require this file to exist at the pull request base: on +# first introduction the base tree predates it, so the workflow carries an +# equivalent bootstrap (see `.github/workflows/ci.yml`). This script stays the +# single policy definition and `fork-ci-routing.test.py` cross-checks the +# bootstrap against it. +set -euo pipefail + +EVENT_NAME="${EVENT_NAME:-}" +HEAD_REPO="${HEAD_REPO:-}" +REPOSITORY="${REPOSITORY:-}" +AUTHORIZED="${AUTHORIZED:-}" +REQUIRED_ROLES="${REQUIRED_ROLES:-linux}" + +fail() { + local token="$1" message="$2" code="$3" + printf '%s: %s\n' "$token" "$message" >&2 + exit "$code" +} + +trim() { + printf '%s' "$1" | tr -d '[:space:]' +} + +is_authorized() { + local label="$1" candidate + local IFS=',' + for candidate in $AUTHORIZED; do + candidate="$(trim "$candidate")" + if [ "$candidate" = "$label" ]; then + return 0 + fi + done + return 1 +} + +runner_var() { + case "$1" in + linux) printf '%s' "${T3CODE_LINUX_RUNNER:-}" ;; + macos) printf '%s' "${T3CODE_MACOS_X64_RUNNER:-}" ;; + esac +} + +# 0. Context. Only the two events this workflow is wired to are routable, and an +# unidentified repository cannot be verified. Refuse anything else instead of +# falling through to admission. +case "$EVENT_NAME" in + pull_request | push) ;; + *) + fail UNTRUSTED_CONTEXT \ + "unsupported event '$EVENT_NAME'; only pull_request and push are routed" 5 + ;; +esac +if [ -z "$REPOSITORY" ]; then + fail UNTRUSTED_CONTEXT \ + "repository identity is empty; the event origin cannot be verified" 5 +fi + +# 1. Trust. A pull request must come from this repository. An empty head +# repository is unidentified, which is not the same as trusted, so it is +# rejected too. The workflow's job-level guard also blocks external PRs +# before a runner is even allocated; this check keeps the policy honest if +# that changes. +if [ "$EVENT_NAME" = "pull_request" ] && [ "$HEAD_REPO" != "$REPOSITORY" ]; then + fail UNTRUSTED_FORK \ + "pull request head '$HEAD_REPO' is not '$REPOSITORY'; external or unidentified PR code is never routed to self-hosted runner capacity" 3 +fi + +# 2. Owner-declared admission. An absent declaration is unknown capacity, not +# permission to guess a label. +if [ -z "$AUTHORIZED" ]; then + fail CAPACITY_NOT_CONFIGURED \ + "repository variable T3CODE_AUTHORIZED_RUNNERS is unset or empty; set it to the comma-separated self-hosted runner labels this fork may use" 2 +fi + +admitted="" +for role in $REQUIRED_ROLES; do + case "$role" in + linux | macos) ;; + *) fail CAPACITY_NOT_CONFIGURED "unknown required role '$role'" 2 ;; + esac + + label="$(runner_var "$role")" + if [ -z "$label" ]; then + case "$role" in + linux) var="T3CODE_LINUX_RUNNER" ;; + macos) var="T3CODE_MACOS_X64_RUNNER" ;; + esac + fail CAPACITY_NOT_CONFIGURED \ + "repository variable $var is unset or empty; no admitted $role validation capacity is declared" 2 + fi + if ! is_authorized "$label"; then + fail RUNNER_NOT_AUTHORIZED \ + "declared $role runner '$label' is not listed in T3CODE_AUTHORIZED_RUNNERS" 4 + fi + admitted="$admitted $role=$label" +done + +printf 'ADMITTED%s\n' "$admitted" \ No newline at end of file diff --git a/.github/scripts/fork-ci-routing.test.py b/.github/scripts/fork-ci-routing.test.py new file mode 100644 index 000000000000..f6ff937d72a1 --- /dev/null +++ b/.github/scripts/fork-ci-routing.test.py @@ -0,0 +1,283 @@ +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("fork-ci-routing.sh") +WORKFLOW = Path(__file__).resolve().parents[2] / ".github" / "workflows" / "ci.yml" +REPOSITORY = "nullStack65/t3code" + +# Every input the guard reads. Cleared from the inherited environment so a +# developer's own shell cannot change a fixture's outcome. +INPUTS = ( + "EVENT_NAME", + "HEAD_REPO", + "REPOSITORY", + "AUTHORIZED", + "T3CODE_LINUX_RUNNER", + "T3CODE_MACOS_X64_RUNNER", + "REQUIRED_ROLES", +) + +BEGIN = "# >>> fork-ci-bootstrap >>>" +END = "# <<< fork-ci-bootstrap <<<" + +# The canonical routing decisions. The workflow's inline first-introduction +# bootstrap must reproduce every one of these, so the same table drives both the +# guard and the bootstrap. +CASES = ( + ( + "trusted push on authorized linux", + dict(EVENT_NAME="push", AUTHORIZED="t3-ci-linux, t3-ci-macos", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 0, + "ADMITTED", + ), + ( + "same-repository pull request", + dict(EVENT_NAME="pull_request", HEAD_REPO=REPOSITORY, AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 0, + "ADMITTED", + ), + ( + "external fork pull request", + dict(EVENT_NAME="pull_request", HEAD_REPO="someone-else/t3code", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 3, + "UNTRUSTED_FORK", + ), + ( + "pull request with empty head repository", + dict(EVENT_NAME="pull_request", HEAD_REPO="", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 3, + "UNTRUSTED_FORK", + ), + ( + "missing authorized list", + dict(EVENT_NAME="push", AUTHORIZED="", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 2, + "CAPACITY_NOT_CONFIGURED", + ), + ( + "missing runner variable", + dict(EVENT_NAME="push", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER=""), + 2, + "CAPACITY_NOT_CONFIGURED", + ), + ( + "unauthorized label", + dict(EVENT_NAME="push", AUTHORIZED="some-other-runner", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 4, + "RUNNER_NOT_AUTHORIZED", + ), + ( + "macos role required and admitted", + dict( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux, t3-ci-macos", + T3CODE_LINUX_RUNNER="t3-ci-linux", + T3CODE_MACOS_X64_RUNNER="t3-ci-macos", + REQUIRED_ROLES="linux macos", + ), + 0, + "ADMITTED", + ), + ( + "macos role required but unset", + dict( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="t3-ci-linux", + T3CODE_MACOS_X64_RUNNER="", + REQUIRED_ROLES="linux macos", + ), + 2, + "CAPACITY_NOT_CONFIGURED", + ), + ( + "unknown required role", + dict(EVENT_NAME="push", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux", REQUIRED_ROLES="windows"), + 2, + "CAPACITY_NOT_CONFIGURED", + ), + ( + "unknown event", + dict(EVENT_NAME="workflow_dispatch", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 5, + "UNTRUSTED_CONTEXT", + ), + ( + "unidentified repository", + dict(EVENT_NAME="push", REPOSITORY="", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="t3-ci-linux"), + 5, + "UNTRUSTED_CONTEXT", + ), +) + + +def environment(values): + env = {k: v for k, v in os.environ.items() if k not in INPUTS} + env["REPOSITORY"] = REPOSITORY + env.update(values) + return env + + +def dedent(lines): + widths = [len(line) - len(line.lstrip()) for line in lines if line.strip()] + width = min(widths) if widths else 0 + return "\n".join(line[width:] if line.strip() else "" for line in lines) + + +def extract_bootstrap_blocks(): + lines = WORKFLOW.read_text().splitlines() + blocks = [] + current = None + for line in lines: + if BEGIN in line: + current = [] + elif END in line: + if current is None: + raise AssertionError(f"{END} without {BEGIN}") + blocks.append(dedent(current)) + current = None + elif current is not None: + current.append(line) + if current is not None: + raise AssertionError(f"{BEGIN} without {END}") + return blocks + + +class ForkCiRoutingTests(unittest.TestCase): + def run_script(self, script, values, cwd=None): + return subprocess.run( + ["bash", str(script)], + env=environment(values), + cwd=cwd, + capture_output=True, + text=True, + ) + + def run_guard(self, **values): + return self.run_script(SCRIPT, values) + + def test_guard_decision_table(self): + for name, values, code, token in CASES: + with self.subTest(name=name): + result = self.run_guard(**values) + self.assertIn(token, result.stdout + result.stderr, result.stderr) + self.assertEqual(result.returncode, code, result.stderr) + if code == 0: + self.assertNotIn("ADMITTED", result.stderr) + self.assertTrue(result.stdout.strip().startswith("ADMITTED")) + + def test_admits_trusted_push_output(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux, t3-ci-macos", + T3CODE_LINUX_RUNNER="t3-ci-linux", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "ADMITTED linux=t3-ci-linux") + + def test_requires_and_admits_macos_role_output(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux, t3-ci-macos", + T3CODE_LINUX_RUNNER="t3-ci-linux", + T3CODE_MACOS_X64_RUNNER="t3-ci-macos", + REQUIRED_ROLES="linux macos", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "ADMITTED linux=t3-ci-linux macos=t3-ci-macos") + + def test_missing_authorized_list_mentions_variable(self): + result = self.run_guard(EVENT_NAME="push", AUTHORIZED="", T3CODE_LINUX_RUNNER="t3-ci-linux") + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("T3CODE_AUTHORIZED_RUNNERS", result.stderr) + + def test_missing_runner_variable_mentions_variable(self): + result = self.run_guard(EVENT_NAME="push", AUTHORIZED="t3-ci-linux", T3CODE_LINUX_RUNNER="") + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("T3CODE_LINUX_RUNNER", result.stderr) + + def test_missing_macos_capacity_mentions_variable(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="t3-ci-linux", + T3CODE_MACOS_X64_RUNNER="", + REQUIRED_ROLES="linux macos", + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("T3CODE_MACOS_X64_RUNNER", result.stderr) + + def test_bootstrap_blocks_match(self): + blocks = extract_bootstrap_blocks() + self.assertEqual(len(blocks), 2, "expected one bootstrap per authorize job") + self.assertEqual(blocks[0], blocks[1], "the two bootstrap copies drifted") + + +class ForkCiBootstrapTests(unittest.TestCase): + """Run the workflow's actual inline bootstrap, not only the guard file.""" + + def setUp(self): + self.blocks = extract_bootstrap_blocks() + self.assertTrue(self.blocks, "no bootstrap block found in ci.yml") + + def materialize(self, tree_has_guard): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + if tree_has_guard: + guard = root / ".github" / "scripts" / "fork-ci-routing.sh" + guard.parent.mkdir(parents=True) + guard.write_text(SCRIPT.read_text()) + script = root / "bootstrap.sh" + script.write_text(self.blocks[0] + "\n") + return root, script + + def run_bootstrap(self, root, script, values): + return subprocess.run( + ["bash", str(script)], + env=environment(values), + cwd=str(root), + capture_output=True, + text=True, + ) + + def test_first_introduction_runs_inline_policy(self): + # Actual pull request base tree on first introduction: no guard file. + root, script = self.materialize(tree_has_guard=False) + for name, values, code, token in CASES: + with self.subTest(name=name): + result = self.run_bootstrap(root, script, values) + self.assertEqual(result.returncode, code, result.stderr) + self.assertIn(token, result.stdout + result.stderr, result.stderr) + + def test_later_pull_request_runs_checked_out_guard(self): + # Once the guard is on the default branch the base tree carries it. + root, script = self.materialize(tree_has_guard=True) + for name, values, code, token in CASES: + with self.subTest(name=name): + result = self.run_bootstrap(root, script, values) + self.assertEqual(result.returncode, code, result.stderr) + self.assertIn(token, result.stdout + result.stderr, result.stderr) + + def test_bootstrap_and_guard_agree(self): + root, script = self.materialize(tree_has_guard=False) + for name, values, code, token in CASES: + with self.subTest(name=name): + inline = self.run_bootstrap(root, script, values) + guard = subprocess.run( + ["bash", str(SCRIPT)], + env=environment(values), + capture_output=True, + text=True, + ) + self.assertEqual(inline.returncode, guard.returncode, name) + self.assertEqual(inline.stdout, guard.stdout, name) + self.assertEqual(inline.stderr, guard.stderr, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f65c15696d2c..c89d7ce31f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,31 @@ name: CI +# Fork validation routing. Upstream schedules Blacksmith labels that this fork +# has no installation for, so those jobs queue forever. Every substantive job +# here instead runs on owner-admitted self-hosted capacity declared through the +# same repository variables the landed fork-release pipeline uses: +# T3CODE_AUTHORIZED_RUNNERS comma-separated labels this fork may use +# T3CODE_LINUX_RUNNER the admitted Linux validation label +# T3CODE_MACOS_X64_RUNNER the admitted Intel macOS validation label +# The `authorize` jobs validate the declared label against the authorized list +# before any checkout or install, so source never executes on unauthorized +# capacity. When capacity is not declared the run fails closed — an unschedulable +# startup, or the guard's CAPACITY_NOT_CONFIGURED/RUNNER_NOT_AUTHORIZED once a +# label is declared — rather than waiting on an unmatched label. External +# pull requests are never scheduled on self-hosted capacity. See +# docs/internals/fork-ci.md. +# +# The admission step is a small explicit bootstrap in this workflow. On the pull +# request that first introduces the routing guard, the guard does not exist at +# the PR base, so the bootstrap runs the identical policy inline instead of +# trying to load a missing file. Once the guard is on the default branch the +# bootstrap runs the checked-out guard from the trusted ref. Actions are pinned +# to immutable commit SHAs and every source checkout disables persisted +# credentials. +# +# Incremental upstream behavior is preserved: the same jobs, check names, test +# commands and coverage. Only the runner target, host-portability of setup and +# the security pins changed. on: pull_request: push: @@ -14,14 +40,98 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Admission runs first and owns the trust decision. Job-level `if` is + # evaluated before a runner is allocated, so untrusted external PRs are + # skipped instead of being scheduled on self-hosted capacity. Every other job + # needs this one, so a skipped or failed admission also skips them. + authorize: + name: Authorize fork CI runners + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 5 + steps: + - name: Checkout trusted routing guard + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + sparse-checkout: .github/scripts/fork-ci-routing.sh + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Assert admitted Linux validation capacity + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} + T3CODE_LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }} + REQUIRED_ROLES: linux + run: | + # >>> fork-ci-bootstrap >>> + set -euo pipefail + # First-introduction bootstrap. The routing guard is introduced by the change + # that also adds this workflow, so it does not exist at the pull request base. + # Run the checked-out trusted guard when it is present; otherwise enforce the + # identical admission policy inline instead of silently executing unmerged + # guard source. `.github/scripts/fork-ci-routing.test.py` runs this exact block. + guard=".github/scripts/fork-ci-routing.sh" + if [ -f "$guard" ]; then + exec bash "$guard" + fi + + EVENT_NAME="${EVENT_NAME:-}" + HEAD_REPO="${HEAD_REPO:-}" + REPOSITORY="${REPOSITORY:-}" + AUTHORIZED="${AUTHORIZED:-}" + REQUIRED_ROLES="${REQUIRED_ROLES:-linux}" + + fail() { + printf '%s: %s\n' "$1" "$2" >&2 + exit "$3" + } + + case "$EVENT_NAME" in + pull_request | push) ;; + *) fail UNTRUSTED_CONTEXT "unsupported event '$EVENT_NAME'; only pull_request and push are routed" 5 ;; + esac + [ -n "$REPOSITORY" ] || fail UNTRUSTED_CONTEXT "repository identity is empty; the event origin cannot be verified" 5 + if [ "$EVENT_NAME" = "pull_request" ] && [ "$HEAD_REPO" != "$REPOSITORY" ]; then + fail UNTRUSTED_FORK "pull request head '$HEAD_REPO' is not '$REPOSITORY'; external or unidentified PR code is never routed to self-hosted runner capacity" 3 + fi + + [ -n "$AUTHORIZED" ] || fail CAPACITY_NOT_CONFIGURED "repository variable T3CODE_AUTHORIZED_RUNNERS is unset or empty; set it to the comma-separated self-hosted runner labels this fork may use" 2 + + admitted="" + for role in $REQUIRED_ROLES; do + case "$role" in + linux) var=T3CODE_LINUX_RUNNER; label="${T3CODE_LINUX_RUNNER:-}" ;; + macos) var=T3CODE_MACOS_X64_RUNNER; label="${T3CODE_MACOS_X64_RUNNER:-}" ;; + *) fail CAPACITY_NOT_CONFIGURED "unknown required role '$role'" 2 ;; + esac + [ -n "$label" ] || fail CAPACITY_NOT_CONFIGURED "repository variable $var is unset or empty; no admitted $role validation capacity is declared" 2 + found="" + IFS=',' + for candidate in $AUTHORIZED; do + candidate="$(printf '%s' "$candidate" | tr -d '[:space:]')" + if [ "$candidate" = "$label" ]; then found=1; break; fi + done + unset IFS + [ -n "$found" ] || fail RUNNER_NOT_AUTHORIZED "declared $role runner '$label' is not listed in T3CODE_AUTHORIZED_RUNNERS" 4 + admitted="$admitted $role=$label" + done + printf 'ADMITTED%s\n' "$admitted" + # <<< fork-ci-bootstrap <<< + check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ @@ -36,7 +146,7 @@ jobs: fi - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true @@ -58,12 +168,22 @@ jobs: - name: Typecheck run: vpr typecheck - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries + - name: Verify browser secret helper build libraries run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + # The admitted image must already carry these. CI never mutates host + # packages: a missing prerequisite is an image defect that fails here + # with the exact packages to provision, rather than a silent + # `sudo apt-get` on whatever host happened to be selected. + missing="" + for pkg in libsecret-1-dev pkg-config; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + missing="${missing:+$missing }$pkg" + fi + done + if [ -n "$missing" ]; then + printf '::error::admitted runner image is missing required build libraries: %s. Provision them in the runner image; CI does not change host packages.\n' "$missing" >&2 + exit 1 + fi - name: Build desktop pipeline run: vp run build:desktop @@ -78,19 +198,21 @@ jobs: # limit stays at the default 4 so peak load per runner is unchanged. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true @@ -99,12 +221,22 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries + - name: Verify browser secret helper build libraries run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + # The admitted image must already carry these. CI never mutates host + # packages: a missing prerequisite is an image defect that fails here + # with the exact packages to provision, rather than a silent + # `sudo apt-get` on whatever host happened to be selected. + missing="" + for pkg in libsecret-1-dev pkg-config; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + missing="${missing:+$missing }$pkg" + fi + done + if [ -n "$missing" ]; then + printf '::error::admitted runner image is missing required build libraries: %s. Provision them in the runner image; CI does not change host packages.\n' "$missing" >&2 + exit 1 + fi - name: Test preview artifact validation run: python3 -B .github/scripts/stage-preview-bundle.test.py @@ -112,6 +244,9 @@ jobs: - name: Test nightly release checks run: node --test .github/scripts/check-nightly-release.test.cjs + - name: Test fork CI routing guards + run: python3 -B .github/scripts/fork-ci-routing.test.py + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test @@ -121,7 +256,8 @@ jobs: # isolation that flag buys is preserved exactly. test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 strategy: fail-fast: false @@ -129,15 +265,16 @@ jobs: shard: [1, 2, 3] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true @@ -177,7 +314,7 @@ jobs: - name: Upload thread transfer result if: always() && steps.transfer_budget.outputs.present == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer-result.json @@ -188,41 +325,61 @@ jobs: # for checks that take under 3s, on the critical path of every PR. rust: name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: rustfmt + # Discover crates instead of hardcoding them, so a new native crate is + # checked the moment it lands rather than only after a second CI edit + # (see docs/internals/fork-ci.md for the #10 windows-service-host wiring). - name: Check Rust formatting run: | - for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do - cargo fmt --manifest-path "native/$crate/Cargo.toml" -- --check + set -euo pipefail + shopt -s nullglob + manifests=(native/*/Cargo.toml) + if [ "${#manifests[@]}" -eq 0 ]; then + echo "::error::No native/*/Cargo.toml crates were discovered" >&2 + exit 1 + fi + for manifest in "${manifests[@]}"; do + cargo fmt --manifest-path "$manifest" -- --check done - name: Test Rust crates run: | - for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do - cargo test --locked --manifest-path "native/$crate/Cargo.toml" + set -euo pipefail + shopt -s nullglob + manifests=(native/*/Cargo.toml) + if [ "${#manifests[@]}" -eq 0 ]; then + echo "::error::No native/*/Cargo.toml crates were discovered" >&2 + exit 1 + fi + for manifest in "${manifests[@]}"; do + cargo test --locked --manifest-path "$manifest" done - # The static analysis below needs a macOS runner, which bills ~6.7x a Linux - # minute, so gate it on the native sources it actually lints instead of paying - # for it on every push. Detection is API-only (no checkout) and fails open: if - # the diff cannot be resolved, the lint runs. + # The static analysis below needs a macOS runner, so gate it on the native + # sources it actually lints instead of paying for it on every push. Detection + # is API-only (no checkout) and fails open: if the diff cannot be resolved, + # the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 5 permissions: contents: read @@ -294,25 +451,109 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" fi + # macOS capacity is validated separately, and only when the macOS job will + # actually run, so a fork with Linux-only capacity is not blocked on every PR. + # The gate itself runs on admitted Linux capacity. + authorize_macos: + name: Authorize fork macOS validation capacity + needs: [authorize, mobile_native_changes] + if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' && needs.authorize.result == 'success' && needs.mobile_native_changes.result == 'success' }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} + timeout-minutes: 5 + steps: + - name: Checkout trusted routing guard + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + sparse-checkout: .github/scripts/fork-ci-routing.sh + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Assert admitted macOS validation capacity + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} + T3CODE_MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }} + REQUIRED_ROLES: macos + run: | + # >>> fork-ci-bootstrap >>> + set -euo pipefail + # First-introduction bootstrap. The routing guard is introduced by the change + # that also adds this workflow, so it does not exist at the pull request base. + # Run the checked-out trusted guard when it is present; otherwise enforce the + # identical admission policy inline instead of silently executing unmerged + # guard source. `.github/scripts/fork-ci-routing.test.py` runs this exact block. + guard=".github/scripts/fork-ci-routing.sh" + if [ -f "$guard" ]; then + exec bash "$guard" + fi + + EVENT_NAME="${EVENT_NAME:-}" + HEAD_REPO="${HEAD_REPO:-}" + REPOSITORY="${REPOSITORY:-}" + AUTHORIZED="${AUTHORIZED:-}" + REQUIRED_ROLES="${REQUIRED_ROLES:-linux}" + + fail() { + printf '%s: %s\n' "$1" "$2" >&2 + exit "$3" + } + + case "$EVENT_NAME" in + pull_request | push) ;; + *) fail UNTRUSTED_CONTEXT "unsupported event '$EVENT_NAME'; only pull_request and push are routed" 5 ;; + esac + [ -n "$REPOSITORY" ] || fail UNTRUSTED_CONTEXT "repository identity is empty; the event origin cannot be verified" 5 + if [ "$EVENT_NAME" = "pull_request" ] && [ "$HEAD_REPO" != "$REPOSITORY" ]; then + fail UNTRUSTED_FORK "pull request head '$HEAD_REPO' is not '$REPOSITORY'; external or unidentified PR code is never routed to self-hosted runner capacity" 3 + fi + + [ -n "$AUTHORIZED" ] || fail CAPACITY_NOT_CONFIGURED "repository variable T3CODE_AUTHORIZED_RUNNERS is unset or empty; set it to the comma-separated self-hosted runner labels this fork may use" 2 + + admitted="" + for role in $REQUIRED_ROLES; do + case "$role" in + linux) var=T3CODE_LINUX_RUNNER; label="${T3CODE_LINUX_RUNNER:-}" ;; + macos) var=T3CODE_MACOS_X64_RUNNER; label="${T3CODE_MACOS_X64_RUNNER:-}" ;; + *) fail CAPACITY_NOT_CONFIGURED "unknown required role '$role'" 2 ;; + esac + [ -n "$label" ] || fail CAPACITY_NOT_CONFIGURED "repository variable $var is unset or empty; no admitted $role validation capacity is declared" 2 + found="" + IFS=',' + for candidate in $AUTHORIZED; do + candidate="$(printf '%s' "$candidate" | tr -d '[:space:]')" + if [ "$candidate" = "$label" ]; then found=1; break; fi + done + unset IFS + [ -n "$found" ] || fail RUNNER_NOT_AUTHORIZED "declared $role runner '$label' is not listed in T3CODE_AUTHORIZED_RUNNERS" 4 + admitted="$admitted $role=$label" + done + printf 'ADMITTED%s\n' "$admitted" + # <<< fork-ci-bootstrap <<< + mobile_native_static_analysis: name: Mobile Native Static Analysis - needs: mobile_native_changes + needs: [authorize, mobile_native_changes, authorize_macos] # Skip only on an explicit "no": a gate job that failed or errored leaves the - # output empty, and that must run the lint rather than silently skip it. - if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + # output empty, and that must run the lint rather than silently skip it. The + # authorize results keep a failed or skipped admission from being bypassed. + if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' && needs.authorize.result == 'success' && needs.authorize_macos.result == 'success' }} + runs-on: ${{ vars.T3CODE_MACOS_X64_RUNNER }} timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true @@ -328,19 +569,21 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true diff --git a/docs/internals/fork-ci.md b/docs/internals/fork-ci.md new file mode 100644 index 000000000000..b9cfc19c51ce --- /dev/null +++ b/docs/internals/fork-ci.md @@ -0,0 +1,209 @@ +# Fork CI routing and validation capacity + +> For the `nullStack65/t3code` fork. Upstream CI docs do not apply here. Release +> runner admission lives in [fork-release.md](../operations/fork-release.md). + +The fork inherits upstream's `.github/workflows/ci.yml`, which schedules +Blacksmith runner labels (`blacksmith-8vcpu-ubuntu-2404`, …). The fork has no +Blacksmith installation, so those jobs never acquire a runner: every completed +CI run on this fork has been `cancelled`, never `success` or `failure`, and the +queued runs sit on unmatched labels indefinitely. Which label upstream uses is +also upstream billing policy, not a fork decision. + +## Admitted capacity, not guessed labels + +Substantive jobs run on **owner-admitted self-hosted capacity** declared through +the same repository variables the landed fork-release pipeline uses: + +| Variable | Meaning | +| --- | --- | +| `T3CODE_AUTHORIZED_RUNNERS` | Comma-separated labels this fork may execute on. | +| `T3CODE_LINUX_RUNNER` | The admitted Linux validation label. | +| `T3CODE_MACOS_X64_RUNNER` | The admitted Intel macOS validation label (mobile native lint only). | + +No label is guessed or defaulted, and there is no GitHub-hosted fallback: a +missing declaration fails closed rather than silently running elsewhere. The +`authorize` jobs run before any job that checks out or installs source, and +every substantive job needs them. + +`.github/scripts/fork-ci-routing.sh` is the single source of truth for the +policy. It prints a machine-readable reason and exits non-zero: + +| Token | Exit | Condition | +| --- | --- | --- | +| `ADMITTED` | 0 | Every required role is declared and authorized. | +| `CAPACITY_NOT_CONFIGURED` | 2 | `T3CODE_AUTHORIZED_RUNNERS`, or a required runner variable, is unset or empty. | +| `UNTRUSTED_FORK` | 3 | A pull request head is external **or unidentified** (empty `HEAD_REPO`). | +| `RUNNER_NOT_AUTHORIZED` | 4 | A declared label is absent from `T3CODE_AUTHORIZED_RUNNERS`. | +| `UNTRUSTED_CONTEXT` | 5 | An unknown event or an empty repository identity. | + +`.github/scripts/fork-ci-routing.test.py` exercises every outcome with bounded +fixtures and runs in the `Test` job. It also extracts the workflow's inline +first-introduction bootstrap (below) from `ci.yml` and asserts the guard and the +bootstrap return byte-identical results on the whole fixture table. + +### First introduction: the bootstrap that cannot load a missing file + +The `authorize` jobs used to check out `github.event.pull_request.base.sha` and +execute `.github/scripts/fork-ci-routing.sh`. That file is introduced by the same +change that adds the workflow, so on the pull request that first introduces it +the base tree predates the file and the job failed with "No such file" — before a +runner could even report the real reason. Even provisioned capacity would not +have fixed that. + +The admission step is now a **small explicit bootstrap in the reviewed +workflow**. It runs the checked-out trusted guard when the guard exists at the +selected ref, and otherwise enforces the identical policy inline (same env +contract, same tokens, same exit codes) instead of executing unmerged guard +source or requiring a preliminary push to the default branch. The unit test +drives the real base/candidate trees: base without the guard (first +introduction) and base with it (later pull requests). Trust in the bootstrap +rests on the reviewed workflow plus the job-level and repository boundaries +below — not on executing arbitrary pull-request code as a trusted guard. + +### When capacity is not configured + +The current fork state is `CAPACITY_NOT_CONFIGURED`: the repository has **no** +registered self-hosted runners and **no** Actions variables, while +`T3CODE_AUTHORIZED_RUNNERS` and the runner variables are how admission is +expressed. Until the owner supplies them, CI cannot execute here; do not read a +queued job as a result, and do not add an unmatched label to make the queue look +intentional. + +The failure is fail-closed, not a queue. Four states must be read differently: + +| State | What happened | Evidence | +| --- | --- | --- | +| Scheduling validation failure | `runs-on` is empty, so GitHub cannot create the job; the run concludes `failure` at startup, no runner is allocated, dependents skip. | PR #12 run `36425220326` (`5a1f0322d`) and `36424904599` (`81159961d`): `authorize` absent, all other jobs `skipped`, `runner_name` empty. | +| Queued on an unmatched label | No online runner matches the selected label; the job waits indefinitely. No diagnostic can print from a runner that does not exist. | Pre-repair run `36415749632` on Blacksmith labels; also the historical 48/48 `cancelled` runs. | +| Executed guard refusal | A runner was allocated and the guard ran, exiting `2`/`3`/`4`/`5` and printing the exact prerequisite. | Reaches only once a label schedules. | +| Actual job execution | A runner was allocated and a substantive job ran. | Requires admitted capacity below. | + +When a Linux label **is** declared but missing from `T3CODE_AUTHORIZED_RUNNERS`, +the job schedules and the guard exits with `RUNNER_NOT_AUTHORIZED` (or +`CAPACITY_NOT_CONFIGURED` when a required variable is empty) and prints the exact +prerequisite. Never manufacture a green no-op or an unmatched label to make the +queue look intentional. + +### Smallest suitable existing capacity route and its owner + +The existing supported self-hosted Linux CI platform for this account is the +repository-managed K3s + Actions Runner Controller (ARC) cluster on +`nullStack65/Closura`. Its operating contract is `Closura:llm/runners-and-ci.md` +(normative) with `infra/proxmox/README.md`; it is **owned and operated by the +Closura infrastructure lane**, and it is the only supported local ephemeral Linux +route (GitHub-hosted runners are explicitly not a fallback for this account). +There is already one scale set per repository (`closura-ci-arc`, +`closura-agent-config-ci-arc`), so the smallest suitable addition is a +`t3code`-scoped scale set on the same cluster. + +Exact gap today: **no self-hosted runner and no scale set is registered to +`nullStack65/t3code`.** The existing runners are repository-scoped and cannot +serve a User-owned repository (`Closura`: `closura-ci-arc-*`, +`closura-staging-closura-01`, `runner-01-observability-r720`; +`closura-agent-config`: `runner-01-delivery-manager`). In particular the R720 +observability/staging runners are recovery/deployment paths, not general CI +capacity, and adopting them would violate the no-R720-load direction. Queued +jobs will not fix this. + +Owner packet (to be sequenced by ENV-1 with the ARC infrastructure owner; this +source repair does **not** perform it): + +1. Add an ephemeral, non-root ARC scale set `t3code-ci-arc` to the existing + cluster, registered to `nullStack65/t3code` with its own short-lived, + repository-scoped GitHub App registration token (Infisical `/ci/arc`). Reuse + the `closura-ci-arc`/`closura-agent-config-ci-arc` image and NetworkPolicy + contract; standard workers only, no DinD, no host socket, no host paths. +2. Set on `nullStack65/t3code`: `T3CODE_LINUX_RUNNER=t3code-ci-arc` and + `T3CODE_AUTHORIZED_RUNNERS=t3code-ci-arc`. +3. For the mobile native lint only, register an Intel macOS runner, set + `T3CODE_MACOS_X64_RUNNER`, and append its label to + `T3CODE_AUTHORIZED_RUNNERS`. +4. Bounded concurrency: keep the standard scale set's configured `min/max` + runners and the cluster's health/queue-drain gates; do not raise `maxRunners` + to silence a queue. Conditional macOS capability stays opt-in via the change + detector. +5. Public-repo trust: set fork-PR approval to `all_external_contributors` before + admission, keep `default_workflow_permissions: read`, and consider enabling + `sha_pinning_required`. +6. Rollback: set the scale set's desired runners to zero or remove it, and unset + `T3CODE_AUTHORIZED_RUNNERS` / `T3CODE_*_RUNNER`; the guard then fails closed + and no source executes on self-hosted capacity. The recovery/staging runners + are never touched. + +Release admission (`fork-release.yml`) does not authorize arbitrary PR +execution; it is a separate workload with separate suitability. The fork CI +route above is distinct even though it reuses the same variable names. + +### Runner image prerequisites + +The admitted image must already provide what CI assumes: `bash`, `git`, `gh` +(the change detector calls the GitHub API), `python3`, a Rust toolchain (the +pinned `dtolnay/rust-toolchain` action supplies it), and — for the macOS lint — +`brew`. Node comes from the pinned `setup-vp` action. + +The browser-secret build libraries (`libsecret-1-dev`, `pkg-config`) must also be +baked into the image. CI **verifies** them and fails with the exact missing +package names; it never runs `sudo apt-get` on whatever host was selected. If the +image lacks a prerequisite the job fails visibly; nothing falls back to hosted +capacity and no host package is silently mutated. + +## Trust boundary + +CI keeps the fork's self-hosted capacity off untrusted code: + +- Only `pull_request` and `push` to `main` trigger the workflow. There is no + `pull_request_target`, and no workflow runs PR code with a write token. +- Every `actions/checkout` step uses `persist-credentials: false`, including the + substantive jobs; no job leaves a usable token in a checkout's git config for + later steps. Workflow permissions stay `contents: read` (plus + `pull-requests: read` for the API-only change detector), and + `default_workflow_permissions` on the repository is `read`. +- Actions are pinned to immutable commit SHAs (`actions/checkout`, + `voidzero-dev/setup-vp`, `dtolnay/rust-toolchain`, `actions/upload-artifact`) + with the major version in a trailing comment. No mutable tag selects code that + runs on self-hosted capacity. +- The `authorize` job skips when a `pull_request` head repository is not this + repository, before a runner is allocated, and the routing script rejects + external and unidentified heads as defense in depth. +- No secret, publish, deploy, or model call runs in CI. No release credential, + active-user home mount, or privileged host socket is made available to a job. + +### The real external-PR boundary is a repository setting, not the YAML + +A job-level `if` lives in YAML that a pull request can edit, so it is defense in +depth — not an immutable security boundary. The boundary that actually holds is +the repository's Actions approval policy plus the read-only token: + +- As of this review `nullStack65/t3code` reports + `approval_policy: first_time_contributors`, `default_workflow_permissions: + read`, `can_approve_pull_request_reviews: false`, and + `sha_pinning_required: false`. +- Before any self-hosted runner is registered **to this public repository**, the + owner should set fork-PR approval to `all_external_contributors` (and consider + enabling `sha_pinning_required`). A returning external contributor otherwise + needs no approval, and the only thing stopping an edited workflow would be the + non-immutable `if`. This is an owner action; it is not performed by the source + repair in this PR. + +## Host portability + +Upstream's `Check`/`Test` jobs rewrite `/etc/apt/blacksmith-ubuntu-mirrors.txt` +through `.github/actions/setup-apt-mirrors` and an inline `sed`. Those edits only +make sense on a Blacksmith image and would mutate a shared agent host's apt +configuration. The fork's jobs instead **verify** that `libsecret-1-dev` and +`pkg-config` are already present and fail with the exact missing package names; +they never touch apt sources or install host packages. Caches remain the standard +Actions cache used by `setup-vp`. + +## Lifecycle interface (#10 `windows-service-host`) + +`t3code#10` adds `native/windows-service-host`, a Rust crate that is not yet on +`main`. The `Rust` job used to hardcode its crate list, so a newly landed crate +would have been silently skipped until someone edited the workflow. It now +discovers every `native/*/Cargo.toml` and runs the same +`cargo fmt -- --check` / `cargo test --locked` per crate, failing if none are +found. When `windows-service-host` lands it is covered automatically; no crate +is imported or tested here before it exists. The fork CI owner does not depend on +the lifecycle writer, and the lifecycle writer does not need to edit this +workflow. \ No newline at end of file