diff --git a/.github/actions/bazel-cache/action.yml b/.github/actions/bazel-cache/action.yml index d6f35135e..23bdab8a0 100644 --- a/.github/actions/bazel-cache/action.yml +++ b/.github/actions/bazel-cache/action.yml @@ -1,34 +1,22 @@ name: "Compose bazel cache config" description: > - Single source of truth for how a CI job caches bazel work, emitted as a - bazelrc fragment (rc output) consumers pass via `bazelisk --bazelrc=...`. + Selects the CI cache backend and emits a bazelrc fragment. A shell probe + exposes the backend through step outputs before any action condition uses it. - omp-kata pods (detected via BAZEL_REMOTE_USER/BAZEL_REMOTE_PASSWORD from the - bazel-remote-ci secret) get read-write gRPC access to the in-cluster - bazel-remote service — an address that only resolves inside the cluster, so - nothing about the infrastructure leaks from this public repo. With - `export: true` (the main-push rust job), the kata job additionally writes a - bazel disk cache and saves it to the GitHub Actions cache at job end under - the main branch scope — that is what makes pull requests warm: PR-created - caches are never shared across PRs, main-created ones are readable by every - PR. + omp-kata jobs use the cluster remote cache. An exporting main job first + performs an exact GitHub cache lookup without downloading the archive. A + genuine miss switches that job to a local disk cache for one build. The + workflow saves that populated cache explicitly after the build. - GitHub-hosted runners never talk to the cluster: they restore the - main-exported disk cache (plus their own branch-scoped refresh saves). + GitHub-hosted jobs restore the exported disk cache and never contact the + cluster. Build callers can save a new exact-key archive after a miss. inputs: scope: - description: > - Disk-cache key discriminator. Every linux-family consumer (validation, - TS test jobs, linux/musl/win32 release jobs) shares the `linux` scope - so PRs hit the cache exported from main's rust job; darwin release - jobs keep per-target scopes and self-populate. + description: Disk-cache key discriminator shared by compatible consumers required: true export: - description: > - Write a disk cache during the build and save it to the GitHub cache - at job end (main-push rust job only). No-op when the key already - exists for the current lockfiles. + description: Prepare a portable disk cache after an exact lookup miss required: false default: "false" @@ -36,78 +24,120 @@ outputs: rc: description: Path to the generated bazelrc fragment value: ${{ steps.compose.outputs.rc }} + cache-key: + description: Exact GitHub cache key for a later explicit save + value: ${{ steps.backend.outputs.cache-key }} + export-needed: + description: Whether the remote exporter switched to a portable disk cache + value: ${{ steps.compose.outputs.export-needed }} + save-needed: + description: Whether a disk-cache build can save a new exact-key archive + value: ${{ steps.compose.outputs.save-needed }} + remote: + description: Whether the shell probe selected the cluster remote cache + value: ${{ steps.backend.outputs.remote }} runs: using: composite steps: - - name: Restore bazel disk cache (GitHub-hosted) - if: env.BAZEL_REMOTE_USER == '' - uses: actions/cache@v4 + - name: Detect bazel cache backend + id: backend + shell: bash + env: + CACHE_KEY: bazel-disk-${{ inputs.scope }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.Bazel.lock', 'MODULE.bazel', 'rust-toolchain.toml') }} + run: | + set -euo pipefail + remote=false + if [ -n "${BAZEL_REMOTE_USER:-}" ] || [ -n "${BAZEL_REMOTE_PASSWORD:-}" ]; then + if [ -z "${BAZEL_REMOTE_USER:-}" ] || [ -z "${BAZEL_REMOTE_PASSWORD:-}" ]; then + echo "::error::BAZEL_REMOTE_USER and BAZEL_REMOTE_PASSWORD must both be set" + exit 1 + fi + remote=true + fi + { + echo "remote=$remote" + echo "cache-key=$CACHE_KEY" + } >> "$GITHUB_OUTPUT" + + - name: Restore bazel disk cache + id: restore + if: steps.backend.outputs.remote != 'true' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: | - ~/.cache/omp-bazel-disk - ~/.cache/omp-bazel-repo - key: bazel-disk-${{ inputs.scope }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.Bazel.lock', 'MODULE.bazel', 'rust-toolchain.toml') }} + path: ~/.cache/omp-bazel-disk + key: ${{ steps.backend.outputs.cache-key }} restore-keys: | bazel-disk-${{ inputs.scope }}-${{ runner.os }}-${{ runner.arch }}- - # Combined restore+save: restore is a no-op on the first run for these - # lockfiles (that's exactly when we want to build the export), and the - # post-job save only fires on a primary-key miss — so the export is - # written once per lockfile change, from a trusted main push. - - name: Prepare disk cache export (omp-kata) - if: env.BAZEL_REMOTE_USER != '' && inputs.export == 'true' + - name: Look up bazel disk cache export + if: steps.backend.outputs.remote == 'true' && inputs.export == 'true' id: export - uses: actions/cache@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/omp-bazel-disk - key: bazel-disk-${{ inputs.scope }}-Linux-X64-${{ hashFiles('Cargo.Bazel.lock', 'MODULE.bazel', 'rust-toolchain.toml') }} + key: ${{ steps.backend.outputs.cache-key }} + lookup-only: true - name: Compose cache config id: compose shell: bash env: - EXPORT_DISK: ${{ inputs.export == 'true' && steps.export.outputs.cache-hit != 'true' && 'true' || 'false' }} + REMOTE: ${{ steps.backend.outputs.remote }} + EXPORT_REQUESTED: ${{ inputs.export }} + EXPORT_HIT: ${{ steps.export.outputs.cache-hit }} + RESTORE_HIT: ${{ steps.restore.outputs.cache-hit }} run: | set -euo pipefail + export_needed=false + if [ "$REMOTE" = "true" ] && [ "$EXPORT_REQUESTED" = "true" ] && [ "$EXPORT_HIT" != "true" ]; then + export_needed=true + fi + save_needed=$export_needed + if [ "$REMOTE" != "true" ] && [ "$RESTORE_HIT" != "true" ]; then + save_needed=true + fi + rc="$RUNNER_TEMP/bazel-cache.rc" - if [ -n "${BAZEL_REMOTE_USER:-}" ]; then - auth="$(printf %s "${BAZEL_REMOTE_USER}:${BAZEL_REMOTE_PASSWORD}" | base64 | tr -d '\n')" + if [ "$REMOTE" = "true" ]; then { - # The PVC repo-cache mount lives OUTSIDE $HOME (/opt): kubelet - # creates missing mountpoint parents root-owned, and a - # root-owned $HOME/.cache breaks bazel's default - # output_user_root and zig's wrapper cache. Pods are - # single-job ephemeral, so RUNNER_TEMP hosts the output root. + # The PVC mount lives outside $HOME. Pods are single-job and + # use RUNNER_TEMP for Bazel's output root. echo "startup --output_user_root=$RUNNER_TEMP/bazel-root" echo "common --config=ci" - echo "common --config=cache-rw" - echo "common --remote_cache=grpcs://bazel-remote.bazel-cache.svc.cluster.local:9092" - echo "common --tls_certificate=infra/bazel-remote/ca.crt" - echo "common --remote_header='authorization=Basic ${auth}'" - # PVC-backed shared repository cache (pods are ephemeral; without - # it every job re-downloads toolchains + crate archives). The - # xwin MSVC splat reuses the same PVC via OMP_XWIN_CACHE_DIR - # (its ~1GiB CDN payload is not repository-cacheable). echo "common --repository_cache=/opt/bazel-repo-cache" echo "common --repo_env=OMP_XWIN_CACHE_DIR=/opt/bazel-repo-cache/xwin" - if [ "$EXPORT_DISK" = "true" ]; then - # Export runs (once per lockfile change) write the disk cache - # PRs restore. They must download everything: with top-level- - # only downloading, remote hits would export action entries - # whose blobs were never materialized. - echo "common --disk_cache=$HOME/.cache/omp-bazel-disk" - else - # Cache-hit work stays metadata-only; only requested top-level - # outputs (the .node addons) are actually downloaded. - echo "common --remote_download_toplevel" - fi } > "$rc" + + if [ "$export_needed" = "true" ]; then + # Do not combine remote and disk caches. Remote hits do not + # materialize a portable disk cache for hosted runners. + mkdir -p "$HOME/.cache/omp-bazel-disk" + echo "common --disk_cache=$HOME/.cache/omp-bazel-disk" >> "$rc" + else + raw_auth="${BAZEL_REMOTE_USER}:${BAZEL_REMOTE_PASSWORD}" + auth="$(printf %s "$raw_auth" | base64 | tr -d '\n')" + echo "::add-mask::$raw_auth" + echo "::add-mask::$auth" + { + echo "common --config=cache-rw" + echo "common --remote_cache=grpcs://bazel-remote.bazel-cache.svc.cluster.local:9092" + echo "common --tls_certificate=infra/bazel-remote/ca.crt" + echo "common --remote_header='authorization=Basic ${auth}'" + echo "common --remote_download_toplevel" + } >> "$rc" + fi else + mkdir -p "$HOME/.cache/omp-bazel-disk" "$HOME/.cache/omp-bazel-repo" { echo "common --config=ci" echo "common --disk_cache=$HOME/.cache/omp-bazel-disk" echo "common --repository_cache=$HOME/.cache/omp-bazel-repo" } > "$rc" fi - echo "rc=$rc" >> "$GITHUB_OUTPUT" + + { + echo "rc=$rc" + echo "export-needed=$export_needed" + echo "save-needed=$save_needed" + } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/bazel-natives/action.yml b/.github/actions/bazel-natives/action.yml index 452683e16..8dfbf9457 100644 --- a/.github/actions/bazel-natives/action.yml +++ b/.github/actions/bazel-natives/action.yml @@ -28,10 +28,20 @@ runs: - name: Build native addons shell: bash + env: + OMP_BAZEL_RC: ${{ steps.cache.outputs.rc }} + NATIVE_TARGETS: ${{ inputs.targets }} + NATIVE_DEST: ${{ inputs.dest }} run: | set -euo pipefail # Raise-only fd guard: huge toolchain input trees exhaust low soft # limits during sandbox setup. if [ "$(ulimit -Sn)" != unlimited ] && [ "$(ulimit -Sn)" -lt 65536 ]; then ulimit -Sn 65536 || true; fi - export OMP_BAZEL_RC="${{ steps.cache.outputs.rc }}" - bun scripts/bazel-natives.ts ${{ inputs.targets }} --dest "${{ inputs.dest }}" + read -r -a targets <<< "$NATIVE_TARGETS" + bun scripts/bazel-natives.ts "${targets[@]}" --dest "$NATIVE_DEST" + - name: Save bazel disk cache + if: steps.cache.outputs.save-needed == 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/omp-bazel-disk + key: ${{ steps.cache.outputs.cache-key }} \ No newline at end of file diff --git a/.github/actions/native-artifacts/action.yml b/.github/actions/native-artifacts/action.yml new file mode 100644 index 000000000..df8c6e8c0 --- /dev/null +++ b/.github/actions/native-artifacts/action.yml @@ -0,0 +1,38 @@ +name: "Install native addon artifacts" +description: > + Downloads the native addons built once by the Rust job and installs exact + targets without invoking Bazel. + +inputs: + targets: + description: Space-separated scripts/bazel-natives.ts target names + required: true + dest: + description: Destination directory for the .node files + required: false + default: packages/natives/native + +runs: + using: composite + steps: + - name: Download native addon artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: native-addons + path: ${{ runner.temp }}/omp-native-artifacts + + - name: Install native addon targets + shell: bash + env: + NATIVE_TARGETS: ${{ inputs.targets }} + NATIVE_DEST: ${{ inputs.dest }} + run: | + set -euo pipefail + read -r -a targets <<< "$NATIVE_TARGETS" + if [ "${#targets[@]}" -eq 0 ]; then + echo "::error::At least one native addon target is required" + exit 1 + fi + bun scripts/bazel-natives.ts "${targets[@]}" \ + --source "$RUNNER_TEMP/omp-native-artifacts" \ + --dest "$NATIVE_DEST" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4baac77f3..812b3ba03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,21 +118,16 @@ jobs: - name: Build collab web run: bun run collab:web:build - # Bazel validation and cache warm — replaces the old cargo pipeline - # (rust_validation + native build matrices + hand-rolled artifact caching). - # `bazel test` covers the Rust suite, the clippy/rustfmt aspect configs cover - # linting, and on main pushes (omp-kata, read-write cache) an additional - # //:natives-linux-all build populates the shared bazel-remote cache so every - # downstream job — TS tests, releases, PR runners — gets cache hits instead - # of rebuilding. No toolchain setup: bazelisk is on the GitHub images and - # baked into the kata runner image; bazel fetches the rest hermetically. + # One Bazel job validates Rust changes and builds native addons for every + # downstream job. Main builds all Linux-hosted targets. Pull requests build + # only the Linux x64 pair required by tests. rust: name: Validate Rust workspace (bazel) runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }} steps: - uses: actions/checkout@v4 - # TS-only PRs skip Rust validation entirely; addons for the TS test - # jobs come from the main-exported disk cache. Pushes always run. + # TS-only PRs skip Rust validation. They still materialize the Linux + # x64 addons once from the main-exported disk cache. - name: Detect Rust-affecting changes id: changes shell: bash @@ -153,7 +148,6 @@ jobs: - if: steps.changes.outputs.rust == 'true' uses: ./.github/actions/bun-install - id: cache - if: steps.changes.outputs.rust == 'true' uses: ./.github/actions/bazel-cache with: scope: linux @@ -184,11 +178,30 @@ jobs: - name: Rustfmt if: steps.changes.outputs.rust == 'true' run: bazelisk --bazelrc="${{ steps.cache.outputs.rc }}" build --config=rustfmt //crates/... - - name: Warm native addon cache (main push) - if: github.event_name != 'pull_request' + - name: Build native addons once + env: + EVENT_NAME: ${{ github.event_name }} run: | + set -euo pipefail if [ "$(ulimit -Sn)" != unlimited ] && [ "$(ulimit -Sn)" -lt 65536 ]; then ulimit -Sn 65536 || true; fi - bazelisk --bazelrc="${{ steps.cache.outputs.rc }}" build //:natives-linux-all + targets=(//:natives-linux-x64-baseline //:natives-linux-x64-modern) + if [ "$EVENT_NAME" != "pull_request" ]; then + targets=(//:natives-linux-all) + fi + bazelisk --bazelrc="${{ steps.cache.outputs.rc }}" build "${targets[@]}" + - name: Save Bazel disk cache + if: steps.cache.outputs.save-needed == 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/omp-bazel-disk + key: ${{ steps.cache.outputs.cache-key }} + - name: Upload native addon artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-addons + path: bazel-bin/natives-*/*.node + if-no-files-found: error + retention-days: 1 test_workspace: name: Test TS workspace fast @@ -200,10 +213,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test workspace packages and repo scripts (TS) env: OMP_TEST_CONCURRENCY: "4" @@ -219,10 +231,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test coding-agent singleton/global-state bucket # Keep global Settings/env/fake-timer tests serial; native addon # artifacts are still available like every other coding-agent bucket. @@ -238,10 +249,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test native/TUI/browser-ish packages (TS) env: OMP_TEST_CONCURRENCY: "4" @@ -257,10 +267,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test coding-agent UI/TUI bucket env: OMP_TEST_CONCURRENCY: "2" @@ -276,10 +285,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test coding-agent runtime bucket # Runtime/session tests import native-backed barrels too; keep this # separate for concurrency, not as a native-free guardrail. @@ -297,10 +305,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Test coding-agent native/unit bucket env: OMP_TEST_CONCURRENCY: "4" @@ -316,10 +323,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: CLI smoke test run: bun run ci:test:smoke @@ -332,10 +338,9 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-system-deps - uses: ./.github/actions/bun-install - - uses: ./.github/actions/bazel-natives + - uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Install method smoke tests env: OMP_INSTALL_TEST_SKIP_NATIVE_BUILD: "1" @@ -398,7 +403,7 @@ jobs: arch: x64, target_id: darwin-x64, binary_path: packages/coding-agent/binaries/omp-darwin-x64, - native_targets: darwin-all, + native_targets: darwin-x64-baseline, } - { os: macos-14, @@ -406,7 +411,7 @@ jobs: arch: arm64, target_id: darwin-arm64, binary_path: packages/coding-agent/binaries/omp-darwin-arm64, - native_targets: darwin-all, + native_targets: darwin-arm64, } - { os: ubuntu-22.04, @@ -442,14 +447,20 @@ jobs: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - run: bun install --frozen-lockfile - # Release runners are GitHub-hosted and never touch the private - # cluster cache: they build with the actions/cache-backed disk cache, - # so repeat releases with unchanged Rust are mostly local cache hits. - - name: Build native addon(s) (bazel) + # Linux and Windows addons come from the Rust job. Darwin runners + # build only their own architecture because cross-hosted artifacts do + # not exist for macOS. + - name: Install prebuilt native addon(s) + if: matrix.platform != 'darwin' + uses: ./.github/actions/native-artifacts + with: + targets: ${{ matrix.native_targets }} + - name: Build native addon (bazel) + if: matrix.platform == 'darwin' uses: ./.github/actions/bazel-natives with: targets: ${{ matrix.native_targets }} - cache-scope: ${{ startsWith(matrix.target_id, 'darwin') && format('release-{0}', matrix.target_id) || 'linux' }} + cache-scope: release-${{ matrix.target_id }} - name: Build release binary env: RELEASE_TARGETS: ${{ matrix.target_id }} @@ -609,14 +620,12 @@ jobs: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - run: bun install --frozen-lockfile - # The pi-coding-agent prepack executes workspace code (bundle-dist - # imports the pi-utils barrel, which loads the pi-natives addon), so - # this job needs the Linux x64 native addons just like TS tests do. - - name: Build native addons (bazel) - uses: ./.github/actions/bazel-natives + # The prepack executes workspace code which loads the Linux x64 + # addon, so install the Rust job's artifact before publishing. + - name: Install prebuilt native addons + uses: ./.github/actions/native-artifacts with: targets: linux-x64-baseline linux-x64-modern - cache-scope: linux - name: Publish to npm env: # Fallback auth: setup-node wrote an .npmrc referencing diff --git a/infra/docs/04-arc-and-caching.md b/infra/docs/04-arc-and-caching.md index 5977c5e6e..59ec20097 100644 --- a/infra/docs/04-arc-and-caching.md +++ b/infra/docs/04-arc-and-caching.md @@ -203,10 +203,10 @@ template: resources: requests: cpu: "8" - memory: "12Gi" + memory: "24Gi" limits: cpu: "8" - memory: "12Gi" + memory: "24Gi" volumes: - name: runner-cache persistentVolumeClaim: @@ -222,7 +222,7 @@ Field by field: goes in a workflow's `runs-on:`. - **`minRunners: 0` / `maxRunners: 4`** - **scale-to-zero**. With no queued jobs there are zero runner microVMs. Each admitted runner gets an honest 8-vCPU, - 12-GiB request and limit; excess jobs queue instead of ten 16-vCPU guests + 24-GiB request and limit; excess jobs queue instead of ten 16-vCPU guests fighting over the reference host's 32 physical CPUs. - **`containerMode.type: ""`** - **none**. The default chart offers `dind` (Docker-in-Docker sidecar) or `kubernetes` mode for job-container isolation; @@ -255,7 +255,7 @@ Field by field: mounts to the `arc-runners/runner-cache` PVC. `ReadWriteOnce` is enough on this single-node k3s host; use a RWX-capable storage class before spreading runners across nodes. -- **`resources`** - requests `2` CPU / `4Gi`, limits `16` CPU / `12Gi`. Kata reads +- **`resources`** - requests `8` CPU / `24Gi`, limits `8` CPU / `24Gi`. Kata reads these and sizes the guest accordingly: the VM now boots at the same guaranteed floor (`default_vcpus: 2`, `default_memory: 4096`) and only hotplugs beyond that toward the limits, with `default_maxvcpus: 0` allowing up diff --git a/infra/reload-runner.sh b/infra/reload-runner.sh index 6e7949493..398aec327 100755 --- a/infra/reload-runner.sh +++ b/infra/reload-runner.sh @@ -34,7 +34,7 @@ # BUILDKIT_VERSION BuildKit release to bootstrap on demand [0.25.1] # RUNNER_MAX_RUNNERS maximum concurrent Kata runner pods [4] # RUNNER_CPU requested and limited CPU cores per runner [8] -# RUNNER_MEMORY requested and limited memory per runner [12Gi] +# RUNNER_MEMORY requested and limited memory per runner [24Gi] set -euo pipefail : "${CI_HOST:?set CI_HOST to the ssh target of your CI host, e.g. CI_HOST=my-ci-host}" @@ -50,7 +50,7 @@ NERDCTL_VERSION="${NERDCTL_VERSION:-2.1.6}" BUILDKIT_VERSION="${BUILDKIT_VERSION:-0.25.1}" RUNNER_MAX_RUNNERS="${RUNNER_MAX_RUNNERS:-4}" RUNNER_CPU="${RUNNER_CPU:-8}" -RUNNER_MEMORY="${RUNNER_MEMORY:-12Gi}" +RUNNER_MEMORY="${RUNNER_MEMORY:-24Gi}" arg="${1:-$(date +%Y-%m-%d-%H%M%S)}" case "$arg" in *:*) IMAGE="$arg";; *) IMAGE="omp-kata-runner:$arg";; esac diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 70400e14f..2e309c06f 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -6,6 +6,7 @@ ### Changed +- CI now exports Bazel disk caches only after exact misses and reuses one native addon artifact across Linux test and release jobs. macOS release jobs now build only their own architecture. - Native addons now build with Bazel (rules_rust + hermetic zig cc toolchains for linux-gnu/musl, host Xcode for darwin, and a hermetic clang-cl + xwin toolchain for windows-msvc) instead of the napi CLI + cargo-zigbuild/cargo-xwin pipeline. `bun run build` drives `scripts/bazel-natives.ts`; TypeScript binding regeneration moved to `bun run build:bindings` (needed only when the Rust API surface changes). CI caches through a content-addressed bazel-remote action cache instead of sccache + target-directory snapshots, cutting warm native rebuilds from ~20 minutes to seconds and cold cache-hit builds to ~2.5 minutes. ### Fixed diff --git a/scripts/bazel-natives.test.ts b/scripts/bazel-natives.test.ts index b8fdddeea..4ae5acc0d 100644 --- a/scripts/bazel-natives.test.ts +++ b/scripts/bazel-natives.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $ } from "bun"; import { conventionOutputPaths, type HostInfo, @@ -95,21 +99,58 @@ describe("parseBazelFilesOutput", () => { }); describe("parseCliArgs", () => { - test("splits targets, --dest, and passthrough bazel args", () => { + test("splits targets, paths, and passthrough bazel args", () => { expect( parseCliArgs(["linux-x64-baseline", "linux-x64-modern", "--dest", "out", "--", "--config=ci", "--dest"]), ).toEqual({ targets: ["linux-x64-baseline", "linux-x64-modern"], dest: "out", + source: null, bazelArgs: ["--config=ci", "--dest"], }); - expect(parseCliArgs(["host"])).toEqual({ targets: ["host"], dest: null, bazelArgs: [] }); + expect(parseCliArgs(["host", "--source", "artifact"])).toEqual({ + targets: ["host"], + dest: null, + source: "artifact", + bazelArgs: [], + }); }); - test("rejects missing targets, stray flags, and a valueless --dest", () => { + test("rejects invalid build and artifact source combinations", () => { expect(() => parseCliArgs([])).toThrow(/Usage:/); expect(() => parseCliArgs(["--", "--config=ci"])).toThrow(/Usage:/); expect(() => parseCliArgs(["host", "--config=ci"])).toThrow(/Unknown flag --config=ci/); expect(() => parseCliArgs(["host", "--dest"])).toThrow(/--dest requires/); + expect(() => parseCliArgs(["host", "--source"])).toThrow(/--source requires/); + expect(() => parseCliArgs(["host", "--source", "artifact", "--", "--config=ci"])).toThrow( + /--source cannot be combined/, + ); + }); +}); + +describe("artifact source install", () => { + test("installs exact target outputs without invoking Bazel", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-native-artifacts-")); + const source = path.join(root, "source"); + const dest = path.join(root, "dest"); + const baseline = "pi_natives.linux-x64-baseline.node"; + const modern = "pi_natives.linux-x64-modern.node"; + try { + await fs.mkdir(path.join(source, "natives-linux-x64-baseline"), { recursive: true }); + await fs.mkdir(path.join(source, "natives-linux-x64-modern"), { recursive: true }); + await Bun.write(path.join(source, "natives-linux-x64-baseline", baseline), "baseline"); + await Bun.write(path.join(source, "natives-linux-x64-modern", modern), "modern"); + + const result = + await $`${process.execPath} ${path.join(import.meta.dir, "bazel-natives.ts")} linux-x64-baseline linux-x64-modern --source ${source} --dest ${dest}` + .quiet() + .nothrow(); + + expect(result.exitCode).toBe(0); + expect(await Bun.file(path.join(dest, baseline)).text()).toBe("baseline"); + expect(await Bun.file(path.join(dest, modern)).text()).toBe("modern"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } }); }); diff --git a/scripts/bazel-natives.ts b/scripts/bazel-natives.ts index 83a0d9266..d93a2bb28 100755 --- a/scripts/bazel-natives.ts +++ b/scripts/bazel-natives.ts @@ -2,7 +2,7 @@ /** * Canonical Bazel driver for the shipping pi_natives addons. * - * Usage: bun scripts/bazel-natives.ts ... [--dest ] [-- ] + * Usage: bun scripts/bazel-natives.ts ... [--dest ] [--source ] [-- ] * * Targets are the //:natives-* names from BUILD.bazel (e.g. linux-x64-baseline, * darwin-arm64) plus three pseudo-targets: @@ -118,15 +118,19 @@ export function parseBazelFilesOutput(output: string): string[] { return files; } +/** Parsed options for the native addon build and artifact install modes. */ export interface CliOptions { targets: string[]; dest: string | null; + source: string | null; bazelArgs: string[]; } +/** Parse target names and the mutually exclusive build or artifact source options. */ export function parseCliArgs(argv: string[]): CliOptions { const targets: string[] = []; let dest: string | null = null; + let source: string | null = null; const bazelArgs: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -134,10 +138,14 @@ export function parseCliArgs(argv: string[]): CliOptions { bazelArgs.push(...argv.slice(i + 1)); break; } - if (arg === "--dest") { + if (arg === "--dest" || arg === "--source") { const value = argv[++i]; - if (!value) throw new Error("--dest requires a directory argument"); - dest = value; + if (!value) throw new Error(`${arg} requires a directory argument`); + if (arg === "--dest") { + dest = value; + } else { + source = value; + } continue; } if (arg.startsWith("-")) { @@ -146,9 +154,14 @@ export function parseCliArgs(argv: string[]): CliOptions { targets.push(arg); } if (targets.length === 0) { - throw new Error("Usage: bun scripts/bazel-natives.ts ... [--dest ] [-- ]"); + throw new Error( + "Usage: bun scripts/bazel-natives.ts ... [--dest ] [--source ] [-- ]", + ); } - return { targets, dest, bazelArgs }; + if (source && bazelArgs.length > 0) { + throw new Error("--source cannot be combined with extra bazel arguments"); + } + return { targets, dest, source, bazelArgs }; } function resolveBazelBinary(): string { @@ -204,40 +217,48 @@ async function installAddon(sourcePath: string, destPath: string): Promise async function main(): Promise { const options = parseCliArgs(process.argv.slice(2)); const host: HostInfo = { platform: process.platform, arch: process.arch, avx2: detectHostAvx2Support() }; - const labels = resolveTargetLabels(options.targets, host); const destDir = options.dest ? path.resolve(options.dest) : path.join(repoRoot, "packages/natives/native"); - const bazel = resolveBazelBinary(); - // CI hands cache wiring (remote or disk) through a bazelrc fragment so - // endpoint composition stays in .github/actions/bazel-cache. - const rcPath = Bun.env.OMP_BAZEL_RC?.trim(); - const startupArgs = rcPath ? [`--bazelrc=${rcPath}`] : []; - - const buildArgs = [...startupArgs, "build", ...options.bazelArgs, "--", ...labels]; - console.log(`$ ${path.basename(bazel)} ${buildArgs.join(" ")}`); - const build = await runBazel(bazel, buildArgs, "inherit"); - if (build.exitCode !== 0) { - console.error(`\nbazel build failed (exit ${build.exitCode}). stderr tail:\n${build.stderrTail}`); - process.exit(build.exitCode || 1); - } - - // Same flags as the build so cquery resolves the identical configuration. - // cquery takes exactly one query expression, so multiple targets join - // into a single union rather than positional args. - const cquery = await runBazel( - bazel, - [...startupArgs, "cquery", ...options.bazelArgs, "--output=files", labels.join(" + ")], - "pipe", - ); let outputs: string[]; - if (cquery.exitCode === 0) { - outputs = parseBazelFilesOutput(cquery.stdout); + + if (options.source) { + const sourceDir = path.resolve(options.source); + outputs = conventionOutputPaths(options.targets, host).map(output => + path.join(sourceDir, path.relative("bazel-bin", output)), + ); } else { - console.warn(`bazel cquery failed (exit ${cquery.exitCode}); falling back to bazel-bin path convention`); - outputs = conventionOutputPaths(options.targets, host); - } - if (outputs.length === 0) { - console.error("bazel build succeeded but no .node outputs were located"); - process.exit(1); + const labels = resolveTargetLabels(options.targets, host); + const bazel = resolveBazelBinary(); + // CI hands cache wiring (remote or disk) through a bazelrc fragment so + // endpoint composition stays in .github/actions/bazel-cache. + const rcPath = Bun.env.OMP_BAZEL_RC?.trim(); + const startupArgs = rcPath ? [`--bazelrc=${rcPath}`] : []; + + const buildArgs = [...startupArgs, "build", ...options.bazelArgs, "--", ...labels]; + console.log(`$ ${path.basename(bazel)} ${buildArgs.join(" ")}`); + const build = await runBazel(bazel, buildArgs, "inherit"); + if (build.exitCode !== 0) { + console.error(`\nbazel build failed (exit ${build.exitCode}). stderr tail:\n${build.stderrTail}`); + process.exit(build.exitCode || 1); + } + + // Same flags as the build so cquery resolves the identical configuration. + // cquery takes exactly one query expression, so multiple targets join + // into a single union rather than positional args. + const cquery = await runBazel( + bazel, + [...startupArgs, "cquery", ...options.bazelArgs, "--output=files", labels.join(" + ")], + "pipe", + ); + if (cquery.exitCode === 0) { + outputs = parseBazelFilesOutput(cquery.stdout); + } else { + console.warn(`bazel cquery failed (exit ${cquery.exitCode}); falling back to bazel-bin path convention`); + outputs = conventionOutputPaths(options.targets, host); + } + if (outputs.length === 0) { + console.error("bazel build succeeded but no .node outputs were located"); + process.exit(1); + } } const seen = new Map();