vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: a job can name the machine it needs
6c03e461ee409f4bd053ee552edd87b6188a5eb2 · Keenan Nemetz · 16d ago · parent b238ac24
Phase 4: a job can name the machine it needs `runs-on: [linux, arm64]` on a job, `--label linux` on a runner, and the lease hands a job only to a runner carrying every label it asked for. Extra labels on the runner are not a mismatch — the rule is set inclusion, so a machine more specific than the job needs still qualifies — and a job with no runs-on matches every runner, which is what every job did before this existed. Both spellings are accepted (`runs-on: linux` and a list), because both are the obvious way to write it and a dialect already separate from GitHub's should not also fail on the spelling a person guessed. Labels are validated in pre-receive by the same rule the runner applies to --label at startup: a label no runner can carry leaves a job queued forever and looks exactly like a busy pool, which is the failure nobody debugs because there is no error. Labels are pinned at enqueue next to the steps and the artifact patterns. A workflow edited after a push must not change where an already-queued job is allowed to run. That also settles the derived-store question: run_labels is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `vole index rebuild` does not restore labels and nothing needs it to. A label is a claim by the runner about itself, unlike --repos, which the server enforces. A runner that lies about its architecture only takes work it will do badly; a runner that could name repositories it was not granted would be an access-control hole. /runners now shows what each connected runner takes, and says "any job" for an unlabelled one — the other half of "why has my build not started", since queue depth alone cannot say that no runner carrying the needed label is connected. Enqueue takes labels as a second argument rather than a field on Run: they are lease-time policy, not part of what a run is, and every reader of a Run (a gate, a page, a record rebuild) would be looking at a field only a lease ever consulted. Measured: TestRunsOnWaitsForARunnerThatCarriesTheLabel — a runner carrying only `linux` leaves a `[linux, fast]` job queued and the runner carrying both builds it. TestLabelsDecideWhichRunnerMayTakeAJob covers no requirement, exact match, one label missing, wrong labels and extra labels; TestLabelsArePinnedAtEnqueue, TestForgettingARepositoryDropsItsLabels, TestABadRunnerLabelIsRefusedBeforeConnecting, TestRunsOnLabelsAreParsedAndValidated and TestRunnersPageShowsWhatEachRunnerTakes cover the edges.
docs/roadmap.md +37−4
| @@ -559,10 +559,42 @@ can still lease it; `TestARepoFilterTakesOnlyWhatItNames`, `TestNarrowCanOnlySub | |||
| 559 | 559 | `TestRepoFilterThatCanNeverMatchIsRefused`, | |
| 560 | 560 | `TestARepoFilterOutsideTheGrantIsRefusedAtStartup`.) | |
| 561 | 561 | ||
| 562 | Remaining: | ||
| 563 | |||
| 564 | - Job-level targeting (`runs-on`) and runner labels: one instance-wide pool is right | ||
| 565 | for identical machines and wrong for one macOS runner in a Linux pool | ||
| 562 | ✅ **Slice 12 — a job can name the machine it needs.** `runs-on: linux` or | ||
| 563 | `runs-on: [linux, arm64]` on a job, `--label linux` on a runner, and the lease hands a | ||
| 564 | job only to a runner carrying *every* label it asked for. Extra labels on the runner are | ||
| 565 | not a mismatch: the rule is set inclusion, so a machine that is more specific than the | ||
| 566 | job needs still qualifies. A job with no `runs-on` matches every runner, which is what | ||
| 567 | every job did before this existed — so nothing that already runs changed behaviour. | ||
| 568 | |||
| 569 | Both spellings are accepted because both are the obvious way to write it, and a dialect | ||
| 570 | that is already separate from GitHub's should not also fail on the spelling a person | ||
| 571 | guessed. Labels are lowercase and validated in `pre-receive` for the same reason the | ||
| 572 | steps are: a label no runner can ever carry — `Linux`, a typo, a stray space — leaves the | ||
| 573 | job queued forever and looks exactly like a busy pool. The runner refuses a bad `--label` | ||
| 574 | at startup with the same validator, so the two halves cannot disagree about what a label | ||
| 575 | is. | ||
| 576 | |||
| 577 | The labels are **pinned at enqueue** next to the steps and the artifact patterns. A | ||
| 578 | workflow edited after a push must not change where an already-queued job is allowed to | ||
| 579 | run, which is the same rule as "a workflow that mutates its own definition mid-run still | ||
| 580 | executes the version it was queued with". That also settles the derived-store question: | ||
| 581 | `run_labels` is queue state, not history, because a finished run's requirement is | ||
| 582 | recoverable from the pinned workflow blob — so `vole index rebuild` does not restore | ||
| 583 | labels, and nothing needs it to. | ||
| 584 | |||
| 585 | A label is a **claim by the runner about itself**, unlike `--repos`, which the server | ||
| 586 | enforces. That asymmetry is deliberate: a runner that lies about its architecture only | ||
| 587 | takes work it will do badly, which is its own problem, whereas a runner that could name | ||
| 588 | repositories it was not granted would be an access-control hole. `/runners` now shows | ||
| 589 | what each connected runner takes, and says `any job` for an unlabelled one — the other | ||
| 590 | half of "why has my build not started", since the depth alone cannot say that no runner | ||
| 591 | carrying the needed label is connected. | ||
| 592 | (Measured: `TestRunsOnWaitsForARunnerThatCarriesTheLabel` — a runner carrying only | ||
| 593 | `linux` leaves a `[linux, fast]` job queued, and the runner carrying both builds it; | ||
| 594 | `TestLabelsDecideWhichRunnerMayTakeAJob` (eight cases: no requirement, exact match, one | ||
| 595 | label missing, wrong labels, extra labels), `TestLabelsArePinnedAtEnqueue`, | ||
| 596 | `TestForgettingARepositoryDropsItsLabels`, `TestABadRunnerLabelIsRefusedBeforeConnecting`, | ||
| 597 | `TestRunsOnLabelsAreParsedAndValidated`, `TestRunnersPageShowsWhatEachRunnerTakes`.) | ||
| 566 | 598 | ||
| 567 | 599 | **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in | |
| 568 | 600 | the job being requeued and completing on another runner ✅; a workflow that mutates its | |
| @@ -1065,6 +1097,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 1065 | 1097 | | 2026-10-23 | A record names the machine (OS, architecture), not the toolchain | The roadmap asked for the runner's Go version too, and it is the one field here that would mislead: `runtime.Version()` reports what compiled the *runner binary*, not what a job builds with, so in a signed record it would be a confident answer to a question nobody asked. OS and architecture describe the machine, which is the fact worth keeping — it is what turns "one macOS runner in a Linux pool" into something a finished run can answer. Both are the binary's build target rather than a live probe: a `uname` subprocess would add a failure path on Windows to sharpen a fact the binary already knows, and the only case it would correct is emulation. The keys are optional so records written before them still read, since a record is never rewritten | | |
| 1066 | 1098 | | 2026-10-23 | A repository may be bounded with `max_jobs_per_repo`, and finishing a job wakes the lease | Ordering and bounding answer different questions: ordering (least busy repository first) keeps one repository's burst from taking the whole fleet, and a cap is what bounds one repository's footprint on purpose. Enforcing it as a skip in the lease rather than a refusal keeps the other repositories an account may run serviceable, which makes `nil` mean two things — "nothing to do" and "work, but not for you yet" — and a cap-bound queue is exactly where the second meaning would hang forever, since a parked runner has nothing to wake it. So `Finish` notifies as well: a released slot is leasable work. The runners page renders `4 of 4 (at the cap)` for the same reason, because a depth that is full and not draining with no explanation is the question that page exists to answer | | |
| 1067 | 1099 | | 2026-10-23 | A runner's `--repos` filter is applied by the server, as an intersection with the allowlist | A filter and a grant are different things: `--repos` says what one machine is *for*, and the runner allowlist says what its account *may do*. Applying the filter server-side, against the grant, means the client's list can only shrink the answer — if the server honoured the request list directly, an SSH flag would have become an access-control decision, and the first person to notice would be whoever read the flag's name and assumed otherwise. An empty filter means "no narrowing" rather than "nothing" so an unfiltered runner keeps following allowlist edits instead of freezing at connect time. The cost is a filter that matches nothing, which presents as a healthy idle runner, so `ping` reports the grant and the runner refuses at startup instead. `Lease` consequently takes a struct: it now holds two lists of names, and adjacent `[]string` arguments are the shape that transposes silently | | |
| 1100 | | 2026-10-23 | `runs-on` labels are pinned at enqueue, and a runner's labels are a claim rather than a grant | Pinning reuses the rule that already governs steps and artifact patterns: a workflow edited after a push must not change what an already-queued job is. That settles the derived-store question too — `run_labels` is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `index rebuild` does not restore labels and nothing needs it to. The asymmetry with `--repos` is the interesting part: a runner's labels are a claim about itself, so lying only wins it work it will do badly, while a runner's repository list is enforced by the server because an SSH flag must not become an access-control decision. Both are in the same lease request and neither is trusted for the same reason. Labels are lowercase and validated by `pre-receive` and by the runner with one shared rule, because a label no runner can carry leaves a job queued forever behind an error nobody sees | | ||
| 1068 | 1101 | ||
| 1069 | 1102 | ## Open questions | |
| 1070 | 1103 | ||