-
Notifications
You must be signed in to change notification settings - Fork 0
feat: runner selection moves to the RUNNER_LABELS repository variable #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b8f6390
3a43bc8
1ca709d
1859a3c
7b5891e
a4fbc91
d2f859b
403c42b
8cc46f9
56cadf6
978df54
0c2ed50
8504ce6
656e4ef
d3fa7d8
c1938dd
5430d51
190d07f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,10 @@ concurrency: | |
|
|
||
| jobs: | ||
| release-please: | ||
| runs-on: ubuntu-latest | ||
| # Runner selection: the RUNNER_LABELS repo variable, a JSON ARRAY of labels. | ||
| # Unset = ubuntu-latest. A malformed value stops this job reporting any | ||
| # check at all -- see docs/setup/runners.md and ci.yml's header. | ||
| runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: write | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # ADR-0005: Runner selection is an adopter variable, not a workflow edit | ||
|
|
||
| - **Status**: Accepted | ||
| - **Date**: 2026-09-02 | ||
|
|
||
| ## Context | ||
|
|
||
| All four workflows hardcoded `runs-on: ubuntu-latest`. An adopter needing a different runner — self-hosted, larger, or on their own hardware for compliance — had to edit the workflow YAML. | ||
|
|
||
| That contradicts the template's own contract twice. `AGENTS.md` says "customize the Makefile, never the workflows", and `docs/template/design-principles.md` promises "workflow YAML stays untouched and upgradable". But `runs-on` is resolved by GitHub when it schedules the job, before any make target exists to be called, so it is the one adopter-facing property the Makefile physically cannot own. The rule was unsatisfiable for it, and following the rule was impossible. | ||
|
|
||
| It also degrades upgrades: `docs/template/upgrading.md` recommends bulk cherry-picking `.github/workflows/` precisely because it is rarely customized locally. A forked `runs-on` line makes every template update a manual merge. | ||
|
|
||
| ## Decision | ||
|
|
||
| All four workflows resolve their runner from a single repository variable, defaulting to today's behaviour: | ||
|
|
||
| ```yaml | ||
| runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }} | ||
| ``` | ||
|
|
||
| 1. **One variable, not one per workflow.** Design principle 1 is "one good default beats three options". Three of the four jobs are trivial and identical in shape, so there is no plausible case for a bigger runner on the issue labeler but not on CI, and every extra name is another thing to typo. Splitting one variable into several later is easy; merging four after adopters have set them is not. | ||
| 2. **A JSON array, via `fromJSON`.** Multi-label selection (`["self-hosted","linux","x64"]`) is the main reason self-hosted users need this at all. | ||
| 3. **`AGENTS.md` is amended to name the exception**, rather than leaving it as folklore. An undocumented sanctioned exception is how a contract rots. `skills/github-actions-hygiene/SKILL.md` rule 1 is amended to match, or the skill would contradict the shipped workflows. | ||
| 4. **The failure mode is documented at the point of risk**, in `ci.yml`'s header and in `docs/setup/runners.md`, with the recovery command. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Adopters change runners from repository settings, and `.github/workflows/` stays byte-identical to upstream and cherry-pickable. | ||
| - **A new route into the "unmergeable repository" failure.** Measured with a throwaway probe workflow rather than assumed: with the variable set to the bare string `ubuntu-latest` instead of a JSON array, `fromJSON` fails during scheduling, the run completes with conclusion `failure` and the message "This run likely failed because of a workflow file issue", **zero jobs are created, and zero check runs are attached to the commit**. Applied to `ci.yml` — this repository's only required status check — the check is never reported, the pull request parks on "Expected — waiting for status", and the Actions tab never names the variable. Recovery is `gh variable delete RUNNER_LABELS`. | ||
| - The variable is bounded to linux x86_64 by `scripts/install-ci-tools.sh`, whose tarball tools ship no other assets. That constraint now has to be documented, because the failure surfaces as a `uname` message rather than a configuration error. | ||
| - Self-hosted runners acquire caveats that did not previously need writing down: the npm and pip installs escape `INSTALL_DIR` and run globally on the runner host every job. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **Four variables**, one per workflow, as the original field report proposed. Rejected per decision 1. | ||
| - **`${{ vars.RUNNER_LABELS || 'ubuntu-latest' }}` — a plain string, no `fromJSON`.** This removes the malformed-JSON footgun entirely, which is the single largest cost of this change. Rejected because it cannot express multi-label self-hosted selection, which is the primary use case. This is the closest call in this ADR; if the footgun proves worse in practice than the multi-label capability is worth, switching is a one-line change. | ||
| - **Leave `runs-on` hardcoded and accept that adopters fork the YAML.** Rejected: it makes two documented promises false, and the fork is permanent and invisible, whereas a misconfigured variable is transient and recoverable. | ||
| - **A `.github/actionlint.yaml` with declared self-hosted labels.** Only needed if `runs-on` held a hardcoded custom label; an expression bypasses actionlint's `runner-label` check entirely (verified against the pinned actionlint 1.7.12). Not needed. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| # Runner selection | ||
|
|
||
| Every workflow in this template resolves its runner from one repository | ||
| variable: | ||
|
|
||
| ```yaml | ||
| runs-on: ${{ fromJSON(vars.RUNNER_LABELS || '["ubuntu-latest"]') }} | ||
| ``` | ||
|
|
||
| Leave `RUNNER_LABELS` unset and behaviour is identical to hardcoding | ||
| `ubuntu-latest`. Set it once and all four workflows move together. | ||
|
|
||
| This is the single sanctioned exception to "customize the Makefile, never the | ||
| workflows" (`AGENTS.md`). It has to be an exception because GitHub resolves | ||
| `runs-on` when it schedules the job — before any `make` target exists to be | ||
| called — so it is the one adopter-facing knob the Makefile cannot own. | ||
|
|
||
| ## Setting it | ||
|
|
||
| Before setting it to a self-hosted runner, read the security note below — on a | ||
| public repository that combination lets anyone who opens a pull request run code | ||
| on your machine. | ||
|
|
||
| The value is a **JSON array**, not a bare string: | ||
|
|
||
| ```bash | ||
| gh variable set RUNNER_LABELS --body '["ubuntu-latest-4-cores"]' | ||
| gh variable set RUNNER_LABELS --body '["self-hosted","linux","x64"]' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this template remains public and an adopter uses this documented setting, Useful? React with 👍 / 👎. |
||
| gh variable delete RUNNER_LABELS # back to the default | ||
| ``` | ||
|
|
||
| ## Get this wrong and the repository stops being mergeable | ||
|
|
||
| The tempting mistake is the bare string: | ||
|
|
||
| ```bash | ||
| gh variable set RUNNER_LABELS --body 'ubuntu-latest' # WRONG - not an array | ||
| ``` | ||
|
|
||
| Measured on this repository with a throwaway probe workflow, not assumed: | ||
|
|
||
| ```text | ||
| workflow run : completed, conclusion = failure | ||
| run message : "This run likely failed because of a workflow file issue." | ||
| jobs created : 0 | ||
| check runs : 0 | ||
| ``` | ||
|
|
||
| `fromJSON` fails while the job is being scheduled, so **no job and no check run | ||
| are ever created.** For the `ci` workflow that is the worst case available: `ci` | ||
| is this repository's only required status check, so the pull request sits on | ||
| "Expected — waiting for status to be reported" forever, cannot merge, and the | ||
| only evidence is a failed run in the Actions tab whose message never mentions | ||
| the variable. | ||
|
|
||
| A valid-but-unknown label fails the same way from the other direction: the job | ||
| queues for a runner that never appears, for up to 24 hours. | ||
|
|
||
| **Recovery, which is not obvious from the symptom:** | ||
|
|
||
| ```bash | ||
| gh variable delete RUNNER_LABELS | ||
| ``` | ||
|
|
||
| Then push any commit to re-trigger. Because of this, change the variable and | ||
| immediately open a throwaway pull request to confirm `ci` still reports, before | ||
| you rely on it. | ||
|
|
||
| ## Hard constraint: linux x86_64 only | ||
|
|
||
| `scripts/install-ci-tools.sh` installs actionlint, gitleaks and lychee as | ||
| checksum-verified `linux_amd64` tarballs, and `require_supported_platform` | ||
| hard-fails on anything else. Point `RUNNER_LABELS` at `macos-latest` or an arm64 | ||
| runner and `make ci-tools` fails at runtime with a message about `uname -m`, | ||
| which reads like a broken script rather than a misconfigured variable. | ||
|
|
||
| Supporting other architectures means adding per-tool asset names to that script, | ||
| not relaxing the guard. | ||
|
|
||
| ## Self-hosted runners: do not use them on a public repository | ||
|
|
||
| **This is a security boundary, not a preference.** `.github/workflows/ci.yml` | ||
| triggers on every `pull_request`. On a **public** repository that includes pull | ||
| requests from forks, and the job checks out the pull request's own tree and then | ||
| runs `make ci-tools` and `make ci-pr` from it. Anyone on the internet who opens | ||
| a pull request therefore executes their own `Makefile` and their own | ||
| `scripts/` on your machine — with your filesystem, your network position, and | ||
| any credentials reachable from that host. Ephemeral cleanup does not help: the | ||
| damage happens while the job is running. | ||
|
|
||
| GitHub's default "require approval for first-time contributors" narrows the | ||
| window; it does not close it, because approval is per-contributor, not | ||
| per-diff, and a returning contributor's next pull request runs unreviewed. | ||
|
|
||
| So: **on a public repository, leave `RUNNER_LABELS` unset.** If you genuinely | ||
| need self-hosted CI on public code, the only safe shapes are to make the | ||
| repository private, or to split the workflow so that fork pull requests stay on | ||
| GitHub-hosted runners and self-hosted runners only ever run on `push` to | ||
| branches you control. That second option is a real workflow change, not a | ||
| variable. | ||
|
|
||
| On a **private** repository, where every contributor already has write access, | ||
| the rest of this section applies. | ||
|
|
||
| ### Operational caveats, private repositories | ||
|
|
||
| Even a *compatible* self-hosted Linux x86_64 runner behaves differently from a | ||
| hosted one, because a hosted runner is destroyed after every job and yours is | ||
| not: | ||
|
|
||
| - `install_npm_global` and `install_python_tool` install **globally**, escaping | ||
| `INSTALL_DIR` entirely. | ||
| - `require_install_consent` waves those through whenever `CI` is set, and GitHub | ||
| Actions always sets it. | ||
| - `place_binary` uses `sudo` when `/usr/local/bin` is not writable, so the | ||
| runner account needs passwordless sudo. | ||
|
|
||
| In practice: every run performs a real global `npm install -g` and | ||
| `pip install --user` on the runner host. That is fine on a throwaway container | ||
| and a slow accumulating mess on a long-lived VM. Prefer an ephemeral | ||
| self-hosted runner, or pre-install the five pinned tools into the image and | ||
| accept that `make ci-tools` will reinstall them anyway. | ||
|
|
||
| ## Sizing: do not downsize to save money | ||
|
|
||
| The instinct to move CI onto a smaller, cheaper runner is usually wrong for a | ||
| repository shaped like this one, and `ci.yml`'s header already explains why for | ||
| the skip-CI case. The same arithmetic applies here: | ||
|
|
||
| - On a **public** repository, GitHub-hosted runners are free. A smaller runner | ||
| saves exactly nothing. | ||
|
Comment on lines
+130
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an adopter follows the earlier Useful? React with 👍 / 👎. |
||
| - On a **private** repository, Actions minutes are billed **rounded up to the | ||
| whole minute**. This template's lint job finishes in well under a minute, so | ||
| it already bills the one-minute floor. A smaller, slower runner cannot go | ||
| below that floor — it can only push the job over it and start billing two. | ||
| - Minimal images reinstall at runtime what a standard image preinstalls. | ||
| Measured by an adopter on a 1 vCPU runner: the same lint job took 32 seconds | ||
| to 2 minutes 7 seconds, against 21 to 45 seconds on `ubuntu-latest` — mostly | ||
| spent installing markdownlint-cli2 via npm and yamllint via pip on one core. | ||
|
|
||
| Downsize only when a job **materially exceeds a minute** *and* you are past your | ||
| plan's included minutes. For a lint-shaped job neither is usually true. | ||
|
|
||
| Upsizing is the more common real need: a big test suite, or a compliance | ||
| requirement that builds run on your own hardware. That is what this variable is | ||
| for. | ||
|
|
||
| ## See also | ||
|
|
||
| - `docs/setup/bootstrap.md` — the rest of the GitHub-side configuration | ||
| - `` `skills/github-actions-hygiene/SKILL.md` `` — why workflows stay thin | ||
| - `docs/adr/ADR-0005-runner-selection-variable.md` — why this is a variable | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a repository belongs to an organization that already exposes an organization-level
RUNNER_LABELS,vars.RUNNER_LABELSresolves that value even though the repository variable is unset, bypassing both the documentedubuntu-latestfallback and the public-repository safety instruction; an inherited self-hosted value would immediately route fork PR code onto that runner.gh variable set --helpconfirms that organization variables are “available to GitHub Actions runs ... within an organization.” Use a collision-resistant name and/or have bootstrap create a repository-level default that shadows organization scope; deleting the repository variable alone is not reliable recovery.AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.