Skip to content

feature(mise): add mise dev container feature - #41

Draft
nozaq wants to merge 16 commits into
mainfrom
claude/features-proposal-s0vbyc
Draft

nozaq wants to merge 16 commits into
mainfrom
claude/features-proposal-s0vbyc

Conversation

@nozaq

@nozaq nozaq commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds ghcr.io/bare-devcontainer/features/mise, a Feature that installs mise on any Debian or Ubuntu based image, for the toolchains and CLIs a project pins itself (linters, terragrunt, a second language runtime) that the images deliberately leave out.

  • Install and verification — the release binary comes from https://github.com/jdx/mise/releases/ and is checked against src/mise/SHASUMS256.txt, upstream's own checksum file committed here verbatim together with the minisign signature upstream published for it. The install therefore needs sha256sum and nothing else: no package goes into the container in order to verify an artifact, and there is no signature step an install could be talked into skipping. The signature is verified where the tooling is free — scripts/verify-material.sh checks the committed pair against the vendored public key, and the new verify-material job in ci.yml runs it on every change.
  • No options, versioned as mise — the committed checksums cover one release, so they are the version pin, and the Feature's version is the version of the mise it installs (2026.9.10). scripts/pin-checksums.sh <feature> <tag> re-pins them to a named release and sets the Feature version from the tag, writing into the working tree only once the signature verifies; a newer mise therefore arrives as one pull request carrying both.
  • The musl build — the statically linked linux-*-musl build is installed unconditionally, because it runs whatever the image's glibc version is. That is one download and no fallback path, where the glibc build needs one: a release can require a newer glibc than the image provides (mise ≥ 2026.7.0 on bookworm), and the only way to find out is to run it and see it fail.
  • Prerequisites — wget (or curl), sha256sum and a CA bundle, installed with apt-get only when the image lacks them. Package lists the image shipped are kept: a Feature runs after the image was built, so the install removes /var/lib/apt/lists/* only when it was empty to begin with and the lists are its own apt-get update's doing.
  • Nothing outside /usr/local/bin and the remote user's home — the tool installs and the download cache stay at mise's own defaults under the home directory, which the Dev Containers CLI chowns when it renumbers the remote user, so nothing needs a system group, a setgid bit, or a caveat about stale UIDs. The Feature declares no mounts and sets no environment: a volume to keep the tools across rebuilds, and a PATH entry for the shims, both name a path under the home directory that fixed Feature text cannot express, and both belong in the consumer's devcontainer.json, where remoteEnv also scopes PATH to the remote user instead of to every process in the container. NOTES.md documents both, as the node Feature already does for the npm cache.
  • Trusted material — scripts/signed-material.json records which checksum file, signature and key belong to which Feature, for both scripts above. scripts/update-material.sh keeps refreshing signing keys weekly and now carries the mise key; release checksums are excluded there because they move with a release rather than on their own.
  • README.md is generated with devcontainer features generate-docs; the other Feature READMEs regenerate byte-identical.

Test plan

  • shellcheck -S warning clean on every install.sh, smoke_test.sh and scripts/*.sh
  • install.sh run end to end against the real release: the downloaded binary's sha256sum matches the committed line exactly, and a static-pie binary lands at /usr/local/bin/mise
  • Installed as a non-root remote user, then its UID and GID renumbered exactly the way the CLI's updateUID.Dockerfile does (sed on /etc/passwd and /etc/group, chown -R $HOME). All ten smoke checks pass before and after, with no group involved. Also run with _REMOTE_USER=root, and re-run over an existing layout.
  • ~/.local, ~/.local/share and ~/.cache come out owned by the remote user, not root — creating the directories as root would otherwise lock the account out of everything else that lives there
  • Negative paths: tampered checksum line → sha256sum -c FAILED, exit 1; no entry for the binary → fails before any download; a checksum file naming two mise versions → exit 1; missing checksum file → exit 1
  • Both branches of the apt cleanup driven through the real install_prerequisites with apt-get stubbed: against lists holding index files all entries survive, against a cleaned directory (lock and partial only) what apt-get update fetched is removed. A full run on a host with populated lists leaves them at 28 entries before and after.
  • pin-checksums.sh: usage, unknown feature, already-pinned no-op, bogus tag and a signature that does not verify all leave the working tree untouched; re-pinning to v2026.9.9 on a scratch copy moved the checksums, the signature and "version": "2026.9.9" together, with the rest of devcontainer-feature.json byte-identical
  • verify-material.sh: verifies the committed pair, exits non-zero against a mismatched key, and rejects an empty table
  • The mise exec checks run against a real install with api.github.com unreachable, so they do not depend on the GitHub API
  • The musl build checked for regressions against the glibc one, since it is now the only one installed: same TLS behaviour through a MITM proxy, and both request python-precompiled-x86_64-unknown-linux-gnu on a Debian host, so mise detects the image's libc rather than its own build target and the choice does not reach the tools. A tool install is ~1s slower (5.4s against 4.5s for node@24), paid once per tool.
  • Dropping trap - EXIT from pin-checksums.sh checked against a minimal reproduction: after the moves the temporary paths are gone, so the trap cannot reach the installed files
  • CI green: verify-material, and smoke-test for all three Features on both mcr/base:debian and bare/debian:trixie

Notes

The Feature was first versioned 1.0.0 independently of mise. Since each release pins exactly one mise, it now carries mise's own calendar version. Two consequences: the major tag becomes the year, so :2026 stops moving at year end and the usage example points at :latest; and a fix to install.sh alone cannot be published without a new mise release, because semver build metadata (+1) is not a valid OCI tag and a prerelease suffix (-1) sorts below the release. mise releases close to daily, so such a fix waits at most a short while.

The first CI run of smoke-test failed on bare/debian:trixie: the install and all the structural checks passed, but mise exec python@3 returned a 403. mise's core:python backend verifies the GitHub artifact attestations of the precompiled build through the unauthenticated GitHub REST API, whose rate limit is per IP and therefore shared across GitHub-hosted runners. The check now installs node@24, whose backend takes both the version list and the tarball from nodejs.org, so nothing in it draws on that quota. Disabling the attestation check (MISE_PYTHON_GITHUB_ATTESTATIONS=false) would have kept Python at the cost of testing a weaker configuration than a user gets.

The glibc build was originally installed first with the musl build as a fallback. Upstream's installer picks the glibc build on a glibc host only because it matches the host libc — it offers MISE_INSTALL_MUSL=1 for the opposite choice — so there was nothing holding the fallback in place, and it is gone.

The Feature originally declared the two volumes itself. A Feature's mount target is fixed text, so they had to sit outside the home directory, which is the only reason the mise group, the setgid bit and the stale-UID caveat existed. Dropping the mounts removed all three at once.

It then put the shims on PATH through containerEnv, with /usr/local/share/mise symlinked into the remote user's home so a fixed path would resolve. That symlink read as system-wide while belonging to one account, and containerEnv is the whole container's environment, so root's PATH ended up naming a directory the remote user can write. remoteEnv has the scope this wants and a Feature cannot set it, so the PATH entry moved to the consumer's devcontainer.json and the symlink is gone.

The notes and comments were cut back to what a consumer or a future maintainer cannot derive from the tree: the reasoning for the design lives here rather than in the code, mise's own CLI is left to mise's documentation, and every comment that restated the line it sat on is gone. That pass also turned up trap - EXIT in pin-checksums.sh guarding against something that could not happen; it is removed.

The same src/claude-code/install.sh:63 and src/node/install.sh:87 remove /var/lib/apt/lists/* unconditionally, so they discard lists their image shipped. Out of scope here; worth a follow-up.

An earlier commit on this branch dropped two files it claimed to change: the mise row in the root README.md table and the mise key entry in scripts/update-material.sh were reverted locally before staging. Both are restored.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ

Installs mise from its GitHub release into /usr/local/bin, verifying the
release checksums against their minisign signature with the mise public key
vendored in the feature, the same key and method the mise image uses. The
glibc build is installed where it runs and the statically linked musl build
otherwise, so releases that outgrew an older Debian's glibc still work there.

Tool installs and the download cache go to /var/lib/mise and /var/cache/mise,
which the feature mounts volumes on and points MISE_DATA_DIR and
MISE_CACHE_DIR at, with the shims directory on PATH. Both directories are
owned by a mise system group with the setgid bit, following the claude-code
feature, so they stay writable after updateRemoteUserUID renumbers the
remote user.

The minisign key is registered with update-material.sh so the weekly
workflow refreshes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The install script fetched upstream's checksum file and its minisign
signature at build time, and apt-installed minisign to check them. That
put a package into the container for the sole purpose of verifying an
artifact, and left the trust anchor outside the repository.

Commit upstream's own SHASUMS256.txt and the minisign signature published
for it, and have install.sh look the expected hash up there and check the
download with sha256sum alone. The signature is verified where the tooling
is free: scripts/verify-material.sh checks the committed pair against the
vendored public key, and the CI workflow runs it on every change. An
install has no signature step left to skip.

Because the checksums cover one release, they are the version pin, so the
version option is gone and the feature's own version selects which mise it
installs. scripts/pin-checksums.sh re-pins a named release, writing what it
downloaded only once the signature verifies; update-material.sh is back to
refreshing signing keys, and now carries the mise key entry the previous
commit dropped along with the README table row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
mise's core:python backend verifies the GitHub artifact attestations of the
precompiled build it downloads through the GitHub REST API, unauthenticated.
That API's rate limit is per IP, and a GitHub-hosted runner shares its
address with every other runner behind it, so the check failed with a 403
once that quota was spent.

Install node@24 instead: mise's node backend takes the version list and the
tarball from nodejs.org, so nothing in the check depends on a quota shared
with the rest of GitHub Actions. Verified with api.github.com unreachable,
where node@24 installs and runs while python@3 fails as CI did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The glibc build was installed first and the statically linked musl build
only when the glibc one turned out not to run, which is a download and an
error path that exist to serve a case the musl build never has.

Install the musl build directly. Measured against v2026.9.10 on linux-x64:

- It does not depend on the image's glibc at all, so the case the fallback
  existed for cannot arise.
- It starts in ~12ms against ~74ms for the glibc build (50 invocations,
  three alternating rounds, unchanged with MISE_OFFLINE=1, so it is the
  dynamic relocation of a 132MB PIE rather than a network check). The
  feature puts the shims directory on PATH and a shim re-execs mise, so
  every tool invocation pays that.
- It resolves the same tool builds: both variants request
  python-precompiled-x86_64-unknown-linux-gnu on a Debian host, because
  mise detects the image's libc rather than its own build target.

A tool install is ~1s slower (5.4s against 4.5s for node@24), which is
paid once per tool against the startup cost paid per invocation.

Upstream's installer picks the glibc build here only because it matches the
host libc; it offers MISE_INSTALL_MUSL=1 for this choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The notes said a feature's mounts cannot be disabled from devcontainer.json
and offered redirecting MISE_DATA_DIR as the only way out, which leaves the
volume mounted and unused.

Mounts are merged by target with the last entry winning, and devcontainer.json
is merged last, so a mount declared there on the same target replaces the
feature's instead of being added beside it. That is the useful escape hatch:
a volume shared across projects, or a directory somewhere else entirely.
Document it, and keep the containerEnv route for pointing mise elsewhere
without touching the mounts.

Merge order and the per-property rules are the specification's ("Collected
list of all mountpoints. Conflicts: Last source wins", "Per variable, last
value wins", "When the order matters, the devcontainer.json is considered
last"), confirmed against the merge the Dev Containers CLI 0.89.0 performs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
…ents

The notes and the comments added with this feature argued for the design as
much as they described it, and documented mise's own CLI. Neither belongs in
the repository.

Drop the justification of where verification happens, of why the musl build is
installed, and of why the version has no option; drop the walkthrough of mise
install, mise exec, mise activate and the postCreateCommand suggestion; drop
the positioning against the mise image. Keep what a consumer or a future
maintainer cannot derive from upstream: the requirements, the version pin,
the volume paths and the group and setgid mechanism behind them, the mount and
containerEnv override, and the supply chain facts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The comment led with startup cost, which is not why the build was chosen and
is not a cost anyone in a dev container is positioned to notice. The reason is
that a statically linked binary runs on any image, which is what removes the
glibc fallback and its error path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
Delete the comments a maintainer loses nothing by not having: the ones
restating a trap, a case body, a function name or a function's own statements,
and the workflow job comment repeating the script it runs. What is left is the
material a maintainer cannot derive from the tree — _REMOTE_USER coming from
the CLI, the coupling to devcontainer-feature.json, why only wget is a
prerequisite, why the checksum lookup compares strings, why mise_runs
redirects every directory, why the musl build, and how group membership
survives the UID renumbering.

Drop `trap - EXIT` from pin-checksums.sh with its comment: after the moves the
temporary paths no longer exist, so the trap it guarded against was already a
no-op.

Cut the mount-target and volume-seeding rationale from the notes, keeping the
group and setgid behaviour a reader needs to make sense of the stale-UID case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The install script removed /var/lib/apt/lists/* unconditionally after
installing a missing prerequisite. In a Dockerfile that idiom deletes only
what the same layer fetched, but a feature runs on an image someone else
built: an image that ships package lists so its users can apt-get install
without apt-get update loses them, and the next install in that container
fails to find any package.

Record whether the lists held anything before apt-get update runs, and remove
them only when they did not. "lock" and "partial" are what a cleaned image
leaves behind, so they do not count as content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The feature declared volumes for the tool installs and the download cache.
A feature's mount target is fixed text, so those had to sit at /var/lib/mise
and /var/cache/mise, outside the home directory the Dev Containers CLI chowns
when it renumbers the remote user — which is why a mise group, the setgid bit
and a caveat about stale UIDs existed at all.

Leave both at mise's own defaults under the home directory instead. The CLI's
chown covers them, so the group, the setgid bit and the caveat are gone, and a
volume mounted over either of them is seeded from a directory that already has
the right owner. Whether to keep them across rebuilds is now the consumer's
call, with the mounts documented in the notes, as the node feature already does
for the npm cache.

containerEnv cannot name a path in the home directory, so PATH keeps naming a
fixed one: /usr/local/share/mise is a symlink to the data directory, and
/usr/local/share/mise/shims is what goes on PATH.

Creating a directory under the home as root leaves ~/.local, ~/.local/share and
~/.cache owned by root, which locks the account out of everything else that
lives there, so every level the install creates is given to the remote user.

Verified by installing as a non-root remote user, renumbering its UID and GID
the way the CLI's updateUID.Dockerfile does, and re-running the smoke test:
all twelve checks pass before and after, with no group involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The helper walked a path component by component so it could chown exactly the
levels it created. A loop over dirname reaches the same levels from the same
constants in a third of the lines, and handing an already-present ~/.local or
~/.cache to the remote user is what a dev container home wants anyway.

Verified on a fresh account, across a UID and GID renumbering, and on a repeat
run over an existing layout: every level comes out owned by the remote user and
all twelve smoke checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The loop derived each level from a constant with dirname, which buys nothing
here: the two directories are shallow and share no prefix, so it was five
chowns written as a loop. List the five paths and let install -d create what is
missing and set the owner on each, which leaves the contents of a directory
that already exists alone. The two path constants go with it.

Verified on a fresh account, across a UID and GID renumbering, and on a repeat
run over an existing layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
Simplifying the directory creation reintroduced comments that carry the
reasoning rather than the constraint. Keep the part a plausible edit would
break — that the install creates the directories and lists their parents, that
the apt cleanup is conditional and does not count "lock" or "partial", that
SHIMS_PARENT is coupled to PATH in devcontainer-feature.json — and drop the
rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
A feature can only set containerEnv, which is the whole container's
environment, so putting the shims directory on PATH put it on root's PATH too
— pointing at a directory the remote user can write. The symlink that made a
fixed path resolve there, /usr/local/share/mise, claimed to be system-wide
while belonging to one account.

remoteEnv is the property with the right scope and a feature cannot set it, so
the PATH entry belongs in the consumer's devcontainer.json, where the remote
user's home is known. The notes document it, next to mise activate for shells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The root README and the mise notes described the CI job and the scripts that
keep the vendored checksums in step with upstream, which is how the repository
maintains itself rather than anything a consumer acts on. Keep what a consumer
needs to decide whether to trust the feature — where the binary comes from,
what it is checked against, and how to check that material by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
The vendored checksums pin exactly one mise release, so the feature
version now equals that release's version, and pin-checksums.sh sets it
from the tag it pins. The usage example points at :latest, since the
major tag of a calendar version stops moving at the end of the year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ
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