feat(devcontainer): bake the rate-limit-fragile toolchain into a Dockerfile - #12
Merged
justinmerrell merged 2 commits intoAug 16, 2026
Merged
Conversation
…erfile
`.devcontainer/` grows a Dockerfile and loses its compose file, so it now
reads image -> environment -> services: Dockerfile, devcontainer.json,
stacks/.
WHY THE DOCKERFILE
bun, uv and go-task were Features. All three route through nanolayer's
gh-release installer, which lists a release's assets by calling
api.github.com with no credentials:
nanolayer/installers/gh_release/resolvers/asset_resolver.py:126
urllib.request.urlopen(f"https://api.github.com/repos/{repo}/releases/tags/{tag}")
Codespaces build hosts and GitHub-hosted runners share egress IP pools, so
the 60 req/hr anonymous limit is routinely exhausted, the call 403s, and one
failed Feature fails the whole image build -- after which Codespaces drops
the developer into a bare recovery container. Pinning does not help: the pin
supplies the tag, but _get_release_assets() still calls the API to list
assets.
The three are now installed by .devcontainer/Dockerfile from URLs that never
touch the API, as pinned ARGs. mise joins them for a different reason: it was
an unpinned `curl https://mise.run | sh` in post-create, the only unpinned
tool in a template that pins everything else.
Every other third-party Feature was checked and kept: devcontainers-extra
deno and lukewiwa shellcheck curl releases/download/... directly, and
robbert229 postgresql-client is apt.
`mise install` deliberately stays in post-create -- four of the six entries in
mise.toml use the npm: and pipx: backends, and Node and Python arrive from
Features, which layer after the Dockerfile stage.
WHY THE COMPOSE MOVE
The orchestrator moves to .devcontainer/stacks/compose.yaml so stacks/ is
self-contained. That breaks Compose's positional .env discovery, which
resolves against the directory of the first -f file. Left alone the failure is
silent: every ${VAR:-default} takes its default and COMPOSE_PROFILES reads as
empty, so all opt-in stacks vanish without an error. Measured, same file:
with --env-file, 10 services resolve; without, 1.
So every caller -- startup.sh, the MOTD, and CI -- now names the env file, and
the orchestrator pins `name: musher-dev` because Compose would otherwise
derive the project name from the stacks/ folder.
ENFORCEMENT
Re-adding one of those Features looks like a harmless simplification, so it is
a check rather than a comment. New `toolchain` policy:
TC-01 bun/uv/go-task must not appear in the features block
TC-02 every image-baked ARG is an exact pin
TC-03 TASK_VERSION matches CI's arduino/setup-task version
It runs under the existing governance job, and scripts/verify-toolchain.sh
replaces the build job's `echo` so CI asserts the built container reports the
pinned versions. No new CI job, so the rulesets and hooks policies are
untouched.
Verified: shellcheck, markdownlint, yamllint, actionlint, codespell and all
five governance policies pass; `compose config` resolves under default and all
profiles; each stack's relative bind mounts still resolve against its own
folder. The image build and the docker-run version assertion are unrun here --
no Docker daemon -- and land with the first CI run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Dockerfile added in the previous commit opened with 52 comment lines before its first instruction -- twice the next-longest block in the repo, and 8% of every comment line under .devcontainer/. For a template read before it is run, the first file a new developer opens read like an incident report. The goal is not fewer comments; it is that each one earns its line, and each rationale is written once. MEASURED FIRST Across 22 files (2,225 lines, 643 comment lines) the repo averages 0.49 comment:code. The Dockerfile was 3.03. Block sizes are bimodal -- 43% are one line, a secondary mode sits at 4-8, and only four blocks anywhere exceeded 20 lines, all of them file headers. Separately, one decision was explained six times: the nanolayer/api.github.com diagnosis occupied ~1,461 words across Dockerfile, devcontainer.json, the toolchain policy, CONFIGURATION.md and .repo/README.md. WHAT CHANGED CONFIGURATION.md is now the single account (the asset_resolver call, why the pin does not help, the recovery-container symptom, and which Features were cleared). Everything else carries a one-line summary and a pointer. The depth belongs in the Violation `docs` field, which reporting.py already provides for exactly this -- _WHY_NOT_A_FEATURE was 102 words against a repo norm of 37-49, and is now 44. Dockerfile: 129 -> 65 lines, ratio 3.03 -> 0.97, longest block 52 -> 13. The four hazards that are destructive or silent when ignored stay, at 1-2 lines each: the bun installer's HOME rewrite, the bunx symlink, presence-only assertions, and the absent USER. Also trimmed: stacks/compose.yaml (the 12-line port table duplicated CONFIGURATION.md and was the one copy `repo ports check` never read -- a fourth unverified copy free to drift), .env.example, initialize.sh, .dockerignore, mise.toml, devcontainer.json. Removed the ~13 comments that restate the line below them, in motd.sh, startup.sh and postgres/compose.yaml, plus three common.sh dividers guarding a single function each. Fixed startup.sh's function header, the only one in the repo ordering Arguments before Globals; Google's order is Globals, Arguments, Outputs, Returns. The Google-style headers on every function in scripts/lib/ are untouched -- Google mandates them for libraries regardless of length, and they are why the shell libs sit at ~1.0 rather than 0.2. ENFORCEMENT New `comments` policy, under the existing governance job: CMT-01 contiguous comment block over 20 lines CMT-02 an ALLOWED_LONG_BLOCKS entry that no longer excuses anything CMT-03 a docs: pointer whose anchor no longer resolves The limit is 20 because the data has a gap there -- nothing legitimate sits between 16 and 22 -- so it catches outliers without fighting lefthook.yml's dense 16-line header. ALLOWED_LONG_BLOCKS ships empty. CMT-03 is what makes the rest safe: replacing prose with pointers only works while pointers resolve. All five existing anchors resolve, so it starts green as a regression guard. Markdown is excluded from the scan -- `#` is a heading there -- as are shebangs and shellcheck/syntax directives. Verified: all three CMT codes negative-tested by sabotaging what they guard; shellcheck, markdownlint, yamllint, actionlint, codespell and all six policies pass; compose resolves under default and all profiles. Net across the touched files: 361 -> 233 comment lines, 0.67 -> 0.44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
justinmerrell
deleted the
feat/devcontainer-dockerfile-and-stacks-layout
branch
August 16, 2026 06:23
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.
.devcontainer/gains a Dockerfile and loses its compose file, so it now readsimage → environment → services:
Why the Dockerfile
bun,uvandgo-taskwere Features. All three route through nanolayer'sgh-releaseinstaller, which lists a release's assets by callingapi.github.comwith no credentials —asset_resolver.py:126:There is no
Authorizationheader and noGITHUB_TOKENread anywhere in thatmodule. Codespaces build hosts and GitHub-hosted runners share egress IP pools,
so the 60 req/hr anonymous limit is routinely exhausted, the call 403s, and one
failed Feature fails the whole image build — after which Codespaces drops the
developer into a bare recovery container showing
task: command not foundrather than the real error.
Pinning does not help. The pin supplies the tag;
_get_release_assets()still calls the API to list assets. This was diagnosed downstream in
musher-dev/platform#1942; I re-verified every claim against upstream sourcebefore porting it.
Two findings that differ from platform's write-up:
go-taskis exposed too, which platform missed — it uses the samenanolayer helper. platform doesn't pin that Feature, so it never surfaced there.
denoandlukewiwa/shellcheckcurlreleases/download/...directly;robbert229/postgresql-clientis apt. The rule is about the installer'sbehaviour, not the publisher.
miseis baked for a different reason: it was an unpinnedcurl https://mise.run | shin post-create — the only unpinned tool in atemplate that pins everything else.
Deliberately not baked. The boundary is the Dockerfile bakes
version-pinned tools; post-create owns what is runtime or self-updating. In
particular
mise installcannot run at build time — four of the sixentries in
mise.tomluse thenpm:/pipx:backends, and Node and Pythonarrive from Features, which layer after the Dockerfile stage.
Pins live in Dockerfile
ARGs rather than aversions.sh(platform'sapproach) because nothing else in this repo consumes them, and a third version
home would violate
CONFIGURATION.md's "One need, one place". That also meansno
COPY, so.dockerignorecan be*and the gitignored.envneverreaches the daemon.
Why the compose move, and the trap in it
Moving the orchestrator to
stacks/compose.yamlbreaks Compose's positional.envdiscovery, which resolves against the directory of the first-ffile.Left alone, the failure is silent — no error, just defaults. Measured on
this branch, same compose file:
POSTGRES_USER--env-fileproofuser(from the file)postgres(default)So every caller —
startup.sh, the MOTD, and CI — now names the env file, andthe orchestrator pins
name: musher-devbecause Compose would otherwise derivethe project name from the
stacks/folder..env.example's header, whichdocumented the old sibling contract, is updated to match.
Enforcement, not a comment
Re-adding one of those Features looks like a harmless simplification, so it is
a check. New
toolchainpolicy (5th under.repo/):TC-01bun/uv/go-taskmust not appear in the features blockTC-02ARGis an exact pin, notlatestTC-03TASK_VERSIONmatches CI'sarduino/setup-taskversionTC-03replaces two hand-maintained "keep in lockstep with the go-task Featurepin" comments that removing the Feature would have orphaned. The full
api.github.comdiagnosis lives in the violation'sreason, so it surfaces atthe point of failure.
scripts/verify-toolchain.shreplaces the build job'secho, asserting thebuilt container reports the pinned versions (the runtime half the Dockerfile's
presence-only
test -xleaves open). It reads the pins back out of theDockerfile, so the ARGs stay the one source of truth.
Both ride existing CI jobs — no new job, so
.github/rulesets/and thehookspolicy are untouched.Verification
Green:
shellcheck(CI opts),markdownlint,yamllint,actionlint,codespell, all five governance policies, and the full pre-commit hook set.docker compose configresolves under default and all profiles, and eachstack's relative bind mounts (
./init,./config) still resolve against itsown folder rather than the orchestrator's.
Each
TC-*code was negative-tested by sabotaging the file it guards.Not run — no Docker daemon on the authoring host: the image build itself,
the
docker runversion assertion, and--frozen-lockfile. The lockfile wasedited by removing exactly the three dropped Features (11/11 parity with
devcontainer.jsonverified). These land with the first CI run; if theDevcontainer BuildorDevcontainer Lockfilejob is unhappy, that is whereit will show.
Reviewer notes
Dockerfile(the rationale header is the point of the file),policies/toolchain/violations.py, and the--env-filethreading.post-create.sh:39gates lefthookinstallation on a root
lefthook.ymlthatconfig/check.py:94forbids, sogit hooks are never installed by post-create.
CONFIGURATION.md's "Runtimes & Tools" goes from three tiers to four; thedecision tree and quick reference follow.
🤖 Generated with Claude Code