A single self-hosted GitHub Actions runner image that both agent-pane and
streaming-markdown (and any future org repo) can share — replacing the two
per-repo, per-tier fleets that previously lived in agent-pane/.github/runner
(e2e) and agent-pane/.github/runner-checks (checks).
It folds four changes into one image:
-
One superset image, one pool. The check tier's image is a strict subset of the e2e image, so this image carries the full e2e stack and runs both tiers. A runner registers with both
copse-e2eandcopse-checkslabels, so any box is eligible for whichever job is queued — the pool self-balances instead of one labelled fleet idling while the other is saturated. (This is the fix for "some are idle whilst the others are overworking.") -
Org-level registration.
GITHUB_URLcan be an org URL. The entrypoint already picks the org vs repo registration endpoint automatically, so the same image registers an org-shared pool with no code change. -
Store-only dependency warming. The image may clone one target lockfile and run
pnpm fetch, but it never runs package lifecycle scripts and never containsnode_modules. Each job copies or restores package inputs into its own temp directory, removes any existing installed tree, and runspnpm install --frozen-lockfile. Native binaries therefore belong to one checkout and cannot poison a later job. -
Real restart isolation.
--ephemerallimits the runner to one GitHub job, but Docker'srestart: alwaysrestarts the same writable container. The entrypoint now lives in a root-owned image template; before registration it rebuilds the runner runtime and clears the runner home, work tree, and temp directories. A prior job cannot replace the next run's agent or leave files for it to consume.
Status: prototype — not yet live. This directory replaces the two old runner dirs (
.github/runner,.github/runner-checks), which are removed in this branch;make runners*now drives this unified fleet. But the pool has not been built or stood up yet, and CI routing is unchanged — so do not merge until the unified pool is registered and one green run (an e2e job and a check job) has landed on it. See Cutover.
⚠️ The runners currently serving CI were built from the now-deleted dirs. Once this merges you can no longer rebuild them from source — migrate by reprovisioning from THIS image (make runners-reprovision) before tearing the old containers down.
cd ci-runners
cp .env.example .env # GITHUB_URL (org), ACCESS_TOKEN; optional BUILD_GH_TOKEN
DOCKER_BUILDKIT=1 docker compose up -d --build --scale runner=3That builds the image (optionally prefetching TARGET_REPO) and registers three
ephemeral runners labelled self-hosted,linux,docker,copse-e2e,copse-checks.
docker compose logs -f
docker compose ps
docker compose down # tear downApple container runs the same OCI image without Docker Desktop. It requires Apple silicon and macOS 26 or newer. Install the signed package, start its service, and prepare the runner environment:
container system start
cp ci-runners/.env.example ci-runners/.env
# edit GITHUB_URL, ACCESS_TOKEN, and optionally BUILD_GH_TOKEN
pnpm run runners:apple -- --count 3The command builds ci-runners/Dockerfile, verifies that bubblewrap can create
the namespace/procfs shape required by ASRT, and supervises three ephemeral runners.
It stays in the foreground because Apple container does not provide Compose or a
restart policy; press Ctrl-C for a graceful stop and GitHub deregistration. Use
another terminal for status:
pnpm run runners:apple -- statusThe default guest architecture is amd64, using Rosetta, to match the existing CI
fleet and native x64 dependencies. Use --arch arm64 only when the workflows and
dependencies are known to support arm64. Labels are truthful:
apple-container,x64 for the default or apple-container,arm64 when selected;
override them with APPLE_RUNNER_LABELS only when the corresponding capability is
real. The supervisor removes Apple container's default masked/read-only path rules
because nested bubblewrap needs to construct its own /proc; the pre-registration
probe fails closed if that still does not work.
Docker Compose remains the supported runner orchestration path on Linux/cloud hosts. The Apple path is intentionally a host-specific supervisor rather than a partial Compose translator.
Scaleway is the cheapest/easiest burst path when AWS billing or capacity is in
the way. It uses the same runner image as the local/AWS flows. Default burst
registers e2e-only labels (copse-e2e, not copse-checks) so check jobs
routed via SELF_HOSTED_CHECKS do not compete with the e2e shard matrix; spin up a
separate checks-only burst when the check tier also queues (below).
Prerequisites:
- Scaleway CLI installed and configured (
scw init) with permission to create, list, wait for, and terminate Instances in your project. - Your Scaleway project has an SSH public key installed for the default
rootuser (Scaleway propagates console SSH keys to new Linux Instances). Pass--key-pathif SSH should use a specific private key — the wait loop fails fast onPermission denied. - The default (or selected) security group must allow inbound TCP/22 from the
machine running the CLI. A long
Waiting for SSHwith connection timeouts almost always means port 22 is dropped. Outbound traffic must also reach GitHub and apt repositories. GITHUB_RUNNER_PATwith GitHub self-hosted runner registration permission forGITHUB_URL(org or repo), andBUILD_GH_TOKENwith read access toagent-paneplus the private@copse/streaming-markdowndependency.
Default (POP2-HC-8C-16G, one e2e runner per host — omit --zone to auto-pick an
AZ with quota):
GITHUB_RUNNER_PAT=ghp_... BUILD_GH_TOKEN=ghp_... \
npm run runners:burst:scw -- up \
--instances 3 \
--ttl-minutes 240Cheaper, granular alternative — one runner on the half-size HC box:
GITHUB_RUNNER_PAT=ghp_... BUILD_GH_TOKEN=ghp_... \
npm run runners:burst:scw -- up \
--instances 6 \
--scw-type POP2-HC-4C-8G \
--runners-per-instance 1 \
--ttl-minutes 240Useful follow-ups (status/down scan all AZs unless --zone is set):
npm run runners:burst:scw -- status
npm run runners:burst:scw -- drain --instances 1 --yes # gracefully remove newest host
npm run runners:burst:scw -- down --yes --waitUse down --instances N --yes to terminate only the newest N hosts immediately,
or Scaleway's drain --instances N --yes to stop those hosts accepting new jobs,
wait for in-flight jobs, and then terminate them. The partial form must leave at
least one host; omit --instances for the existing explicit whole-fleet teardown.
Scaleway sizing guidance:
- Omit
--zoneonupto fill--instancesacross Paris → Amsterdam → Warsaw → Milan AZs (Scaleway quotas are per-AZ). Partial creates are kept when an AZ hits quota; the remainder is requested in the next AZ. Pass--zoneto pin.status/downwithout--zonescan all. - The e2e tier is CPU-bound (Chromium-under-Xvfb) and wants ~4 vCPU + ~6 GiB per
runner, i.e. ~1.5 GiB/vCPU. The High-CPU
POP2-HCline (2 GiB/vCPU) matches that profile at ~half the €/vCPU of the generalPRO2line (4 GiB/vCPU), whose extra RAM the runner never uses. - Default:
POP2-HC-8C-16G(8 vCPU / 16 GiB) with two runners — 4 vCPU + a 6 GiBmem_limiteach, with one shared image build amortized across both. The Scaleway analogue of the AWSc7i.2xlarge/2-runner balanced default. - Granular/cheaper:
POP2-HC-4C-8G(4 vCPU / 8 GiB) with one runner (all 4 vCPU to it) — finer scale-down, half the blast radius, at the cost of an extra per-host image build. Do not put two runners on this box: 2 × 6 GiB caps oversubscribe an 8 GiB, swapless host, so the host OOM-killer (not the container cap) becomes the arbiter — the failure that reads as clustered spec timeouts. Two runners need a 16 GiB box. PRO2-XS/BASIC3-X4C-16G(4 vCPU / 16 GiB) remain fine fallbacks whenPOP2-HCAZ quota is exhausted; they just pay for RAM the runner won't use. Avoid the shared-vCPUPLAY2line for the e2e tier — CPU steal reintroduces the timeout variance this fleet exists to avoid; it's fine for check-only.- Default burst is already e2e-only. For check-only bursts, run a separate
upon a smaller box with checks-only labels, e.g.--runner-labels self-hosted,linux,x64,docker,copse-checks,burst. --ttl-minutesdefaults to 240. On Scaleway the host self-terminates via the Instance API after that TTL (server + SBS volume + flexible IP), matching AWS terminate-on-shutdown — a guestshutdownalone would only enter billed standby. RequiresSCW_SECRET_KEYor a configuredscwsecret-key atuptime. Preferdown --yeswhen the queue drains; the TTL is a backstop, not the primary cleanup path.--volume-size-gbdefaults to 80 (Scaleway SBS root). The default PLAY2 image disk is too small fordocker compose build+ dep bake; omit the flag to get 80 GB, or raise it if builds still hitno space left on device.- Scaleway root SBS volumes receive the same fleet ownership tags as their
server. Normal teardown deletes and verifies those volumes; the daily
Prune Scaleway Volumesworkflow is a backstop that deletes only tagged, unattached Copse volumes after 24 hours. Run the same guard manually withnpm run scaleway:prune-volumes -- --yes --older-than-hours 24. - Zonal flexible IPs are the costliest thing to leak: Scaleway bills them from
reservation until deletion whether or not a server is attached, so an orphan
never stops charging on its own.
upreserves each IP with the fleet's ownership tags before creating its server, so a failed create can hand it straight back and the reaper can tell fleet garbage from an address someone reserved deliberately. The dailyPrune Scaleway IPsworkflow deletes only tagged IPs seen unattached in two passes 120s apart —npm run scaleway:prune-ips -- --yes --settle-seconds 120. Untagged IPs are never touched, so any predating this tagging must be removed by hand. - With
--instances N(N>1), hosts are provisioned in parallel after create (SSH wait + Docker build). Pass--serialfor one-at-a-time logs.
For short queue-draining bursts, run the same Docker runner fleet on temporary x64 EC2 hosts:
Prerequisites:
- AWS CLI installed and authenticated with permission to call EC2
RunInstances,DescribeInstances,TerminateInstances,CreateTags/tag-on-create, and SSMGetParameterfor the Ubuntu AMI lookup. - An EC2 key pair and local private key (
--key-name,--key-path). - A subnet that can reach GitHub and apt repositories. If you use
--ssh-host public(the default), instances must receive public IPv4 addresses; otherwise run from a host with private VPC access and pass--ssh-host private. - A security group allowing SSH from the machine running the CLI.
GITHUB_RUNNER_PATwith GitHub self-hosted runner registration permission forGITHUB_URL(org or repo), andBUILD_GH_TOKENwith read access toagent-paneplus the private@copse/streaming-markdowndependency.
GITHUB_RUNNER_PAT=ghp_... BUILD_GH_TOKEN=ghp_... \
npm run runners:burst -- up \
--region us-east-1 \
--instances 3 \
--instance-type c7i.2xlarge \
--runners-per-instance 2 \
--ttl-minutes 240 \
--key-name copse-ci \
--key-path ~/.ssh/copse-ci.pem \
--subnet-id subnet-123 \
--security-group-id sg-123The CLI launches Ubuntu 24.04 amd64 hosts, waits for EC2 status checks, SSHes in,
uploads this ci-runners/ directory, writes the remote .env, and runs
docker compose up -d --build --scale runner=N. It deliberately requires an
existing subnet, security group, and key pair instead of creating networking; the
security group must allow SSH from the machine running the command.
Cost/packing guidance:
- EC2 c7i pricing is close to linear by size, so savings come mostly from high utilization and avoiding idle capacity, not from a large-instance discount.
- Each runner should be budgeted at roughly 2 vCPU and 4-6 GiB RAM. A
c7i.xlarge(4 vCPU / 8 GiB) with one runner is the smallest safe general e2e/check shape;c7i.2xlargewith 2 runners is the balanced default because it halves duplicated Docker builds and setup while keeping the same per-runner CPU/RAM budget.c7i.4xlargewith 4 runners is a denser option. - Default burst is e2e-only (
copse-e2e,burst). For check-only bursts, use a separateupon a smaller box with checks-only labels — do not addcopse-e2e; the current e2e failures look exactly like memory pressure. - Many
c7i.xlargehosts cost about the same per vCPU as fewer larger c7i hosts, but duplicate Docker builds, EBS volumes, and setup. One very large host has coarser scale-down and larger single-host blast radius. Prefer a few medium hosts that each pack whole runners safely. - With
--instances N(N>1), hosts are provisioned in parallel after create. Pass--serialfor one-at-a-time logs. --ttl-minutesdefaults to 240. Instances launch withinstance-initiated-shutdown-behavior=terminate, so the scheduled shutdown auto-terminates forgotten burst capacity. Pass--ttl-minutes 0only when you have another cleanup mechanism.
Useful follow-ups:
npm run runners:burst -- status --region us-east-1
npm run runners:burst -- down --region us-east-1 --instances 1 --yes --wait
npm run runners:burst -- down --region us-east-1 --yes --waitdown --instances N removes only the newest N hosts and refuses to remove the
entire remaining fleet. Omit --instances when a whole-fleet teardown is
intentional. AWS does not yet support graceful drain, so check the Actions
queue before using partial or full down there.
Secrets are read from environment variables (GITHUB_RUNNER_PAT and
BUILD_GH_TOKEN by default) rather than command-line flags so they do not appear
in shell history. down terminates instances tagged with the burst fleet name
(default copse-burst).
The same image doubles as the remote e2e dev loop (see
docs/plans/remote-e2e-dev-loop.md):
run the e2e suite from your working tree on an on-demand cloud host, so
your machine stays free while it runs. These hosts are not GitHub runners —
no registration, no runner PAT; the only secret is BUILD_GH_TOKEN at image
build time, exactly like a burst host.
# Preferred: publish the baked image once to Scaleway Container Registry, then
# provision hosts that only pull (no on-host bake, no BUILD_GH_TOKEN on the host).
BUILD_GH_TOKEN=ghp_... SCW_SECRET_KEY=... \
COPSE_CI_REGISTRY=rg.fr-par.scw.cloud/<namespace> \
npm run e2e:remote -- publish
SCW_SECRET_KEY=... COPSE_CI_REGISTRY=rg.fr-par.scw.cloud/<namespace> \
npm run e2e:remote -- up # pull + ready in minutes
# Registry pull defaults to a 40 GB root (no on-host bake scratch). On-host bake
# / --rebuild still defaults to 80 GB. Override with --volume-size-gb (SBS cannot
# shrink after create).
npm run e2e:remote -- run # oracle subset of your diff
npm run e2e:remote -- run --all --shard 2 --detach # full CI suite, 2 containers
npm run e2e:remote -- wait <run-id>
npm run e2e:remote -- down --yesWithout COPSE_CI_REGISTRY, up still works but bakes on the host
(BUILD_GH_TOKEN required; slow). Create a private namespace in the Scaleway
console (Storage → Container Registry), then
docker login
uses SCW_SECRET_KEY with user nologin. Image tags are
copse-ci-runner:<sha256(pnpm-lock.yaml)> and :latest.
Secrets never land in Scaleway images. BUILD_GH_TOKEN is a BuildKit
secret at bake/publish time only. Registry auth on up is an ephemeral host
docker login (password on stdin → pull → logout + wipe ~/.docker/config.json);
it is not written into Docker layers or instance snapshots. For the stricter
path (host never sees registry credentials), pass --transfer-image (local
pull + docker save|load over SSH).
Each run pushes a snapshot commit (staged + unstaged + untracked) to a bare
repo on the host and starts a fresh one-shot container from this image with
exec-run.sh as the entrypoint override: checkout → copy baked
package inputs and perform a clean install (the setup action's .lockhash
contract) → build → wdio
under Xvfb → collect logs + changed reference screenshots. Results land in
.tmp/remote-e2e/runs/<run-id>/ locally; the exit code mirrors wdio's.
Dev hosts carry their own tag namespace (copse-remote-e2e /
copse-remote-e2e-hosts), so e2e:remote down can never terminate burst CI
capacity and runners:burst down can never take a dev host. The same
TTL backstop applies (--ttl-minutes, default 240; Scaleway self-terminates
via API, AWS uses terminate-on-shutdown) — down --yes remains the real
cleanup. After a pnpm-lock.yaml change, runs warn and fall back to
pnpm install; refresh with npm run e2e:remote -- rebake --push (publish + pull
onto the saved host) or rebake --rebuild (on-host bake).
A spare machine with Docker + passwordless sudo works too:
npm run e2e:remote -- adopt --host user@box.
There are two distinct token roles. The build-time token is optional: it only warms package inputs and never affects correctness.
| Role | Used when | Needs | New? |
|---|---|---|---|
Registration (ACCESS_TOKEN) |
container start, to register the runner | Org: classic admin:org or fine-grained org Self-hosted runners: Read & write. (Repo-level: repository Administration R/W — repos have no separate runner permission.) |
Existing token, but scope widens repo→org if you go org-level |
Build-time prefetch (BUILD_GH_TOKEN) |
docker build, to clone TARGET_REPO and let pnpm fetch read the private @copse/streaming-markdown git dep |
Contents: Read on both agent-pane and streaming-markdown (classic repo, or a fine-grained token scoped to both) |
Optional performance input |
Runner routing needs no token. The old pick-runner probe job — which used
a RUNNERS_PAT secret (org Self-hosted runners: Read) to enumerate online
runners on every run — was removed in #740. Check-tier jobs now read the
SELF_HOSTED_CHECKS Actions variable directly
(runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}): GitHub-hosted
is the default, and setting the variable to copse-checks opts the tier onto
this fleet. Clear it to go back to hosted. Do not re-create a RUNNERS_PAT
— no workflow reads it, and the org runner-read scope it carried is not needed
for routing.
Set these at repo scope, not org scope. Both variables are read as
vars.X, which resolves repo-then-org, so an org-level value silently applies to every repo that reads the name. That is why the names changed fromCHECKS_RUNNER/E2E_RUNNERwhenagent-panewent public — seedocs/ci-runner-security.md.
The prefetch token needs both repositories because the lockfile contains the
private @copse/streaming-markdown source. Leave it empty to build a cold image;
the setup action restores the same pnpm content store per job.
The clone token is passed as a BuildKit secret (--secret), mounted on
tmpfs and consumed only inside the bake RUN via GIT_CONFIG_* env vars — it is
never written into an image layer. Build with DOCKER_BUILDKIT=1.
If you point TARGET_REPO at a fully public repo, prefetch can work without a
token. With no token or a failed fetch, jobs simply restore package inputs from
Actions and run the same clean install.
The image writes /opt/deps/{store,.lockhash,.ready} and exposes it through
COPSE_BAKED_STORE. The setup action copies that content-addressed store only
when the checked-out lockfile hash matches, then performs a clean offline
install. A mismatch restores the remote store cache instead. Installed package
trees and lifecycle outputs are never shared between jobs.
The superset image runs Chromium-under-Xvfb for e2e, so size for the heavy tier
even though light jobs share the box: ~4-6 GB + ~2 cores per concurrent
runner. Both docker-compose.yml and the Apple supervisor cap each at 6 GB
with a 2 GB
/dev/shm. Light check jobs simply under-use that budget — the cost of pooling.
Done in this branch (code):
- Removed the two old runner dirs; repointed the
Makefile(make runners*) at this unified fleet. agent-panecheck jobs route via theSELF_HOSTED_CHECKSActions variable (runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}); the oldpick-runnerprobe job was removed in #740. The e2e job targets["self-hosted","copse-e2e"], which these runners carry, but only whenSELF_HOSTED_E2E=copse-e2e— hosted is otherwise the default.streaming-markdown/.github/workflows/ci.ymlroutes its check job the same way, so it lands on the sharedcopse-checkspool when the variable is set (fork PRs stay on hosted).
Remaining (infra — do before/at merge):
- Bring the pool up at org scope: set
.envGITHUB_URLto the org, giveACCESS_TOKENorg runner admin, and put both repos in the runner group (the org default group grants all repos). Build + register withmake runners— ormake runners-reprovisionto replace the containers built from the now-deleted dirs. - Set the opt-in variables so jobs route to the fleet, per repo:
gh variable set SELF_HOSTED_CHECKS --repo copse-dev/agent-pane --body copse-checksand, for the e2e tier,gh variable set SELF_HOSTED_E2E --repo copse-dev/agent-pane --body copse-e2e(or Settings → Secrets and variables → Actions → Variables). Unset means hosted — CI still works, it just never uses self-hosted. Nothing auto-detects an offline fleet, so clear the variables when taking the fleet down or routed jobs will queue. No PAT is involved. - Confirm one green run lands on the pool (an e2e job and a check job in agent-pane, a check job in streaming-markdown), then merge.
This directory is self-contained (OCI Dockerfile + entrypoint + orchestration reference
only THIS dir, not the repo root). To split it into copse-dev/ci-runners
preserving history:
git subtree split --prefix=ci-runners -b ci-runners-export
# push ci-runners-export to the new repo's mainNothing here imports from the agent-pane tree, so the split is clean — the only
coupling to a consumer repo is the runtime TARGET_REPO build arg.