Skip to content

Commit aeb7423

Browse files
committed
ci: route the windows leg to a self-hosted runner on trusted events only
The windows leg dominates this matrix at 11.8min against ubuntu's 4.6min, and #711/#653 were decided by runner variance rather than by the code under review. A maintainer's own Windows box removes that, but it is a personal machine on a home network: whatever runs on it runs as a local user with that user's files and LAN in reach. So the routing decision is made from the event, not from the workflow body. `push` on dev/main/preview requires the push permission, which only the MAINTAINERS.md set holds, and `workflow_dispatch` requires write access. Those two go to the home box. `pull_request` never does. An author check inside this file would not be equivalent. `ci.yml` runs from the PR head on `pull_request`, so any `if: author_association == ...` guard is deletable by the same patch it is meant to stop, as are the tsconfig, lockfile and postinstall hooks the run would otherwise honour. Only a decision made before a runner is chosen survives a hostile head. Two supporting details. `matrix.include` carries a fixed `name` because a label-array runner renders into the check name, so deriving it would rename the required check depending on which machine served it. And the self-hosted leg wipes its workspace first: a persistent working directory keeps files that the commit under test deleted, which passes a suite against a tree that no longer exists in git. Routing is gated on the `OCX_SELF_HOSTED_WINDOWS` repository variable, which is unset today, so every leg still resolves to windows-latest until the runner is registered and the variable is flipped.
1 parent 320814a commit aeb7423

1 file changed

Lines changed: 86 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,62 @@ concurrency:
4545
cancel-in-progress: true
4646

4747
jobs:
48+
# Which Windows runner this run is allowed to use.
49+
#
50+
# The self-hosted Windows box is a maintainer's PERSONAL machine on a home
51+
# network, reachable over Tailscale. Anything that runs on it runs as a local
52+
# user with that user's filesystem and LAN in reach, so the only question that
53+
# matters is whether the commit is trusted BEFORE a runner ever sees it.
54+
#
55+
# `push` on dev/main/preview is trusted: reaching those branches requires the
56+
# push permission, which only the maintainers in MAINTAINERS.md hold.
57+
# `pull_request` is NOT trusted, and no author check can make it so — the
58+
# workflow file itself comes from the PR head, so any `if:` guard written here
59+
# is deleted by the same patch it is meant to stop. Fork PRs therefore stay on
60+
# GitHub-hosted runners, unconditionally.
61+
#
62+
# `workflow_dispatch` is the maintainer escape hatch: dispatch requires write
63+
# access, so a maintainer can deliberately aim a branch at the home box.
64+
select-windows-runner:
65+
name: select windows runner
66+
runs-on: ubuntu-latest
67+
timeout-minutes: 2
68+
outputs:
69+
runner: ${{ steps.pick.outputs.runner }}
70+
label: ${{ steps.pick.outputs.label }}
71+
steps:
72+
- name: Pick runner
73+
id: pick
74+
env:
75+
# Read through env rather than interpolating directly into the script:
76+
# `github.event_name` is a fixed vocabulary, but keeping the habit means
77+
# no future edit here can grow a script-injection sink.
78+
EVENT_NAME: ${{ github.event_name }}
79+
USE_SELF_HOSTED: ${{ vars.OCX_SELF_HOSTED_WINDOWS }}
80+
shell: bash
81+
run: |
82+
set -euo pipefail
83+
trusted=no
84+
case "$EVENT_NAME" in
85+
push|workflow_dispatch) trusted=yes ;;
86+
esac
87+
88+
# Repository variable OCX_SELF_HOSTED_WINDOWS acts as a kill switch. When
89+
# it is anything other than `1` — including unset, which is the state
90+
# before the runner exists — every job falls back to windows-latest and
91+
# CI keeps working with the box powered off.
92+
if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then
93+
echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT"
94+
echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT"
95+
else
96+
echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT"
97+
echo 'label=windows-latest' >> "$GITHUB_OUTPUT"
98+
fi
99+
48100
test:
49-
name: ${{ matrix.os }}
50-
runs-on: ${{ matrix.os }}
101+
name: ${{ matrix.name }}
102+
needs: select-windows-runner
103+
runs-on: ${{ matrix.runner }}
51104
# Windows dominates this matrix: on run 30459554635 the same suite took
52105
# ubuntu 4.6min / macos 5.6min / windows 11.8min. Against the previous
53106
# 12-minute ceiling that left ~12s of headroom, so runner variance decided
@@ -62,8 +115,34 @@ jobs:
62115
strategy:
63116
fail-fast: false
64117
matrix:
65-
os: [ubuntu-latest, windows-latest, macos-latest]
118+
# `name` is spelled out rather than derived from `runner`: a runner given
119+
# as a label array renders as "self-hosted Windows X64 ocx-home", so the
120+
# check name would change with the routing and break any branch
121+
# protection rule that names it. Keeping `windows` fixed means the
122+
# required check stays the same whichever machine served it.
123+
include:
124+
- name: ubuntu
125+
runner: ubuntu-latest
126+
- name: windows
127+
runner: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }}
128+
- name: macos
129+
runner: macos-latest
66130
steps:
131+
- name: Show selected runner
132+
if: matrix.name == 'windows'
133+
shell: bash
134+
run: echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}"
135+
136+
# A self-hosted runner keeps its working directory between jobs. Without an
137+
# explicit wipe, a file deleted in the commit under test survives on disk
138+
# and the suite passes against a tree that no longer exists in git.
139+
# `--ephemeral` registration de-registers the runner after each job but does
140+
# not clean the workspace, so this step is what makes the checkout honest.
141+
- name: Clean workspace (self-hosted only)
142+
if: runner.environment == 'self-hosted'
143+
shell: bash
144+
run: git clean -xffd . || true
145+
67146
- name: Checkout
68147
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
69148

@@ -113,6 +192,10 @@ jobs:
113192
strategy:
114193
fail-fast: false
115194
matrix:
195+
# Deliberately NOT routed to the self-hosted box. This job runs
196+
# `npm install -g`, which writes into the machine's global prefix and
197+
# would leave an `ocx` on a maintainer's personal PATH. It is an
198+
# 8-minute job, so there is nothing to win by moving it.
116199
os: [ubuntu-latest, windows-latest, macos-latest]
117200
steps:
118201
- name: Checkout

0 commit comments

Comments
 (0)