CI Runners (RunsOn)¶
TradAI's CI compute runs on RunsOn — self-hosted GitHub Actions runners on ephemeral EC2 instances in our own AWS account, standing in for GitHub-hosted runners on migrated jobs. This page covers what it is, the runner classes, the label convention, the kill switch, and how to watch it.
What & where¶
RunsOn is deployed as a single aws.cloudformation.Stack wrapping RunsOn's own CloudFormation template, managed by the Pulumi project infra/ci/ in a dedicated AWS account (tradai-ci, eu-central-1). It is not applied by deploy-infra.yml — apply it by hand:
Runner classes & presets¶
Presets live in .github/runs-on.yml, referenced from a workflow via the runs-on=<label>/runner=<preset> label described in Label convention below. All presets are x64 (ubuntu24-full-x64).
| Preset | vCPU / RAM | Pricing | Retry | Cache | Use for |
|---|---|---|---|---|---|
ci-2 | 2 / 4-8 GB | spot (price-capacity-optimized) | on interruption | Magic Cache (s3-cache) | idempotent jobs, no deploy credentials |
ci-4 | 4 / 16 GB | spot (price-capacity-optimized) | on interruption | Magic Cache (s3-cache) | idempotent jobs, no deploy credentials |
ci-8 | 8 / 32 GB | spot (price-capacity-optimized) | on interruption | Magic Cache (s3-cache) | idempotent jobs, no deploy credentials |
perf-4 | 4 / 16 GB | spot, pinned to the m7a family | on interruption | Magic Cache (s3-cache) | performance.yml baselines (stable instance family) |
deploy-2 | 2 / 4-8 GB | on-demand | never | none | ECR/CodeArtifact push, Pulumi state mutation, ASG refresh, Renovate |
deploy-4 | 4 / 16 GB | on-demand | never | none | same as above, heavier jobs |
deploy-8 | 8 / 32 GB | on-demand | never | none | same as above, heaviest jobs |
Glue jobs (changes, lint-required, test-required, version-bump-guard, version, report-*, notify-*) stay on ubuntu-latest.
deploy-* is deliberately not shared with ci-*: the release path must not consume a cache a PR run could have seeded (deploy-lambdas.yml:133-145 documents the same concern for GitHub's own cache), so deploy-* runners get no s3-cache extra at all.
Label convention¶
Every migrated job resolves its runs-on: label through the RUNS_ON_ENABLED repo variable, so the fleet can be killed with one variable flip. The label convention is run-id-only — no github.job discriminator. github.job renders as null in a runs-on: expression (GitHub documents github.job as unavailable outside job steps, and runs-on: is evaluated before the job starts); this was confirmed with live evidence — RunsOn canary run 32800592037 and a CI dispatch both showed the empty render. github.job must never appear in a label, and tests/unit/test_runs_on_labels.py rejects any label that contains it.
Plain job:
runs-on: ${{ vars.RUNS_ON_ENABLED == 'true' && format('runs-on={0}/runner=<preset>', github.run_id) || 'ubuntu-latest' }}
Matrix job — discriminate on strategy.job-index instead:
runs-on: ${{ vars.RUNS_ON_ENABLED == 'true' && format('runs-on={0}-{1}/runner=<preset>', github.run_id, strategy.job-index) || 'ubuntu-latest' }}
_test.yml reusable callers pass a runner: input built the same way, keyed off matrix.package:
The presets canary (fans out one job per preset) builds its label directly rather than through format(), discriminating on strategy.job-index the same way a matrix job does:
Jobs within one run that end up sharing a plain runs-on=<run_id>/runner=<preset> label are interchangeable by design: RunsOn launches one runner per queued job, so two jobs with the same label just get two separate runners — only a matrix fan-out needs the extra discriminator to keep its parallel jobs distinct.
A reusable-workflow caller job (e.g. uses: ./.github/workflows/_lint.yml or _test.yml) has no runs-on: or steps: of its own, so it will never show runs-on: or runs-on/action — but its with: runner: input is where the kill-switch expression, the preset, and (for a matrix caller) the matrix discriminator are actually supplied. The called workflow's job is what consumes that value (runs-on: ${{ inputs.runner }}) and carries the runs-on/action first step. When auditing a job (e.g. against the PR template's "CI runner label" checklist item), review both: the caller's with: runner: for the label expression/preset/ matrix key, and the called workflow's runs-on:/runs-on/action for how it's consumed. (The runner input on _lint.yml/_test.yml lands with the light-jobs migration PR; until then those two reusable workflows run on ubuntu-latest and have nothing to audit.)
The first step of every migrated job is:
This step is a no-op on GitHub-hosted runners, so it's safe to leave in place even when the kill switch has fallen back to ubuntu-latest.
The typo trap¶
A misspelled preset name doesn't fail the job outright — GitHub just never finds a runner that satisfies the label, so it queues until GitHub's ~24 h queued-job timeout cancels it — a typo surfaces as a failure only after a ~24 h delay, not immediately; tests/unit/test_runs_on_labels.py is the pre-merge guard. Actionlint cannot catch this: the label is a computed expression, not a static string, so there's nothing for a linter to check against .github/runs-on.yml. That test asserts every runs-on=.../runner=<preset> label in the workflow files names a preset that actually exists in .github/runs-on.yml.
Kill switch & rollback¶
Fleet outage (spot capacity, GitHub App broken, licence problem) — first response:
Every migrated label reads that variable, so the next run of every workflow lands on GitHub-hosted runners with no PR and no merge required. Unset (or never set) also means GitHub-hosted. Required checks keep flowing either way. Flip it back to true once the cause is fixed.
Per-job rollback: delete the ternary for that one job so it always resolves to ubuntu-latest.
Monitoring¶
The first canary ran all seven presets; queue + boot came in at 25–28 s per job across the board — use that as the baseline when judging whether a run's p50/p95 below looks off.
Queue + boot time (p50/p95/max) for a run:
gh api "repos/tradai-bot/tradai/actions/runs/$RUN/jobs?per_page=100" --jq '[.jobs[] | select(.labels[0] | startswith("runs-on=")) | ((.started_at|fromdate)-(.created_at|fromdate))] | sort | {n: length, p50: (if length == 0 then null else .[((length * 0.5 | ceil) - 1)] end), p95: (if length == 0 then null else .[((length * 0.95 | ceil) - 1)] end), max: max}'
Stuck jobs (queued > 10 min usually means a label typo or a capacity shortage). gh run list --status queued lists runs, not jobs, and a run can show in_progress while one of its jobs is still queued — query the jobs API instead:
for st in queued in_progress; do
gh run list --status "$st" --limit 30 --json databaseId --jq '.[].databaseId'
done | while read -r r; do
gh api "repos/tradai-bot/tradai/actions/runs/$r/jobs?per_page=100" \
| jq -r --argjson now "$(date +%s)" --arg run "$r" \
'.jobs[] | select(.status=="queued" and (.labels[0] // "" | startswith("runs-on=")) and (($now - (.created_at|fromdate)) > 600)) | "\($run)\t\(.name)\t\(($now - (.created_at|fromdate)))s"'
done
(gh api has no --argjson flag — that's plain jq's; pipe its output into a separate jq instead. gh run list --status also only accepts one value, so a second --status silently overrides the first — loop over the statuses instead of repeating the flag.)
Any row printed = label typo or capacity problem.
Spot interruptions: jobs with run_attempt > 1 on ci-* jobs, cross-checked against the RunsOn CloudWatch log group filtered for interrupt. The RunsOn service itself (not the ephemeral runners) logs to /aws/ecs/runs-on/runs-on-worker in the tradai-ci account:
Get the log group name programmatically from the infra/ci Pulumi stack output — it's the same name, just resolved instead of hardcoded:
If the stack output isn't available, fall back to the raw CloudFormation output (RunsOnServiceLogGroupName):
aws cloudformation describe-stacks --stack-name runs-on --profile tradai-ci \
--query "Stacks[0].Outputs[?OutputKey=='RunsOnServiceLogGroupName'].OutputValue" --output text
Cost: the per-job cost line the runs-on/action step prints in that step's own post-step log segment (gh run view <id> --log | grep -i cost), and AWS Cost Explorer grouped by the tradai-ci-class tag.
Operating the stack¶
The GitHub App used to register runners with GitHub is created once via the stack's RunsOnEntryPoint output. After initial setup, infra/ci config sets enable_admin_routes to false and protect to true, so routine operation doesn't expose the admin surface and the stack can't be destroyed by accident.
Besides service_log_group_name, the Pulumi stack also outputs entry_point (the RunsOn control-plane URL used for GitHub App setup) and license_status_parameter_name (the SSM parameter, /runs-on/license/status, the licence health check reads). Read any of them the same way: