✨ feat(bazel): automate the workspace with Bazel - #227
Open
roninjin10 wants to merge 10 commits into
Open
Conversation
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
force-pushed
the
build-compare/bazel
branch
from
August 18, 2026 22:25
67c524b to
c57883e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tssystem. 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 indocs/build-systems/bazel.md— it is the actual deliverable.Naming note: the repo's
BUILD.tsfiles belong to our build system, not Bazel. Bazel loads onlyBUILD/BUILD.bazel; this PR usesBUILD.bazeleverywhere and touches noBUILD.ts.What was wired
MODULE.bazel(bzlmod) + committedMODULE.bazel.lock.aspect_rules_js3.4.0,aspect_rules_ts3.10.0,aspect_rules_lint2.7.2,aspect_gazelle_prebuilt0.0.24,rules_nodejs6.7.5,rules_rust0.73.0,bazel_skylib1.9.2,rules_multirun0.14.0. Hermetic Node 22.19.0, pnpm 11.21.0, Rust 1.89.0 withwasm32-wasip1. TypeScript version is read frompackages/canonical/package.jsonso the Bazel and pnpm toolchains cannot drift.npm_translate_lockoverpnpm-lock.yaml(lockfileVersion 9.0). No install step: Bazel fetches every tarball itself and verifies lockfile integrity hashes. Lifecycle hooks: the workspace'sallowBuildstable sets every package to false and CI installs with--ignore-scripts;npm_translate_lockruns no hooks unless allowlisted, so the default already matches policy.aspect_gazelle_prebuilt, the current JS/TS path).bazel run //:gazelleregenerates;bazel run //:gazelle.checkfails 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.canonical(leaf),crypto(leaf),keys(workspace deps on both). Each has ats_projectlibrary build (tsc transpiler, declarations, maps), a test-typecheckts_projectovertsconfig.test.json, a coverage-gated Vitestjs_test(100% thresholds enforced as the Bazel test result), and a publishablepkg(pkg.publish) — all three aresmthrs.group === "engine".bazel testruns the three Vitest suites, the two typecheck tests, and the Rust suites: 9 tests, all passing.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/formatand//tools/format:format.check(verified: exit 20 on a misformatted probe file, 0 clean).tools/cargo/exposesvendor/jjas 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, andflows_jj_wasmbuilt with the root release profile andbuild-wasm.mjs's exact remap tokens.wasm_repro_testbyte-compares against the committed artifact viadiff_test, gated to the canonical host (x86_64-linux), and runs in CI there..bazelrcdone properly. bzlmod-only, explicit--disk_cachepath (a valueless one silently eats the next flag as its path), explicit sandboxing flags,--config=ci,--config=remotewith Build without the Bytes (--remote_download_toplevel),--config=lint,.bazelversionvia bazelisk..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.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)
What does not work, stated plainly
bazel build //...fails at analysis.@smthrs/kerneland@smthrs/platform-browserdeclare 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 taggedmanualby rules_js, so those are clean). Adopting Bazel fully requires breaking that cycle — a product decision.build-wasm.mjspredicts. If it drifts on linux, the cause is flag-level differences betweenbuild-wasm.mjsand the bridge, and the fix is to align them.bazel runworkspace targets, not sandboxed tests. Vendoring the plugins is the fix.The verdict versus our
BUILD.tssystemBazel'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,
PackageDefaultssynthesis 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