Skip to content

feat(devcontainer): bake the rate-limit-fragile toolchain into a Dockerfile - #12

Merged
justinmerrell merged 2 commits into
mainfrom
feat/devcontainer-dockerfile-and-stacks-layout
Aug 16, 2026
Merged

feat(devcontainer): bake the rate-limit-fragile toolchain into a Dockerfile#12
justinmerrell merged 2 commits into
mainfrom
feat/devcontainer-dockerfile-and-stacks-layout

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

.devcontainer/ gains a Dockerfile and loses its compose file, so it now reads
image → environment → services:

.devcontainer/
  Dockerfile          bun, uv, task, mise as pinned ARGs
  .dockerignore       `*` — nothing is copied, and .env must not reach the daemon
  devcontainer.json   Features, mounts, env, ports, VS Code
  mise.toml           runtime-only CLIs
  scripts/
  stacks/
    compose.yaml      the orchestrator (moved from .devcontainer/)
    postgres/ redis/ minio/ registry/ azimutt/ observability/

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
asset_resolver.py:126:

response = urllib.request.urlopen(
    f"https://api.github.com/repos/{repo}/releases/tags/{tag}"
)  # nosec

There is no Authorization header and no GITHUB_TOKEN read anywhere in that
module. 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 found
rather 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 source
before porting it.

Two findings that differ from platform's write-up:

  • go-task is exposed too, which platform missed — it uses the same
    nanolayer helper. platform doesn't pin that Feature, so it never surfaced there.
  • The other third-party Features are clean and stay Features. deno and
    lukewiwa/shellcheck curl releases/download/... directly;
    robbert229/postgresql-client is apt. The rule is about the installer's
    behaviour, not the publisher.

mise is baked 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.

Deliberately not baked. The boundary is the Dockerfile bakes
version-pinned tools; post-create owns what is runtime or self-updating.
In
particular mise install cannot run at build time — four of the six
entries in mise.toml use the npm:/pipx: backends, and Node and Python
arrive from Features, which layer after the Dockerfile stage.

Pins live in Dockerfile ARGs rather than a versions.sh (platform's
approach) because nothing else in this repo consumes them, and a third version
home would violate CONFIGURATION.md's "One need, one place". That also means
no COPY, so .dockerignore can be * and the gitignored .env never
reaches the daemon.

Why the compose move, and the trap in it

Moving the orchestrator to stacks/compose.yaml breaks Compose's positional
.env discovery, which resolves against the directory of the first -f file.
Left alone, the failure is silent — no error, just defaults. Measured on
this branch, same compose file:

services resolved POSTGRES_USER
with --env-file 10 proofuser (from the file)
without 1 postgres (default)

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. .env.example's header, which
documented 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 toolchain policy (5th under .repo/):

Code Enforces
TC-01 bun/uv/go-task must not appear in the features block
TC-02 every image-baked ARG is an exact pin, not latest
TC-03 TASK_VERSION matches CI's arduino/setup-task version

TC-03 replaces two hand-maintained "keep in lockstep with the go-task Feature
pin" comments that removing the Feature would have orphaned. The full
api.github.com diagnosis lives in the violation's reason, so it surfaces at
the point of failure.

scripts/verify-toolchain.sh replaces the build job's echo, asserting the
built container reports the pinned versions (the runtime half the Dockerfile's
presence-only test -x leaves open). It reads the pins back out of the
Dockerfile, so the ARGs stay the one source of truth.

Both ride existing CI jobs — no new job, so .github/rulesets/ and the
hooks policy are untouched.

Verification

Green: shellcheck (CI opts), markdownlint, yamllint, actionlint,
codespell, all five governance policies, and the full pre-commit hook set.
docker compose config resolves under default and all profiles, and each
stack's relative bind mounts (./init, ./config) still resolve against its
own 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 run version assertion, and --frozen-lockfile. The lockfile was
edited by removing exactly the three dropped Features (11/11 parity with
devcontainer.json verified). These land with the first CI run; if the
Devcontainer Build or Devcontainer Lockfile job is unhappy, that is where
it will show.

Reviewer notes

  • Worth a look: Dockerfile (the rationale header is the point of the file),
    policies/toolchain/violations.py, and the --env-file threading.
  • Unrelated pre-existing bug left alone: post-create.sh:39 gates lefthook
    installation on a root lefthook.yml that config/check.py:94 forbids, so
    git hooks are never installed by post-create.
  • CONFIGURATION.md's "Runtimes & Tools" goes from three tiers to four; the
    decision tree and quick reference follow.

🤖 Generated with Claude Code

justinmerrell and others added 2 commits August 16, 2026 12:24
…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
justinmerrell merged commit dbff632 into main Aug 16, 2026
7 checks passed
@justinmerrell
justinmerrell deleted the feat/devcontainer-dockerfile-and-stacks-layout branch August 16, 2026 06:23
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