Skip to content

Commit 6a034cf

Browse files
committed
feat(runs-on-selector): add runs-on selector action
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 035a71e commit 6a034cf

12 files changed

Lines changed: 973 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
#MISE description="Run the Node unit tests"
3+
set -euo pipefail
4+
5+
# Quoted so node expands the pattern itself, and pointed at a glob rather than
6+
# the directory, because the directory form would run non-test files too.
7+
node --test 'tests/node/**/*.test.mjs'

‎.github/release-please-config.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"actions/stale": {
3232
"component": "stale",
3333
"initial-version": "0.0.1"
34+
},
35+
"actions/runs-on-selector": {
36+
"component": "runs-on-selector",
37+
"initial-version": "0.0.1"
3438
}
3539
},
3640
"plugins": [
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"actions/semconv/pull-request": "0.0.1",
33
"actions/release-please": "0.0.2",
4-
"actions/stale": "0.0.2"
4+
"actions/stale": "0.0.2",
5+
"actions/runs-on-selector": "0.0.0"
56
}

‎.github/workflows/ci.yml‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,39 @@ concurrency:
1212
cancel-in-progress: true
1313

1414
jobs:
15+
# The repository picks its own runner with the action it ships, so a change to
16+
# that action is exercised by the pull request that makes the change.
17+
runner:
18+
name: Runner
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 5
21+
permissions:
22+
contents: read
23+
outputs:
24+
runs-on: ${{ steps.pick.outputs.runs-on }}
25+
steps:
26+
# Only because `uses: ./actions/runs-on-selector` reads the action itself
27+
# from the workspace. A consumer of the published action needs no
28+
# checkout, because the map is an input.
29+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30+
with:
31+
persist-credentials: false
32+
sparse-checkout: actions/runs-on-selector
33+
sparse-checkout-cone-mode: false
34+
- id: pick
35+
uses: ./actions/runs-on-selector
36+
with:
37+
default: github
38+
pools-json: |
39+
{
40+
"github": "ubuntu-24.04",
41+
"blacksmith": "blacksmith-2vcpu-ubuntu-2404"
42+
}
43+
1544
check:
1645
name: Lint
17-
runs-on: ubuntu-latest
46+
needs: runner
47+
runs-on: ${{ needs.runner.outputs.runs-on }}
1848
permissions:
1949
contents: read
2050
steps:
@@ -27,3 +57,34 @@ jobs:
2757
- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
2858

2959
- run: mise run github:actions:ci:lint
60+
61+
test:
62+
name: Test
63+
needs: runner
64+
runs-on: ${{ needs.runner.outputs.runs-on }}
65+
permissions:
66+
contents: read
67+
steps:
68+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69+
with:
70+
persist-credentials: false
71+
72+
- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
73+
74+
- run: mise run github:actions:tests:node
75+
76+
# The `Runner` job above already proves `pools-json` arrives and the
77+
# output reads back. This covers the rest. `label-prefix` is a dashed
78+
# input name, so the runner spells it INPUT_LABEL-PREFIX, and a unit test
79+
# here can only assert the same guess the code was written from.
80+
- id: contract
81+
uses: ./actions/runs-on-selector
82+
with:
83+
default: github
84+
label-prefix: action-runner
85+
label: action-runner:blacksmith
86+
pools-json: '{"github":"ubuntu-24.04","blacksmith":"blacksmith-2vcpu-ubuntu-2404"}'
87+
88+
- env:
89+
RUNS_ON: ${{ steps.contract.outputs.runs-on }}
90+
run: '[ "$RUNS_ON" = "blacksmith-2vcpu-ubuntu-2404" ]'

‎actions/runs-on-selector/README.md‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# runs-on-selector
2+
3+
Resolves the runner label a run's jobs should schedule on, from a label on the
4+
pull request and a pool map the caller owns. A maintainer moves a single pull
5+
request onto other hardware by adding one label, and nothing else changes.
6+
7+
`runs-on` is resolved before a job exists, and [the contexts it accepts][contexts]
8+
are `github`, `needs`, `strategy`, `matrix`, `vars` and `inputs`. `steps` is not
9+
among them, so no action can set the `runs-on` of the job it runs in. This one
10+
goes in a job of its own and the jobs that care read its output through `needs`.
11+
12+
[contexts]: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts
13+
14+
## Usage
15+
16+
The map is an input, so this needs no checkout, no token, and no permissions.
17+
18+
```yaml
19+
name: CI
20+
21+
on:
22+
pull_request:
23+
24+
permissions: {}
25+
26+
jobs:
27+
runner:
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 5
30+
outputs:
31+
runs-on: ${{ steps.pick.outputs.runs-on }}
32+
steps:
33+
- id: pick
34+
uses: TrogonStack/github-actions/actions/runs-on-selector@<sha> # vX.Y.Z
35+
with:
36+
default: github
37+
pools-json: |
38+
{
39+
"github": "ubuntu-24.04",
40+
"github-arm": "ubuntu-24.04-arm",
41+
"fleet": "acme-ci-linux-x64"
42+
}
43+
44+
test:
45+
needs: runner
46+
runs-on: ${{ needs.runner.outputs.runs-on }}
47+
steps:
48+
- run: echo test
49+
```
50+
51+
`needs: runner` is not optional on a job that reads the output. A job that reads
52+
it without waiting for it gets an empty string, and an empty `runs-on` is a job
53+
queued against no pool: no error, no runner, forever.
54+
55+
The resolver job's own `runs-on` is a literal and has to be. It is the job that
56+
resolves a pool, so it cannot resolve its own.
57+
58+
Every other job now waits on a runner boot, a few seconds, serialized in front
59+
of work that used to start at once.
60+
61+
## One copy of the map
62+
63+
Written as above the map is repeated in every workflow, which is the drift this
64+
exists to remove. Put the resolver job in a reusable workflow of your own, with
65+
the `pools-json` above, and call it:
66+
67+
```yaml
68+
jobs:
69+
runner:
70+
uses: ./.github/workflows/runner.yml
71+
72+
test:
73+
needs: runner
74+
runs-on: ${{ needs.runner.outputs.runs-on }}
75+
```
76+
77+
A local reusable workflow resolves from the ref with no checkout. Wrapping it in
78+
a local *action* instead (`./.github/actions/runner`) does not: a local action
79+
ref is read from the workspace, so every resolver job would need a checkout.
80+
81+
## Labels
82+
83+
A pool named `fleet` is asked for with the label `runs-on:fleet`, which names
84+
the workflow key it ends up controlling. That half is `label-prefix`, and it is
85+
worth leaving alone: one vocabulary across repositories is the point of shipping
86+
this once. Change it where those labels are already spoken for.
87+
88+
The suffix is a pool name, not a runner label. `runs-on:fleet` asks for the
89+
pool `fleet`, which `pools-json` maps to whatever runner label that pool
90+
schedules on.
91+
92+
The labels are not created for you, because the pools they name are yours.
93+
Create one per pool so they are available in the label picker, and skip the
94+
`default` pool if you would rather nobody asked for it by name.
95+
96+
| On the run | Result |
97+
| --- | --- |
98+
| No `runs-on:*` label | `default`. Covers `push`, `schedule`, `workflow_run` and `workflow_dispatch`, none of which carry a pull request. |
99+
| One `runs-on:*` label | That pool. |
100+
| Two different ones | Fails. Picking in a fixed order would make the answer depend on the order pools happen to be written in. |
101+
| A pool that is not in `pools-json` | Fails, listing the ones that are. |
102+
103+
Because every job reads the same output, one label moves the whole run, which is
104+
what keeps caches warm: `actions/cache` and any vendor's cache proxy are local to
105+
the fleet that wrote them. A job that belongs elsewhere writes its own literal
106+
`runs-on` and ignores the output.
107+
108+
## Inputs
109+
110+
| Input | Default | Description |
111+
| --- | --- | --- |
112+
| `pools-json` | required | JSON object mapping pool names to the single runner label each schedules on. A pool name is lowercase letters, digits and dashes, because it is half of a GitHub label. |
113+
| `default` | required | Pool to use when the run asks for none. Must name one of `pools-json`. |
114+
| `pool` | none | Pool to use regardless of the labels on the run. Wins over both the labels and `default`. |
115+
| `label-prefix` | `runs-on` | Prefix of the labels this reads, without the colon. Change it only where `runs-on` collides with labels already in use. |
116+
| `label` | event's pull request | A single `runs-on:<pool>` label to read instead. For an event carrying no pull request of its own, such as `issue_comment` or `workflow_run`. |
117+
118+
The step logs which of the three decided, so a surprising answer is one grep away.
119+
120+
## Outputs
121+
122+
| Output | Description |
123+
| --- | --- |
124+
| `runs-on` | The runner label the calling workflow's jobs should schedule on, named after the key it feeds. |
125+
| `pool` | The name of the pool that label came from. Useful in job names and `if:`. |
126+
127+
## Fixed behaviour
128+
129+
These are not inputs, on purpose.
130+
131+
- The `runs-on:` prefix is the same everywhere. One vocabulary across every
132+
repository is the reason this ships once rather than being copied.
133+
- A name that does not match a pool fails the run, in a label, in `pool`, and in
134+
`default`. Falling back would schedule work on hardware nobody chose and
135+
report green, which is the failure mode that is expensive to notice.
136+
- `default` names one of the pools rather than being a pool of its own, so
137+
trialling a new default is a one-line edit and the label on the one pull
138+
request it breaks is already the way out.
139+
- A pool maps to a single runner label, not to the `runs-on` list form. A fleet
140+
that needs several labels should be given one label of its own, or a runner
141+
group, so the name a maintainer types stays the name of a decision.
142+
143+
## Forks
144+
145+
A pull request from a fork runs the workflow file from its own head, so a fork
146+
can already write `runs-on` directly or delete the resolver job. This action
147+
changes nothing about that in either direction, and nothing here is a defence
148+
against it.
149+
150+
Where forks and self-hosted hardware meet, the controls are GitHub's: require
151+
approval for fork runs, keep self-hosted pools off public repositories, or use
152+
`pull_request_target`, which runs the base branch's workflow file.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Runs-on selector
2+
description: >-
3+
Resolves the runner label a run's jobs should schedule on, from a label on the
4+
pull request and a pool map the caller owns.
5+
author: TrogonStack
6+
7+
inputs:
8+
pools-json:
9+
description: >-
10+
JSON object mapping pool names to the single runner label each pool
11+
schedules on. A pool name is lowercase letters, digits and dashes,
12+
because it is half of a GitHub label.
13+
required: true
14+
default:
15+
description: >-
16+
Pool to use when the run asks for none. Must name one of `pools-json`.
17+
required: true
18+
pool:
19+
description: >-
20+
Pool to use regardless of the labels on the run. Wins over both the
21+
labels and `default` when non-empty, and must name one of `pools-json`.
22+
For a `workflow_dispatch` pool picker, or a called workflow that pins one
23+
stage without inventing a label.
24+
required: false
25+
default: ''
26+
label-prefix:
27+
description: >-
28+
Prefix of the labels this reads, without the colon. A pool named `fleet`
29+
is then asked for with the label `runs-on:fleet`, which names the key it
30+
controls. One vocabulary across repositories is worth more than a local
31+
spelling, so change this only where `runs-on` collides with labels
32+
already in use.
33+
required: false
34+
default: runs-on
35+
label:
36+
description: >-
37+
A single `runs-on:<pool>` label to read instead of the labels on the
38+
event's pull request. For an event that carries no pull request of its
39+
own, such as `issue_comment` or `workflow_run`.
40+
required: false
41+
default: ''
42+
43+
outputs:
44+
runs-on:
45+
description: >-
46+
The runner label the calling workflow's jobs should schedule on, named
47+
after the key it is meant to feed.
48+
pool:
49+
description: The name of the pool that label came from.
50+
51+
runs:
52+
using: node24
53+
main: lib/main.mjs
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// A stand-in for the four functions this action uses from `@actions/core`,
2+
// carrying their names and their behaviour. The package itself is ESM-only and
3+
// reaches `undici` through `@actions/http-client`, so depending on it would mean
4+
// a bundler and a committed `dist/`, and the tests would then exercise something
5+
// other than the file the runner executes.
6+
7+
import crypto from "node:crypto";
8+
import fs from "node:fs";
9+
import { EOL } from "node:os";
10+
11+
// The runner uppercases an input name and replaces spaces, and nothing else, so
12+
// `pools-json` arrives as INPUT_POOLS-JSON and not INPUT_POOLS_JSON.
13+
export function getInput(name, options = {}) {
14+
const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? "";
15+
16+
if (options.required && !value) {
17+
throw new Error(`Input required and not supplied: ${name}`);
18+
}
19+
20+
return options.trimWhitespace === false ? value : value.trim();
21+
}
22+
23+
// Workflow commands end at a newline, so a message carrying one would close the
24+
// annotation and log the remainder as its own line.
25+
function escapeData(value) {
26+
return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
27+
}
28+
29+
export function info(message) {
30+
process.stdout.write(`${message}${EOL}`);
31+
}
32+
33+
export function error(message) {
34+
process.stdout.write(`::error::${escapeData(message)}${EOL}`);
35+
}
36+
37+
export function setFailed(message) {
38+
// Not `process.exit`, which can truncate output still buffered on stdout.
39+
process.exitCode = 1;
40+
error(message);
41+
}
42+
43+
export function setOutput(name, value) {
44+
const file = process.env.GITHUB_OUTPUT;
45+
46+
if (!file) {
47+
throw new Error("Unable to find environment variable for file command OUTPUT");
48+
}
49+
50+
// The delimited form, because a value holding a newline would otherwise be
51+
// read as the start of the next output.
52+
const delimiter = `ghadelimiter_${crypto.randomUUID()}`;
53+
54+
if (name.includes(delimiter) || String(value).includes(delimiter)) {
55+
throw new Error(`Unexpected input: name and value should not contain the delimiter`);
56+
}
57+
58+
fs.appendFileSync(file, `${name}<<${delimiter}${EOL}${value}${EOL}${delimiter}${EOL}`, {
59+
encoding: "utf8",
60+
});
61+
}

0 commit comments

Comments
 (0)