From b4e3bc2bba9fc14bfe1ab89fa65d0a1c74b04404 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 3 Sep 2026 22:47:39 -0500 Subject: [PATCH] ci: run the macOS and Windows jobs on main, or by label September's Actions usage for this organization was 11,883 Linux minutes, 4,407 macOS and 2,819 Windows. macOS bills at ten times the Linux rate and Windows at twice it, so those two account for most of a $373 gross that the included allowance covered almost exactly -- and jobs then stopped starting at all. Four jobs now wait for main: phase1-conformance (macos-15), rust (macos-latest), windows-supervisor-behavior (windows-2025), and the darwin-arm64 half of unix-supervisor. linux-x64 keeps running on every branch, as do web, e2e, contract-canary, and desktop. `rust` cannot simply move to Linux: it cross-builds the Windows supervisor from an arm64 Homebrew mingw-w64 bottle pinned by digest, so gating it is the only lever that does not rewrite the toolchain. That gate would otherwise make some branches unverifiable -- the two open pull requests touching src-tauri and the Windows supervisor are exactly the ones that need these jobs -- so a pull request can ask for them by carrying the `ci:full` label. Any branch touching src-tauri or the supervisors should. A matrix entry cannot carry its own condition, so unix-supervisor selects its include list with the same expression instead of an `if`. The two guards that encoded the old policy now encode this one, and assert more than they did: every expensive job still gates on docs_only -- the list grew to include windows-supervisor-behavior, which the previous anchored regex silently missed -- and each gated job must carry both halves of the main-or-label condition. ci.yml is pinned by digest in the harness authority, so its blob and SHA-256 in phase1-conformance.lock.json move with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KacEJmkX5GhkViPUhx9Mie --- .github/workflows/ci.yml | 40 +++++++++++++++++----- phase1-conformance.lock.json | 4 +-- src/client-v1-conformance-workflow.test.ts | 9 +++-- src/specification-guards.test.ts | 32 +++++++++++++++-- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd10a0a9..5dee6b72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,7 +252,13 @@ jobs: name: Phase 1 real-authority conformance runs-on: macos-15 needs: [web, changes] - if: needs.changes.outputs.docs_only != 'true' + # macOS bills at ten times the Linux rate and this job is the longest one + # here, so it runs on main and on a pull request that asks for it by + # carrying the `ci:full` label. + if: >- + needs.changes.outputs.docs_only != 'true' + && ((github.event_name == 'push' && github.ref == 'refs/heads/main') + || contains(github.event.pull_request.labels.*.name, 'ci:full')) timeout-minutes: 120 permissions: contents: read @@ -458,7 +464,15 @@ jobs: needs: changes # macOS runners bill at ten times the Linux rate. Spending that on a branch # that changed only prose is the least defensible minute in this file. - if: needs.changes.outputs.docs_only != 'true' + # + # It cannot move to Linux: it cross-builds the Windows supervisor from an + # arm64 Homebrew mingw-w64 bottle, pinned by digest below. So it runs on + # main, and on a pull request that carries the `ci:full` label -- which any + # branch touching src-tauri or the supervisors should. + if: >- + needs.changes.outputs.docs_only != 'true' + && ((github.event_name == 'push' && github.ref == 'refs/heads/main') + || contains(github.event.pull_request.labels.*.name, 'ci:full')) timeout-minutes: 30 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -589,12 +603,16 @@ jobs: timeout-minutes: 15 strategy: fail-fast: false + # linux-x64 runs everywhere. darwin-arm64 is a macOS runner, so it + # joins on main or under the `ci:full` label. A matrix entry cannot + # carry its own condition, so the include list is selected here. matrix: - include: - - platform: linux-x64 - runner: ubuntu-24.04 - - platform: darwin-arm64 - runner: macos-14 + include: >- + ${{ fromJSON( + ((github.event_name == 'push' && github.ref == 'refs/heads/main') + || contains(github.event.pull_request.labels.*.name, 'ci:full')) + && '[{"platform":"linux-x64","runner":"ubuntu-24.04"},{"platform":"darwin-arm64","runner":"macos-14"}]' + || '[{"platform":"linux-x64","runner":"ubuntu-24.04"}]') }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -606,7 +624,13 @@ jobs: name: Windows supervisor behavior runs-on: windows-2025 needs: [changes, rust] - if: always() && needs.rust.result == 'success' && needs.changes.outputs.docs_only != 'true' + # Windows bills at twice the Linux rate, and this job needs the artifact + # `rust` produces, so it follows the same gate. + if: >- + always() && needs.rust.result == 'success' + && needs.changes.outputs.docs_only != 'true' + && ((github.event_name == 'push' && github.ref == 'refs/heads/main') + || contains(github.event.pull_request.labels.*.name, 'ci:full')) timeout-minutes: 20 permissions: contents: read diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index df41a549..d6847c61 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -141,8 +141,8 @@ }, { "path": ".github/workflows/ci.yml", - "blob": "bd10a0a97f7d450086319e9e644591337c574e7b", - "sha256": "62fe688a15e0ca7d8a244af51f3a6c87a2624e2ded8e312c2bb3590542ca6dc2" + "blob": "5dee6b72205f7098b832a0da6c996afad3f5874e", + "sha256": "c3659b47808dcde989c10c7b37efe0845c743e18b25381182748329ce1e6cc71" }, { "path": ".github/workflows/client-v1-conformance.yml", diff --git a/src/client-v1-conformance-workflow.test.ts b/src/client-v1-conformance-workflow.test.ts index f4f3d3fc..96aa427d 100644 --- a/src/client-v1-conformance-workflow.test.ts +++ b/src/client-v1-conformance-workflow.test.ts @@ -2239,8 +2239,13 @@ describe('Chat-local protected Windows conformance workflow', () => { const workflow = readFileSync(resolve(projectRoot, '.github', 'workflows', 'ci.yml'), 'utf8'); const job = workflowJob(workflow, 'unix-supervisor'); expect(job).toContain('runs-on: $' + '{{ matrix.runner }}'); - expect(job).toContain('runner: ubuntu-24.04'); - expect(job).toContain('runner: macos-14'); + // The include list is selected by expression -- linux-x64 on every branch, + // darwin-arm64 on main or under the `ci:full` label -- because a matrix + // entry cannot carry its own condition. Both platforms must still be + // reachable, and the macOS one must be the gated half. + expect(job).toContain('"platform":"linux-x64","runner":"ubuntu-24.04"'); + expect(job).toContain('"platform":"darwin-arm64","runner":"macos-14"'); + expect(job).toContain("contains(github.event.pull_request.labels.*.name, 'ci:full')"); expect(job).toContain('bash scripts/unix-producer-supervisor.test.sh'); const runtimeTest = readFileSync( diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index ad659378..d6ff42c3 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -1042,11 +1042,37 @@ describe('Phase 1 specification guards', () => { expect(workflow).toMatch(/^ {2}changes:$/m); expect(workflow).toContain('docs_only: $' + '{{ steps.classify.outputs.docs_only }}'); - const gatedJobs = [...workflowJobs(workflow)] - .filter(([, job]) => /^ {4}if: needs\.changes\.outputs\.docs_only != 'true'$/m.test(job)) + const jobs = new Map(workflowJobs(workflow)); + const gatedJobs = [...jobs] + .filter(([, job]) => job.includes("needs.changes.outputs.docs_only != 'true'")) .map(([name]) => name); - expect(gatedJobs).toEqual(['e2e', 'phase1-conformance', 'desktop', 'rust', 'unix-supervisor']); + expect(gatedJobs).toEqual([ + 'e2e', + 'phase1-conformance', + 'desktop', + 'rust', + 'unix-supervisor', + 'windows-supervisor-behavior', + ]); + + // Prose is not the only minute worth not spending. macOS bills at ten + // times the Linux rate and Windows at twice it, so those runners also wait + // for main -- or for a pull request that asks for them by carrying the + // `ci:full` label, which any branch touching src-tauri or the supervisors + // should. `unix-supervisor` keeps linux-x64 on every branch and admits + // darwin-arm64 under the same gate, so it carries the expression in its + // matrix rather than in an `if`. + for (const name of [ + 'phase1-conformance', + 'rust', + 'unix-supervisor', + 'windows-supervisor-behavior', + ]) { + const job = jobs.get(name) ?? ''; + expect(job).toContain("github.event_name == 'push' && github.ref == 'refs/heads/main'"); + expect(job).toContain("contains(github.event.pull_request.labels.*.name, 'ci:full')"); + } // The classification has to fail towards running everything. A wrong guess // that way wastes a few minutes; the other way merges untested code.