Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 19 additions & 119 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,72 +68,6 @@ concurrency:
cancel-in-progress: true

jobs:
# Which Windows runner this run is allowed to use.
#
# READ THIS BEFORE TREATING IT AS A SECURITY BOUNDARY: it is not one.
#
# On `pull_request` this workflow is loaded from the PR head, so the `case`
# below is owned by the proposed patch exactly like an `if:` guard would be.
# A hostile PR can delete the branch and hardcode the self-hosted labels into
# `$GITHUB_OUTPUT`, and `runs-on` will honour it. That this job runs on
# `ubuntu-latest` changes nothing — the untrusted part is its OUTPUT, not its
# host. `.github/workflows/ci.yml` is in this workflow's `pull_request.paths`,
# so such an edit triggers its own run.
#
# What actually keeps untrusted code off a self-hosted runner lives OUTSIDE
# this file, where a PR cannot reach it: the fork-PR approval policy
# (`all_external_contributors`) and the judgement of whoever clicks approve.
# Runner groups would be the other lever, but they are an organisation
# feature and this repository is user-owned, so the approval policy is the
# only one available here. GitHub's own guidance is to avoid self-hosted
# runners on public repositories for this reason.
#
# So read the routing below as a STABILITY/OPERATIONS control that keeps
# honest pull requests on GitHub-hosted runners and lets trusted branch runs
# avoid the hosted-Windows Bun crashes. It is not the security boundary.
#
# `push` on dev/main/preview requires the push permission, and
# `workflow_dispatch` requires write access, so both carry a trusted author.
# A trusted author is not audited code: merging a contributor PR into `dev`
# fires `push`, and its dependencies and postinstall hooks then run here.
select-windows-runner:
name: select windows runner
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
runner: ${{ steps.pick.outputs.runner }}
label: ${{ steps.pick.outputs.label }}
steps:
- name: Pick runner
id: pick
env:
# Read through env rather than interpolating directly into the script:
# `github.event_name` is a fixed vocabulary, but keeping the habit means
# no future edit here can grow a script-injection sink.
EVENT_NAME: ${{ github.event_name }}
USE_SELF_HOSTED: ${{ vars.OCX_SELF_HOSTED_WINDOWS }}
shell: bash
run: |
set -euo pipefail
trusted=no
case "$EVENT_NAME" in
push|workflow_dispatch) trusted=yes ;;
esac

# Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch,
# not a security control: a PR that rewrites this script ignores it for
# the same reason it ignores the event check above. Its job is to keep CI
# working when the box is off or busy. Anything other than `1` —
# including unset, the state before a runner exists — falls back to
# windows-latest.
if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then
echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT"
echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT"
else
echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT"
echo 'label=windows-latest' >> "$GITHUB_OUTPUT"
fi

# Which areas this push actually touches.
#
# Deliberately a job-level filter rather than a wider workflow-level `paths:`
Expand All @@ -160,9 +94,8 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

- name: Detect changed areas
Expand Down Expand Up @@ -228,9 +161,8 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

- name: Setup Bun
Expand Down Expand Up @@ -275,9 +207,8 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

- name: Setup Bun
Expand Down Expand Up @@ -341,9 +272,8 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

- name: Setup Bun
Expand Down Expand Up @@ -387,10 +317,9 @@ jobs:
# tracked failures are fixed, not before.
platform-windows:
name: windows ${{ matrix.shard }}/4
needs: select-windows-runner
if: >-
github.event_name == 'workflow_dispatch'
runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }}
runs-on: windows-latest
# Sharded like the Linux legs. The single-leg run reached 30 minutes on a
# green suite and was killed in cleanup; four shards put each leg inside the
# same budget the Linux shards already hold.
Expand All @@ -400,34 +329,11 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Show selected runner
shell: bash
run: echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}"

# A self-hosted runner keeps its working directory between jobs. Without an
# explicit wipe, a file deleted in the commit under test survives on disk
# and the suite passes against a tree that no longer exists in git.
# `--ephemeral` registration de-registers the runner after each job but does
# not clean the workspace, so this step is what makes the checkout honest.
- name: Clean workspace (self-hosted only)
if: runner.environment == 'self-hosted'
shell: bash
# `|| true` used to swallow this, which defeats the point: a clean that
# fails on permissions leaves the deleted files in place and the checkout
# below then validates a tree that no longer exists in git. Only the
# not-a-repository case is tolerated — that is the first run on a fresh
# box, where there is nothing to clean.
run: |
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git clean -xffd .
fi

- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

- name: Setup Bun
Expand All @@ -454,8 +360,7 @@ jobs:
run: bun run src/cli/index.ts help

# Keep every OS credential-store check on a disposable GitHub-hosted machine.
# A force-cancelled process cannot run its in-process finally cleanup, so no
# keyring matrix leg may use the persistent self-hosted Windows runner.
# A force-cancelled process cannot run its in-process finally cleanup.
keyring-smoke:
name: keyring ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
Expand Down Expand Up @@ -526,18 +431,15 @@ jobs:
strategy:
fail-fast: false
matrix:
# Deliberately NOT routed to the self-hosted box. This job runs
# `npm install -g`, which writes into the machine's global prefix and
# would leave an `ocx` on a maintainer's personal PATH. It is an
# 8-minute job, so there is nothing to win by moving it.
# This job runs `npm install -g`, so keep every leg on a disposable
# GitHub-hosted runner.
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
# No job here pushes, so leaving a usable token in .git/config is
# avoidable residue.
persist-credentials: false

# Deliberately NO setup-bun: prove `npm install -g` works without a
Expand Down Expand Up @@ -588,11 +490,9 @@ jobs:
ci:
name: ci
if: always()
# Every producer, including the ones that only feed other jobs. `needs` holds
# direct dependencies only, so a failing `select-windows-runner` would
# otherwise reach this gate as nothing at all while its dependents report
# `skipped` — which the gate is required to read as a deliberate skip.
needs: [changes, select-windows-runner, test, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke]
# Every producer is a direct dependency so this gate can distinguish a
# deliberate skip from a failure.
needs: [changes, test, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand Down
Loading