@@ -45,9 +45,75 @@ concurrency:
4545 cancel-in-progress : true
4646
4747jobs :
48+ # Which Windows runner this run is allowed to use.
49+ #
50+ # READ THIS BEFORE TREATING IT AS A SECURITY BOUNDARY: it is not one.
51+ #
52+ # On `pull_request` this workflow is loaded from the PR head, so the `case`
53+ # below is owned by the proposed patch exactly like an `if:` guard would be.
54+ # A hostile PR can delete the branch and hardcode the self-hosted labels into
55+ # `$GITHUB_OUTPUT`, and `runs-on` will honour it. That this job runs on
56+ # `ubuntu-latest` changes nothing — the untrusted part is its OUTPUT, not its
57+ # host. `.github/workflows/ci.yml` is in this workflow's `pull_request.paths`,
58+ # so such an edit triggers its own run.
59+ #
60+ # What actually keeps untrusted code off a self-hosted runner lives OUTSIDE
61+ # this file, where a PR cannot reach it: the fork-PR approval policy
62+ # (`all_external_contributors`) and the judgement of whoever clicks approve.
63+ # Runner groups would be the other lever, but they are an organisation
64+ # feature and this repository is user-owned, so the approval policy is the
65+ # only one available here. GitHub's own guidance is to avoid self-hosted
66+ # runners on public repositories for this reason.
67+ #
68+ # So read the routing below as a COST control that keeps honest pull requests
69+ # on GitHub-hosted runners, not as a guarantee about hostile ones.
70+ #
71+ # `push` on dev/main/preview requires the push permission, and
72+ # `workflow_dispatch` requires write access, so both carry a trusted author.
73+ # A trusted author is not audited code: merging a contributor PR into `dev`
74+ # fires `push`, and its dependencies and postinstall hooks then run here.
75+ select-windows-runner :
76+ name : select windows runner
77+ runs-on : ubuntu-latest
78+ timeout-minutes : 2
79+ outputs :
80+ runner : ${{ steps.pick.outputs.runner }}
81+ label : ${{ steps.pick.outputs.label }}
82+ steps :
83+ - name : Pick runner
84+ id : pick
85+ env :
86+ # Read through env rather than interpolating directly into the script:
87+ # `github.event_name` is a fixed vocabulary, but keeping the habit means
88+ # no future edit here can grow a script-injection sink.
89+ EVENT_NAME : ${{ github.event_name }}
90+ USE_SELF_HOSTED : ${{ vars.OCX_SELF_HOSTED_WINDOWS }}
91+ shell : bash
92+ run : |
93+ set -euo pipefail
94+ trusted=no
95+ case "$EVENT_NAME" in
96+ push|workflow_dispatch) trusted=yes ;;
97+ esac
98+
99+ # Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch,
100+ # not a security control: a PR that rewrites this script ignores it for
101+ # the same reason it ignores the event check above. Its job is to keep CI
102+ # working when the box is off or busy. Anything other than `1` —
103+ # including unset, the state before a runner exists — falls back to
104+ # windows-latest.
105+ if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then
106+ echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT"
107+ echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT"
108+ else
109+ echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT"
110+ echo 'label=windows-latest' >> "$GITHUB_OUTPUT"
111+ fi
112+
48113 test :
49- name : ${{ matrix.os }}
50- runs-on : ${{ matrix.os }}
114+ name : ${{ matrix.name }}
115+ needs : select-windows-runner
116+ runs-on : ${{ matrix.runner }}
51117 # Windows dominates this matrix: on run 30459554635 the same suite took
52118 # ubuntu 4.6min / macos 5.6min / windows 11.8min. Against the previous
53119 # 12-minute ceiling that left ~12s of headroom, so runner variance decided
@@ -62,8 +128,34 @@ jobs:
62128 strategy :
63129 fail-fast : false
64130 matrix :
65- os : [ubuntu-latest, windows-latest, macos-latest]
131+ # `name` is spelled out rather than derived from `runner`: a runner given
132+ # as a label array renders as "self-hosted Windows X64 ocx-home", so the
133+ # check name would change with the routing and break any branch
134+ # protection rule that names it. Keeping `windows` fixed means the
135+ # required check stays the same whichever machine served it.
136+ include :
137+ - name : ubuntu
138+ runner : ubuntu-latest
139+ - name : windows
140+ runner : ${{ fromJSON(needs.select-windows-runner.outputs.runner) }}
141+ - name : macos
142+ runner : macos-latest
66143 steps :
144+ - name : Show selected runner
145+ if : matrix.name == 'windows'
146+ shell : bash
147+ run : echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}"
148+
149+ # A self-hosted runner keeps its working directory between jobs. Without an
150+ # explicit wipe, a file deleted in the commit under test survives on disk
151+ # and the suite passes against a tree that no longer exists in git.
152+ # `--ephemeral` registration de-registers the runner after each job but does
153+ # not clean the workspace, so this step is what makes the checkout honest.
154+ - name : Clean workspace (self-hosted only)
155+ if : runner.environment == 'self-hosted'
156+ shell : bash
157+ run : git clean -xffd . || true
158+
67159 - name : Checkout
68160 uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
69161
@@ -113,6 +205,10 @@ jobs:
113205 strategy :
114206 fail-fast : false
115207 matrix :
208+ # Deliberately NOT routed to the self-hosted box. This job runs
209+ # `npm install -g`, which writes into the machine's global prefix and
210+ # would leave an `ocx` on a maintainer's personal PATH. It is an
211+ # 8-minute job, so there is nothing to win by moving it.
116212 os : [ubuntu-latest, windows-latest, macos-latest]
117213 steps :
118214 - name : Checkout
0 commit comments