From 6752709c016cecb012b14295f8821d9cf5310a2a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 22:57:41 -0400 Subject: [PATCH 1/2] feat(runs-on-selector): add runs-on selector action Signed-off-by: Yordis Prieto --- .config/mise/tasks/github/actions/tests/node | 7 + .github/release-please-config.json | 4 + .github/release-please-manifest.json | 3 +- .github/workflows/ci.yml | 47 ++++- actions/runs-on-selector/README.md | 150 +++++++++++++ actions/runs-on-selector/action.yml | 40 ++++ actions/runs-on-selector/lib/core.mjs | 61 ++++++ actions/runs-on-selector/lib/index.mjs | 171 +++++++++++++++ actions/runs-on-selector/lib/main.mjs | 3 + mise.toml | 1 + tests/node/runs-on-selector/core.test.mjs | 157 ++++++++++++++ tests/node/runs-on-selector/index.test.mjs | 209 +++++++++++++++++++ 12 files changed, 851 insertions(+), 2 deletions(-) create mode 100755 .config/mise/tasks/github/actions/tests/node create mode 100644 actions/runs-on-selector/README.md create mode 100644 actions/runs-on-selector/action.yml create mode 100644 actions/runs-on-selector/lib/core.mjs create mode 100644 actions/runs-on-selector/lib/index.mjs create mode 100644 actions/runs-on-selector/lib/main.mjs create mode 100644 tests/node/runs-on-selector/core.test.mjs create mode 100644 tests/node/runs-on-selector/index.test.mjs diff --git a/.config/mise/tasks/github/actions/tests/node b/.config/mise/tasks/github/actions/tests/node new file mode 100755 index 0000000..6f1949d --- /dev/null +++ b/.config/mise/tasks/github/actions/tests/node @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="Run the Node unit tests" +set -euo pipefail + +# Quoted so node expands the pattern itself, and pointed at a glob rather than +# the directory, because the directory form would run non-test files too. +node --test 'tests/node/**/*.test.mjs' diff --git a/.github/release-please-config.json b/.github/release-please-config.json index c08dfee..d8e93bd 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -31,6 +31,10 @@ "actions/stale": { "component": "stale", "initial-version": "0.0.1" + }, + "actions/runs-on-selector": { + "component": "runs-on-selector", + "initial-version": "0.0.1" } }, "plugins": [ diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json index ee2235e..f958e8d 100644 --- a/.github/release-please-manifest.json +++ b/.github/release-please-manifest.json @@ -1,5 +1,6 @@ { "actions/semconv/pull-request": "0.0.1", "actions/release-please": "0.0.2", - "actions/stale": "0.0.2" + "actions/stale": "0.0.2", + "actions/runs-on-selector": "0.0.0" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a308c8..62752e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,39 @@ concurrency: cancel-in-progress: true jobs: + # The repository picks its own runner with the action it ships, so a change to + # that action is exercised by the pull request that makes the change. + runner: + name: Runner + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + runs-on: ${{ steps.pick.outputs.runs-on }} + steps: + # Only because `uses: ./actions/runs-on-selector` reads the action itself + # from the workspace. A consumer of the published action needs no + # checkout, because the map is an input. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: actions/runs-on-selector + sparse-checkout-cone-mode: false + - id: pick + uses: ./actions/runs-on-selector + with: + default-pool-name: github + pools-json: | + { + "github": "ubuntu-24.04", + "blacksmith": "blacksmith-2vcpu-ubuntu-2404" + } + check: name: Lint - runs-on: ubuntu-latest + needs: runner + runs-on: ${{ needs.runner.outputs.runs-on }} permissions: contents: read steps: @@ -27,3 +57,18 @@ jobs: - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - run: mise run github:actions:ci:lint + + test: + name: Test + needs: runner + runs-on: ${{ needs.runner.outputs.runs-on }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + + - run: mise run github:actions:tests:node diff --git a/actions/runs-on-selector/README.md b/actions/runs-on-selector/README.md new file mode 100644 index 0000000..62b6d33 --- /dev/null +++ b/actions/runs-on-selector/README.md @@ -0,0 +1,150 @@ +# runs-on-selector + +Resolves the runner label a run's jobs should schedule on, from a label on the +pull request and a pool map the caller owns. A maintainer moves a single pull +request onto other hardware by adding one label, and nothing else changes. + +`runs-on` is resolved before a job exists, and [the contexts it accepts][contexts] +are `github`, `needs`, `strategy`, `matrix`, `vars` and `inputs`. `steps` is not +among them, so no action can set the `runs-on` of the job it runs in. This one +goes in a job of its own and the jobs that care read its output through `needs`. + +[contexts]: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts + +## Usage + +The map is an input, so this needs no checkout, no token, and no permissions. + +```yaml +name: CI + +on: + pull_request: + +permissions: {} + +jobs: + runner: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runs-on: ${{ steps.pick.outputs.runs-on }} + steps: + - id: pick + uses: TrogonStack/github-actions/actions/runs-on-selector@ # vX.Y.Z + with: + default-pool-name: github + pools-json: | + { + "github": "ubuntu-24.04", + "github-arm": "ubuntu-24.04-arm", + "fleet": "acme-ci-linux-x64" + } + + test: + needs: runner + runs-on: ${{ needs.runner.outputs.runs-on }} + steps: + - run: echo test +``` + +`needs: runner` is not optional on a job that reads the output. A job that reads +it without waiting for it gets an empty string, and an empty `runs-on` is a job +queued against no pool: no error, no runner, forever. + +The resolver job's own `runs-on` is a literal and has to be. It is the job that +resolves a pool, so it cannot resolve its own. + +Every other job now waits on a runner boot, a few seconds, serialized in front +of work that used to start at once. + +## One copy of the map + +Written as above the map is repeated in every workflow, which is the drift this +exists to remove. Put the resolver job in a reusable workflow of your own, with +the `pools-json` above, and call it: + +```yaml +jobs: + runner: + uses: ./.github/workflows/runner.yml + + test: + needs: runner + runs-on: ${{ needs.runner.outputs.runs-on }} +``` + +A local reusable workflow resolves from the ref with no checkout. Wrapping it in +a local *action* instead (`./.github/actions/runner`) does not: a local action +ref is read from the workspace, so every resolver job would need a checkout. + +## Labels + +A pool named `fleet` is asked for with the label `runs-on:fleet`, which names +the workflow key it ends up controlling. That half is `label-prefix`, and it is +worth leaving alone: one vocabulary across repositories is the point of shipping +this once. Change it where those labels are already spoken for. + +The suffix is a pool name, not a runner label. `runs-on:fleet` asks for the +pool `fleet`, which `pools-json` maps to whatever runner label that pool +schedules on. + +The labels are not created for you, because the pools they name are yours. +Create one per pool so they are available in the label picker, and skip the +`default-pool-name` pool if you would rather nobody asked for it by name. + +| On the run | Result | +| --- | --- | +| No `runs-on:*` label | `default-pool-name`. Covers `push`, `schedule`, `workflow_run` and `workflow_dispatch`, none of which carry a pull request. | +| One `runs-on:*` label | That pool. | +| Two different ones | Fails. Picking in a fixed order would make the answer depend on the order pools happen to be written in. | +| A pool that is not in `pools-json` | Fails, listing the ones that are. | + +Because every job reads the same output, one label moves the whole run, which is +what keeps caches warm: `actions/cache` and any vendor's cache proxy are local to +the fleet that wrote them. A job that belongs elsewhere writes its own literal +`runs-on` and ignores the output. + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `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. | +| `default-pool-name` | required | Pool to use when no `runs-on:*` label asks for one. Must name one of `pools-json`. An expression here covers events that carry no labels at all, such as a `workflow_dispatch` pool picker. | +| `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. | + +The step logs which of the two decided, so a surprising answer is one grep away. + +## Outputs + +| Output | Description | +| --- | --- | +| `runs-on` | The runner label the calling workflow's jobs should schedule on, named after the key it feeds. | +| `pool-name` | The name of the pool that label came from. Useful in job names and `if:`. | + +## Fixed behaviour + +These are not inputs, on purpose. + +- The `runs-on:` prefix is the same everywhere. One vocabulary across every + repository is the reason this ships once rather than being copied. +- A name that does not match a pool fails the run, in a label and in + `default-pool-name`. Falling back would schedule work on hardware nobody chose and + report green, which is the failure mode that is expensive to notice. +- `default-pool-name` names one of the pools rather than being a pool of its own, so + trialling a new default is a one-line edit and the label on the one pull + request it breaks is already the way out. +- A pool maps to a single runner label, not to the `runs-on` list form. A fleet + that needs several labels should be given one label of its own, or a runner + group, so the name a maintainer types stays the name of a decision. + +## Forks + +A pull request from a fork runs the workflow file from its own head, so a fork +can already write `runs-on` directly or delete the resolver job. This action +changes nothing about that in either direction, and nothing here is a defence +against it. + +Where forks and self-hosted hardware meet, the controls are GitHub's: require +approval for fork runs, keep self-hosted pools off public repositories, or use +`pull_request_target`, which runs the base branch's workflow file. diff --git a/actions/runs-on-selector/action.yml b/actions/runs-on-selector/action.yml new file mode 100644 index 0000000..aae5867 --- /dev/null +++ b/actions/runs-on-selector/action.yml @@ -0,0 +1,40 @@ +name: Runs-on selector +description: >- + Resolves the runner label a run's jobs should schedule on, from a label on the + pull request and a pool map the caller owns. +author: TrogonStack + +inputs: + pools-json: + description: >- + JSON object mapping pool names to the single runner label each pool + schedules on. A pool name is lowercase letters, digits and dashes, + because it is half of a GitHub label. + required: true + default-pool-name: + description: >- + Pool to use when no `runs-on:*` label asks for one. Must name one of + `pools-json`. An expression here covers the events that carry no labels + at all, such as a `workflow_dispatch` pool picker. + required: true + label-prefix: + description: >- + Prefix of the labels this reads, without the colon. A pool named `fleet` + is then asked for with the label `runs-on:fleet`, which names the key it + controls. One vocabulary across repositories is worth more than a local + spelling, so change this only where `runs-on` collides with labels + already in use. + required: false + default: runs-on + +outputs: + runs-on: + description: >- + The runner label the calling workflow's jobs should schedule on, named + after the key it is meant to feed. + pool-name: + description: The name of the pool that label came from. + +runs: + using: node24 + main: lib/main.mjs diff --git a/actions/runs-on-selector/lib/core.mjs b/actions/runs-on-selector/lib/core.mjs new file mode 100644 index 0000000..ec8eacc --- /dev/null +++ b/actions/runs-on-selector/lib/core.mjs @@ -0,0 +1,61 @@ +// A stand-in for the four functions this action uses from `@actions/core`, +// carrying their names and their behaviour. The package itself is ESM-only and +// reaches `undici` through `@actions/http-client`, so depending on it would mean +// a bundler and a committed `dist/`, and the tests would then exercise something +// other than the file the runner executes. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import { EOL } from "node:os"; + +// The runner uppercases an input name and replaces spaces, and nothing else, so +// `pools-json` arrives as INPUT_POOLS-JSON and not INPUT_POOLS_JSON. +export function getInput(name, options = {}) { + const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? ""; + + if (options.required && !value) { + throw new Error(`Input required and not supplied: ${name}`); + } + + return options.trimWhitespace === false ? value : value.trim(); +} + +// Workflow commands end at a newline, so a message carrying one would close the +// annotation and log the remainder as its own line. +function escapeData(value) { + return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +export function info(message) { + process.stdout.write(`${message}${EOL}`); +} + +export function error(message) { + process.stdout.write(`::error::${escapeData(message)}${EOL}`); +} + +export function setFailed(message) { + // Not `process.exit`, which can truncate output still buffered on stdout. + process.exitCode = 1; + error(message); +} + +export function setOutput(name, value) { + const file = process.env.GITHUB_OUTPUT; + + if (!file) { + throw new Error("Unable to find environment variable for file command OUTPUT"); + } + + // The delimited form, because a value holding a newline would otherwise be + // read as the start of the next output. + const delimiter = `ghadelimiter_${crypto.randomUUID()}`; + + if (name.includes(delimiter) || String(value).includes(delimiter)) { + throw new Error(`Unexpected input: name and value should not contain the delimiter`); + } + + fs.appendFileSync(file, `${name}<<${delimiter}${EOL}${value}${EOL}${delimiter}${EOL}`, { + encoding: "utf8", + }); +} diff --git a/actions/runs-on-selector/lib/index.mjs b/actions/runs-on-selector/lib/index.mjs new file mode 100644 index 0000000..e6732f8 --- /dev/null +++ b/actions/runs-on-selector/lib/index.mjs @@ -0,0 +1,171 @@ +import fs from "node:fs"; + +import { getInput, info, setFailed, setOutput } from "./core.mjs"; + +// One vocabulary across every repository is the reason this ships once rather +// than being copied, so `label-prefix` is for a collision with labels a +// repository already uses, not for taste. +export const DEFAULT_LABEL_PREFIX = "runs-on"; + +const POOL_NAME = /^[a-z0-9][a-z0-9-]*$/; + +// No colon, because the colon is appended here. A prefix carrying one would +// produce `a::b` and match nothing anybody typed. +const LABEL_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +// Thrown for anything a caller can fix by editing their workflow, so main can +// report it as a GitHub error annotation rather than a stack trace. +export class InputError extends Error {} + +function parseJson(text) { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Resolves a runner label without touching the environment, so the tests drive +// the code that ships rather than a copy of it. +export function resolve({ poolsJson, defaultPoolName, labelPrefix, eventLabels }) { + const logs = []; + + const configured = labelPrefix || DEFAULT_LABEL_PREFIX; + if (!LABEL_PREFIX.test(configured)) { + throw new InputError( + "The `label-prefix` input must be letters, digits, dots, dashes and underscores, with no colon: the colon is added for you.", + ); + } + const prefix = `${configured}:`; + + // Checked one at a time so a message names what is wrong rather than + // reporting that something is. + const parsed = parseJson(poolsJson); + if (!parsed.ok || !isPlainObject(parsed.value)) { + throw new InputError( + "The `pools-json` input must be a JSON object mapping pool names to runner labels.", + ); + } + + const pools = parsed.value; + const names = Object.keys(pools); + + if (names.length === 0) { + throw new InputError( + "The `pools-json` input is an empty object, so there is nothing to schedule on.", + ); + } + + if (!names.every((name) => POOL_NAME.test(name))) { + throw new InputError( + "Every pool name in `pools-json` must be lowercase letters, digits and dashes: it is half of a GitHub label.", + ); + } + + if (!names.every((name) => typeof pools[name] === "string" && pools[name].length > 0)) { + throw new InputError( + "Every pool in `pools-json` must map to a single non-empty runner label.", + ); + } + + // Both spellings of the vocabulary, for the messages below: pool names for the + // inputs, prefixed labels for whoever is labelling a pull request. + const known = names.join(", "); + const askable = names.map((name) => prefix + name).join(", "); + + if (!defaultPoolName) { + throw new InputError( + `The \`default-pool-name\` input is required, and must name one of \`pools-json\`: ${known}.`, + ); + } + + if (!Object.hasOwn(pools, defaultPoolName)) { + throw new InputError( + `The \`default-pool-name\` input '${defaultPoolName}' names no pool in \`pools-json\`. It knows: ${known}.`, + ); + } + + let chosen; + + // The labels on the pull request behind this run. A run with no pull + // request finds none and takes `default-pool-name`: `push`, `schedule`, + // `workflow_run` and `workflow_dispatch` all carry no `pull_request`. + // + // One label decides, so two is a question this cannot answer. Matching in a + // fixed order instead would make the answer depend on the order pools + // happen to be written in. + const asked = [...new Set(eventLabels.filter((name) => name.startsWith(prefix)))].sort(); + + if (asked.length === 0) { + chosen = defaultPoolName; + logs.push(`No ${prefix}* label on this run, so the default pool '${chosen}' applies.`); + } else if (asked.length === 1) { + chosen = asked[0].slice(prefix.length); + // A bare prefix is a label somebody half typed. Left to fall through it + // would look exactly like asking for nothing. + if (!chosen) { + throw new InputError( + `The label '${asked[0]}' names no pool. Ask for one of: ${askable}.`, + ); + } + logs.push(`Label '${asked[0]}' asks for pool '${chosen}'.`); + } else { + throw new InputError( + `This run carries ${asked.length} ${prefix}* labels (${asked.join(" ")}). Leave exactly one, or none to take the default.`, + ); + } + + // A label naming a pool that does not exist is a mistake, not a request for + // the default. Falling back would schedule the run somewhere nobody asked for + // and say nothing about it. + if (!Object.hasOwn(pools, chosen)) { + throw new InputError(`No pool named '${chosen}' in \`pools-json\`. It knows: ${askable}.`); + } + + const runsOn = pools[chosen]; + logs.push(`Pool '${chosen}' resolves to runner label '${runsOn}'.`); + + return { runsOn, poolName: chosen, logs }; +} + +export function readEventLabels(path) { + let event; + try { + event = JSON.parse(fs.readFileSync(path, "utf8")); + } catch (error) { + throw new InputError(`Could not read the event payload at ${path}: ${error.message}`); + } + + const labels = event?.pull_request?.labels; + return Array.isArray(labels) + ? labels.map((label) => label?.name).filter((name) => typeof name === "string") + : []; +} + +export function main() { + try { + const { runsOn, poolName, logs } = resolve({ + poolsJson: getInput("pools-json"), + defaultPoolName: getInput("default-pool-name"), + labelPrefix: getInput("label-prefix"), + eventLabels: readEventLabels(process.env.GITHUB_EVENT_PATH), + }); + + for (const line of logs) { + info(line); + } + + setOutput("runs-on", runsOn); + setOutput("pool-name", poolName); + } catch (thrown) { + if (thrown instanceof InputError) { + setFailed(thrown.message); + return; + } + throw thrown; + } +} diff --git a/actions/runs-on-selector/lib/main.mjs b/actions/runs-on-selector/lib/main.mjs new file mode 100644 index 0000000..89c6de9 --- /dev/null +++ b/actions/runs-on-selector/lib/main.mjs @@ -0,0 +1,3 @@ +import { main } from "./index.mjs"; + +main(); diff --git a/mise.toml b/mise.toml index 6707bf1..16eec5c 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,4 @@ [tools] actionlint = "1.7.12" +node = "24.21.0" shellcheck = "0.11.0" diff --git a/tests/node/runs-on-selector/core.test.mjs b/tests/node/runs-on-selector/core.test.mjs new file mode 100644 index 0000000..6b1160e --- /dev/null +++ b/tests/node/runs-on-selector/core.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + error, + getInput, + info, + setFailed, + setOutput, +} from "../../../actions/runs-on-selector/lib/core.mjs"; + +function captureStdout(run) { + const written = []; + const original = process.stdout.write; + process.stdout.write = (chunk) => { + written.push(String(chunk)); + return true; + }; + try { + run(); + } finally { + process.stdout.write = original; + } + return written.join(""); +} + +function outputFile() { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "runs-on-selector-")), "output.txt"); + fs.writeFileSync(file, ""); + return file; +} + +function withOutputFile(run) { + const file = outputFile(); + const previous = process.env.GITHUB_OUTPUT; + process.env.GITHUB_OUTPUT = file; + try { + run(); + } finally { + if (previous === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = previous; + } + } + return fs.readFileSync(file, "utf8"); +} + +function withInput(name, value, run) { + const key = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; + process.env[key] = value; + try { + return run(); + } finally { + delete process.env[key]; + } +} + +test("getInput reads the dash spelling the runner writes", () => { + assert.equal( + withInput("pools-json", '{"github":"ubuntu-24.04"}', () => getInput("pools-json")), + '{"github":"ubuntu-24.04"}', + ); +}); + +test("getInput is empty for an absent input", () => { + assert.equal(getInput("nothing-set-here"), ""); +}); + +test("getInput trims by default", () => { + assert.equal( + withInput("pool", " fleet ", () => getInput("pool")), + "fleet", + ); +}); + +test("getInput keeps whitespace when asked", () => { + assert.equal( + withInput("pool", " fleet ", () => getInput("pool", { trimWhitespace: false })), + " fleet ", + ); +}); + +test("getInput enforces required", () => { + assert.throws(() => getInput("absent-input", { required: true }), { + message: "Input required and not supplied: absent-input", + }); +}); + +test("info writes the message and a line ending", () => { + assert.equal( + captureStdout(() => info("hello")), + `hello${os.EOL}`, + ); +}); + +test("error writes an error annotation", () => { + assert.equal( + captureStdout(() => error("broken")), + `::error::broken${os.EOL}`, + ); +}); + +test("error escapes what would end the annotation", () => { + const written = captureStdout(() => error("one\ntwo\rthree 50%")); + assert.equal(written, `::error::one%0Atwo%0Dthree 50%25${os.EOL}`); + assert.equal(written.trimEnd().split("\n").length, 1); +}); + +test("setFailed annotates and sets a failing exit code", () => { + const previous = process.exitCode; + try { + const written = captureStdout(() => setFailed("nope")); + assert.equal(written, `::error::nope${os.EOL}`); + assert.equal(process.exitCode, 1); + } finally { + process.exitCode = previous; + } +}); + +test("setOutput writes the delimited form", () => { + const written = withOutputFile(() => setOutput("runs-on", "ubuntu-24.04")); + assert.match(written, /^runs-on< { + const written = withOutputFile(() => setOutput("runs-on", "first\nsecond")); + const [header, ...rest] = written.split(/\r?\n/); + const delimiter = header.slice("runs-on<<".length); + assert.deepEqual(rest, ["first", "second", delimiter, ""]); +}); + +test("setOutput appends rather than replacing", () => { + const written = withOutputFile(() => { + setOutput("runs-on", "ubuntu-24.04"); + setOutput("pool", "github"); + }); + assert.match(written, /^runs-on< { + const previous = process.env.GITHUB_OUTPUT; + delete process.env.GITHUB_OUTPUT; + try { + assert.throws(() => setOutput("runs-on", "ubuntu-24.04"), { + message: "Unable to find environment variable for file command OUTPUT", + }); + } finally { + if (previous !== undefined) { + process.env.GITHUB_OUTPUT = previous; + } + } +}); diff --git a/tests/node/runs-on-selector/index.test.mjs b/tests/node/runs-on-selector/index.test.mjs new file mode 100644 index 0000000..a97df6c --- /dev/null +++ b/tests/node/runs-on-selector/index.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + DEFAULT_LABEL_PREFIX, + InputError, + readEventLabels, + resolve, +} from "../../../actions/runs-on-selector/lib/index.mjs"; + +const POOLS = JSON.stringify({ + github: "ubuntu-24.04", + "github-arm": "ubuntu-24.04-arm", + fleet: "acme-ci-linux-x64", +}); + +function pick(overrides) { + return resolve({ + poolsJson: POOLS, + defaultPoolName: "github", + labelPrefix: "", + eventLabels: [], + ...overrides, + }); +} + +// assert.throws does not hand back the error, and every failure case here is +// about the message it carries. +function failure(overrides) { + try { + pick(overrides); + } catch (error) { + assert.ok(error instanceof InputError, `not an InputError: ${error}`); + return error; + } + assert.fail("expected an InputError"); +} + +test("no label takes default", () => { + const { runsOn, poolName } = pick({ eventLabels: ["bug", "enhancement"] }); + assert.equal(runsOn, "ubuntu-24.04"); + assert.equal(poolName, "github"); +}); + +test("no label logs the default", () => { + const { logs } = pick({}); + assert.match(logs[0], /^No runs-on:\* label on this run, so the default pool 'github' applies\.$/); +}); + +test("label picks pool", () => { + const { runsOn, poolName } = pick({ eventLabels: ["runs-on:fleet"] }); + assert.equal(runsOn, "acme-ci-linux-x64"); + assert.equal(poolName, "fleet"); +}); + +test("label logs pool name", () => { + const { logs } = pick({ eventLabels: ["runs-on:fleet"] }); + assert.deepEqual(logs, [ + "Label 'runs-on:fleet' asks for pool 'fleet'.", + "Pool 'fleet' resolves to runner label 'acme-ci-linux-x64'.", + ]); +}); + +test("a pool name with a dash resolves", () => { + const { runsOn, poolName } = pick({ eventLabels: ["runs-on:github-arm"] }); + assert.equal(runsOn, "ubuntu-24.04-arm"); + assert.equal(poolName, "github-arm"); +}); + +test("unknown label fails", () => { + const error = failure({ eventLabels: ["runs-on:nope"] }); + assert.match(error.message, /No pool named 'nope' in `pools-json`/); +}); + +test("unknown label lists the known pools", () => { + const error = failure({ eventLabels: ["runs-on:nope"] }); + assert.match( + error.message, + /It knows: runs-on:github, runs-on:github-arm, runs-on:fleet\.$/, + ); +}); + +test("bare prefix fails", () => { + const error = failure({ eventLabels: ["runs-on:"] }); + assert.match(error.message, /^The label 'runs-on:' names no pool\. Ask for one of: /); +}); + +test("two labels fail", () => { + const error = failure({ eventLabels: ["runs-on:fleet", "runs-on:github"] }); + assert.match(error.message, /^This run carries 2 runs-on:\* labels /); +}); + +test("two labels name both", () => { + const error = failure({ eventLabels: ["runs-on:fleet", "runs-on:github"] }); + assert.match( + error.message, + /\(runs-on:fleet runs-on:github\)\. Leave exactly one, or none to take the default\.$/, + ); +}); + +test("duplicate label is one", () => { + const { poolName } = pick({ eventLabels: ["runs-on:fleet", "runs-on:fleet"] }); + assert.equal(poolName, "fleet"); +}); + +test("unrelated labels are ignored", () => { + const { poolName } = pick({ + eventLabels: ["runner:fleet", "runs-on", "needs runs-on:fleet"], + }); + assert.equal(poolName, "github"); +}); + +test("push takes default", () => { + const { poolName } = pick({ eventLabels: [] }); + assert.equal(poolName, "github"); +}); + +test("default-pool-name must name a pool", () => { + const error = failure({ defaultPoolName: "nope" }); + assert.match(error.message, /^The `default-pool-name` input 'nope' names no pool in `pools-json`\./); +}); + +test("default-pool-name is required", () => { + const error = failure({ defaultPoolName: "" }); + assert.match(error.message, /^The `default-pool-name` input is required, and must name one of `pools-json`: /); +}); + +test("pools must be json", () => { + const error = failure({ poolsJson: "{" }); + assert.match(error.message, /^The `pools-json` input must be a JSON object /); +}); + +test("pools must be an object", () => { + failure({ poolsJson: '["github"]' }); +}); + +test("pools must not be empty", () => { + const error = failure({ poolsJson: "{}" }); + assert.match(error.message, /is an empty object, so there is nothing to schedule on\.$/); +}); + +test("pool names are restricted to the label charset", () => { + const error = failure({ poolsJson: '{"GitHub":"ubuntu-24.04"}', defaultPoolName: "GitHub" }); + assert.match(error.message, /must be lowercase letters, digits and dashes/); +}); + +test("a pool maps to a string", () => { + const error = failure({ poolsJson: '{"github":["a","b"]}' }); + assert.match(error.message, /must map to a single non-empty runner label\.$/); +}); + +test("a pool label is non-empty", () => { + failure({ poolsJson: '{"github":""}' }); +}); + +test("the default prefix is the published vocabulary", () => { + assert.equal(DEFAULT_LABEL_PREFIX, "runs-on"); +}); + +test("an empty label-prefix takes the default", () => { + const { poolName } = pick({ labelPrefix: "", eventLabels: ["runs-on:fleet"] }); + assert.equal(poolName, "fleet"); +}); + +test("label-prefix changes which labels are read", () => { + const { poolName } = pick({ + labelPrefix: "action-runner", + eventLabels: ["action-runner:fleet", "runs-on:github-arm"], + }); + assert.equal(poolName, "fleet"); +}); + +test("label-prefix appears in the messages that teach the vocabulary", () => { + const error = failure({ labelPrefix: "action-runner", eventLabels: ["action-runner:nope"] }); + assert.match(error.message, /It knows: action-runner:github, action-runner:github-arm, action-runner:fleet\.$/); +}); + +test("label-prefix rejects a colon, which the action appends itself", () => { + const error = failure({ labelPrefix: "action-runner:" }); + assert.match(error.message, /^The `label-prefix` input must be letters, digits/); +}); + +test("label-prefix rejects a space", () => { + failure({ labelPrefix: "runs on" }); +}); + +function writeEvent(payload) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "runs-on-selector-")), "event.json"); + fs.writeFileSync(file, payload); + return file; +} + +test("event labels come from the pull request", () => { + const file = writeEvent( + JSON.stringify({ pull_request: { labels: [{ name: "runs-on:fleet" }, { name: "bug" }] } }), + ); + assert.deepEqual(readEventLabels(file), ["runs-on:fleet", "bug"]); +}); + +test("an event with no pull request has no labels", () => { + assert.deepEqual(readEventLabels(writeEvent(JSON.stringify({ ref: "refs/heads/main" }))), []); +}); + +test("an unreadable event payload fails", () => { + assert.throws(() => readEventLabels("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/nonexistent/event.json"), InputError); +}); From fc480168b328d8169cb751cc2ca6f310b2bd3bd6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 22 Sep 2026 01:00:17 -0400 Subject: [PATCH 2/2] fix(ci): let a label change where a run schedules A runner label nobody provisioned queues instead of failing, so a job reading the resolved value needs a ceiling. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62752e4..66963b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,10 @@ name: CI on: push: branches: [main] + # `labeled` and `unlabeled` are not in the default set, so without them a + # `runs-on:*` label changes nothing until the next push. pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] permissions: {} @@ -45,6 +48,7 @@ jobs: name: Lint needs: runner runs-on: ${{ needs.runner.outputs.runs-on }} + timeout-minutes: 10 permissions: contents: read steps: @@ -62,6 +66,7 @@ jobs: name: Test needs: runner runs-on: ${{ needs.runner.outputs.runs-on }} + timeout-minutes: 10 permissions: contents: read steps: