Files
kandev/docs/specs/platform/system-design/external-e2e-runner-capacity.md
T
2026-09-06 14:45:59 +01:00

238 lines
13 KiB
Markdown

---
status: current
system: platform
requirements:
- REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001
- REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-002
- REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-003
created: 2026-09-06
owners:
- kandev
---
# External CI Runner Capacity System Design
## Purpose and boundaries
This design adds provider-neutral runner tiers to the normal Linux CI
workflows. Two repository variables store the approved Linux x64 labels. One
burst-mode variable activates those labels when GitHub-hosted capacity is full.
An optional percentage variable lets operators run a deterministic fraction of
eligible jobs on the external fleet during a pilot.
The tier labels remain configured while burst mode is inactive. An operator
can therefore activate or deactivate paid capacity with one variable change.
The operator can also change an instance type without a workflow edit.
The design changes only runner placement. The duration-aware planner, shard
manifests, build artifacts, reports, required gates, and workflow permissions
remain authoritative. Host-Docker, Kind, compatibility, image-resolution,
desktop, service, Windows, checkout-token, report-token, release, and
deployment jobs remain outside the initial pilot.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001` | [Runner-selection contract](#runner-selection-contract), [Job placement](#job-placement), [Failure and recovery](#failure-and-recovery) |
| `REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-002` | [Security and trust boundary](#security-and-trust-boundary), [Rollout and observability](#rollout-and-observability) |
| `REQ-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-003` | [Percentage allocation](#percentage-allocation), [Workflow integration](#workflow-integration), [Failure and recovery](#failure-and-recovery) |
## Runner-selection contract
The repository stores these Actions variables:
| Variable | Purpose | Initial Ubicloud value |
| --- | --- | --- |
| `KANDEV_CI_EXTERNAL_ENABLED` | Activates configured external tiers only when its value is `true`. | Unset or `false` |
| `KANDEV_CI_EXTERNAL_PERCENT` | Selects the percentage of eligible job instances assigned to external capacity when burst mode is active. | Unset or `0` |
| `KANDEV_CI_RUNNER_LIGHT` | Selects capacity for change detection and required gates. | `ubicloud-standard-2-ubuntu-2404` |
| `KANDEV_CI_RUNNER_STANDARD` | Selects capacity for eligible browser and frontend test jobs. | `ubicloud-standard-4-ubuntu-2404` |
The workflow consumes the planner's resolved light-tier value with this
`runs-on` expression:
```yaml
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).changes_runner }}
```
The planner returns the configured external label or `ubuntu-latest`, never an
expression fragment. Matrix jobs read their matrix from the same JSON plan and
use `runs-on: ${{ matrix.runner }}`. GitHub evaluates the value before it
dispatches the job.
Percentage rollout cannot be implemented safely with a direct `runs-on`
expression. GitHub expressions provide comparisons and boolean operators but no
random or modulo operator. A planner job therefore computes assignments before
eligible jobs start and exposes one JSON plan through a job output. Downstream
jobs consume that plan with `needs` and `fromJSON`.
If burst mode is active, the expression selects the complete label for that
tier. An empty tier label uses `ubuntu-latest`. A non-empty invalid label stays
visible as a queued or failed job.
Variable changes affect jobs that GitHub dispatches after the change. GitHub
does not migrate queued or active jobs. To recover from an external incident,
set `KANDEV_CI_EXTERNAL_ENABLED` to `false` and rerun the workflow.
The workflow does not parse provider names or instance sizes. It also does not
mix instance types inside one shard cohort. Comparable runner capacity keeps
the duration-aware timing profile useful across normal shards.
## Percentage allocation
`.github/scripts/runner-plan.py` is a generic read-only planner that runs on
`ubuntu-latest`. It receives the workflow identifier, run identifier,
workflow-owned JSON family descriptors, burst switch, percentage, and tier
labels. It contains no workflow or job-family catalog. It validates the
percentage and emits one JSON assignment plan plus a visible warning when the
percentage is invalid.
The planner applies these rules:
1. Any burst value other than `true`, or a percentage of `0`, assigns every
eligible instance to `ubuntu-latest`.
2. A percentage of `100` assigns every eligible instance with a non-empty tier
label to that tier. Empty tier labels still use `ubuntu-latest`.
3. For a matrix family with `N` instances, it hashes the workflow, job family,
run identifier, and matrix key, sorts the keys, and assigns exactly
`floor(N * percentage / 100)` instances to the external tier.
4. For a singleton family, it hashes the workflow, job-family key, and run
identifier into a stable bucket. A rerun keeps the same allocation, and
different workflow runs approach the configured percentage.
5. A missing percentage is treated as `0`. A non-integer or out-of-range
percentage assigns all instances to `ubuntu-latest` and emits a planner
warning. It never emits an unvalidated external label for invalid input.
Each job family is allocated independently. At 50 percent, the fourteen E2E
shards receive seven external assignments. Protected backend checkout jobs
remain hosted; the backend aggregate gate and frontend jobs are still
eligible. Singleton jobs use a stable hash cohort, so their share approaches
50 percent across runs. The planner does not promise an exact percentage of
total compute across workflows with different family sizes.
## Workflow integration
Each workflow with eligible jobs adds a small planner job on `ubuntu-latest`.
Eligible jobs depend on the planner only for their runner assignment; their
existing test dependencies, matrices, artifacts, timeouts, permissions, and
required conclusions remain unchanged. A workflow with no eligible jobs does
not run the planner.
The planner steps use the reusable composite action at
`.github/actions/plan-external-runners/action.yml`. The action owns the
validated environment wiring and `runner-plan.py` invocation, while each
workflow owns the family JSON and exposes one `plan` job output. This keeps the
job contract explicit without copying planner logic or teaching Python about
workflow-specific families.
The JSON plan contains only resolved runner labels and matrix data declared by
the workflow. Protected jobs keep explicit `runs-on: ubuntu-latest` and do not
consume planner output. Contract tests assert the output wiring and the
protected-job boundary.
## Job placement
| Job | Initial runner selection | Rationale |
| --- | --- | --- |
| `E2E changes`, `e2e-gate` | Light tier with GitHub fallback | Control jobs are short but can wait many minutes for capacity. |
| `E2E build`, `e2e-report` | `ubuntu-latest` | They query GitHub history or download artifacts with the job token. |
| `e2e` | Standard tier with GitHub fallback | The fourteen normal shards need comparable CPU and memory and do not use explicit repository-token inputs. |
| `Backend changes`, `static_checks`, `test_shards`, `test_ambient_env` | `ubuntu-latest` | Their checkout action receives the short-lived job token. |
| `Backend test` gate | Light tier with GitHub fallback | The aggregate gate is short and has no checkout or service dependency. |
| `Frontend changes`, `frontend-gate` | Light tier with GitHub fallback | Control jobs are short and queue-sensitive. |
| `Frontend frontend` | Standard tier with GitHub fallback | The single frontend test job is a recurring queue bottleneck. |
| `Architecture lint`, action-pinning, harness-lint | `ubuntu-latest` | Their checkout action receives the short-lived job token. |
| `playwright_image` | `ubuntu-latest` | Retains the reviewed host-Docker and GHCR metadata path during the pilot. |
| `e2e-containers` | `ubuntu-latest` | Requires direct host Docker and creates Kind resources. |
| `e2e-kubernetes-compatibility` | `ubuntu-latest` | Requires direct host Docker and pinned Kind clusters. |
| `desktop-e2e` | `ubuntu-latest` | Retains the dedicated desktop container and toolchain boundary during the pilot. |
| Backend `postgres-boot`, Windows, and container/service jobs | `ubuntu-latest` | Preserve host-service, operating-system, and credential boundaries. |
The runner expression does not alter `needs`, `if`, the matrix, the container
image, the environment, the timeout, or artifacts. The gate keeps
`if: always()` and its current result rules.
## Workflow contract coverage
The E2E, backend, and frontend workflow contract tests own the executable
placement contract. Dedicated checks verify that architecture-lint,
action-pinning, and harness-lint do not create planner jobs. They also verify
that every protected CI job uses `runs-on: ubuntu-latest` without the burst,
percentage, or tier variables.
The contract test also protects the provider-neutral behavior: it asserts the
variable names and GitHub fallback, not the Ubicloud labels. Provider
installation and live variable values remain operator-owned external state.
## Security and trust boundary
An external runner executes pull-request and merge-group code. It receives the
short-lived job token, source, artifacts, and logs for its job. Provider
isolation does not make the provider part of GitHub's hosted trust boundary.
The initial eligible jobs keep the workflow's existing top-level read-only
permissions: `contents: read`, `actions: read`, and `packages: read`. Checkout
continues to use `persist-credentials: false`. Jobs whose checkout, GitHub API,
or artifact action receives the short-lived token remain on `ubuntu-latest`.
The change adds no repository, environment, deployment, publishing, signing,
or production secret.
The initial Ubicloud installation uses clean ephemeral x64 VMs and just-in-time
runner registration. Keep the default branch protection if the Ubicloud
transparent cache is enabled. Do not replace the pinned `actions/cache`
references during this rollout.
Jobs triggered by `pull_request_target`, jobs with production or deployment
credentials, checkout-token paths, E2E build/report token paths, releases,
publishing, signing, and Windows builds are not eligible for burst mode.
Future container jobs can use a heavy tier only after a separate host-
dependency, performance, and security review.
Ubicloud Premium Runners remain disabled. Ubicloud controls Premium Runners at
the account level, so that setting cannot provide job-level tier selection.
## Failure and recovery
| Condition | Required behavior |
| --- | --- |
| Burst mode is absent, empty, or not `true` | GitHub dispatches every eligible job to `ubuntu-latest`. |
| Burst mode is `true` and the tier has an approved label | GitHub dispatches new jobs in that class to the configured fleet. |
| Burst mode is `true` and the tier label is empty | GitHub dispatches that job class to `ubuntu-latest`. |
| A non-empty tier label is unavailable or invalid | The job remains visibly queued or fails. No unreviewed fallback runs. |
| External provider has an incident | Set burst mode to `false` and rerun. GitHub does not migrate queued or active jobs. |
| External image differs from the GitHub image | The repository container images own most toolchains. The pilot must verify the host-only change-detection and gate behavior. |
| External cache is cold | Existing dependency and artifact paths continue to work. Cache state can affect duration but not correctness. |
## Rollout and observability
1. Merge the workflow while `KANDEV_CI_EXTERNAL_ENABLED` is absent or `false`.
2. Install the Ubicloud GitHub App only for the Kandev repository.
3. Keep transparent-cache branch protection enabled.
4. Disable Premium Runners in the Ubicloud account.
5. Configure `KANDEV_CI_RUNNER_LIGHT` as
`ubicloud-standard-2-ubuntu-2404`.
6. Configure `KANDEV_CI_RUNNER_STANDARD` as
`ubicloud-standard-4-ubuntu-2404`.
7. When the GitHub queue is full, set `KANDEV_CI_EXTERNAL_ENABLED` to `true`.
8. Observe at least three representative pull-request or merge-group runs.
Record runner labels, queue delay, build duration, shard tail, report/gate
delay, cancellations, retries, infrastructure failures, and cache behavior.
9. After the queue recovers, set `KANDEV_CI_EXTERNAL_ENABLED` to `false`.
10. If the pilot shows a regression, disable burst mode and rerun failed jobs.
Keep the two tier labels configured between bursts. Change the standard-tier
label to `ubicloud-standard-8-ubuntu-2404` only after CPU or memory evidence.
The existing timing diagnostics separate queue delay from setup and test
execution. The pilot succeeds when runner wait decreases without a higher job
failure, cancellation, retry, cache-verification, or artifact-transfer rate.
GitHub merge-group ordering remains an independent source of serialization and
is not attributed to runner capacity.
## Related decisions
- [Opt in selected Linux CI jobs to external runners](../../../decisions/2026-09-06-opt-in-external-e2e-runners.md)
- [Duration-aware E2E sharding uses rolling `main` timings](../../../decisions/2026-08-10-duration-aware-e2e-sharding.md)
- [Cache host-runner E2E browser provisioning](../../../decisions/2026-08-30-e2e-browser-cache.md)