From 81159961dba733bcc76fa22dd7776b5ac0bfbe19 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Mon, 28 Sep 2026 08:52:52 -0400 Subject: [PATCH 1/3] ci(fork): route validation to owner-admitted self-hosted capacity The fork inherits upstream ci.yml, which schedules Blacksmith labels the fork has no installation for, so every CI job queues forever (all 48 completed runs are cancelled, run 36415749632 is queued). Reuse the owner-controlled route policy landed in #5 instead: T3CODE_AUTHORIZED_RUNNERS plus T3CODE_LINUX_RUNNER/T3CODE_MACOS_X64_RUNNER repository variables. - Add authorize/authorize_macos gates that validate the declared label before any checkout or install and fail closed with CAPACITY_NOT_CONFIGURED, RUNNER_NOT_AUTHORIZED or UNTRUSTED_FORK instead of an endless queue. - Skip external-fork PRs at the job level, before a runner is allocated. - Drop Blacksmith-only apt mirror rewrites; install build libs only when missing so a shared agent host is not mutated. - Discover native/*/Cargo.toml so the #10 windows-service-host crate is covered when it lands, with no crate imported here. - Preserve job names, test commands and coverage; add the routing guard fixture test to the Test job. Refs nullStack65/closura-agent-config#237 --- .github/scripts/fork-ci-routing.sh | 104 +++++++++++++++ .github/scripts/fork-ci-routing.test.py | 128 +++++++++++++++++++ .github/workflows/ci.yml | 160 +++++++++++++++++++----- docs/internals/fork-ci.md | 112 +++++++++++++++++ 4 files changed, 476 insertions(+), 28 deletions(-) create mode 100755 .github/scripts/fork-ci-routing.sh create mode 100644 .github/scripts/fork-ci-routing.test.py create mode 100644 docs/internals/fork-ci.md diff --git a/.github/scripts/fork-ci-routing.sh b/.github/scripts/fork-ci-routing.sh new file mode 100755 index 000000000000..aa45347d1e0b --- /dev/null +++ b/.github/scripts/fork-ci-routing.sh @@ -0,0 +1,104 @@ +#!/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 pull request; never routed onto +# self-hosted capacity +# 4 RUNNER_NOT_AUTHORIZED a declared label is absent from the authorized list +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 +} + +# 1. Trust. A pull request whose head repository is not this repository carries +# unreviewed code and must never be scheduled on privileged self-hosted +# capacity. The workflow's job-level guard also blocks this before a runner +# is even allocated; this check keeps the policy honest if that changes. +if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$HEAD_REPO" ] && [ "$HEAD_REPO" != "$REPOSITORY" ]; then + fail UNTRUSTED_FORK \ + "pull request head '$HEAD_REPO' is not '$REPOSITORY'; external 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..0709bed6e8ab --- /dev/null +++ b/.github/scripts/fork-ci-routing.test.py @@ -0,0 +1,128 @@ +import os +import subprocess +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("fork-ci-routing.sh") +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", +) + + +class ForkCiRoutingTests(unittest.TestCase): + def run_guard(self, **values): + env = {k: v for k, v in os.environ.items() if k not in INPUTS} + env["REPOSITORY"] = REPOSITORY + env.update(values) + return subprocess.run( + ["bash", str(SCRIPT)], + env=env, + capture_output=True, + text=True, + ) + + def test_admits_trusted_push_on_authorized_linux(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_admits_same_repository_pull_request(self): + result = self.run_guard( + EVENT_NAME="pull_request", + HEAD_REPO=REPOSITORY, + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="t3-ci-linux", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("ADMITTED", result.stdout) + + def test_rejects_external_fork_pull_request(self): + result = self.run_guard( + EVENT_NAME="pull_request", + HEAD_REPO="someone-else/t3code", + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="t3-ci-linux", + ) + self.assertEqual(result.returncode, 3, result.stderr) + self.assertIn("UNTRUSTED_FORK", result.stderr) + self.assertNotIn("ADMITTED", result.stdout) + + def test_missing_authorized_list_is_capacity_not_configured(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="", + T3CODE_LINUX_RUNNER="t3-ci-linux", + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) + self.assertIn("T3CODE_AUTHORIZED_RUNNERS", result.stderr) + + def test_missing_runner_variable_is_capacity_not_configured(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="", + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) + self.assertIn("T3CODE_LINUX_RUNNER", result.stderr) + + def test_unauthorized_label_is_rejected(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="some-other-runner", + T3CODE_LINUX_RUNNER="t3-ci-linux", + ) + self.assertEqual(result.returncode, 4, result.stderr) + self.assertIn("RUNNER_NOT_AUTHORIZED", result.stderr) + + def test_requires_and_admits_macos_role(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_macos_capacity_when_required(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("CAPACITY_NOT_CONFIGURED", result.stderr) + self.assertIn("T3CODE_MACOS_X64_RUNNER", result.stderr) + + def test_unknown_role_is_capacity_not_configured(self): + result = self.run_guard( + EVENT_NAME="push", + AUTHORIZED="t3-ci-linux", + T3CODE_LINUX_RUNNER="t3-ci-linux", + REQUIRED_ROLES="windows", + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f65c15696d2c..ef2cfcadc945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,22 @@ 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 with +# CAPACITY_NOT_CONFIGURED rather than waiting on an unmatched label. External +# pull requests are never scheduled on self-hosted capacity. See +# docs/internals/fork-ci.md. +# +# Incremental upstream behavior is preserved: the same jobs, check names, test +# commands and coverage. Only the runner target and host-portability of setup +# changed. on: pull_request: push: @@ -14,9 +31,38 @@ 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@v6 + 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: bash .github/scripts/fork-ci-routing.sh + check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + needs: [authorize] + runs-on: ${{ vars.T3CODE_LINUX_RUNNER }} timeout-minutes: 10 steps: - name: Checkout @@ -58,12 +104,14 @@ jobs: - name: Typecheck run: vpr typecheck - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries + - name: Ensure 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 is expected to carry these; install only the + # missing packages and never rewrite the host's apt sources. + if ! dpkg -s libsecret-1-dev pkg-config >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends libsecret-1-dev pkg-config + fi - name: Build desktop pipeline run: vp run build:desktop @@ -78,7 +126,8 @@ 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 @@ -99,12 +148,14 @@ 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: Ensure 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 is expected to carry these; install only the + # missing packages and never rewrite the host's apt sources. + if ! dpkg -s libsecret-1-dev pkg-config >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends libsecret-1-dev pkg-config + fi - name: Test preview artifact validation run: python3 -B .github/scripts/stage-preview-bundle.test.py @@ -112,6 +163,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 +175,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 @@ -188,7 +243,8 @@ 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 @@ -204,25 +260,43 @@ jobs: 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,13 +368,42 @@ 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@v6 + 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: bash .github/scripts/fork-ci-routing.sh + 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 @@ -328,7 +431,8 @@ 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 diff --git a/docs/internals/fork-ci.md b/docs/internals/fork-ci.md new file mode 100644 index 000000000000..ba6bccf247a5 --- /dev/null +++ b/docs/internals/fork-ci.md @@ -0,0 +1,112 @@ +# 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 | The pull request head is not this repository. | +| `RUNNER_NOT_AUTHORIZED` | 4 | A declared label is absent from `T3CODE_AUTHORIZED_RUNNERS`. | + +`.github/scripts/fork-ci-routing.test.py` exercises all four outcomes with +bounded fixtures and runs in the `Test` job. + +### 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. + +To admit capacity, the repository owner must: + +1. Register at least one self-hosted runner **to `nullStack65/t3code`**. A + user-owned account has no organization runner groups, so self-hosted runners + registered to another repository (for example the `Closura` runners) cannot + run `t3code` jobs. The runner must be a disposable, isolated validation host, + never the active desktop. +2. Set `T3CODE_LINUX_RUNNER` to that runner's label and list the same label in + `T3CODE_AUTHORIZED_RUNNERS`. +3. Only for repositories that run the mobile native lint, register an Intel + macOS runner, set `T3CODE_MACOS_X64_RUNNER`, and add its label to + `T3CODE_AUTHORIZED_RUNNERS` too. + +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 are installed only when missing (see below). If the image lacks a +prerequisite the job fails visibly; nothing falls back to hosted capacity. + +## 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. +- The `authorize` job skips when a `pull_request` head repository is not this + repository, before a runner is allocated. External PRs are therefore never + scheduled on self-hosted capacity; the routing script enforces the same rule + as defense in depth. +- Workflow permissions stay `contents: read` (plus `pull-requests: read` for the + API-only change detector). Actions stay pinned to their existing major + versions, and the guard checkout uses `persist-credentials: false`. +- No secret, publish, deploy, or model call runs in CI. + +## 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 install `libsecret-1-dev` and +`pkg-config` only when they are missing, and never touch apt sources. The +admitted image is expected to carry them; if it does not, the install is bounded +and idempotent. 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 From 5a1f0322dc657a19ba2a4335197bd1dccf204c09 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Mon, 28 Sep 2026 08:57:16 -0400 Subject: [PATCH 2/3] docs(fork-ci): record observed fail-closed behavior PR #12 run 36424904599 fails at workflow start (no runner allocated, all dependent jobs skipped) versus the pre-repair run 36415749632 that queued forever. Clarify the two distinct fail-closed paths in the workflow header and the internals doc. --- .github/workflows/ci.yml | 5 +++-- docs/internals/fork-ci.md | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef2cfcadc945..7c3407131841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,9 @@ name: CI # 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 with -# CAPACITY_NOT_CONFIGURED rather than waiting on an unmatched label. External +# 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. # diff --git a/docs/internals/fork-ci.md b/docs/internals/fork-ci.md index ba6bccf247a5..f6dfe5cf05af 100644 --- a/docs/internals/fork-ci.md +++ b/docs/internals/fork-ci.md @@ -48,6 +48,15 @@ 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. With neither variable set the run +cannot schedule its `authorize` job, so it concludes `failure` at startup with +every dependent job skipped and no runner allocated — PR #12 run `36424904599` +versus the pre-repair run `36415749632`, which queued indefinitely on Blacksmith +labels. 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. + To admit capacity, the repository owner must: 1. Register at least one self-hosted runner **to `nullStack65/t3code`**. A From 57155017e04edb40722485c87ecf80852457141b Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Mon, 28 Sep 2026 19:40:53 -0400 Subject: [PATCH 3/3] ci(fork): bootstrap first-run admission and pin the job contract The authorize job checked out the PR base and executed .github/scripts/fork-ci-routing.sh, which does not exist on that base, so the first PR that introduces the guard could never admit. Replace it with a small explicit bootstrap in the workflow: run the checked-out trusted guard when present, otherwise enforce the identical inline policy. Reject an empty HEAD_REPO and unknown event/repository identity instead of admitting them. Pin every action to an immutable commit SHA, disable persisted credentials on every source checkout, and replace the conditional sudo apt-get with a precise missing-prerequisite failure. Document the real public-repo approval boundary and the smallest existing self-hosted capacity route (ARC) plus its owner and the exact registration/variable packet. Refs nullStack65/closura-agent-config#237 --- .github/scripts/fork-ci-routing.sh | 40 +++- .github/scripts/fork-ci-routing.test.py | 291 ++++++++++++++++++------ .github/workflows/ci.yml | 200 +++++++++++++--- docs/internals/fork-ci.md | 164 +++++++++---- 4 files changed, 550 insertions(+), 145 deletions(-) diff --git a/.github/scripts/fork-ci-routing.sh b/.github/scripts/fork-ci-routing.sh index aa45347d1e0b..7c94e727a5aa 100755 --- a/.github/scripts/fork-ci-routing.sh +++ b/.github/scripts/fork-ci-routing.sh @@ -22,9 +22,17 @@ # 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 pull request; never routed onto -# self-hosted capacity +# 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:-}" @@ -62,13 +70,29 @@ runner_var() { esac } -# 1. Trust. A pull request whose head repository is not this repository carries -# unreviewed code and must never be scheduled on privileged self-hosted -# capacity. The workflow's job-level guard also blocks this before a runner -# is even allocated; this check keeps the policy honest if that changes. -if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$HEAD_REPO" ] && [ "$HEAD_REPO" != "$REPOSITORY" ]; then +# 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 PR code is never routed to self-hosted runner capacity" 3 + "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 diff --git a/.github/scripts/fork-ci-routing.test.py b/.github/scripts/fork-ci-routing.test.py index 0709bed6e8ab..f6ff937d72a1 100644 --- a/.github/scripts/fork-ci-routing.test.py +++ b/.github/scripts/fork-ci-routing.test.py @@ -1,9 +1,12 @@ 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 @@ -18,79 +21,165 @@ "REQUIRED_ROLES", ) +BEGIN = "# >>> fork-ci-bootstrap >>>" +END = "# <<< fork-ci-bootstrap <<<" -class ForkCiRoutingTests(unittest.TestCase): - def run_guard(self, **values): - env = {k: v for k, v in os.environ.items() if k not in INPUTS} - env["REPOSITORY"] = REPOSITORY - env.update(values) - return subprocess.run( - ["bash", str(SCRIPT)], - env=env, - capture_output=True, - text=True, - ) - - def test_admits_trusted_push_on_authorized_linux(self): - result = self.run_guard( +# 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", - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "ADMITTED linux=t3-ci-linux") - - def test_admits_same_repository_pull_request(self): - result = self.run_guard( - EVENT_NAME="pull_request", - HEAD_REPO=REPOSITORY, + 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", - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("ADMITTED", result.stdout) + 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 test_rejects_external_fork_pull_request(self): - result = self.run_guard( - EVENT_NAME="pull_request", - HEAD_REPO="someone-else/t3code", - AUTHORIZED="t3-ci-linux", - T3CODE_LINUX_RUNNER="t3-ci-linux", - ) - self.assertEqual(result.returncode, 3, result.stderr) - self.assertIn("UNTRUSTED_FORK", result.stderr) - self.assertNotIn("ADMITTED", result.stdout) - def test_missing_authorized_list_is_capacity_not_configured(self): - result = self.run_guard( - EVENT_NAME="push", - AUTHORIZED="", - T3CODE_LINUX_RUNNER="t3-ci-linux", - ) - self.assertEqual(result.returncode, 2, result.stderr) - self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) - self.assertIn("T3CODE_AUTHORIZED_RUNNERS", result.stderr) +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 test_missing_runner_variable_is_capacity_not_configured(self): - result = self.run_guard( - EVENT_NAME="push", - AUTHORIZED="t3-ci-linux", - T3CODE_LINUX_RUNNER="", + +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, ) - self.assertEqual(result.returncode, 2, result.stderr) - self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) - self.assertIn("T3CODE_LINUX_RUNNER", result.stderr) - def test_unauthorized_label_is_rejected(self): + 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="some-other-runner", + AUTHORIZED="t3-ci-linux, t3-ci-macos", T3CODE_LINUX_RUNNER="t3-ci-linux", ) - self.assertEqual(result.returncode, 4, result.stderr) - self.assertIn("RUNNER_NOT_AUTHORIZED", result.stderr) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "ADMITTED linux=t3-ci-linux") - def test_requires_and_admits_macos_role(self): + def test_requires_and_admits_macos_role_output(self): result = self.run_guard( EVENT_NAME="push", AUTHORIZED="t3-ci-linux, t3-ci-macos", @@ -101,7 +190,17 @@ def test_requires_and_admits_macos_role(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "ADMITTED linux=t3-ci-linux macos=t3-ci-macos") - def test_missing_macos_capacity_when_required(self): + 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", @@ -110,19 +209,75 @@ def test_missing_macos_capacity_when_required(self): REQUIRED_ROLES="linux macos", ) self.assertEqual(result.returncode, 2, result.stderr) - self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) self.assertIn("T3CODE_MACOS_X64_RUNNER", result.stderr) - def test_unknown_role_is_capacity_not_configured(self): - result = self.run_guard( - EVENT_NAME="push", - AUTHORIZED="t3-ci-linux", - T3CODE_LINUX_RUNNER="t3-ci-linux", - REQUIRED_ROLES="windows", + 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, ) - self.assertEqual(result.returncode, 2, result.stderr) - self.assertIn("CAPACITY_NOT_CONFIGURED", result.stderr) + + 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() \ No newline at end of file + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c3407131841..c89d7ce31f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,17 @@ name: CI # 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 and host-portability of setup -# changed. +# commands and coverage. Only the runner target, host-portability of setup and +# the security pins changed. on: pull_request: push: @@ -43,7 +51,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout trusted routing guard - uses: actions/checkout@v6 + 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 @@ -58,7 +66,61 @@ jobs: AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} T3CODE_LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }} REQUIRED_ROLES: linux - run: bash .github/scripts/fork-ci-routing.sh + 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 @@ -67,8 +129,9 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + persist-credentials: false sparse-checkout: | /* !/.repos/ @@ -83,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 @@ -105,13 +168,21 @@ jobs: - name: Typecheck run: vpr typecheck - - name: Ensure browser secret helper build libraries + - name: Verify browser secret helper build libraries run: | - # The admitted image is expected to carry these; install only the - # missing packages and never rewrite the host's apt sources. - if ! dpkg -s libsecret-1-dev pkg-config >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y --no-install-recommends 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 @@ -132,15 +203,16 @@ jobs: 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 @@ -149,13 +221,21 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - - name: Ensure browser secret helper build libraries + - name: Verify browser secret helper build libraries run: | - # The admitted image is expected to carry these; install only the - # missing packages and never rewrite the host's apt sources. - if ! dpkg -s libsecret-1-dev pkg-config >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y --no-install-recommends 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 @@ -185,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 @@ -233,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 @@ -249,15 +330,16 @@ jobs: 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 @@ -380,7 +462,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout trusted routing guard - uses: actions/checkout@v6 + 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 @@ -395,7 +477,61 @@ jobs: AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }} T3CODE_MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }} REQUIRED_ROLES: macos - run: bash .github/scripts/fork-ci-routing.sh + 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 @@ -408,15 +544,16 @@ jobs: 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 @@ -437,15 +574,16 @@ jobs: 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 index f6dfe5cf05af..b9cfc19c51ce 100644 --- a/docs/internals/fork-ci.md +++ b/docs/internals/fork-ci.md @@ -33,11 +33,33 @@ policy. It prints a machine-readable reason and exits non-zero: | --- | --- | --- | | `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 | The pull request head is not this repository. | +| `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`. | - -`.github/scripts/fork-ci-routing.test.py` exercises all four outcomes with -bounded fixtures and runs in the `Test` job. +| `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 @@ -48,27 +70,66 @@ 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. With neither variable set the run -cannot schedule its `authorize` job, so it concludes `failure` at startup with -every dependent job skipped and no runner allocated — PR #12 run `36424904599` -versus the pre-repair run `36415749632`, which queued indefinitely on Blacksmith -labels. 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. - -To admit capacity, the repository owner must: - -1. Register at least one self-hosted runner **to `nullStack65/t3code`**. A - user-owned account has no organization runner groups, so self-hosted runners - registered to another repository (for example the `Closura` runners) cannot - run `t3code` jobs. The runner must be a disposable, isolated validation host, - never the active desktop. -2. Set `T3CODE_LINUX_RUNNER` to that runner's label and list the same label in +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`. -3. Only for repositories that run the mobile native lint, register an Intel - macOS runner, set `T3CODE_MACOS_X64_RUNNER`, and add its label to - `T3CODE_AUTHORIZED_RUNNERS` too. +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 @@ -79,9 +140,13 @@ route above is distinct even though it reuses the same variable names. 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 are installed only when missing (see below). If the image lacks a -prerequisite the job fails visibly; nothing falls back to hosted capacity. +`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 @@ -89,24 +154,47 @@ 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. External PRs are therefore never - scheduled on self-hosted capacity; the routing script enforces the same rule - as defense in depth. -- Workflow permissions stay `contents: read` (plus `pull-requests: read` for the - API-only change detector). Actions stay pinned to their existing major - versions, and the guard checkout uses `persist-credentials: false`. -- No secret, publish, deploy, or model call runs in CI. + 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 install `libsecret-1-dev` and -`pkg-config` only when they are missing, and never touch apt sources. The -admitted image is expected to carry them; if it does not, the install is bounded -and idempotent. Caches remain the standard Actions cache used by `setup-vp`. +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`)