Close macOS detection gaps and stop pull requests saving Zig caches

The macOS detector now fails the check when it cannot read either
revision or the platform test list, instead of reporting that macOS is
not needed. It also treats Linux and BSD code paths as platform code,
because macOS takes the other side of a Linux branch. Windows-only
branches still skip macOS. Replaying the last 150 commits on main moves
macOS runs from 72 to 76.

The macOS platform E2E files now run in two weighted shards that share
one baseline-CPU build. tui-performance and tui-resume join the list
because they branch on the platform.

Linux Zig jobs restore a cache keyed by the Zig version and build files,
and only runs on main save it. Pull request runs no longer add cache
entries that push main's out of the quota. The macOS jobs never run on
main, so their Zig cache is off.

Build & Test now also requires the SDK jobs, which Publish libfx relies
on. Manual dispatches get their own concurrency group so they cannot
cancel the push run on main. The N-API build pins Node 24, Binary Size
smoke-tests every target, and the shellcheck job syntax-checks each
script instead of only the first.
This commit is contained in:
Pranit committed 2026-10-01 14:02:22 -04:00
1 parent ae00fdf095
commit ebba84f05e
13 files changed
+544 -60

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
name: Save Zig cache
description: >-
Save the Zig cache restored by the setup-zig action. Saves only on main and
only when the exact key was missing, so the cache gets a new entry when the
Zig version or build files change instead of on every run.
inputs:
cache-key:
description: The cache-key output of the setup-zig action
required: true
cache-hit:
description: The cache-hit output of the setup-zig action
required: true
runs:
using: composite
steps:
- name: Save Zig cache
if: github.ref == 'refs/heads/main' && inputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/.zig-cache
key: ${{ inputs.cache-key }}
+52
View File
@@ -0,0 +1,52 @@
name: Setup Zig
description: >-
Install Zig and restore the job's Zig cache. Only runs on main save the cache,
through save-zig-cache, so pull requests reuse main's cache without adding
entries of their own. The key changes only with the Zig version and the build
files: fx itself recompiles on every change, so the cache pays off through the
dependencies, compiler_rt, and libc it keeps.
inputs:
version:
description: Zig version to install
required: true
cache-key:
description: Cache name for the job, unique per build configuration
required: true
outputs:
cache-key:
description: Exact cache key for save-zig-cache
value: ${{ steps.key.outputs.key }}
cache-hit:
description: Whether the exact cache key was restored
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: ${{ inputs.version }}
use-cache: false
- name: Compute Zig cache key
id: key
shell: bash
env:
PREFIX: zig-v1-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}-${{ inputs.cache-key }}-
BUILD_HASH: ${{ hashFiles('build.zig', 'build.zig.zon') }}
run: |
printf 'prefix=%s\n' "$PREFIX" >> "$GITHUB_OUTPUT"
printf 'key=%s%s\n' "$PREFIX" "$BUILD_HASH" >> "$GITHUB_OUTPUT"
# Main starts from an empty cache when the key misses, so each saved
# snapshot holds one build instead of growing with every build change.
- name: Restore Zig cache
id: restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/.zig-cache
key: ${{ steps.key.outputs.key }}
restore-keys: ${{ github.ref != 'refs/heads/main' && steps.key.outputs.prefix || '' }}
+16 -4
View File
@@ -28,7 +28,7 @@ on:
pull_request:
concurrency:
group: bench-${{ github.ref }}${{ (inputs.pgso_control_artifact != '' || inputs.pgso_candidate_artifact != '') && '-pgso-artifacts' || '' }}
group: bench-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.ref }}${{ (inputs.pgso_control_artifact != '' || inputs.pgso_candidate_artifact != '') && '-pgso-artifacts' || '' }}
cancel-in-progress: true
permissions:
@@ -43,9 +43,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: bench-ReleaseSafe
- name: Build (ReleaseSafe)
run: zig build -Doptimize=ReleaseSafe
@@ -85,6 +87,12 @@ jobs:
path: ${{ runner.temp }}/tui-performance.json
if-no-files-found: warn
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
long-turn-memory:
if: github.event_name == 'workflow_dispatch' && inputs.long_turn_memory && inputs.signed_run == '' && inputs.pgso_control_artifact == '' && inputs.pgso_candidate_artifact == ''
name: Long-turn memory
@@ -94,9 +102,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: bench-ReleaseSafe
- name: Build (ReleaseSafe)
run: zig build -Doptimize=ReleaseSafe
@@ -126,9 +136,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: bench-ReleaseSafe
- name: Setup Node
uses: actions/setup-node@v4
+1 -2
View File
@@ -83,10 +83,9 @@ jobs:
fi
cat "$evidence_dir/head-sections.txt"
# Linux x86_64 gets the same smoke test in CI. The other platforms run
# These are release-style builds. Linux arm64 and both macOS targets run
# nowhere else on a pull request, so this is where a broken binary shows up.
- name: Smoke test native binary
if: matrix.name != 'linux-x86_64'
env:
AI_GATEWAY_API_KEY: ""
VERCEL_OIDC_TOKEN: ""
+77 -17
View File
@@ -15,7 +15,9 @@ on:
pull_request:
concurrency:
group: ci-${{ github.ref }}
# A manual dispatch on main must not cancel the push run that Dev Release
# and Publish libfx wait on.
group: ci-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -32,7 +34,8 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-unit-${{ matrix.optimize }}
@@ -49,8 +52,8 @@ jobs:
- name: Check compactor boundary
run: ./scripts/check-compactor-boundary.sh
- name: Test signing and release routing
run: python3 -m unittest scripts.tests.test_macos_signing scripts.tests.test_signed_macos -q
- name: Test CI and release scripts
run: python3 -m unittest scripts.tests.test_macos_signing scripts.tests.test_signed_macos scripts.tests.test_detect_macos_need -q
- name: Build
run: zig build -Doptimize=${{ matrix.optimize }}
@@ -63,16 +66,26 @@ jobs:
trap 'printf "Unit tests elapsed: %ss\n" "$((SECONDS - started_at))"' EXIT
timeout "${TEST_TIMEOUT_SECONDS}s" zig build test -Doptimize=${{ matrix.optimize }}
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
# The SDK jobs are part of this required check because Publish libfx relies
# on them instead of rerunning the package qualification before publishing.
zig:
name: Build & Test
if: ${{ always() }}
needs: zig_mode
needs: [zig_mode, sdk-node-wasm, sdk-node-napi, sdk-bun, sdk-browser-wasm]
runs-on: ubuntu-latest
steps:
- name: Require ReleaseSafe
- name: Require unit tests and every SDK job
env:
MATRIX_RESULT: ${{ needs.zig_mode.result }}
run: test "$MATRIX_RESULT" = success
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
printf '%s\n' "$RESULTS"
jq -e 'length == 5 and all(.[]; . == "success")' <<<"$RESULTS"
build:
name: Build fx (ReleaseSafe)
@@ -85,7 +98,8 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-build-ReleaseSafe
@@ -107,6 +121,12 @@ jobs:
if-no-files-found: error
retention-days: 1
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
sdk-wasm:
name: SDK build (WASM)
runs-on: ubuntu-latest
@@ -115,9 +135,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-sdk-wasm
- name: Build WASM surfaces (ReleaseSmall)
run: |
@@ -134,6 +156,12 @@ jobs:
if-no-files-found: error
retention-days: 1
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
sdk-napi:
name: SDK build (N-API, ${{ matrix.platform }})
runs-on: ubuntu-latest
@@ -153,8 +181,15 @@ jobs:
steps:
- uses: actions/checkout@v4
# build.zig locates node_api.h through the node on PATH.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-sdk-napi-${{ matrix.platform }}
@@ -173,6 +208,12 @@ jobs:
if-no-files-found: error
retention-days: 1
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
sdk-node-wasm:
name: SDK (Node + WASM)
needs: sdk-wasm
@@ -422,7 +463,8 @@ jobs:
# Some E2E files build their own Zig fixtures.
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-e2e-fixtures
@@ -494,6 +536,13 @@ jobs:
printf ' %s\n' "${selected[@]}"
./ci-run-files.sh "${selected[@]}"
- name: Save Zig cache
if: matrix.shard.index == 0
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
e2e_conformance:
name: E2E (MCP conformance)
needs: build
@@ -517,10 +566,11 @@ jobs:
# The client-request-state scenario builds a Zig dispatcher fixture.
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-e2e-fixtures
cache-key: ci-mcp-fixtures
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -544,6 +594,12 @@ jobs:
run: npm test
working-directory: tests/e2e/conformance
- name: Save Zig cache
uses: ./.github/actions/save-zig-cache
with:
cache-key: ${{ steps.zig.outputs.cache-key }}
cache-hit: ${{ steps.zig.outputs.cache-hit }}
e2e:
name: E2E (deterministic)
if: ${{ always() }}
@@ -574,7 +630,10 @@ jobs:
scripts/smoke-binary.sh
tests/e2e/ci-run-files.sh
)
bash -n "${scripts[@]}"
# bash -n parses only its first argument, so check each script.
for script in "${scripts[@]}"; do
bash -n "$script"
done
shellcheck "${scripts[@]}"
e2e_acp_live:
@@ -585,10 +644,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
id: zig
uses: ./.github/actions/setup-zig
with:
version: "0.16.0"
cache-key: ci-acp-full-ReleaseSafe
cache-key: ci-unit-ReleaseSafe
- name: Build
run: zig build -Doptimize=ReleaseSafe
+67 -15
View File
@@ -64,7 +64,7 @@ jobs:
uses: mlugg/setup-zig@v2
with:
version: "0.16.0"
cache-key: macos-unit-ReleaseSafe
use-cache: false
- name: Test signing and release routing
run: python3 -m unittest scripts.tests.test_macos_signing scripts.tests.test_signed_macos -q
@@ -72,33 +72,80 @@ jobs:
- name: Run unit tests
run: zig build test -Doptimize=ReleaseSafe
e2e:
name: macOS arm64 platform E2E
build:
name: macOS arm64 build
needs: detect
if: needs.detect.outputs.needed == 'true'
runs-on: macos-15
timeout-minutes: 90
timeout-minutes: 30
env:
AI_GATEWAY_API_KEY: ""
VERCEL_OIDC_TOKEN: ""
FX_REQUIRE_TMUX: "1"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: "0.16.0"
cache-key: macos-e2e-ReleaseSafe
use-cache: false
# Both E2E shards run this binary on other runners, so build for the
# baseline CPU rather than the build runner's.
- name: Build
run: zig build -Doptimize=ReleaseSafe
run: zig build -Doptimize=ReleaseSafe -Dcpu=baseline
- name: Smoke test fresh binary
run: scripts/smoke-binary.sh zig-out/bin/fx
- name: Share the binary with the E2E shards
uses: actions/upload-artifact@v4
with:
name: fx-macos-arm64
path: zig-out/bin/fx
retention-days: 1
if-no-files-found: error
e2e:
name: macOS arm64 platform E2E (shard ${{ matrix.shard.label }})
needs: [detect, build]
if: needs.detect.outputs.needed == 'true'
runs-on: macos-15
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard:
- index: 0
label: "1/2"
- index: 1
label: "2/2"
env:
AI_GATEWAY_API_KEY: ""
VERCEL_OIDC_TOKEN: ""
FX_REQUIRE_TMUX: "1"
SHARD_INDEX: ${{ matrix.shard.index }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download the shared binary
uses: actions/download-artifact@v4
with:
name: fx-macos-arm64
path: zig-out/bin
- name: Restore the executable bit
run: chmod +x zig-out/bin/fx
# Some E2E fixtures build small Zig helpers.
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: "0.16.0"
use-cache: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -115,26 +162,29 @@ jobs:
run: bun install --frozen-lockfile
working-directory: tests/e2e
- name: Run macOS platform E2E files
- name: Run this shard of the macOS platform E2E files
working-directory: tests/e2e
shell: bash
run: |
bun ci-shards.ts --shard-count 2 --shard-index "$SHARD_INDEX" \
--only macos-platform-tests.json > "$RUNNER_TEMP/fx-macos-shard.txt"
files=()
while IFS= read -r test_file; do
files+=("$test_file")
done < <(jq -r '.[]' tests/e2e/macos-platform-tests.json)
done < "$RUNNER_TEMP/fx-macos-shard.txt"
test "${#files[@]}" -gt 0
printf 'Running %s macOS platform E2E files:\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
tests/e2e/ci-run-files.sh "${files[@]}"
./ci-run-files.sh "${files[@]}"
- name: Setup Node
if: needs.detect.outputs.sdk_native == 'true'
if: needs.detect.outputs.sdk_native == 'true' && matrix.shard.index == 0
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build and load the native SDK addon
if: needs.detect.outputs.sdk_native == 'true'
if: needs.detect.outputs.sdk_native == 'true' && matrix.shard.index == 0
run: |
zig build libfx-napi -Dnapi-surface=core -Doptimize=ReleaseSafe
npm run --prefix sdk test:node-napi
@@ -142,7 +192,7 @@ jobs:
macos:
name: macOS arm64
if: ${{ always() }}
needs: [detect, unit, e2e]
needs: [detect, unit, build, e2e]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
@@ -151,6 +201,7 @@ jobs:
DETECT_RESULT: ${{ needs.detect.result }}
NEEDED: ${{ needs.detect.outputs.needed }}
UNIT_RESULT: ${{ needs.unit.result }}
BUILD_RESULT: ${{ needs.build.result }}
E2E_RESULT: ${{ needs.e2e.result }}
run: |
test "$DETECT_RESULT" = success
@@ -159,4 +210,5 @@ jobs:
exit 0
fi
test "$UNIT_RESULT" = success
test "$BUILD_RESULT" = success
test "$E2E_RESULT" = success
+4 -2
View File
@@ -278,11 +278,13 @@ Do not run the complete deterministic test suite locally as the default developm
After the focused checks pass, create a clean checkpoint commit, push the non-`main` feature branch, and open a draft PR immediately. Linux is the gate for every change because most fx behavior is identical on every platform:
* `.github/workflows/ci.yml` runs on Linux x86_64. It checks formatting, the PGSO corpus, the public surface, and the compactor boundary, then runs the ReleaseSafe unit tests. It builds fx once, smoke-tests it, and shares that binary with four duration-balanced E2E shards and the MCP conformance baseline. Checked-in weights assign every E2E file to exactly one shard, and files inside each shard run sequentially in separate Bun processes so terminal fixtures and process state cannot leak between files. A failed file receives one retry after its tmux server is reset. A file that passes only on retry gets a warning annotation; investigate it as a possible race. The SDK jobs build WASM and the Node-API addons once and run the complete package qualification.
* `.github/workflows/ci.yml` runs on Linux x86_64. It checks formatting, the PGSO corpus, the public surface, and the compactor boundary, then runs the ReleaseSafe unit tests. It builds fx once, smoke-tests it, and shares that binary with four duration-balanced E2E shards and the MCP conformance baseline. Checked-in weights assign every E2E file to exactly one shard, and files inside each shard run sequentially in separate Bun processes so terminal fixtures and process state cannot leak between files. A failed file receives one retry after its tmux server is reset. A file that passes only on retry gets a warning annotation; investigate it as a possible race. The SDK jobs build WASM and the Node-API addons once and run the complete package qualification. `Build & Test` passes only when the unit tests and every SDK job pass, because Publish libfx relies on that result instead of qualifying the package again.
* `.github/workflows/binary-size.yml` builds each release target on its native runner, reports the size change, and smoke-tests the Linux arm64, macOS x86_64, and macOS arm64 binaries. See **Binary Size Observability**.
* `.github/workflows/bench.yml` enforces the startup latency budget. See **Benchmarks**.
macOS runs only when a change can behave differently there. `.github/workflows/macos.yml` uses `scripts/detect-macos-need.sh` to check the diff. The macOS arm64 unit tests and the E2E files in `tests/e2e/macos-platform-tests.json` run when the change touches a Zig file with macOS-specific code, `build.zig`, the macOS signing script, the native SDK addon, a listed E2E file, or the macOS checks themselves. Native SDK addon changes also build and load the addon on macOS. To request macOS for any other change, add the `ci:macos` label or run `gh workflow run macos.yml --ref <branch>`. When the change does not need macOS, the `macOS arm64` check passes without starting a macOS runner. Every E2E file that branches on `process.platform === "darwin"` must appear in `tests/e2e/macos-platform-tests.json`, and `tests/e2e/ci-shards.test.ts` enforces that.
Linux jobs install Zig through `.github/actions/setup-zig`, which restores the Zig cache that runs on `main` save with `.github/actions/save-zig-cache`. Pull requests never save a Zig cache, so they cannot push `main`'s entries out of the repository's cache quota.
macOS runs only when a change can behave differently there. `.github/workflows/macos.yml` uses `scripts/detect-macos-need.sh` to check the diff. The macOS arm64 unit tests and the E2E files in `tests/e2e/macos-platform-tests.json` run when the change touches a Zig file with a macOS, BSD, or Linux code path, `build.zig`, the macOS signing script, the native SDK addon, a listed E2E file, or the macOS checks themselves. A Linux-only branch counts because macOS takes its other path; a Windows-only branch does not. The listed E2E files run in two weighted shards that share one macOS build. Native SDK addon changes also build and load the addon on macOS. If the detector cannot read the change, the `macOS arm64` check fails instead of passing. To request macOS for any other change, add the `ci:macos` label or run `gh workflow run macos.yml --ref <branch>`. When the change does not need macOS, the `macOS arm64` check passes without starting a macOS runner. Every E2E file that branches on `process.platform === "darwin"` must appear in `tests/e2e/macos-platform-tests.json`, and `tests/e2e/ci-shards.test.ts` enforces that.
The macOS arm64 PGSO workflow runs on a pull request only when the PGSO pipeline changes: `scripts/pgso/` other than `corpus.json`, the `setup-pgso` action, or `.github/workflows/pgso-macos-arm64.yml`. To qualify any other change, run `gh workflow run pgso-macos-arm64.yml --ref <branch>`. The stable release always runs it.
+1 -1
View File
@@ -41,7 +41,7 @@ Keep the local development loop focused: run the narrowest test that covers the
Once the focused checks pass, create a clean checkpoint commit, push the non-`main` feature branch, and open a draft PR immediately. **CI** runs the complete deterministic suite on Linux x86_64, which is the gate for every change. It checks formatting, the PGSO corpus, the public surface, and the compactor boundary, runs the ReleaseSafe unit tests, and builds fx once for the E2E jobs. Four duration-balanced E2E shards use checked-in weights to assign every Bun test file once, and files inside each shard run sequentially in separate Bun processes so terminal fixtures and process state cannot leak between files. A failed file receives one retry after tmux is reset, and a file that passes only on retry gets a warning annotation.
macOS checks run only when a change can behave differently on macOS: a Zig file with macOS-specific code, `build.zig`, the macOS signing script, the native SDK addon, or an E2E file listed in `tests/e2e/macos-platform-tests.json`. Add the `ci:macos` label or run `gh workflow run macos.yml --ref <branch>` to request them for any other change. The `macOS arm64` check passes without a macOS runner when the change does not need one.
macOS checks run only when a change can behave differently on macOS: a Zig file with a macOS, BSD, or Linux code path, `build.zig`, the macOS signing script, the native SDK addon, or an E2E file listed in `tests/e2e/macos-platform-tests.json`. Add the `ci:macos` label or run `gh workflow run macos.yml --ref <branch>` to request them for any other change. The `macOS arm64` check passes without a macOS runner when the change does not need one.
Do not mark the draft PR ready until `Build & Test`, `E2E (deterministic)`, `Shellcheck`, `Startup Latency`, `macOS arm64`, and the final ship gate have succeeded for the exact current commit. A result from an older commit does not count. Live model evals are separate from this gate because they require credentials and are not deterministic.
+34 -12
View File
@@ -4,14 +4,18 @@
# Usage: detect-macos-need.sh <base-revision> <head-revision>
#
# macOS runs only when a change touches behavior that can differ on macOS: a
# Zig file with macOS-specific code before or after the change, build.zig, the
# macOS signing script, the native SDK addon, an E2E file listed in
# tests/e2e/macos-platform-tests.json, or the macOS checks themselves. Set
# MACOS_REQUESTED to a reason to force the checks, and SDK_NATIVE_REQUESTED=true
# to force the native SDK addon check.
# Zig file with a macOS, BSD, or Linux code path before or after the change,
# build.zig, the macOS signing script, the native SDK addon, an E2E file listed
# in tests/e2e/macos-platform-tests.json, or the macOS checks themselves. A Linux
# branch counts because macOS takes its other path. A Windows-only branch does
# not, because macOS and Linux share its other path. Set MACOS_REQUESTED to a
# reason to force the checks, and SDK_NATIVE_REQUESTED=true to force the native
# SDK addon check.
#
# Writes needed=true|false and sdk_native=true|false to $GITHUB_OUTPUT and the
# reasons to $GITHUB_STEP_SUMMARY when those files are set.
# reasons to $GITHUB_STEP_SUMMARY when those files are set. Exits non-zero when
# either revision cannot be read, so the macOS check fails instead of passing
# without having looked at the change.
set -euo pipefail
if [ "$#" -ne 2 ]; then
@@ -37,12 +41,29 @@ require_sdk_native() {
require "$1"
}
has_macos_code() {
has_platform_code() {
local content
content="$({ git show "$head:$1" 2>/dev/null; git show "$base:$1" 2>/dev/null; } || true)"
grep -Eq '\.macos|isDarwin|[Dd]arwin' <<<"$content"
grep -Eq '\.linux([^A-Za-z0-9_]|$)|isBSD|\.macos|isDarwin|[Dd]arwin' <<<"$content"
}
if ! jq -e 'type == "array"' "$platform_list" >/dev/null 2>&1; then
printf 'error: %s is missing or not a JSON array; the macOS check cannot decide\n' "$platform_list" >&2
exit 1
fi
for revision in "$base" "$head"; do
if ! git rev-parse --verify --quiet "$revision^{commit}" >/dev/null; then
printf 'error: cannot read revision %s; the macOS check cannot decide\n' "$revision" >&2
exit 1
fi
done
if ! changed_files="$(git diff --name-only --no-renames "$base" "$head")"; then
printf 'error: git diff %s %s failed; the macOS check cannot decide\n' "$base" "$head" >&2
exit 1
fi
is_platform_test() {
jq -e --arg file "$1" 'index($file) != null' "$platform_list" >/dev/null
}
@@ -55,10 +76,11 @@ if [ "${SDK_NATIVE_REQUESTED:-false}" = true ]; then
fi
while IFS= read -r path; do
[ -n "$path" ] || continue
case "$path" in
.github/workflows/macos.yml)
require_sdk_native "$path: the macOS checks changed" ;;
scripts/detect-macos-need.sh | scripts/smoke-binary.sh | tests/e2e/ci-run-files.sh | "$platform_list")
scripts/detect-macos-need.sh | scripts/smoke-binary.sh | tests/e2e/ci-run-files.sh | tests/e2e/ci-shards.ts | "$platform_list")
require "$path: the macOS checks changed" ;;
build.zig | build.zig.zon)
require "$path: build configuration changed" ;;
@@ -67,8 +89,8 @@ while IFS= read -r path; do
src/napi_*.zig | sdk/node.js | sdk/tests/test-node-napi.mjs)
require_sdk_native "$path: native SDK addon changed" ;;
*.zig)
if has_macos_code "$path"; then
require "$path: contains macOS-specific code"
if has_platform_code "$path"; then
require "$path: has a macOS or Linux code path"
fi ;;
tests/e2e/*.test.ts)
name="${path#tests/e2e/}"
@@ -76,7 +98,7 @@ while IFS= read -r path; do
require "$path: macOS platform E2E file changed"
fi ;;
esac
done < <(git diff --name-only --no-renames "$base" "$head")
done <<<"$changed_files"
if [ "$needed" = true ]; then
printf 'macOS arm64 checks needed:\n'
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import os
import pathlib
import subprocess
import tempfile
import unittest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "scripts" / "detect-macos-need.sh"
PLATFORM_LIST = "tests/e2e/macos-platform-tests.json"
class DetectMacosNeedTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.temp.name)
self.env = {
"PATH": os.environ["PATH"],
"HOME": self.temp.name,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
}
self.git("init", "-q")
self.write(PLATFORM_LIST, '["listed.test.ts"]\n')
self.write("src/plain.zig", "pub fn plain() void {}\n")
self.write("src/mac.zig", "if (builtin.os.tag == .macos) {}\n")
self.write("README.md", "fx\n")
self.base = self.commit("base")
def tearDown(self) -> None:
self.temp.cleanup()
def git(self, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=self.root, env=self.env, check=True,
capture_output=True, text=True,
)
return result.stdout.strip()
def write(self, path: str, content: str) -> None:
target = self.root / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
def commit(self, message: str) -> str:
self.git("add", "-A")
self.git("commit", "-q", "--allow-empty", "-m", message)
return self.git("rev-parse", "HEAD")
def detect(
self, base: str | None = None, head: str = "HEAD", **env: str,
) -> tuple[int, dict[str, str], str]:
output_path = self.root.parent / f"{self.root.name}-output"
output_path.write_text("", encoding="utf-8")
result = subprocess.run(
["bash", str(SCRIPT_PATH), base or self.base, head],
cwd=self.root,
env={**self.env, "GITHUB_OUTPUT": str(output_path), **env},
capture_output=True,
text=True,
)
outputs = dict(
line.split("=", 1)
for line in output_path.read_text(encoding="utf-8").splitlines()
)
output_path.unlink()
return result.returncode, outputs, result.stdout + result.stderr
def assert_needed(self, *, sdk_native: bool = False, **env: str) -> str:
code, outputs, text = self.detect(**env)
self.assertEqual(0, code, text)
self.assertEqual("true", outputs.get("needed"), text)
self.assertEqual("true" if sdk_native else "false", outputs.get("sdk_native"), text)
return text
def assert_not_needed(self) -> None:
code, outputs, text = self.detect()
self.assertEqual(0, code, text)
self.assertEqual({"needed": "false", "sdk_native": "false"}, outputs, text)
def test_change_without_platform_behavior_skips_macos(self) -> None:
self.write("README.md", "fx docs\n")
self.write("src/plain.zig", "pub fn plain() u8 { return 1; }\n")
self.write("src/windows.zig", "if (builtin.os.tag == .windows) {} else {}\n")
self.write("src/linux_target.zig", "const name = .linux_x64;\n")
self.write("tests/e2e/unlisted.test.ts", "test\n")
self.commit("head")
self.assert_not_needed()
def test_zig_file_with_macos_code_needs_macos(self) -> None:
self.write("src/mac.zig", "if (builtin.os.tag == .macos) { run(); }\n")
self.commit("head")
self.assertIn("src/mac.zig", self.assert_needed())
def test_linux_branch_needs_macos_because_macos_takes_the_else_path(self) -> None:
self.write("src/self_exe.zig", "if (comptime builtin.os.tag == .linux) {} else {}\n")
self.commit("head")
self.assertIn("src/self_exe.zig", self.assert_needed())
def test_linux_namespace_and_bsd_checks_need_macos(self) -> None:
self.write("src/syscall.zig", "const pid = std.os.linux.getpid();\n")
self.write("src/bsd.zig", "if (builtin.os.tag.isBSD()) {}\n")
self.commit("head")
text = self.assert_needed()
self.assertIn("src/syscall.zig", text)
self.assertIn("src/bsd.zig", text)
def test_removing_macos_code_needs_macos(self) -> None:
self.write("src/mac.zig", "pub fn now_plain() void {}\n")
self.commit("head")
self.assertIn("src/mac.zig", self.assert_needed())
def test_deleted_and_renamed_macos_files_need_macos(self) -> None:
self.git("mv", "src/mac.zig", "src/renamed.zig")
self.commit("head")
text = self.assert_needed()
self.assertIn("src/mac.zig", text)
self.assertIn("src/renamed.zig", text)
def test_build_and_signing_changes_need_macos(self) -> None:
for path in ("build.zig", "build.zig.zon", "scripts/sign-and-notarize-macos.sh"):
with self.subTest(path=path):
self.write(path, f"{path}\n")
self.commit(path)
self.assertIn(path, self.assert_needed())
self.base = self.git("rev-parse", "HEAD")
def test_native_sdk_changes_also_check_the_addon(self) -> None:
for path in ("src/napi_core_main.zig", "sdk/node.js", "sdk/tests/test-node-napi.mjs"):
with self.subTest(path=path):
self.write(path, f"// {path}\n")
self.commit(path)
self.assertIn(path, self.assert_needed(sdk_native=True))
self.base = self.git("rev-parse", "HEAD")
def test_macos_check_inputs_need_macos(self) -> None:
self.write(".github/workflows/macos.yml", "name: macOS\n")
self.commit("workflow")
self.assert_needed(sdk_native=True)
self.base = self.git("rev-parse", "HEAD")
for path in (
"scripts/detect-macos-need.sh",
"scripts/smoke-binary.sh",
"tests/e2e/ci-run-files.sh",
"tests/e2e/ci-shards.ts",
):
with self.subTest(path=path):
self.write(path, f"# {path}\n")
self.commit(path)
self.assertIn(path, self.assert_needed())
self.base = self.git("rev-parse", "HEAD")
def test_listed_platform_test_needs_macos_and_nested_paths_do_not(self) -> None:
self.write("tests/e2e/listed.test.ts", "test\n")
self.commit("listed")
self.assertIn("listed.test.ts", self.assert_needed())
self.base = self.git("rev-parse", "HEAD")
self.write("tests/e2e/fixtures/listed.test.ts", "fixture\n")
self.commit("nested")
self.assert_not_needed()
def test_requests_force_the_checks(self) -> None:
self.assertIn("ci:macos label", self.assert_needed(MACOS_REQUESTED="ci:macos label"))
self.assert_needed(sdk_native=True, SDK_NATIVE_REQUESTED="true")
def test_unreadable_revision_fails_instead_of_skipping(self) -> None:
code, outputs, text = self.detect(base="does-not-exist")
self.assertNotEqual(0, code, text)
self.assertEqual({}, outputs)
self.assertIn("cannot read revision", text)
def test_missing_platform_list_fails_instead_of_skipping(self) -> None:
(self.root / PLATFORM_LIST).unlink()
code, outputs, text = self.detect()
self.assertNotEqual(0, code, text)
self.assertEqual({}, outputs)
self.assertIn("not a JSON array", text)
if __name__ == "__main__":
unittest.main()
+29 -4
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildShardPlan, selectShard } from "./ci-shards";
import { buildShardPlan, restrictToFiles, selectShard } from "./ci-shards";
const files = ["a.test.ts", "b.test.ts", "c.test.ts", "d.test.ts"];
@@ -146,12 +146,37 @@ describe("macOS platform E2E list", () => {
expect(names).toEqual([...new Set(names)].sort());
});
test("includes every E2E file that branches on macOS", () => {
const macosBranch = /(process\.platform|platform\(\))\s*[!=]==\s*["']darwin["']/;
test("includes every E2E file that branches on the platform", () => {
const platformBranch = /\bplatform(\(\))?\s*[!=]==\s*["'](darwin|linux)["']/;
const missing = rootTests.filter((name) =>
macosBranch.test(readFileSync(join(import.meta.dir, name), "utf8")) &&
platformBranch.test(readFileSync(join(import.meta.dir, name), "utf8")) &&
!(listed as string[]).includes(name)
);
expect(missing).toEqual([]);
});
test("plans the listed files with their checked-in weights", () => {
const manifest = [
{ file: "a.test.ts", weight: 9 },
{ file: "b.test.ts", weight: 7 },
{ file: "c.test.ts", weight: 5 },
{ file: "d.test.ts", weight: 3 },
];
const restricted = restrictToFiles(files, manifest, ["d.test.ts", "b.test.ts", "c.test.ts"]);
const plan = buildShardPlan(restricted.files, restricted.manifest, 2);
expect(plan).toEqual({
shards: [["b.test.ts"], ["c.test.ts", "d.test.ts"]],
totals: [7, 8],
});
});
test("rejects empty, unknown, and duplicate listed files", () => {
const manifest = files.map((file) => ({ file, weight: 1 }));
expect(() => restrictToFiles(files, manifest, [])).toThrow("non-empty array");
expect(() => restrictToFiles(files, manifest, ["z.test.ts"])).toThrow("not a discovered test");
expect(() => restrictToFiles(files, manifest, ["a.test.ts", "a.test.ts"])).toThrow("duplicate");
});
});
+40 -3
View File
@@ -1,5 +1,5 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
export type ShardPlan = {
shards: string[][];
@@ -92,6 +92,29 @@ export function buildShardPlan(
return { shards, totals };
}
// Narrows a shard plan to a listed subset of the discovered files, such as the
// macOS platform list, while keeping each file's checked-in weight.
export function restrictToFiles(
discoveredFiles: string[],
manifestValue: unknown,
listValue: unknown,
): { files: string[]; manifest: WeightEntry[] } {
if (!Array.isArray(listValue) || listValue.length === 0) {
throw new Error("file list must be a non-empty array");
}
const discovered = new Set(discoveredFiles);
const listed = new Set<string>();
for (const file of listValue) {
if (typeof file !== "string" || !discovered.has(file)) {
throw new Error(`listed file is not a discovered test: ${String(file)}`);
}
if (listed.has(file)) throw new Error(`duplicate listed file: ${file}`);
listed.add(file);
}
const manifest = parseManifest(manifestValue).filter((entry) => listed.has(entry.file));
return { files: [...listed], manifest };
}
export function selectShard(plan: ShardPlan, shardIndex: number): string[] {
if (!Number.isSafeInteger(shardIndex) ||
shardIndex < 0 || shardIndex >= plan.shards.length) {
@@ -108,15 +131,29 @@ function integerArgument(args: string[], name: string): number {
return value;
}
function optionalArgument(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index < 0) return undefined;
if (index + 1 >= args.length) throw new Error(`missing ${name} value`);
return args[index + 1];
}
function run(args: string[]): void {
const shardCount = integerArgument(args, "--shard-count");
const shardIndex = integerArgument(args, "--shard-index");
const manifest = JSON.parse(
let manifest: unknown = JSON.parse(
readFileSync(join(import.meta.dir, "ci-shard-weights.json"), "utf8"),
);
const discovered = readdirSync(import.meta.dir, { withFileTypes: true })
let discovered = readdirSync(import.meta.dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts"))
.map((entry) => entry.name);
const listPath = optionalArgument(args, "--only");
if (listPath !== undefined) {
const list = JSON.parse(readFileSync(resolve(import.meta.dir, listPath), "utf8"));
const restricted = restrictToFiles(discovered, manifest, list);
discovered = restricted.files;
manifest = restricted.manifest;
}
const plan = buildShardPlan(discovered, manifest, shardCount);
const selected = selectShard(plan, shardIndex);
+2
View File
@@ -10,7 +10,9 @@
"tui-command-permissions.test.ts",
"tui-full-transcript-brutal.test.ts",
"tui-interrupt-recovery.test.ts",
"tui-performance.test.ts",
"tui-resume-brutal.test.ts",
"tui-resume.test.ts",
"tui-slash-extra.test.ts",
"tui-terminal-tool.test.ts",
"vision-route-fake-gateway.test.ts",