Repository navigation
Conversation
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
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.
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.https://github.com/jdx/mise/releases/and is checked againstsrc/mise/SHASUMS256.txt, upstream's own checksum file committed here verbatim together with the minisign signature upstream published for it. The install therefore needssha256sumand 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.shchecks the committed pair against the vendored public key, and the newverify-materialjob inci.ymlruns it on every change.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.linux-*-muslbuild 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.wget(orcurl),sha256sumand a CA bundle, installed withapt-getonly 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 ownapt-get update's doing./usr/local/binand 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 aPATHentry for the shims, both name a path under the home directory that fixed Feature text cannot express, and both belong in the consumer'sdevcontainer.json, whereremoteEnvalso scopesPATHto the remote user instead of to every process in the container.NOTES.mddocuments both, as thenodeFeature already does for the npm cache.scripts/signed-material.jsonrecords which checksum file, signature and key belong to which Feature, for both scripts above.scripts/update-material.shkeeps 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.mdis generated withdevcontainer features generate-docs; the other Feature READMEs regenerate byte-identical.Test plan
shellcheck -S warningclean on everyinstall.sh,smoke_test.shandscripts/*.shinstall.shrun end to end against the real release: the downloaded binary'ssha256summatches the committed line exactly, and astatic-piebinary lands at/usr/local/bin/miseupdateUID.Dockerfiledoes (sedon/etc/passwdand/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/shareand~/.cachecome out owned by the remote user, not root — creating the directories as root would otherwise lock the account out of everything else that lives theresha256sum -cFAILED, exit 1; no entry for the binary → fails before any download; a checksum file naming two mise versions → exit 1; missing checksum file → exit 1install_prerequisiteswithapt-getstubbed: against lists holding index files all entries survive, against a cleaned directory (lockandpartialonly) whatapt-get updatefetched 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 tov2026.9.9on a scratch copy moved the checksums, the signature and"version": "2026.9.9"together, with the rest ofdevcontainer-feature.jsonbyte-identicalverify-material.sh: verifies the committed pair, exits non-zero against a mismatched key, and rejects an empty tablemise execchecks run against a real install withapi.github.comunreachable, so they do not depend on the GitHub APIpython-precompiled-x86_64-unknown-linux-gnuon 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 fornode@24), paid once per tool.trap - EXITfrompin-checksums.shchecked against a minimal reproduction: after the moves the temporary paths are gone, so the trap cannot reach the installed filesverify-material, andsmoke-testfor all three Features on bothmcr/base:debianandbare/debian:trixieNotes
The Feature was first versioned
1.0.0independently 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:2026stops moving at year end and the usage example points at:latest; and a fix toinstall.shalone 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-testfailed onbare/debian:trixie: the install and all the structural checks passed, butmise exec python@3returned a 403. mise'score:pythonbackend 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 installsnode@24, whose backend takes both the version list and the tarball fromnodejs.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=1for 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
misegroup, the setgid bit and the stale-UID caveat existed. Dropping the mounts removed all three at once.It then put the shims on
PATHthroughcontainerEnv, with/usr/local/share/misesymlinked into the remote user's home so a fixed path would resolve. That symlink read as system-wide while belonging to one account, andcontainerEnvis the whole container's environment, so root'sPATHended up naming a directory the remote user can write.remoteEnvhas the scope this wants and a Feature cannot set it, so thePATHentry moved to the consumer'sdevcontainer.jsonand 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 - EXITinpin-checksums.shguarding against something that could not happen; it is removed.The same
src/claude-code/install.sh:63andsrc/node/install.sh:87remove/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
miserow in the rootREADME.mdtable and the mise key entry inscripts/update-material.shwere reverted locally before staging. Both are restored.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ