Skip to content

✨ feat(bazel): automate the workspace with Bazel - #227

Open
roninjin10 wants to merge 10 commits into
mainfrom
build-compare/bazel
Open

✨ feat(bazel): automate the workspace with Bazel#227
roninjin10 wants to merge 10 commits into
mainfrom
build-compare/bazel

Conversation

@roninjin10

@roninjin10 roninjin10 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What this PR is

A canonical, modern Bazel setup for the workspace, built to be read side by side with the Nx and Turborepo PRs and with our own BUILD.ts system. Bazel 8.7.0 (newest 8.x LTS; 9.2.0 is the current line) under bazelisk, bzlmod-only, Aspect rules throughout. The full comparison is in docs/build-systems/bazel.md — it is the actual deliverable.

Naming note: the repo's BUILD.ts files belong to our build system, not Bazel. Bazel loads only BUILD/BUILD.bazel; this PR uses BUILD.bazel everywhere and touches no BUILD.ts.

What was wired

  • MODULE.bazel (bzlmod) + committed MODULE.bazel.lock. aspect_rules_js 3.4.0, aspect_rules_ts 3.10.0, aspect_rules_lint 2.7.2, aspect_gazelle_prebuilt 0.0.24, rules_nodejs 6.7.5, rules_rust 0.73.0, bazel_skylib 1.9.2, rules_multirun 0.14.0. Hermetic Node 22.19.0, pnpm 11.21.0, Rust 1.89.0 with wasm32-wasip1. TypeScript version is read from packages/canonical/package.json so the Bazel and pnpm toolchains cannot drift.
  • npm_translate_lock over pnpm-lock.yaml (lockfileVersion 9.0). No install step: Bazel fetches every tarball itself and verifies lockfile integrity hashes. Lifecycle hooks: the workspace's allowBuilds table sets every package to false and CI installs with --ignore-scripts; npm_translate_lock runs no hooks unless allowlisted, so the default already matches policy.
  • Gazelle (aspect_gazelle_prebuilt, the current JS/TS path). bazel run //:gazelle regenerates; bazel run //:gazelle.check fails CI on stale files (verified: exit 1 when a generated rule is removed, exit 0 after regeneration). 50 BUILD.bazel files are generated: every workspace package gets scaffolding (npm_link_all_packages, ts_config, pkg); per-directory source-target generation is disabled repo-wide because the plugin's one-package-per-directory model cannot express this repo's package-rooted tsconfigs.
  • The migrated vertical slice, all green: canonical (leaf), crypto (leaf), keys (workspace deps on both). Each has a ts_project library build (tsc transpiler, declarations, maps), a test-typecheck ts_project over tsconfig.test.json, a coverage-gated Vitest js_test (100% thresholds enforced as the Bazel test result), and a publishable pkg (pkg.publish) — all three are smthrs.group === "engine".
  • Tests as Bazel tests. bazel test runs the three Vitest suites, the two typecheck tests, and the Rust suites: 9 tests, all passing.
  • Lint and format. rules_lint ESLint aspect over ts_project targets (bazel build --config=lint), sandboxed and cached, green on the slice; a root aggregator flat config solves ESLint 9's CWD-only config discovery. dprint via //tools/format and //tools/format:format.check (verified: exit 20 on a misformatted probe file, 0 clean).
  • The Rust half. crate_universe cannot render this graph — cargo-bazel's splicer needs every manifest as a Bazel label, and the pinned jj fork is a git submodule whose tree cannot carry committed BUILD files (full analysis in the doc). The bridge in tools/cargo/ exposes vendor/jj as inputs via a repository rule, fetches a lockfile-verified offline registry at fetch time, and runs the pinned cargo in sandboxed actions: cargo_test, clippy_test, rustfmt_test, and flows_jj_wasm built with the root release profile and build-wasm.mjs's exact remap tokens. wasm_repro_test byte-compares against the committed artifact via diff_test, gated to the canonical host (x86_64-linux), and runs in CI there.
  • .bazelrc done properly. bzlmod-only, explicit --disk_cache path (a valueless one silently eats the next flag as its path), explicit sandboxing flags, --config=ci, --config=remote with Build without the Bytes (--remote_download_toplevel), --config=lint, .bazelversion via bazelisk.
  • CI. .github/workflows/bazel.yml: bazelisk, warm repository + disk caches, build, test, lint aspect, format check, Gazelle drift gate, wasm repro gate. No setup-node, no pnpm install.
  • The remote-cache question. Our cache worker (packages/build/infra) serves /ac/ and /cas/ over HTTP like Bazel's protocol, but AC entries are schema-validated JSON where Bazel PUTs serialized ActionResult protobufs. Not compatible today; a small accept-octet-stream mode in the worker would make it a Bazel remote cache. Details in the doc.

Measured numbers (darwin-arm64, Bazel 8.7.0)

cold build (expunged output base, fresh disk cache, warm repo cache):
  bazel build //packages/... //apps/... //examples/... //crates/... //tools/...
  89.4s wall, 1,564 actions (559 sandboxed executed)

warm no-op build: 1.5s

bazel test (full set): 9/9 pass
  //packages/canonical:test                 PASSED (coverage gate enforced)
  //packages/crypto:test                    PASSED
  //packages/keys:test                      PASSED
  //packages/*:ts_tests_typecheck_test x3   PASSED
  //crates/flows-jj:cargo_test              PASSED (38.9s)
  //crates/flows-jj:clippy_test             PASSED (27.6s)
  //crates/flows-jj:rustfmt_test            PASSED

bazel query 'deps(//packages/keys:ts)' → 2,489 targets

What does not work, stated plainly

  • bazel build //... fails at analysis. @smthrs/kernel and @smthrs/platform-browser declare a runtime dependency cycle in their manifests. pnpm tolerates it; Bazel cannot analyze it. The generated store farm lives in the root package, so any wildcard including // fails. CI uses explicit per-directory wildcards (per-package link targets are tagged manual by rules_js, so those are clean). Adopting Bazel fully requires breaking that cycle — a product decision.
  • Only 3 of 45 packages have real build/test targets. The other 42 have generated scaffolding only. Migration is mechanical from the slice template; the doc's section 6 is the plan.
  • The wasm repro gate is unverified on its canonical host. It is wired correctly and runs in CI on x86_64-linux; on this darwin host the bytes differ from the committed artifact exactly as the host-triple analysis in build-wasm.mjs predicts. If it drifts on linux, the cause is flag-level differences between build-wasm.mjs and the bridge, and the fix is to align them.
  • dprint is not hermetic (wasm plugins download on first use), so format targets are bazel run workspace targets, not sandboxed tests. Vendoring the plugins is the fix.
  • The Rust build is a bridge, not crate_universe — whole-workspace cache granularity instead of per-crate, and host rustup instead of the rules_rust toolchain. The blocker and the upstream fix are documented.

The verdict versus our BUILD.ts system

Bazel's execution model is strictly stronger: action-level caching by default, sandboxed execution (it caught two real undeclared-input bugs during this migration), fetched toolchains, remote execution, and one cross-language graph. Our declaration model is strictly nicer: type-checked TypeScript, dependency inference from attributes, PackageDefaults synthesis with nothing to drift, generated workspace files as target outputs, and a real secrets model. The doc's final section lists the six Bazel decisions our system should copy — cache-by-default, fetched toolchains, sandboxing, node_modules-as-a-view, manifest-cycle refusal — and the five places ours is already ahead. The right long-term shape is Bazel's execution and caching semantics under our declaration model.

🤖 Generated with Claude Code

smithers-lane-agent added 10 commits August 18, 2026 15:09
…ndboxing

Bazelisk reads .bazelversion. .bazelrc is bzlmod-only (no WORKSPACE),
enables the disk cache, states the sandboxing flags the reproducibility
claims rest on, and defines the ci, remote (Build without the Bytes), and
lint configs. .bazelignore lists every workspace importer's node_modules,
which npm_translate_lock verifies. .gitignore ignores bazel-* symlinks.
…ckfile translation

MODULE.bazel pins aspect_rules_js 3.4.0, aspect_rules_ts 3.10.0,
aspect_rules_lint 2.7.2, aspect_gazelle_prebuilt 0.0.24, rules_nodejs
6.7.5, rules_rust 0.73.0, bazel_skylib 1.9.2, and rules_multirun 0.14.0.
Node 22.19.0, pnpm 11.21.0, and Rust 1.89.0 (with wasm32-wasip1) are
fetched by Bazel, never resolved from PATH. npm_translate_lock reads
pnpm-lock.yaml (lockfileVersion 9.0) directly; the TypeScript toolchain
version is read from packages/canonical/package.json so the two sides
cannot drift. The root BUILD.bazel wires the root npm links, the gazelle
target with its drift gate, and the shared eslint jsdoc convention.
…ge with Gazelle

The aspect_gazelle_prebuilt JS plugin generates npm_link_all_packages,
ts_config, and pkg targets for all 45 packages, the apps, and the
examples workspace. Root directives disable per-directory source-target
generation repo-wide: the plugin's one-Bazel-package-per-directory model
cannot express this repository's package-rooted tsconfigs (rootDir src,
outDir dist/esm), so ts_project targets are added per package at
migration time. bazel run //:gazelle regenerates; bazel run
//:gazelle.check fails CI on drift.
…coverage-gated vitest targets

The vertical slice: canonical (leaf package), crypto (leaf), and keys
(workspace dependencies on both). Each package builds its library with
ts_project (tsc as transpiler, declarations and maps to dist/esm,
matching the package tsconfig exactly), typechecks tests with a second
ts_project over tsconfig.test.json, and runs its Vitest suite under
js_test with the 100% coverage thresholds enforced as the Bazel test
result. The pkg npm_package ships sources plus dist and carries a
pkg.publish release target; all three packages are smthrs.group engine.
The vitest.config.ts files gain preserveSymlinks under TEST_SRCDIR so v8
coverage attributes execution to runfiles paths; pnpm behavior is
unchanged.
…rgets

bazel build --config=lint runs ESLint as an aspect over ts_project
targets: sandboxed, cached, remote-execution eligible. ESLint 9 discovers
flat config from the working directory, so the aspect supplies a root
aggregator config that imports each package's own eslint.config.js with
its files globs re-scoped and all global entries ordered first. dprint
has no rules_lint formatter integration, so //tools/format and
//tools/format:format.check run the npm-graph dprint CLI per package,
mirroring the pnpm scripts.
…1 artifact under Bazel

crate_universe cannot render this graph: cargo-bazel's splicer needs every
manifest as a Bazel label, and the pinned jj fork is a git submodule whose
tree cannot carry committed BUILD files. The bridge in tools/cargo exposes
the submodule as inputs via a repository rule, fetches the lockfile-verified
crate registry at fetch time, and runs the pinned cargo offline in sandboxed
actions: cargo_test, clippy_test, rustfmt_test, and the flows_jj_wasm
artifact built with the root release profile and build-wasm.mjs's exact
remap tokens. wasm_repro_test byte-compares the build against the committed
artifact on the canonical host (x86_64-linux), gated by diff_test.
bazelisk, warm repository and disk caches, build, test, the lint aspect,
the dprint format check, the Gazelle drift gate, and the wasm
reproducibility gate on the canonical host. The target set is explicit
per-directory wildcards rather than //... because the kernel and
platform-browser manifests declare a dependency cycle that Bazel cannot
analyze; the details are in docs/build-systems/bazel.md.
Covers what was added file by file, how Bazel models what the BUILD.ts
system models and whether we copied correctly, what Bazel gives that Nx
and Turborepo cannot and which of those paid off here, where Bazel's
model fights this repository, what it costs, what a migration involves,
and the six Bazel design decisions the in-repo system should copy.
A valueless `build --disk_cache` in .bazelrc consumed the next rc token as
its path: the disk cache became a workspace directory literally named
`--spawn_strategy=sandboxed` and the spawn-strategy flag never applied
(sandboxing survived only because it is the macOS default). Give the flag an
explicit relative path and gitignore it.

The CI workflow passed `--disk_cache` before the subcommand, which is a
fatal "unknown startup option", and used `~` after `=`, which bash does
not expand. Move the option after the subcommand and use $HOME.

Drop --remote_download_minimal: it and --remote_download_toplevel are two
spellings of --remote_download_outputs, and naming both sets it twice. Keep
toplevel, which the comment describes.

Align the gazelle.check invocation (bazel run, as verified) across .bazelrc,
the root BUILD.bazel comment, and the workflow, and document the flag-parsing
footguns in the comparison doc.
@roninjin10
roninjin10 force-pushed the build-compare/bazel branch from 67c524b to c57883e Compare August 18, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant