|
| 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. |
0 commit comments