> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# A matter of Facts, and why you should check in your MODULE.bazel.lock

> How the Bazel Facts API lets module extensions cache toolchain download metadata in MODULE.bazel.lock – and why your lockfile belongs in git.

export const BlogPost = ({title, date, authors, tags, image, children}) => {
  const tagList = tags ? tags.split(", ").filter(Boolean) : [];
  const tagSlug = t => t.toLowerCase().replace(/&/g, "").replace(/\+/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
  const formattedDate = date ? new Date(date + "T00:00:00").toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric"
  }) : "";
  return <section className="w-full flex justify-center px-4 py-12 md:py-16">
      <div style={{
    maxWidth: "800px",
    width: "100%"
  }}>
        {image && (typeof image === "string" ? <img noZoom src={image} alt={title} className="w-full rounded-xl mb-8" style={{
    maxHeight: "400px",
    objectFit: "cover"
  }} /> : <div className="blog-post-hero-image">{image}</div>)}
        <h1 className="text-3xl md:text-4xl font-bold text-zinc-900 dark:text-white">
          {title}
        </h1>
        <div className="flex flex-wrap items-center gap-3 mt-4 text-sm text-zinc-500 dark:text-zinc-400">
          {authors && <span>{authors}</span>}
          {authors && formattedDate && <span>·</span>}
          {formattedDate && <span>{formattedDate}</span>}
        </div>
        {tagList.length > 0 && <div className="flex flex-wrap gap-2 mt-3">
            {tagList.map(tag => <a key={tag} href={"/blog/tags/" + tagSlug(tag)} className="px-2 py-0.5 rounded-full text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300 transition">
                {tag}
              </a>)}
          </div>}
        <hr className="my-8 border-zinc-200 dark:border-zinc-700" />
        <div className="prose dark:prose-invert max-w-none">{children}</div>
      </div>
    </section>;
};

export const MarketingPage = () => <div className="marketing-page-marker" style={{
  display: "none"
}} />;

export const Section = ({children, className = "", gray = false, dark = false, id}) => <section id={id} className={`w-full flex justify-center px-4 py-16 md:py-24 ${gray ? "bg-gray-50 dark:bg-zinc-900" : dark ? "bg-zinc-900 dark:bg-zinc-950" : ""} ${className}`}>
    <div className="w-full" style={{
  maxWidth: "1140px"
}}>
      {children}
    </div>
  </section>;

<MarketingPage />

<BlogPost title="A matter of Facts, and why you should check in your MODULE.bazel.lock" date="2026-08-19" authors="Jason Bedard" tags="Bazel, JavaScript, Supply Chain Security">
  <img noZoom src="https://mintcdn.com/aspectbuild/x1L7Iep716jCyJVo/images/blog/stock/network-mesh.jpg?fit=max&auto=format&n=x1L7Iep716jCyJVo&q=85&s=0911ebc13499261ebce9979ce56e500d" alt="" className="blog-post-cover" width="800" height="533" data-path="images/blog/stock/network-mesh.jpg" />

  Bazel's [Facts](https://bazel.build/rules/lib/builtins/Facts) API lets a [module extension](https://bazel.build/external/extension) persist arbitrary JSON-like data in `MODULE.bazel.lock` and read it back on later evaluations, no re-run required. It'll cache whatever you give it; toolchain download metadata is just the example this post digs into. If you maintain a ruleset that downloads toolchains, Facts is how you stop shipping an ever-growing table of version hashes – and how your users build against a version you never hardcoded (an old release, a nightly, an RC) and still get reproducible, airgap-friendly results.

  ## The problem

  Bazel requires an integrity hash for every external file it downloads. For many rulesets this means knowing the hash of every external binary the ruleset depends on, across every supported platform, before the download starts. The traditional solution is a `versions.bzl` file – a generated table that maps every known version to its per-platform hashes:

  ```starlark theme={null}
  # versions.bzl
  NODE_VERSIONS = {
      "22.14.0-darwin_arm64": ("node-v22.14.0-darwin-arm64.tar.gz", "node-v22.14.0-darwin-arm64", "abc123..."),
      "22.14.0-linux_amd64":  ("node-v22.14.0-linux-x64.tar.gz",    "node-v22.14.0-linux-x64",    "def456..."),
      # ... hundreds more entries ...
  }
  ```

  This approach has real costs. Every toolchain ruleset ships one of these tables – `rules_nodejs`'s [`node_versions.bzl`](https://github.com/bazel-contrib/rules_nodejs/blob/v6.7.5/nodejs/private/node_versions.bzl) covers every Node release since v8, and `rules_js`, `rules_swc`, and `rules_go` carry similar ones. **The biggest is that the ruleset has to be kept current with every version anyone wants to use.** Every new upstream release needs a PR to add the entry, so users end up bumping to the latest ruleset just to pick up a toolchain version that shipped yesterday. On a version newer than the last ruleset release? Hard build failure until someone adds it. Pre-release, nightly, and custom builds aren't supported at all.

  Before Facts, there were only two ways to handle a version not in the table:

  1. **Always fetch**: Have the extension hit the network on every evaluation to look up the hash. Slow, broken in airgapped environments, and prevents the extension from being marked `reproducible`.
  2. **Make the user supply it manually**: Require an `integrity` attribute in `MODULE.bazel`, pushing the burden of finding and copying hash values onto every user who needs an unlisted version.

  ## The Facts API

  The Facts API lets a module extension persist arbitrary JSON-like data in `MODULE.bazel.lock` and read it back on future evaluations – without triggering re-evaluation.

  There are two sides to the API:

  **Writing facts** – at the end of your extension implementation, return them via [`extension_metadata`](https://bazel.build/rules/lib/builtins/module_ctx#extension_metadata):

  ```starlark theme={null}
  return module_ctx.extension_metadata(
      reproducible = True,
      facts = {"1.22.3": "sha512-abc123..."},
  )
  ```

  **Reading facts** – at the start of your next evaluation, they're available on [`module_ctx.facts`](https://bazel.build/rules/lib/builtins/module_ctx#facts):

  ```starlark theme={null}
  cached = module_ctx.facts.get("1.22.3")
  ```

  [`module_ctx.facts`](https://bazel.build/rules/lib/builtins/Facts) supports key lookup and membership tests (`"key" in facts`) but not iteration – you can't call `.keys()`, `.items()`, or `len()` on it. This is intentional: Bazel may shallow-merge facts from different evaluations, so you should treat facts as a write-once cache keyed by something stable (like a version string).

  ## The pattern

  Facts lets these rulesets stop treating `versions.bzl` as the only source of truth. Instead of failing on an unknown version, the extension can fetch the hash once, cache it in the lockfile, and use it forever after – without the ruleset maintainer ever needing to cut a release.

  `rules_go` ([#4393](https://github.com/bazel-contrib/rules_go/pull/4393)), `rules_js` ([#2698](https://github.com/aspect-build/rules_js/pull/2698)), `rules_swc` ([#332](https://github.com/aspect-build/rules_swc/pull/332)), and `rules_nodejs` ([#3919](https://github.com/bazel-contrib/rules_nodejs/pull/3919)) all implement the same three-tier lookup:

  ```
  1. Is the version in our hardcoded VERSIONS table?  → use it (no network, no lockfile needed)
  2. Is the version in module_ctx.facts?              → use it (no network, hash is in the lockfile)
  3. Fetch from the network, store in facts           → use it (one-time fetch, cached for all future runs)
  ```

  The `versions.bzl` table still exists – it covers well-known releases and requires no network access at all. But it no longer has to be exhaustive. Any version not in the table falls through to tier 2, where the hash is read straight from `MODULE.bazel.lock`. Only on the very first encounter with an unknown version does the extension touch the network (tier 3), and after that the result is locked in.

  **This is the part that matters for users: you can build against a version the ruleset never persisted a hash for** – a brand new Go patch release, a pnpm RC, a custom or nightly build – and get a working, reproducible, airgap-friendly result without waiting for the maintainer to cut a release.

  ## rules\_go: caching Go SDK download metadata

  `rules_go` was the first major ruleset to adopt the Facts API (PR [#4393](https://github.com/bazel-contrib/rules_go/pull/4393)). Before it, the extension fetched `https://go.dev/dl/?mode=json&include=all` inside each individual `go_download_sdk` repository rule. As the PR description notes, this is a download that ["can't hit the repository cache"](https://github.com/bazel-contrib/rules_go/pull/4393), breaking airgapped environments even with a warm download cache – the subject of [#3945](https://github.com/bazel-contrib/rules_go/issues/3945).

  The fix moves the fetch up into the module extension, caches the result as facts, and only fetches again when a version is seen for the first time.

  Reading facts at the start of the extension uses `getattr` rather than `hasattr` – an equally valid pattern that produces an empty dict on older Bazel:

  ```starlark theme={null}
  all_sdks_by_version = {}
  used_sdks_by_version = {}
  facts = getattr(ctx, "facts", {})
  ```

  The [caching helper](https://github.com/bazel-contrib/rules_go/blob/v0.63.0/go/private/extensions.bzl#L212) wraps all the tier logic neatly:

  ```starlark theme={null}
  def get_sdks_by_version_cached(version):
      # Tier 2: check the lockfile facts cache
      sdks = facts.get(version)
      if sdks == None:
          # Tier 3: lazily fetch all SDK versions from go.dev, exactly once
          if not all_sdks_by_version:
              all_sdks_by_version.clear()
              all_sdks_by_version.update(fetch_sdks_by_version(ctx, allow_fail = True) or {
                  "fetch_failed_but_should_not_fetch_again_sentinel": [],
              })
          sdks = all_sdks_by_version.get(version)
      if sdks == None:
          return None
      used_sdks_by_version[version] = sdks
      return sdks
  ```

  A few details that matter:

  * `allow_fail = True` on the fetch means a network failure doesn't hard-crash the build. Users who have explicitly specified SDK hashes for all versions they use can still build even if go.dev is unreachable.
  * The `"fetch_failed_but_should_not_fetch_again_sentinel"` ensures the fetch is only attempted once per extension evaluation, even if it fails.
  * Only `used_sdks_by_version` – the versions actually referenced in this build – is written back to facts. If a user requests Go 1.23 and 1.24, only those two entries end up in the lockfile. Versions that were previously in facts but are no longer used are naturally pruned on the next run – the lockfile reflects current usage, not history.

  Writing facts back at the end gates on both the `reproducible` feature flag and `ctx.facts` availability:

  ```starlark theme={null}
  if bazel_features.external_deps.extension_metadata_has_reproducible:
      kwargs = {"reproducible": True}
      if hasattr(ctx, "facts"):
          kwargs["facts"] = used_sdks_by_version
      return ctx.extension_metadata(**kwargs)
  ```

  ## rules\_js: caching pnpm integrity hashes

  `rules_js` manages the `pnpm` package manager as a repository. When a user specifies a `pnpm_version` not in the bundled `versions.bzl`, the extension needs an integrity hash to safely download it.

  The implementation in [`npm/private/pnpm_extension.bzl`](https://github.com/aspect-build/rules_js/commit/37eb63ff72a677203aec58f57720dd97a4f788a8):

  ```starlark theme={null}
  fetched_facts = None
  used_facts = None

  if hasattr(mctx, "facts") and len([v for v in repositories.values() if not v["integrity"]]) > 0:
      used_facts = {}
      for pnpm in repositories.values():
          if not pnpm["integrity"]:
              # Check facts first – avoids a network call if we've seen this version before
              integrity = mctx.facts.get(pnpm["version"], None)
              if not integrity:
                  # Lazy: only fetch the npm registry once, and only if needed
                  if not fetched_facts:
                      fetched_facts = _fetch_pnpm_versions(mctx)
                  integrity = fetched_facts.get(pnpm["version"], None)

              if integrity:
                  pnpm["integrity"] = integrity
                  used_facts[pnpm["version"]] = integrity
  ```

  The fetch itself downloads the full npm registry metadata for pnpm – a single JSON blob at `https://registry.npmjs.org/pnpm` – and extracts integrity hashes for all versions:

  ```starlark theme={null}
  def _fetch_pnpm_versions(module_ctx):
      result = module_ctx.download(
          url = ["https://registry.npmjs.org/pnpm"],
          output = "pnpm_versions.json",
      )
      if not result.success:
          print("ERROR: failed to fetch pnpm versions from npm registry: {}".format(result))
          return None

      data = module_ctx.read("pnpm_versions.json")
      if not data or data[0] != "{":
          print("ERROR: failed to read pnpm versions fetched from npm registry: {}".format(data))
          return None

      data = json.decode(data)
      versions = {}
      for version, info in data.get("versions", {}).items():
          if int(version.split(".")[0]) < 9:
              continue  # skip pnpm < 9
          dist = info.get("dist", {})
          integrity = dist.get("integrity", None)
          if integrity:
              versions[version] = integrity
      return versions
  ```

  The result is returned from the extension with `reproducible = True`:

  ```starlark theme={null}
  kwargs = {}
  if resolved.facts:
      kwargs["facts"] = resolved.facts

  return module_ctx.extension_metadata(reproducible = True, **kwargs)
  ```

  Note the `hasattr(mctx, "facts")` guard – this makes the implementation backward-compatible with Bazel 7 and earlier, which don't have the Facts API. On older Bazel the extension still works, just without caching.

  ## rules\_swc: caching SWC toolchain hashes from GitHub Releases

  `rules_swc` provides a hermetic SWC compiler toolchain. SWC releases per-platform binaries on GitHub Releases, and the [extension](https://github.com/aspect-build/rules_swc/commit/937ef87dfbfdd1084f88c60ad000ef2f7b26ad6d) fetches integrity hashes from the GitHub Releases API for any version not [in `TOOL_VERSIONS`](https://github.com/aspect-build/rules_swc/commit/937ef87dfbfdd1084f88c60ad000ef2f7b26ad6d#diff-ad85222f5d1c36575744624dd589473d6e963d2b2005199cfdd97a6e17fda2f7R36).

  The three-tier lookup for each toolchain registration looks like this:

  ```starlark theme={null}
  integrity_hashes = toolchain.integrity_hashes
  if not integrity_hashes:
      if swc_version in TOOL_VERSIONS:
          # Tier 1: known version, use hardcoded hashes
          integrity_hashes = TOOL_VERSIONS[swc_version]
      elif hasattr(module_ctx, "facts"):
          # Tier 2: check the facts cache from the lockfile
          integrity_hashes = module_ctx.facts.get(swc_version, None)

          # Validate the cached hashes haven't changed shape
          if integrity_hashes and not _is_valid_sri_hashes(integrity_hashes):
              integrity_hashes = None

          if not integrity_hashes:
              # Tier 3: fetch from GitHub Releases API (once per unique version)
              if swc_version not in fetched_hashes:
                  fetched_hashes[swc_version] = _fetch_version(module_ctx, swc_version)
              integrity_hashes = fetched_hashes[swc_version]

          if integrity_hashes:
              used_facts[swc_version] = integrity_hashes

  if not integrity_hashes:
      reproducible = False
  ```

  Note the `_is_valid_sri_hashes` validation call on tier 2. Because facts are not invalidated when the extension code changes, you need to defensively validate their structure. If you change the format of your facts `dict` in a future version, old cached values may not match the new schema – validate before using and fall back to a re-fetch if they don't.

  The fetch hits the GitHub Releases API and extracts per-platform hashes:

  ```starlark theme={null}
  def _fetch_version(module_ctx, swc_version):
      output = "swc_version_{}.json".format(swc_version)
      result = module_ctx.download(
          url = ["https://api.github.com/repos/swc-project/swc/releases/tags/{}".format(swc_version)],
          output = output,
      )
      # ... error handling ...

      hashes = {}
      for asset in json.decode(data).get("assets", []):
          name = asset.get("name", "")
          # ... normalize filename to platform key ...
          if name in PLATFORMS and "digest" in asset:
              hashes[name] = asset["digest"]

      return hashes
  ```

  ## rules\_nodejs: auto-fetching Node.js versions

  `rules_nodejs` ships the largest version table of the bunch – that 500+ KB `node_versions.bzl`. PR [#3919](https://github.com/bazel-contrib/rules_nodejs/pull/3919) wires the same Facts-backed fallback into its toolchain extension, so a version outside the table no longer hard-fails.

  It detects Facts support the same way as the others:

  ```starlark theme={null}
  supports_facts = hasattr(module_ctx, "facts")
  ```

  Its lookup carries one extra tier, because it distinguishes facts fetched earlier in *this* evaluation (`new_repository_facts`) from facts read out of the lockfile (`module_ctx.facts`):

  ```starlark theme={null}
  node_repositories = v.node_repositories or NODE_VERSIONS.get(node_version, {})
  if not node_repositories:
      node_repositories = (
          new_repository_facts.get(node_version) or
          (module_ctx.facts.get(node_version) if supports_facts else None) or
          fetch_node_repositories(module_ctx, node_version)
      )
  ```

  In order: a user-supplied repository, the hardcoded `NODE_VERSIONS` table, anything already fetched earlier in this same evaluation, the lockfile facts, and finally a network fetch of Node's per-release checksum manifest:

  ```starlark theme={null}
  # nodejs/private/fetch_node_repositories.bzl
  url = "https://nodejs.org/dist/v{}/SHASUMS256.txt".format(version)
  result = module_ctx.download(url = url, output = shasums_filename)
  # ... parse one `sha256  filename` pair per line into per-platform entries ...
  ```

  What sets `rules_nodejs` apart is that it falls through to the network **even when Facts isn't available**. On a Facts-capable Bazel it caches the result and marks the extension reproducible:

  ```starlark theme={null}
  return module_ctx.extension_metadata(
      reproducible = True,
      facts = new_repository_facts,
  )
  ```

  On older Bazel without Facts it still fetches and produces a working toolchain – it just can't cache the hashes, so it only claims reproducibility when nothing had to be fetched:

  ```starlark theme={null}
  reproducible = not new_repository_facts,
  ```

  This is the most permissive variant of the pattern: known versions stay network-free, unknown versions work on every Bazel, and the lockfile cache kicks in wherever the Bazel version supports it.

  ## Where else Facts could be used

  The pattern isn't specific to the rulesets above – any extension that hard-fails on an unknown version today is a candidate. Here are two more examples from the JS/Node ecosystem.

  ### rules\_esbuild: per-platform SRI from the npm registry

  `rules_esbuild` bundles two kinds of artifacts per version: the platform-specific native binary (published as scoped `@esbuild/{platform}` packages on npm) and the JS launcher (the `esbuild` npm package itself). Both use SRI hashes.

  The npm registry's package endpoint returns exactly what's needed:

  ```
  https://registry.npmjs.org/@esbuild/{platform}/{version}
  # → {"dist": {"integrity": "sha512-..."}}
  ```

  A Facts-powered fallback would call this endpoint for each platform, plus once for the `esbuild` package, and store a dict matching the existing `TOOL_VERSIONS` structure:

  ```starlark theme={null}
  _ESBUILD_PLATFORMS = [
      "darwin-x64", "darwin-arm64",
      "linux-x64", "linux-arm64",
      "win32-x64",
  ]

  def _fetch_esbuild_version(module_ctx, version):
      hashes = {}

      # Fetch the main npm package integrity
      npm_data = _fetch_npm_package(module_ctx, "esbuild", version)
      if npm_data:
          hashes["npm"] = npm_data

      # Fetch each platform binary integrity
      for platform in _ESBUILD_PLATFORMS:
          pkg = "@esbuild/{}".format(platform)
          integrity = _fetch_npm_package(module_ctx, pkg, version)
          if integrity:
              hashes[platform] = integrity

      return hashes

  def _fetch_npm_package(module_ctx, package, version):
      encoded = package.replace("/", "%2F")
      result = module_ctx.download(
          url = ["https://registry.npmjs.org/{}/{}".format(encoded, version)],
          output = "esbuild_{}_{}.json".format(package.replace("/", "_"), version),
      )
      if not result.success:
          return None
      output = "npm_{}_{}.json".format(package.replace("/", "_"), version)
      data = json.decode(module_ctx.read(output))
      return data.get("dist", {}).get("integrity", None)
  ```

  The resulting facts entry would be structurally identical to an entry in `TOOL_VERSIONS` – so the rest of the extension code needs no changes, just a fallback path before the hard `fail()`.

  ### rules\_ts: single integrity hash from the npm registry

  TypeScript is a pure-JS package – no per-platform split. `rules_ts` ships a `versions.bzl` mapping version strings to a single `sha512-` SRI hash, and fails if the version isn't listed.

  This is the simplest possible Facts adoption: one download per unknown version, one hash to store.

  ```starlark theme={null}
  def _fetch_ts_integrity(module_ctx, version):
      result = module_ctx.download(
          url = ["https://registry.npmjs.org/typescript/{}".format(version)],
          output = "typescript_{}.json".format(version),
      )
      if not result.success:
          return None
      data = json.decode(module_ctx.read("typescript_{}.json".format(version)))
      return data.get("dist", {}).get("integrity", None)
  ```

  Facts would be `{"5.8.3": "sha512-..."}` – one entry per unknown version encountered. The extension can declare `reproducible = True` for any build where all versions are either in the table or in facts.

  ## Commit your MODULE.bazel.lock

  The canonical motivation for a lockfile in Bazel is stated in [bazelbuild/bazel#14554](https://github.com/bazelbuild/bazel/issues/14554), the original feature request: *"I want reproducible builds, even if someone has yanked a module since the last build."* The lockfile landed in Bazel 6.2 (and became reliable in 6.3) specifically to address this – without it, every build re-runs module resolution live against the registry, meaning a yanked or modified dependency can silently break a build that was working an hour ago.

  But a lockfile that isn't committed to version control only solves this for one machine. Here are the specific failure modes that a committed lockfile prevents.

  **BCR yanks break builds that a lockfile would protect.** When a version is yanked from the Bazel Central Registry, builds that previously resolved that version fail with a hard error – unless a lockfile is present, in which case [resolution is skipped entirely](https://github.com/bazelbuild/bazel-central-registry/discussions/128) and the pinned result is used as-is. Without a committed lockfile, every developer and CI machine is exposed to this failure independently, and the only recovery is an explicit dependency upgrade.

  **Network-dependent extensions re-run on every cold start.** [bazelbuild/bazel#24723](https://github.com/bazelbuild/bazel/issues/24723) describes the cost: even extensions marked `reproducible = True` re-execute after a Bazel server restart, re-fetching remote metadata, until their results are locked. The Facts API (tracked in [bazelbuild/bazel#24777](https://github.com/bazelbuild/bazel/issues/24777) and implemented in [#26198](https://github.com/bazelbuild/bazel/pull/26198)) solves this by persisting fetched data in the lockfile – but only if the lockfile is committed. Without it, every engineer and every CI runner starts cold, hits the network, and risks getting a different result if the remote endpoint has changed since the last run.

  **Silent state corruption from stale local caches.** [bazelbuild/bazel#18472](https://github.com/bazelbuild/bazel/issues/18472) documents a reproducible P1 bug: upgrade dep A, regenerate the lockfile, then revert `MODULE.bazel` – the on-disk lockfile retains changes from the reverted upgrade. `bazel clean --expunge` does not fix it. If teams treat the lockfile as a local throwaway file and regenerate it freely, this corruption is invisible until the build produces unexpected behavior. A committed lockfile gives you a git-tracked ground truth to compare against and revert to.

  **Delete-and-recreate silently unpins everything.** [bazelbuild/bazel#20272](https://github.com/bazelbuild/bazel/issues/20272) (P1) describes what happens when the lockfile becomes a merge conflict burden: developers delete and recreate it to get past conflicts, *"accidentally losing the pinning of all other Bazel modules."* Dependabot and Renovate PRs that touch `MODULE.bazel` also trigger lockfile drift, pushing teams to either disable lockfile checking in CI or accept the silent unpinning. A committed, consistently maintained lockfile makes this drift visible instead of silent.

  **Cross-platform lockfile divergence blocked teams from committing at all.** [bazelbuild/bazel#19154](https://github.com/bazelbuild/bazel/issues/19154) (P1, fixed in Bazel 7.0) and [#21491](https://github.com/bazelbuild/bazel/issues/21491) describe how, in Bazel 6.x, extensions that produced platform-specific repositories generated different lockfile entries on Linux vs macOS. Teams with mixed-platform developers literally could not commit a shared lockfile. These issues are fixed in Bazel 7+, and the Facts API is explicitly designed to store only platform-neutral data – facts are expected to be *"universally true statements"* such as version-to-hash mappings, not platform-specific build outputs.

  **CI enforcement with `--lockfile_mode=error` requires a committed lockfile.** With [`--lockfile_mode=error`](https://bazel.build/external/lockfile#lockfile-modes), Bazel fails the build if the lockfile is missing or out of date. This is the mechanism for ensuring nobody ships a build whose dependencies were silently updated. It only works if there is a lockfile in the repository to compare against. [bazelbuild/bazel#28717](https://github.com/bazelbuild/bazel/issues/28717) documented an early false-positive that briefly hit teams doing exactly this correctly – using `--lockfile_mode=error` in CI with a committed lockfile: rolling back a `MODULE.bazel` change caused a spurious error comparing the committed lockfile against a stale output-base cache. It was fixed long ago and is a non-issue on any current Bazel.

  With a committed lockfile, you get:

  **Facts that survive fresh clones.** After the first build populates the facts, CI and developer machines can build offline. The integrity hashes are right there in the lockfile – no registry access needed.

  **Faster extension evaluation.** Facts are read directly from the lockfile, skipping the download step entirely. Extensions marked `reproducible = True` are also cached across server restarts.

  **Reviewable dependency changes.** When you upgrade a toolchain version, the new integrity hashes appear in the `MODULE.bazel.lock` diff. Reviewers can see exactly what changed and that it was fetched correctly.

  Here's what facts look like in [`MODULE.bazel.lock`](https://bazel.build/external/lockfile) (the `moduleExtensions` section for the pnpm extension):

  ```json theme={null}
  "@@aspect_rules_js+//npm:extensions.bzl%pnpm": {
    "bzlTransitiveDigest": "...",
    "usagesDigest": "...",
    "generatedRepoSpecs": { ... },
    "moduleExtensionMetadata": {
      "reproducible": true
    },
    "facts": {
      "10.6.2": "sha512-fX27yp6ZRHt8O/enMoavqva+mSUeuUmLrvp9QGiS9nuHmts6HX5of8TMwaOIxxdfuq5WeiarRNEGe1T8sNajFg=="
    }
  }
  ```

  ## Enforce it in CI

  Committing the lockfile only helps if it stays current, and that's a job for CI. [`bazelrc-preset.bzl`](https://github.com/bazel-contrib/bazelrc-preset.bzl) – which generates a recommended `.bazelrc` for your Bazel version – bakes this in. On Bazel 7 and up, its preset adds `--lockfile_mode=error` to the `ci` config:

  ```
  common:ci --lockfile_mode=error
  ```

  The preset's own description is blunt about why:

  > Fail the build if the MODULE.bazel.lock file is out of date. Using this mode in ci prevents the lockfile from being out of date.

  Run CI with `--config=ci` and anything that would change the lockfile – a new dependency, a toolchain bump, a freshly fetched fact – fails the build until the regenerated lockfile is committed. The lockfile becomes the source of truth, CI guarantees it's never silently stale, and a fact fetched on one machine shows up in a reviewable PR diff before it reaches anyone else.

  ### The awkward case: repos that run many Bazel versions

  There's one case where committing the lockfile gets awkward, and rulesets are the primary example: repos tested against a matrix of Bazel versions – the majors they support, plus rolling and pre-release builds. The lockfile isn't portable across those jobs: what Bazel 8 generates differs from what Bazel 9 generates, so a single committed file can't be current for every job at once.

  Historically the easy way out was to give up on the lockfile entirely, as `rules_js` [still does](https://github.com/aspect-build/rules_js/blob/98e3ce17cbfb28cf26df3f56edf75d318006c677/.gitignore#L18):

  ```gitignore theme={null}
  # rules_js/.gitignore
  MODULE.bazel.lock
  ```

  However, as more functionality moves into `MODULE.bazel.lock` – Facts included – rulesets are going to have to start checking it in too. `rules_py` has started committing its `MODULE.bazel.lock`, generated by the minimum supported Bazel version – the one pinned in `.bazelversion`. The trick is in the CI matrix: the job running that minimum version passes `--lockfile_mode=error` so the committed lockfile is validated exactly as a product repo would validate it, while the jobs testing additional Bazel versions pass `--lockfile_mode=update`, letting Bazel regenerate the lockfile in place for that run without failing the build or expecting the result to be committed:

  ```yaml theme={null}
  # rules_py/.github/workflows/ci-workflows.yaml
  bazel:
    # An empty version falls back to the workspace's .bazelversion, which validates
    # the committed MODULE.bazel.lock; alternate versions may regenerate it instead.
    - { id: "bazel-8", version: "", flags: "... --bazel-flag=--lockfile_mode=error" }
    - { id: "bazel-9", version: "9.x", flags: "... --bazel-flag=--lockfile_mode=update" }
  ```

  One version is the source of truth and enforced; the others tolerate their divergence. The ruleset keeps the reproducibility and review benefits of a committed lockfile – including persisted facts – without the matrix thrashing a shared file on every job.

  Either way, that's a tension of ruleset development, not of your build. A product monorepo pins one Bazel version with `.bazelversion` and bazelisk, so it never sees that divergence – which is exactly why it should commit the lockfile and turn on `--lockfile_mode=error`. Don't read a ruleset's `.gitignore` and conclude lockfiles are a hassle to skip; you're not shipping a ruleset, you're building a product on a single, pinned Bazel.

  ## Adopting Facts in your extension

  If your module extension fetches metadata from the network for versions not hardcoded in a `versions.bzl`, here's the checklist:

  1. **Guard with `hasattr`** for Bazel 6/7 backward compatibility:
     ```starlark theme={null}
     if hasattr(module_ctx, "facts"):
         cached = module_ctx.facts.get(version)
     ```

  2. **Only store what you used** – persist `used_facts`, not everything you fetched. This keeps the lockfile minimal and avoids accumulating stale entries.

  3. **Validate cached facts** before using them. Facts survive code changes, so add a schema check and fall back to a re-fetch if the structure looks wrong.

  4. **Set `reproducible = True`** when all repositories have known hashes. Set it to `False` when at least one couldn't be resolved – this tells Bazel the extension result may vary.

  5. **Tell users to commit `MODULE.bazel.lock`**. The API is useless without it.

  ## Requirements and compatibility

  * Bazel 8.5+ for the [Facts API](https://bazel.build/rules/lib/builtins/Facts). The feature has been a stable part of Bazel for a long time now, and behaves consistently across current 8.x and 9.x releases.
  * No special minimum patch version is needed – the fixes for early edge cases (such as a transient [`--lockfile_mode=error`](https://bazel.build/external/lockfile#lockfile-modes) false-positive) have long since shipped in every maintained release.

  For rulesets that need to support older Bazel, the `hasattr(module_ctx, "facts")` pattern gives you the Facts behavior on new Bazel while gracefully degrading to a network fetch on older versions.
</BlogPost>
