Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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