Skip to content

ci(prd-480): Linux CI runs on the owner machine - #404

Merged
jonit-dev merged 19 commits into
developfrom
ci/prd-480-linux-ci-on-owner-machine
Oct 3, 2026
Merged

jonit-dev merged 19 commits into
developfrom
ci/prd-480-linux-ci-on-owner-machine

Conversation

@jonit-dev

Copy link
Copy Markdown
Contributor

TL;DR: Linux CI jobs run on ephemeral tn-local runners on the owner's machine when the TN_RUNNER repo variable is set, and fall back to hosted runners when it is deleted or the PR comes from a fork. PRD-480.

Why

Run 37043413533 spent 239 min waiting for runners against 146 min of work. A local pool removes the queue.

What changed

  • tools/ci-runners/ (Dockerfile + entrypoint): Ubuntu 24.04 actions-runner image with the build/Xvfb/Playwright deps. The entrypoint mints a registration token, unsets the admin token before run.sh so no job sees it, and registers --ephemeral with label tn-local.
  • scripts/ci-runners.sh up [N] | down | status: one host-side docker run --rm loop per slot (a fresh container and hostname for every job). up sets TN_RUNNER only after N runners are online; down deletes it first. It fails closed when the env file or token is missing.
  • ci.yml and integration-*.yml: every movable Linux runs-on uses the routing expression. supply-chain stays hosted; arm64 and native-platforms.yml are untouched.
  • ci-structure.spec.ts enforces the routing.

Deviation from the PRD: no compose.yml. restart: always restarts the same container, so its filesystem and hostname would carry over between jobs.

Evidence

  • scripts/ci-runners.sh up: 4 online; status lists 4 tn-local runners online.
  • ci-structure.spec.ts + ci-needs.spec.ts: 142 passed. The new spec failed with budgets set back to ubuntu-latest.
  • This PR's own CI run is the AC-1 proof (runner names per job). Pending.

Checklist

  • Phase 1: 4 runners online
  • Phase 1: fresh container per job (runner names from this PR's run)
  • Phase 2: structure spec enforces routing
  • Phase 2: PR run lands Linux jobs on tn-local (AC-1)
  • Phase 2: AGENTS.md push rule (after AC-1 is green)
  • Phase 2: fully hosted with TN_RUNNER unset
  • Phase 3: native-platforms Linux legs

Phase 1 of PRD-480: the image, its entrypoint, and the host-side
lifecycle script. No runners are started and nothing is registered
yet — the script fails closed without a token, and `status` reports an
empty pool.

No compose.yml (owner-approved deviation, noted in the PRD): a host-side
loop of `docker run --rm` is what makes each job a fresh container
filesystem and hostname, which `restart: always` on one long-lived
container cannot.

Evidence:
- `docker build -t tn-ci-runner tools/ci-runners` — clean.
- smoke: cmake 3.28.3, clang 18.1.3, ninja 1.11.1, /usr/bin/{Xvfb,ccache,jq},
  `ls -A /tmp | wc -l` → 0.
- `TN_RUNNERS_ENV=/nonexistent scripts/ci-runners.sh up` → exit 2, nothing built
  or started. `up 0` and an unknown subcommand also exit 2.
- `docker run --rm tn-ci-runner` without the env file → exit 1 naming
  RUNNER_ADMIN_TOKEN.
- `scripts/ci-runners.sh status` → TN_RUNNER=unset, 0 containers, 0 online.
- `bash -n` clean on both scripts (no shellcheck installed here).
Phase 2 of PRD-480. 23 `runs-on` lines (18 in ci.yml, one each in the five
integration-*.yml) now read:

  runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}

Deleting the TN_RUNNER repository variable is the whole kill switch, and a fork
PR always runs hosted. `supply-chain` keeps ubuntu-latest because gitleaks needs
`docker run` and the socket would hand every job root on the host;
integration-decals.yml's ubuntu-24.04-arm job and every macOS/Windows job are untouched, as
is native-platforms.yml (Phase 3).

A new ci-structure.spec.ts test enforces it: every job in ci.yml and the
integration workflows either carries that exact expression, is on the hosted
allow-list, or is a non-Linux runner.

Evidence:
- red, no workflow edited: "ci.yml scope runs on `runs-on: ubuntu-latest`".
- red, after routing, ci.yml:1051 (budgets) put back on ubuntu-latest: same test
  failed naming that job, then it was restored.
- green: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts
  scripts/__tests__/ci-needs.spec.ts` — 142 passed, 0 failed.
- `pnpm lint` exit 0, `pnpm check:docs` 2496 links, `pnpm sync:agents --check` 22 mirrors.
- all six workflows parse as YAML and every runs-on resolves to one plain scalar.

One pre-existing assertion was narrowed rather than deleted: template-nonvisual
forbade the substring `pull_request` anywhere in the job, which the routing
expression mentions in every job without gating on it. It now forbids an `if:`
gate naming pull_request.
…ig.sh lives

First live up: 0 of 4 online, the pool tore itself down and cleared TN_RUNNER.
With the fix, 4 tn-local runners come online.
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Oct 2, 2026
@jonit-dev
jonit-dev marked this pull request as ready for review October 2, 2026 20:30
…4.04 does

The scope job runs node straight after checkout; on tn-local it failed with
"node: command not found" (run 37061064658).
…d per slot

PR #404 run 2: test-native failed on "rustc: command not found"; three unit tests
timed out because a --cpus quota leaves nproc at 24, so vitest ran 8 workers in a
4-core budget. Each slot now owns two whole cores (nproc = 4, like hosted), and the
image adds rustup stable, 7zip, ffmpeg, imagemagick, zstd, file, lsof, procps and
mesa-vulkan-drivers.
…t for the host

12 GB per slot with no extra swap (5 x 12 = 60 of 62 GB worst case, typical jobs far
less) and --oom-score-adj 800, so memory pressure kills a CI job before anything
of the owner's. up refuses a pool that would take the host's two reserved cores.
…as none

Workflows that need Mesa install it themselves. With it baked in, the tone and
decals captures went blank on tn-local (run 37062759406: TN_CAPTURE_BLANK) while
the same job passed on the image without it.
…ght lane

Records the pool shape measured on the first live runs and the owner's stability
constraint, and adds Astra's light-lane review point as a Phase 2 box.
A ten-second verdict queued behind five twenty-minute builds is the wait
this removes. `ci-runners.sh up` now starts one light slot alongside the
heavy ones: same image and host loop, `--cpus 1 --memory 2g
--memory-swap 2g --oom-score-adj 900`, no cpuset, registering
`tn-local-light` (entrypoint takes the label from `TN_RUNNER_LABELS`), and
it advertises `TN_RUNNER_LIGHT` only once that runner is online — so the
kill switch stays one deletion and `down` clears both variables before it
stops anything.

`scope`, `golden-path`, `build`, `ci-required` and `run-summary` route to
it: the five ci.yml jobs whose whole work is a script or a summary, none of
which builds the workspace or runs a test suite. Every other movable Linux
job keeps the TN_RUNNER expression. `ci-structure.spec.ts` now requires
each Linux job to use exactly one of the two expressions, and asserts the
light allow-list in both directions, so a heavy job on the light lane and a
light job off it both fail.
test-native on tn-local (run 37062759791): the Android packaging tests drive a fake
gradlew built on jar, javac and java; without a JDK every one exited 127.
test-unit 3/3 on tn-local: the playtest doctor spec failed on "adb was not found".
…tions

Inside bash -c the first argument is $0, so the label landed where the image
name goes and docker tried to pull tn-local:latest. up then failed closed.
test-native run 37068328553: javac read MystralActivity.java as US-ASCII and
rejected an em dash, so the Android metadata probe failed. 1 of 1593 left red.
…pool

Phase 3 of PRD-480. Eight Linux jobs in native-platforms.yml and the
`starter-linux/linux-x64` matrix row now pick their runner from the same
expression ci.yml uses, so the pool is still changed by setting or deleting one
repository variable. `scope` and `networking-matrix` — the two whose whole work
is a script with no `pnpm install` — take the light lane; everything else is
heavy. `github.event` inside a `workflow_call` is the caller's event payload,
so the fork guard reads the same flag ci.yml did when it called this file.

Kept hosted, with reasons in the file and in the spec's allow-list:
`publish-android-v8` is the release write itself (`contents: write` plus
`gh release upload`), and macOS, Windows, `linux-arm64` and iOS have no
tn-local counterpart. The `desktop` matrix has no Linux row at all.

The image gains the SDK the routed Android steps pin: cmdline-tools 16.0 with
licenses accepted, platform-tools, platforms 35 and 36 (compileSdk),
build-tools 35.0.0 and NDK 28.2.13676358 (ndkVersion), owned by `runner`
because sdkmanager writes into ANDROID_HOME at job time. `emulator` and the
system image stay out: android-emulator-runner installs those itself.
9.28 GB against the pool image's 5.25 GB, chowned inside the install layer so
the SDK is not written twice.

Heavy slots pass `--device /dev/kvm` where the host has one, and the entrypoint
sets the node's mode before the job reads it: docker creates it 0660 root:kvm,
which android-emulator-runner cannot open. That also means the lane's
`udevadm control`/`udevadm trigger` lines can never succeed in a container — no
udev, read-only /sys — so they are now `|| true`, which is what the step's own
"report the mode, never assert it" rule already says. The spec compares the
two emulator lanes' provisioning with that suffix normalised.

Evidence: `docker build -t tn-ci-runner:phase3 tools/ci-runners` succeeded, and
`docker run --rm --device /dev/kvm --entrypoint bash tn-ci-runner:phase3 -c
'sdkmanager --version && ls $ANDROID_HOME && ls -l /dev/kvm'` printed sdkmanager
16.0 with build-tools, cmdline-tools, licenses, ndk, platform-tools and
platforms. `emulator -accel-check` inside the container reports "KVM (version
12) is installed and usable" with no missing shared libraries.
`bash -n scripts/ci-runners.sh` passes. `pnpm exec vitest run
scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts` —
142 passed; red first on `.github/workflows/native-platforms.yml scope runs on
ubuntu-latest`, and again on a `starter-linux/linux-x64` row put back to bare
`ubuntu-24.04`. The six sibling workflow specs (ci-efficiency, ci-local,
compiler-cache-setup, hosted-software-callers, workspace-dist-cache,
native-release-proof) pass at 149. `pnpm lint` exits 0, `pnpm check:docs`
checks 2496 links. Not run here and not claimed: a live `native-platforms` run
on the pool, which is what the two phase boxes still wait for. `pnpm typecheck`
and `pnpm budgets` fail in this worktree on missing `packages/*/dist` build
output, in files this change does not touch.
… TN_RUNNER is set

Run 37070815769: 39 jobs green, 33 on tn-local, 5 on tn-local-light, supply-chain
hosted. 38 distinct runner names, so each job got a fresh container. ci-required
started 3 s after its last need. AC-2 measured and not met (317 min summed wait,
42 min wall vs 39 hosted); owner decision recorded as pending.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:25% PRD phase 1 verified labels Oct 2, 2026
Run 37074908699: the emulator lane failed writing its udev rule because the image
had no /etc/udev/rules.d; and starter-desktop.test.mjs pinned a bare ubuntu-24.04
for the linux-x64 row that now routes through TN_RUNNER. Census regenerated for
the runtime-native test edit.
Run 37079046851 and the develop promotion both failed Windows desktop core on
"nsis not installed. The package was not found" for a version the feed still
lists. Three attempts, 30 s apart; makensis -VERSION still fails closed.
The five integration-*.yml files folded into integration.yml on develop; its
paths gate takes the light lane and every other Linux job the TN_RUNNER switch.
lint keeps develop's reused-tree condition with the routing expression. The
routing spec now reads integration.yml. PRD-481 shard box ticked from run
37078784853 (worst leg 329 s).
@jonit-dev
jonit-dev enabled auto-merge October 3, 2026 00:37
On tn-local the emulator got KVM but renders through SwiftShader on the CPU; run
37082733117 spent 41 of its 45 minutes still running APKs (hosted ~30 min). The
hosted allow-list names it with that reason; every other Linux leg still routes.
@jonit-dev
jonit-dev added this pull request to the merge queue Oct 3, 2026
@jonit-dev
jonit-dev removed this pull request from the merge queue due to a manual request Oct 3, 2026
@jonit-dev
jonit-dev merged commit d99a628 into develop Oct 3, 2026
132 of 137 checks passed
jonit-dev added a commit that referenced this pull request Oct 3, 2026
…use, image deps, PRD close-out) (#409)

* ci(prd-380): an ordinary pull request runs the Linux native rows only

The native matrix is one of the three multipliers that starves the runner pool: ci.yml ran its
full macOS/Windows/iOS/arm64 legs for any `selection == 'full'` pull request. Phase 2 makes the
scope planner decide how much of that matrix a run owes, once, and the workflow read the answer.

The plan gains `nativeTier` (`full` | `reduced` | `none`), computed in `selectionPlan` from whether
the change reached native code and the branch it targets, recomputed and compared by `validatePlan`,
and published as the `native_tier` output. `native-platforms.yml` consumes it: `desktop` (macOS,
Windows) and `ios-simulator` skip unless the tier is `full`, and `starter-linux` runs `linux-x64`
alone because a job's `if` cannot read `matrix`, so the `scope` job shapes the rows instead and an
unreadable tier keeps every row. `web-reference`, `android-v8-source`, `android-emulator-parity` and
`desktop-parity` are Linux rows and still run on an ordinary pull request. A `main` push, the
nightly, a `workflow_dispatch` audit and any merge group or pull request into `main` classify
`full`; ci.yml now passes the merge group's own base branch, which is what tells those two apart.

PRD-481's reuse profile follows: a reused plan keeps the native reach of the full plan it stands in
for, and `ci-required` records the tier and demands it of the source run instead of blanket-exempting
`native-platforms` on develop, so a reduced pass can never satisfy a full requirement — nor a
skipped lane satisfy a reduced one. No label exempts the matrix.

Evidence (2026-10-02, red before green):
- scripts/__tests__/ci-efficiency.spec.ts + ci-structure + ci-needs: 216 passed; 2 new
  ci-efficiency assertions and 4 ci-needs cases failed against the pre-change files.
- packages/runtime-native tests/native-platform-workflow + starter-desktop: 79 passed. The arm64
  matrix rows moved from the job body into the `scope` step that shapes them, and that test's row
  assertions follow.
- pnpm lint, pnpm typecheck, pnpm check:docs, pnpm census: clean.

* docs(prd-480): phase 3 proven by run 37089715252

Every native-platforms leg green; Linux legs on tn-local and tn-local-light.

* ci: the janitor also cancels a dequeued PR's merge-group runs

Dequeueing, admin-merging or closing a pull request drops it from the
develop merge queue but does not cancel the merge-group run GitHub
already started, so that run keeps holding self-hosted runners for a
verdict nothing will read: run 37093098790 ran 9 jobs for #404 after #404
had merged. The janitor only swept the head ref, which never names that
branch.

It now also cancels queued and in-progress runs whose branch starts with
`gh-readonly-queue/<base>/pr-<number>-`, built from the event's own base
ref and PR number through `env:` — no `github.*` context reaches the shell
— and filtered in jq off one `gh run list --json
databaseId,status,headBranch --limit 100`. The fork, develop/main-head and
self-run guards are unchanged.

Evidence:
- `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts
  scripts/__tests__/ci-efficiency.spec.ts` — the janitor case now pins
  the env-built prefix and the `startswith` filter: red on the old
  workflow (1 failed | 134 skipped), green on the new one (182 passed).
- `pnpm lint` clean.
- The step's shell, extracted and run against a stub `gh`, cancelled the
  head ref's in-progress run plus both of PR 404's queued/in-progress
  merge-group runs, and spared a completed run, a `main`-based group,
  another PR's group, `pr-4040` and the janitor's own run id.

* fix(ci): tree reuse compares runner class per job, so mixed boards can reuse

currentRun() demanded one runner class for the whole run and returned a miss
otherwise, but every board is mixed on purpose: supply-chain is hosted, the
small joins run on tn-local-light and the platform legs are hosted. Reuse could
therefore never trigger.

currentRun() now returns the class each job routes to, read from that job's own
labels (known before the job starts), and coverageMiss() compares the source's
class for each required job against it. A job whose class is not assigned yet —
a reuse run skips every leg it replaces — falls back to the class most of the
run's known jobs report, so an unprovable leg is a miss unless it agrees with
that routing. Profile coverage, matrix legs and success-only are unchanged.

Spec: the mixed-board case now reuses, and a source whose `test` ran hosted
while this run routes it to tn-local runs full naming the job.

* docs(prd): CI PRD status lines match what is verified

* docs(prd-480): the fallback runs fully hosted (dispatch run 37093694594)

* ci(runners): bake the native jobs' build packages into the image

test-native spent 52 s per run on apt-get install (run 37089715252); the -dev
packages and xcompmgr now ship in the image. Mesa stays per-job.

* docs(prd-380): phase 3 proven by live janitor runs

* ci(runners): three light slots so small joins stop queueing behind one runner

* ci(janitor): force-cancel, since a plain cancel can hang on a job queued for a self-hosted label

* ci(runners): a balancer makes the local pool overflow capacity on top of free hosted runners

* ci(runners): six 3-thread slots instead of five 4-thread ones, same reserved desktop cores

* ci(scope): a CI-configuration-only pull request runs the `ci` selection

A workflow-only PR to develop spent the whole board — test-native, both browser
and playtest lanes, the 28-leg template sweep, golden-path, benchmark, the
native matrix — to prove that a YAML file still parses. None of those jobs has
a consumer for CI configuration.

`ci` keeps exactly the gates that read it: typecheck, lint, budgets, the
secrets scan, and the test-unit shards, which are where every `ci-*.spec.ts`
runs. build-artifacts comes with them because those three jobs consume its
upload; dropping it would leave three required jobs waiting on a producer that
never ran.

Qualifying paths are `.github/workflows/*.yml`, `scripts/ci-*.{mjs,sh,ts}`,
`scripts/__tests__/ci-*.spec.ts`, `tools/ci-runners/**`, and anything already
classified prose or instructions. `.github/actions/**` is excluded because the
native matrix consumes it, and a lockfile because it changes what every job
installs. A diff touching any native path keeps `full` — that includes
`native-platforms.yml`, which is the matrix's own definition, so narrowing it
would waive the lane that has to prove the change.

Never for the merge queue, a push, the nightly, an explicit audit or a
promotion into main. Those qualify a tree rather than review a change, and the
queue already runs the full board before anything lands.

Reuse needed no new guard: `coverageMiss` already refuses a source on the
matrix legs it skipped, and a new test in ci-needs.spec.ts proves a `ci`-lane
run cannot stand in for a full requirement. `classify`'s complexity is
unchanged from its pre-existing warning — the family loop moved to
`diffSelection` rather than pushing it further over.

`pnpm ci:local --affected` accepted only full/prose/instructions, so a
CI-configuration branch failed closed on an unknown selection; it now accepts
`ci` and runs the same bounded docs lane as the other narrowed selections.

* ci(janitor): a CI run that failed only on known flakes reruns its failed jobs once

workflow_run on CI completion: when every failed primary job shows a known
environmental signature (SwiftShader device lost, bridge budget, adapter
provenance, dev server not up, 5xx, runner shutdown) and it is attempt 1, rerun
only the failed jobs. Any other failure stays red. Takes effect from main.

* ci: template legs use the balanced routing, so idle local slots run them too

* fix(ci): starter-linux matrix rows are {include:[...]}, not a bare array

Run 37100382876: the native scope step wrote the rows as an array, GitHub created
no starter-linux job, and the reusable workflow still concluded failure, so
ci-required read a red native lane with every leg green.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:50% PRD half the phases verified

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant