From 2f2f2462fec80b3a7f7fc9eb62f4470672e55882 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sat, 29 Aug 2026 15:15:34 -0700 Subject: [PATCH 1/2] perf(ci): stop wasting self-hosted runner slots on cache and no-op jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR wall-clock was ~13-20 min against only ~3.5 min of actual compute. The bottleneck was never job speed — it was the single macmini runner service, which gives ledgr a concurrency of 1, so even `test` and `mutation` from one PR queued behind each other. Three fixes that cut how much of that scarce runner time we burn: - Drop `cache: pnpm` from both self-hosted jobs. The pnpm store already persists in $HOME between jobs on a self-hosted runner, so actions/cache was round-tripping a tarball over the network for nothing. It cost up to 79s in the post-job cache save — longer than the test step it was meant to speed up. - Gate the mutation job behind a cheap ubuntu-latest job. This repo is public, so hosted runners are free with unlimited concurrency. A PR touching no mutatable source previously spent ~35s of checkout/setup/install on the mini just to print "nothing to mutate"; now it never occupies a self-hosted slot. - Give scripts/mutate-diff.sh a --list-only mode and have the gate call it, so the gate and the run can never disagree about what counts as mutatable. Also adds .github/actionlint.yaml declaring the custom `macmini` label, so `actionlint .github/workflows/*.yml` runs clean locally instead of flagging every self-hosted `runs-on`. --- .github/actionlint.yaml | 6 ++++ .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++++------- scripts/mutate-diff.sh | 16 ++++++++++ 3 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..08121659 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# Custom labels for the self-hosted runner pool (an M4 Mac mini). Declaring +# them here lets `actionlint .github/workflows/*.yml` run clean locally instead +# of flagging every `runs-on: [self-hosted, macmini]` as an unknown label. +self-hosted-runner: + labels: + - macmini diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6802af9..ecaf3e52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,14 @@ jobs: with: version: 11.5.2 + # No `cache: pnpm` on purpose. The runner is self-hosted, so the pnpm + # store already persists in $HOME between jobs — actions/cache would + # round-trip a tarball over the network for zero benefit. Measured cost + # when it was enabled: up to 79s in the post-job cache save, longer than + # the test step it was meant to speed up. - uses: actions/setup-node@v7 with: node-version: 24 - cache: pnpm - run: pnpm install --frozen-lockfile @@ -34,21 +38,60 @@ jobs: - run: pnpm lint - # Integration tests start Postgres via testcontainers; Docker is - # preinstalled on ubuntu-latest. No secrets required. + # Integration tests start Postgres via testcontainers; Docker runs on the + # self-hosted runner. No secrets required. - run: pnpm test + # Cheap gate on a GitHub-hosted runner (free + unlimited concurrency on this + # public repo) so a PR that changes no mutatable source never occupies a + # self-hosted slot. The single macmini runner pool is the scarce resource — + # queueing, not compute, dominates PR wall-clock — so trading ~20s of hosted + # latency for a freed slot is worth it. Shares scripts/mutate-diff.sh with the + # run itself, so the gate and the run agree on what "mutatable" means. + mutation-gate: + name: mutation gate + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + outputs: + should-run: ${{ steps.check.outputs.should-run }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - id: check + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + FILES=$(bash scripts/mutate-diff.sh --list-only "$BASE_SHA" "$HEAD_SHA") + if [ -n "$FILES" ]; then + echo "should-run=true" >> "$GITHUB_OUTPUT" + echo "Mutatable source changed — mutation job will run:" + echo "$FILES" | awk '{print " " $0}' + else + echo "should-run=false" >> "$GITHUB_OUTPUT" + echo "No mutatable source changed — skipping the mutation job." + fi + mutation: name: mutation (diff) runs-on: [self-hosted, macmini] + needs: mutation-gate # Self-hosted runner: never execute a fork PR's code here — only PRs from - # this same repo (see the test job's comment for why). - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + # this same repo (see the test job's comment for why). The gate job carries + # the same guard, so this is belt-and-braces: a fork PR skips the gate, and + # a skipped gate yields an empty output that fails this check too. Kept + # explicit so the protection is visible in the job it protects. + if: >- + needs.mutation-gate.outputs.should-run == 'true' && + github.event.pull_request.head.repo.full_name == github.repository # Diff-scoped: only the source files changed in the PR are mutated, so runs - # take minutes instead of a full sweep. Non-blocking (reports only): Stryker - # currently runs just the unit suite, so files covered mainly by integration - # tests score artificially low — a blocking gate would fail legitimate PRs. - # Flip to blocking once the mutation harness also runs the integration suite. + # take minutes instead of a full cold sweep. Non-blocking (reports only): + # Stryker currently runs just the unit suite, so files covered mainly by + # integration tests score artificially low — a blocking gate would fail + # legitimate PRs. Flip to blocking once the mutation harness also runs the + # integration suite. continue-on-error: true steps: - uses: actions/checkout@v7 @@ -59,10 +102,10 @@ jobs: with: version: 11.5.2 + # No `cache: pnpm` — see the test job for why. - uses: actions/setup-node@v7 with: node-version: 24 - cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/scripts/mutate-diff.sh b/scripts/mutate-diff.sh index 397cd007..59c7e710 100755 --- a/scripts/mutate-diff.sh +++ b/scripts/mutate-diff.sh @@ -6,8 +6,19 @@ # Uses a two-dot diff of explicit commits so CI can pass the PR's exact base and # head SHAs — a three-dot/merge-base diff against a moving branch tip pulls in # files the PR never touched. +# +# With --list-only, prints the changed mutatable files (one per line) and exits +# without running Stryker. CI's cheap hosted gate job uses this to decide +# whether the mutation job is worth a self-hosted runner slot at all, so the +# gate and the run can never disagree about what counts as mutatable. set -euo pipefail +LIST_ONLY=false +if [ "${1:-}" = "--list-only" ]; then + LIST_ONLY=true + shift +fi + BASE="${1:-origin/main}" HEAD="${2:-HEAD}" @@ -15,6 +26,11 @@ FILES=$(git diff --name-only --diff-filter=ACMR "$BASE" "$HEAD" -- \ src/lib src/actions src/queries \ | grep -E '\.ts$' | grep -vE '\.test\.ts$' || true) +if [ "$LIST_ONLY" = true ]; then + printf '%s' "$FILES" + exit 0 +fi + if [ -z "$FILES" ]; then echo "No mutatable source changed between ${BASE} and ${HEAD} — skipping mutation testing." exit 0 From 8c5c38a139e3cbe3a51a109e6f15faba3cfc3ec0 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sat, 29 Aug 2026 15:38:29 -0700 Subject: [PATCH 2/2] fixup: keep mutation on the self-hosted runner, ordered after test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the ubuntu-latest gate job. Everything stays on the mini — no GitHub-hosted runners in ci.yml. Same problem, local solution: the mutation job was starving the blocking checks. A 9-file diff kept Stryker busy for 30+ min while typecheck/lint/test of every other PR queued behind a job that is continue-on-error: true and gates nothing. 'needs: test' orders it after the blocking job releases the runner, so blocking checks always go first. Side effect, and it's a wanted one: a red test job now skips mutation instead of mutating a build that is already broken. Also drops the --list-only mode added to scripts/mutate-diff.sh for the gate, since nothing calls it any more. --- .github/workflows/ci.yml | 48 +++++++--------------------------------- scripts/mutate-diff.sh | 16 -------------- 2 files changed, 8 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecaf3e52..f2364785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,50 +42,18 @@ jobs: # self-hosted runner. No secrets required. - run: pnpm test - # Cheap gate on a GitHub-hosted runner (free + unlimited concurrency on this - # public repo) so a PR that changes no mutatable source never occupies a - # self-hosted slot. The single macmini runner pool is the scarce resource — - # queueing, not compute, dominates PR wall-clock — so trading ~20s of hosted - # latency for a freed slot is worth it. Shares scripts/mutate-diff.sh with the - # run itself, so the gate and the run agree on what "mutatable" means. - mutation-gate: - name: mutation gate - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - outputs: - should-run: ${{ steps.check.outputs.should-run }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - id: check - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - FILES=$(bash scripts/mutate-diff.sh --list-only "$BASE_SHA" "$HEAD_SHA") - if [ -n "$FILES" ]; then - echo "should-run=true" >> "$GITHUB_OUTPUT" - echo "Mutatable source changed — mutation job will run:" - echo "$FILES" | awk '{print " " $0}' - else - echo "should-run=false" >> "$GITHUB_OUTPUT" - echo "No mutatable source changed — skipping the mutation job." - fi - mutation: name: mutation (diff) runs-on: [self-hosted, macmini] - needs: mutation-gate + # Runs only after `test` releases its runner. Mutation is non-blocking, but + # a wide diff can keep Stryker busy for 30+ min (observed on a 9-file diff), + # and while it held the runner the *blocking* typecheck/lint/test jobs of + # every other PR queued behind a job nobody gates on. Ordering it after test + # keeps the blocking checks first without giving up the self-hosted runner. + needs: test # Self-hosted runner: never execute a fork PR's code here — only PRs from - # this same repo (see the test job's comment for why). The gate job carries - # the same guard, so this is belt-and-braces: a fork PR skips the gate, and - # a skipped gate yields an empty output that fails this check too. Kept - # explicit so the protection is visible in the job it protects. - if: >- - needs.mutation-gate.outputs.should-run == 'true' && - github.event.pull_request.head.repo.full_name == github.repository + # this same repo (see the test job's comment for why). + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository # Diff-scoped: only the source files changed in the PR are mutated, so runs # take minutes instead of a full cold sweep. Non-blocking (reports only): # Stryker currently runs just the unit suite, so files covered mainly by diff --git a/scripts/mutate-diff.sh b/scripts/mutate-diff.sh index 59c7e710..397cd007 100755 --- a/scripts/mutate-diff.sh +++ b/scripts/mutate-diff.sh @@ -6,19 +6,8 @@ # Uses a two-dot diff of explicit commits so CI can pass the PR's exact base and # head SHAs — a three-dot/merge-base diff against a moving branch tip pulls in # files the PR never touched. -# -# With --list-only, prints the changed mutatable files (one per line) and exits -# without running Stryker. CI's cheap hosted gate job uses this to decide -# whether the mutation job is worth a self-hosted runner slot at all, so the -# gate and the run can never disagree about what counts as mutatable. set -euo pipefail -LIST_ONLY=false -if [ "${1:-}" = "--list-only" ]; then - LIST_ONLY=true - shift -fi - BASE="${1:-origin/main}" HEAD="${2:-HEAD}" @@ -26,11 +15,6 @@ FILES=$(git diff --name-only --diff-filter=ACMR "$BASE" "$HEAD" -- \ src/lib src/actions src/queries \ | grep -E '\.ts$' | grep -vE '\.test\.ts$' || true) -if [ "$LIST_ONLY" = true ]; then - printf '%s' "$FILES" - exit 0 -fi - if [ -z "$FILES" ]; then echo "No mutatable source changed between ${BASE} and ${HEAD} — skipping mutation testing." exit 0