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.