Skip to content

Let CI own MODULE.bazel.lock, and turn on --config=locked - #4

Merged
codeitlikemiley merged 6 commits into
mainfrom
claude/bazel-module-lockfile
Aug 2, 2026
Merged

Let CI own MODULE.bazel.lock, and turn on --config=locked#4
codeitlikemiley merged 6 commits into
mainfrom
claude/bazel-module-lockfile

Conversation

@codeitlikemiley

Copy link
Copy Markdown
Owner

Merge #3 (single-workspace collapse) first. It rewrites MODULE.bazel, so a lock committed before it would be regenerated immediately after.

MODULE.bazel.lock is what makes a bzlmod build reproducible — it pins the resolved module graph plus registryFileHashes, the hashes of every file bcr.bazel.build served during resolution. The toolchain PR un-ignored it, but it has never actually been committed, so --config=locked (--lockfile_mode=error) could not be switched on.

Why CI has to generate it

Generating the lock requires reaching the real registry. It cannot be produced from an environment that only reaches BCR through a mirror: Bazel records no registryFileHashes at all for a file:// registry, so such a lock is treated as out of date the moment anyone builds against bcr.bazel.build. Committing one would be worse than committing nothing, because it looks valid.

So CI owns it. The new lockfile job runs bazel mod deps --lockfile_mode=update and commits the result whenever it drifts.

Every run after the first is a no-op, which makes the same job a drift detector: a Cargo.lock bump or a rules_rust bump nobody repinned shows up as a commit from CI rather than as silent re-resolution on every machine.

Details worth reviewing:

  • permissions: contents: write, scoped to that one job — not the workflow.
  • Skipped on fork PRs (read-only token) rather than failing there.
  • Checks out github.head_ref rather than the detached PR merge commit, so the push has a branch to land on.
  • A GITHUB_TOKEN push does not re-trigger workflows, so there is no loop.

Why --config=locked is guarded

It is applied to the bazel job's build/test/lint steps, but only when the lock exists. That guard is not defensive coding — verified locally, with no lock:

$ bazel build --config=locked //corex:corex_lib
ERROR: The module extension '@@rules_rust+//crate_universe:extensions.bzl%crate'
       does not exist in the lockfile

Without the guard the very first run — the one that creates the lock — would be red. Once the lock is committed the flag is always on.

CONTRIBUTING.md

Added, and worth noting it was literally unaddable before: the *.md catch-all in .gitignore swallowed it until that was removed. It documents the toolchain-pin invariant, the lockfile workflow, and the pre-push checks — including the trap that a lock built against a registry mirror must never be committed.

Verification

Locally: --config=locked errors without a lock; --config=ci still builds without one. That pair is exactly the behaviour the guard depends on. Also confirmed bazel mod deps --lockfile_mode=update writes the file (677 KB, lockFileVersion: 24).

Not locally verifiable: the lockfile job itself needs GitHub. Its first run on this PR is its own test — expect one chore: update MODULE.bazel.lock commit from github-actions[bot], after which the job goes quiet and --config=locked engages on every subsequent run.


Generated by Claude Code

claude and others added 6 commits August 2, 2026 02:41
MODULE.bazel.lock is what makes a bzlmod build reproducible: it pins the
resolved module graph plus registryFileHashes, the hashes of every file
bcr.bazel.build served during resolution. It was gitignored until the toolchain
change un-ignored it, but it has never actually been committed, so
--config=locked (--lockfile_mode=error) could not be switched on.

Generating it requires network access to the real registry, which is the
awkward part: it cannot be produced from an environment that reaches BCR
through a mirror. Bazel records no registryFileHashes at all for a file://
registry, so such a lock is treated as out of date the moment anyone builds
against bcr.bazel.build -- committing one would be worse than committing
nothing, because it looks valid.

So CI owns it. The new `lockfile` job runs `bazel mod deps
--lockfile_mode=update` and commits the result whenever it drifts. Every run
after the first is a no-op, which makes the same job a drift detector: a
Cargo.lock bump or a rules_rust bump that nobody repinned shows up as a commit
from CI rather than as silent re-resolution on each machine.

  - permissions: contents: write, scoped to that one job.
  - Skipped on fork PRs, which get a read-only token, rather than failing there.
  - Checks out github.head_ref rather than the detached PR merge commit, so the
    push has a branch to land on.
  - A GITHUB_TOKEN push does not re-trigger workflows, so there is no loop.

--config=locked is applied to the bazel job's build/test/lint steps, but guarded
on the lockfile existing. Verified why that guard is needed: with no lock,
`bazel build --config=locked //corex:corex_lib` fails with

  ERROR: The module extension '@@rules_rust+//crate_universe:extensions.bzl%crate'
  does not exist in the lockfile

Without the guard the very first run -- the one that creates the lock -- would
be red. Once the lock is committed the flag is always on.

Also adds CONTRIBUTING.md, which the *.md catch-all in .gitignore used to make
unaddable. It documents the toolchain-pin invariant, the lockfile workflow, and
the pre-push checks, including the trap that a lock built against a registry
mirror must never be committed.

Verified locally: --config=locked errors without a lock and --config=ci still
builds without one, which is exactly the behaviour the guard depends on.
NOT verifiable locally: the `lockfile` job itself needs GitHub. Its first run on
this PR is its own test -- expect one "chore: update MODULE.bazel.lock" commit
from github-actions[bot], after which the job goes quiet.

Sequencing: land the single-workspace collapse first. It rewrites MODULE.bazel,
so a lock committed before it would be regenerated immediately after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3MwkT9fzAzZweK4birHYz
Regenerated by CI from MODULE.bazel. Pins the resolved module graph and the registry file hashes that bcr.bazel.build served; --config=locked verifies it.
The lock committed by CI on this branch was generated against the pre-collapse
MODULE.bazel, which had three crate.from_cargo repos; main now has one. The
lock still carries 214 references to combos_crates/corex_crates/server_crates,
so --config=locked would reject it.

Removing it rather than hand-editing: the bazel job's guard skips
--config=locked when the file is absent, and the lockfile job regenerates it
from the merged MODULE.bazel and commits the correct one. That is the workflow
this PR exists to establish, so letting it do the work is also a test of it.
Regenerated by CI from MODULE.bazel. Pins the resolved module graph and the registry file hashes that bcr.bazel.build served; --config=locked verifies it.
A GITHUB_TOKEN push does not re-trigger workflows, which is what keeps the
lockfile job from looping -- but it also means the run that regenerates the lock
is not the run that verifies it. --config=locked checks the new lock on the next
push instead.

This commit is also that next push: it exists to get the regenerated
MODULE.bazel.lock validated by --config=locked before this PR merges, rather
than finding out on main.
@codeitlikemiley
codeitlikemiley marked this pull request as ready for review August 2, 2026 03:16
@codeitlikemiley
codeitlikemiley merged commit e90218d into main Aug 2, 2026
3 checks passed
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.

2 participants