Let CI own MODULE.bazel.lock, and turn on --config=locked - #4
Merged
Conversation
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.
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.
MODULE.bazel.lockis what makes a bzlmod build reproducible — it pins the resolved module graph plusregistryFileHashes, the hashes of every filebcr.bazel.buildserved 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
registryFileHashesat all for afile://registry, so such a lock is treated as out of date the moment anyone builds againstbcr.bazel.build. Committing one would be worse than committing nothing, because it looks valid.So CI owns it. The new
lockfilejob runsbazel mod deps --lockfile_mode=updateand 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.lockbump or arules_rustbump 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.github.head_refrather than the detached PR merge commit, so the push has a branch to land on.GITHUB_TOKENpush does not re-trigger workflows, so there is no loop.Why
--config=lockedis guardedIt 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:
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
*.mdcatch-all in.gitignoreswallowed 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=lockederrors without a lock;--config=cistill builds without one. That pair is exactly the behaviour the guard depends on. Also confirmedbazel mod deps --lockfile_mode=updatewrites the file (677 KB,lockFileVersion: 24).Not locally verifiable: the
lockfilejob itself needs GitHub. Its first run on this PR is its own test — expect onechore: update MODULE.bazel.lockcommit fromgithub-actions[bot], after which the job goes quiet and--config=lockedengages on every subsequent run.Generated by Claude Code