Skip to content

feat(devcontainer): derive the base-image pin instead of bumping it - #225

Merged
Syndic merged 11 commits into
mainfrom
devcontainer/derive-base-image-pin
Aug 12, 2026
Merged

feat(devcontainer): derive the base-image pin instead of bumping it#225
Syndic merged 11 commits into
mainfrom
devcontainer/derive-base-image-pin

Conversation

@Syndic

@Syndic Syndic commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Follow-up to #222. Supersedes #224 — its one-line digest change is included here, arrived at by construction rather than by Renovate noticing.

Why

I modelled the Dockerfile's FROM digest as a dependency: publish the image, wait for Renovate, merge a bump. It is really a derived file, in the same sense as MODULE.bazel.lock, requirements_lock.txt and devcontainer-lock.json — all of which this repo already regenerates in the commit that invalidates them.

The digest is a pure function of the source tree: pkg_tar fixes timestamps, and oci_push publishes the exact index Bazel assembled rather than rebuilding it. Confirmed against the artifact #222 published:

locally built index digest: sha256:0f31f38a212f69b1831010aad682bda8f99a6f8a6b2f975aef5b9715331d1376
published :latest digest:   sha256:0f31f38a212f69b1831010aad682bda8f99a6f8a6b2f975aef5b9715331d1376

So the round-trip bought nothing except a window — one publish plus one Renovate cycle — in which the pin named a superseded image, and a standing question about whether to automerge the bumps.

What changed

  • meta/scripts/sync_base_image_pin.py rewrites the pin from //meta/devcontainer-base:image's layout index. Pure functions, unit-tested; it refuses to guess when the FROM shape isn't exactly what it expects.
  • The base-image-pin pre-commit hook rewrites it for our own edits, the same way bazel mod tidy and uv lock do for the other derived files.
  • //.devcontainer:test_base_image_pin fails when the pin drifts, printing the expected digest and the command to fix it. It is the backstop under the hook, not a replacement for it — hooks don't run for --no-verify, a web edit, or the helper app's API commits, and don't re-run on a rebase. A plain bazel test rather than a CI job because the digest falls out of an already-built artifact and needs no daemon.
  • renovate-derived-files.yml re-derives the pin when MODULE.bazel moves the upstream base, committing it alongside the other derived files. It runs after bazel mod deps, never before: that step only rewrites the pip facts on a cold output base, and this build would warm it. --config=local, since that job carries no BuildBuddy key.
  • renovate.json disables the dep, so no bump PR can restate what the tree already determines.

The trade-off

On a branch that edits meta/devcontainer-base/, and on main until the publish job finishes a couple of minutes after merge, the pin names an image the registry doesn't have yet. The way through is DEVCONTAINER_BASE_IMAGE pointed at the published :latest, which I verified builds:

DEVCONTAINER_BASE_IMAGE=ghcr.io/syndic/unnatural_designs-devcontainer-base:latest devcontainer build --workspace-folder .

That runs the previous plumbing locally; validating the candidate is CI's job, via bazel run :load. Worth noting I documented :load as the local recipe first and then corrected it — it needs Bazel and a Docker daemon in the same place, which in this repo means the host, contradicting the devcontainer-only rule.

One more consequence, in the good direction: BASE_IMAGE stops being a nice-to-have. On a base-changing PR the pinned digest genuinely doesn't resolve, so a broken override fails the build loudly instead of silently smoke-testing the old base.

This does not transfer to Syndic/.dotfiles. It doesn't build the image, so for it the pin stays a real dependency with a Renovate bump — documented in the base README so phase 5 doesn't copy the wrong half.

Verification

  • Ran the regenerator on a stale tree: it moved the pin to 0f31f38a…, and re-running is a no-op (--check agrees).
  • Mutation-tested, all four kill the suite: pin hand-edited to a wrong digest; image content changed with the pin left alone (the case the guard exists for); digest validation removed from replace_pin; _match relaxed to take the first of several pins.
  • The guard also caught the real thing before I fixed it — main's pin was stale by exactly the digest chore(deps): update ghcr.io/syndic/unnatural_designs-devcontainer-base:latest docker digest to 0f31f38 - autoclosed #224 proposed.
  • bazel test //... 21/21, gazelle clean, ruff / ty / shellcheck / check_modules.py pass.

Reviewer notes

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.92308% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.45%. Comparing base (5e731a7) to head (bbc7ea4).

Files with missing lines Patch % Lines
meta/scripts/sync_base_image_pin.py 96.07% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #225      +/-   ##
==========================================
+ Coverage   84.07%   84.45%   +0.37%     
==========================================
  Files          39       40       +1     
  Lines        2129     2193      +64     
  Branches       91       99       +8     
==========================================
+ Hits         1790     1852      +62     
- Misses        324      325       +1     
- Partials       15       16       +1     
Components Coverage Δ
Go 86.14% <ø> (ø)
Python 79.79% <96.92%> (+2.10%) ⬆️
Category: apps ∅ <ø> (∅)
Category: infra ∅ <ø> (∅)
Category: libs ∅ <ø> (∅)
Category: meta 79.79% <96.92%> (+2.10%) ⬆️
Category: services ∅ <ø> (∅)
Category: tools 86.14% <ø> (ø)
Project: meta/scripts 79.79% <96.92%> (+2.10%) ⬆️
Project: tools/network_infrastructure_maintenance 86.14% <ø> (ø)
Files with missing lines Coverage Δ
meta/scripts/check_modules.py 93.63% <100.00%> (+0.57%) ⬆️
meta/scripts/sync_base_image_pin.py 96.07% <96.07%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5e731a7...bbc7ea4. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Syndic and others added 4 commits August 8, 2026 18:06
The Dockerfile's FROM digest was modelled as a dependency: publish the
image, wait for Renovate to notice, merge a bump. But the digest is a pure
function of the source -- pkg_tar fixes timestamps and oci_push publishes
the exact index Bazel assembled -- so it is knowable in the PR that changes
the image, and the round-trip only bought a window where the pin named a
superseded image. Verified: a local build of the merged tree produced
sha256:0f31f38a..., byte-identical to what CI published.

It becomes a derived file, in the sense MODULE.bazel.lock already is:

  - sync_base_image_pin.py rewrites the pin from the built layout index.
  - //.devcontainer:test_base_image_pin fails when it drifts. Not a
    pre-commit hook: the digest falls out of an already-built Bazel
    artifact and needs no daemon, so it rides `bazel test //...`.
  - renovate-derived-files.yml re-derives it when MODULE.bazel moves the
    upstream base, in the same commit as the other derived files, and
    after `bazel mod deps` -- that step needs a cold output base and this
    build would warm it.
  - Renovate is told to ignore the dep, so no bump PR can restate what
    the tree already determines.

The trade, documented at each site: on a branch that edits the base, and
on main until publish finishes, the pin names an image the registry does
not have yet. DEVCONTAINER_BASE_IMAGE pointed at the published :latest is
the way through. Consumers outside this repo (.dotfiles) cannot derive
anything and keep the Renovate-bumped pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… test

Deriving the pin only closed half the loop: the CI test reported drift and
left you to run the command by hand, where MODULE.bazel.lock and uv.lock
both get a hook that fixes it in place. The pin now gets the same
treatment, and for the same reason -- a derived file nobody has to
remember to regenerate.

`files` covers every input to the image rather than the base directory
alone: MODULE.bazel carries both the upstream pull and the rules_oci /
rules_pkg versions that assemble it, and .bazelversion is in that class
too.

The test stays, and is not redundant. Hooks do not run for --no-verify, a
web edit, or the helper app`s API commits, and they do not re-run when a
branch rebases onto someone else`s base change -- two individually fresh
pins can be jointly stale. Verified both ways against a corrupted pin: the
hook rewrites it and fails the commit, the test fails on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rive it

The two classifications disagreed about what feeds the image.
renovate-derived-files.yml counts .bazelversion as a bazel change and
re-derives the pin from it; devcontainer.yml did not, so the merge would
rebuild and publish nothing. A bazel bump that moved the digest would have
written a pin naming an image no job ever pushes: the consumer build
cannot pull it, and no amount of re-running fixes a red PR whose failure
is a config gap.

Latent until now -- a bazel release changing rules_oci output is unlikely,
and until the pin was derived nothing would have moved. Deriving it is
what makes the asymmetry reachable, so it is worth closing in the same PR
rather than leaving a trap for whoever meets it first.

Cost is a no-op republish per bazel bump, which is what a MODULE.bazel
edit that misses the image already costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hook and renovate-derived-files.yml both run before the merge, so
neither can see the step that fails after it. `crane push` followed by one
`crane tag` per tag is not atomic: a failure in between leaves main pinned
to a digest the registry does not serve, or tags trailing a digest that
is. Both are silent until somebody rebuilds a devcontainer and cannot pull
its base.

The publish job now reads the pin back and checks it three ways: the
manifest resolves by digest (what a consumer FROM needs), and :latest and
sha-<gitsha> both point at it (what Renovate and any non-deriving consumer
read). A half-publish becomes a red main, which is recoverable by
re-running -- the documented remedy, now with something that says when to
apply it.

The pin is read through sync_base_image_pin.py --print-pinned rather than
a grep local to the workflow, so there is one parser for the Dockerfile
shape and no second copy to degrade into a check that always passes. That
mode returns before the layout check because the publish job has no Bazel
output tree; a test pins that, and a mutation making it print nothing is
caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Syndic
Syndic force-pushed the devcontainer/derive-base-image-pin branch from 10ffff1 to a13fce2 Compare August 9, 2026 01:20
@Syndic

Syndic commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main (the renovate.json hunk conflicted with #226's automerge removal — resolved keeping both: no automerge, plus the enabled: false this PR adds), and folded in the three gaps from the coverage review.

build(pre-commit) — the hook this PR argued against. The original commit said the pin didn't need one because the check could be a plain test. That's true of the check and beside the point for the fix: MODULE.bazel.lock and uv.lock both get a hook that rewrites them in place, and the pin was the odd one out at "CI tells you, you run the command yourself." files covers every input to the image — meta/devcontainer-base/, MODULE.bazel (the upstream pull and rules_oci/rules_pkg), .bazelversion — not just the base directory.

The test stays and isn't redundant: hooks don't run for --no-verify, a web edit, or the helper app's API commits, and don't re-run when a branch rebases onto someone else's base change, where two individually-fresh pins are jointly stale. Verified both against a corrupted pin — the hook rewrites it and fails the commit, the test fails on its own.

fix(ci).bazelversion derived a pin nobody would publish. renovate-derived-files.yml classifies it as a bazel change and re-derives the pin from it; devcontainer.yml didn't, so the merge would publish nothing and the consumer build would try to pull an image that was never pushed. A red PR with no way to green it. Latent before this PR (nothing moved the pin); deriving the pin is what makes it reachable, so it's closed here rather than left as a trap.

ci(devcontainer) — nothing verified the publish. Both other mechanisms run before the merge, so neither can see the step that fails after it. The publish job now reads the pin back and checks three things: the manifest resolves by digest, and :latest and sha-<gitsha> both point at it. That turns the documented crane non-atomicity from silent into a red main with "re-run this job" attached. It reads the pin via a new --print-pinned mode rather than its own grep, so there's one parser for the Dockerfile shape; that mode returns before the layout check because the publish job has no Bazel output tree, and a mutation making it print nothing is caught by a test.

On running pre-commit in a CI job as a general backstop: it would work mechanically — pre-commit run --all-files exits non-zero when a fixer modifies anything — but it doesn't earn its place here. test_base_image_pin already covers the pin hermetically, with no toolchain to provision, and ruff/shellcheck/gazelle/secrets all have purpose-built jobs already. The real argument for it is the two hooks with no CI backstop at all: bazel-mod-tidy (CI's --lockfile_mode=update rewrites in memory and stays green) and uv-lock-fresh. The cost is that the hooks resolve their tools from the devcontainer's PATH, so a CI runner would either rebuild the image or re-install every tool and drift from it — which is the problem the devcontainer exists to solve. Worth its own issue, not this PR.

Verification: rebased tree derives sha256:0f31f38a…, which is what main currently builds and what's published — so this PR also lands the pin main is one behind on. bazel test //... 21/21, ty, ruff, check_modules.py clean, the workflow YAML parses and the new step body is shellcheck-clean.

One housekeeping item this doesn't do: #224 (Renovate's bump of the pin) should be closed by hand once this merges — the enabled: false rule stops future ones, but Renovate won't necessarily retract the open PR.

Syndic and others added 2 commits August 8, 2026 18:40
The root README described the pin as written by sync_base_image_pin.py and
checked by bazel test, which was the shape before the hook existed. The
script is what writes it; the hook is what runs the script, and for an edit
made here that is the whole mechanism.

Rewraps a future-considerations line this branch left at 149 characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pin got a backstop in this PR while the other two fixers kept relying
on the hook having run. Both are now checked in CI, fail-only: the
regenerated file belongs in the authoring commit, not in a CI push.

MODULE.bazel.lock had nothing at all -- builds run with
--lockfile_mode=update, which rewrites the lock in memory and stays green,
so a bypassed `bazel mod tidy` was invisible until it blocked someone
else`s commit. A new ci.yml job runs `bazel mod tidy` and diffs
MODULE.bazel and the lock. Verified by staging a lock with a dropped
registry hash: the job`s own commands exit 1.

uv.lock was half-covered. check_uv_lock_fresh compares requirements_lock
against the lock, which stays consistent while the *lock* drifts from
pyproject -- exactly what a bypassed hook leaves. check_uv_lock_current
adds `uv lock --check`, which validates the existing resolution rather
than redoing it (sub-millisecond here), so this is not the full re-lock
that was deliberately kept out of CI.

That new check immediately caught a defect in the old one: `uv export`
updates uv.lock before exporting, so check_uv_lock_fresh was silently
re-locking the tree it was checking. In CI that repairs staleness inside
the runner and then compares against a file the commit does not contain.
Fixed with --frozen, pinned at the argv seam next to the other flags. The
pre-commit hook re-locks before exporting and needs no such flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@Syndic Syndic left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core premise holds: pkg_tar fixes mtimes, oci_push publishes the assembled index, and the layout index.json → single-manifest indirection is the one test_image_layers.py already walks. //meta/devcontainer-base:image was already a data dep of that test, so the new guard adds no build cost to bazel test //.... The mutation testing in the description is the right evidence for a rewriter that can brick every devcontainer, and anchoring _PIN_RE on the repository so COPY --from=ghcr.io/astral-sh/uv:… can't match — with a test for it — is the kind of thing that's only obvious after it bites someone.

Fifteen inline notes below. Four I'd want addressed before merge:

  • the merged paragraphs and the now-false cache rationale in meta/devcontainer-base/README.md
  • the two comments that still claim the uv export invocation mirrors the pre-commit hook "exactly", which --frozen has just made false on both sides
  • main()'s rewrite path — the mutating half both the hook and the workflow invoke — having no test

The rest are judgment calls, cost observations, or follow-ups.

One scope note: bazel-lock-check and check_uv_lock_current are unrelated to the base-image pin. The justification (same gap in sibling derived files) is good and the --frozen bug they surfaced is a real find — flagging only because it makes this harder to bisect later.

Comment thread meta/devcontainer-base/README.md
Comment thread meta/devcontainer-base/README.md Outdated
Comment thread meta/scripts/check_modules.py Outdated
Comment thread meta/scripts/test_check_modules.py
Comment thread meta/scripts/check_modules.py
Comment thread .github/workflows/devcontainer.yml Outdated
Comment thread .pre-commit-config.yaml
Comment thread .claude/CLAUDE.md Outdated
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml Outdated
Behaviour:

- The publish verification was single-shot against a registry whose
  tag->digest resolution is not documented as read-your-writes, seconds
  after `crane tag`. Three attempts with backoff, and one tool for all
  three references -- `imagetools inspect` takes a digest reference too,
  and `docker manifest` is still nominally experimental. Each attempt is
  wrapped in `timeout`: a call that never returns would hang the publish
  job on main.
- A stale uv.lock produced two errors for one cause, the second naming
  requirements_lock.txt. The two uv checks are now sequential, and a test
  pins that the export diff does not run when the lock itself is stale.
- Dropped `--check` from sync_base_image_pin. No caller, no test, and the
  module docstring advertised it -- the shape that rots. The bazel test is
  the check, and reads the same pure functions.
- `//meta/devcontainer-base:image` is visible to `//.devcontainer` rather
  than public, which is what its own comment already claimed.

Tests: main()`s rewrite path is covered -- rewrite, no-op re-run, and a
missing layout returning 2 without touching the Dockerfile, the case with
no backstop anywhere. test_devcontainer_config takes the digest half of
its assertion from sync_base_image_pin instead of carrying a second
sha256 regex.

Comments and docs, all of which said something that stopped being true:
the export "mirrors the hook exactly" (it deliberately differs by
--frozen, in two places), the cache paragraph blaming a later digest bump
that no longer exists, a missing blank line joining two paragraphs, a
175-character line, the base-image-pin hook`s ability to rewrite
MODULE.bazel.lock and be blamed for it, and which cost the .bazelversion
classification takes and why. The new CI job and the pre-existing
shellcheck job are now in the README table.

`bazel mod tidy --config=ci` matches gazelle-check next door; verified the
config resolves for `mod`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Syndic

Syndic commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

All fourteen addressed in 8e7e87a. Taken as-is except one, noted below.

Publish verification — both points taken. Three attempts with backoff, and imagetools inspect now does all three references since it accepts a digest reference too, so docker manifest is gone. One addition you didn't ask for: each attempt is wrapped in timeout 60. While testing the reference forms I hit a hang on a missing tag — that turned out to be my host's keychain credential helper prompting, not something a runner with docker/login-action would see (with an empty DOCKER_CONFIG it fails fast), but a registry call that never returns would hang the publish job on main, and the bound is free.

Two errors for one cause — the uv checks are sequential now, and there's a test asserting check_uv_lock_fresh isn't called when the lock itself is stale. Mutation-checked by summing them again: the suite goes red.

--check — dropped, with its docstring line. You're right that it was the shape that rots; //.devcontainer:test_base_image_pin is the check and reads the same pure functions.

main()'s rewrite path — covered: rewrite to the built digest, re-run is a byte-identical no-op, and a missing layout returns 2 without touching the Dockerfile. That last one is the case you flagged as having no backstop.

Two Dockerfile parsers — took the dedup rather than narrowing the claim. test_devcontainer_config now imports pinned_digest for the digest half and keeps the tag assertion local, so the second sha256: regex is gone. Verified it's a real edge: breaking the shared parser's regex turns the consumer test red.

Visibility//.devcontainer:__pkg__, which is what the comment already claimed.

--config=ci on mod tidy — matched to gazelle-check. I checked the config actually resolves for the mod command first, since mod doesn't inherit build flags.

Comments and docs — all corrected: the "mirrors the hook exactly" claim in both places it appeared, the cache paragraph (rewritten to the surviving reason — main builds with devcontainer-base:ci while PRs build with pinned-base, so the FROM string differs and the cache misses), the missing blank line, the 175-character line and its 104-character neighbour, and the hook's ability to rewrite MODULE.bazel.lock and then be blamed for it. Confirmed this branch now adds no line over 100 characters. The new job and the pre-existing shellcheck gap are both in the README table.

.bazelversion — the one I didn't change. You're right that the comment above spent a paragraph calling MODULE.bazel false positives "real and not cheap" and then this one takes exactly that cost unargued. It now says which cost it takes and why: the bazel version can't enter pkg_tar layers over a pulled base, so every bazelisk bump buys a byte-identical publish, and the cheap alternative leaves the recovery path needing a config change at the moment someone is already confused by a red PR. A recurring known cost over a rare unrecoverable one — but that's a judgement, and if you'd rather have the cheap version it's a two-line change.

bazel test //... 21/21, ty, ruff, check_modules.py, shellcheck on the new step body, and both workflows parse.

Syndic and others added 4 commits August 11, 2026 12:54
Two leftovers from the review pass.

The publish verification dropped stderr, so propagation lag, an auth
failure and an unreachable registry all reached the log as the same
empty answer -- and the annotation tells the reader to re-run the job,
which only helps for the first of those. The last stderr now follows the
::error:: line.

The .bazelversion bullet reflowed only as far as the long line that
needed it, leaving a 29-character orphan mid-paragraph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retry loop slept its full backoff after the last attempt, then fell
straight out and failed -- 15s per ref, three refs, buying nothing. It
also read as though the sleep belonged to an attempt rather than to the
gap between two.

Guarded with `||` rather than `&&` deliberately. An `&&` guard whose
test fails returns 1; that survives set -e as the last line of a loop
body but not as the last line of a function, so the working form here
would have been a property of where it sits. The `||` form returns 0
either way.

The retry count was already spelled twice (the loop list and the error
text, silently coupled); a guard would have made it three. Hoisted to
`attempts` so the three uses cannot drift.

Exercised the failure path with stubs, which CI has never run: two
sleeps for three attempts, the count interpolated into the message, and
a stubbed `unauthorized` reaching the log rather than /dev/null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tempt

A `timeout` kill writes nothing to stderr -- GNU coreutils confirms 0
bytes and exit 124 -- so that failure printed the stderr header followed
by silence. It was the one case a reader could not name: empty stderr
plus an empty answer looked identical to a docker failure that happened
to print nothing.

Capturing the status with `|| rc=$?` closes it, and replaces the `|| true`
that was there for set -e.

rc is reset at the top of each attempt, not only captured. `|| rc=$?`
assigns on failure alone, so without the reset a run where attempt 1
timed out and attempts 2-3 exited 0 with a mismatched digest would have
reported 124 for an attempt that exited cleanly -- reintroducing the
misleading diagnostic this is meant to remove. The second `rc=0`, beside
`resolved=""`, is the set -u guard for if `attempts` ever stops being a
literal.

Exercised all three with stubs, none of which CI runs: a timeout kill
reports 124, an auth failure reports 1 with its message, and the
stale-rc case reports 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `(124 = timed out)` legend printed on every failure, so an auth
failure read `Last attempt exited 1; its stderr follows (124 = timed
out):` -- a legend the reader has to decode mid-sentence, in a step whose
whole purpose is saying plainly what went wrong. Now the parenthetical
appears only when rc is actually 124.

`||` rather than `&&` for the same set -e reason as the sleep guard
above it.

Re-ran the three stub paths: 124 gains ` (timed out)`, exit 1 and exit 0
carry no parenthetical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Syndic
Syndic merged commit 011a9aa into main Aug 12, 2026
44 checks passed
@Syndic
Syndic deleted the devcontainer/derive-base-image-pin branch August 12, 2026 19:08
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