From 2a66b5b0d18829318acc04cf1aaa874dba9c11bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 10:05:06 +0000 Subject: [PATCH 01/16] feature(mise): add mise dev container feature 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/NOTES.md | 154 ++++++++++++++++++++ src/mise/README.md | 178 +++++++++++++++++++++++ src/mise/devcontainer-feature.json | 37 +++++ src/mise/install.sh | 223 +++++++++++++++++++++++++++++ src/mise/mise-minisign.pub | 2 + src/mise/smoke_test.sh | 67 +++++++++ 6 files changed, 661 insertions(+) create mode 100644 src/mise/NOTES.md create mode 100644 src/mise/README.md create mode 100644 src/mise/devcontainer-feature.json create mode 100755 src/mise/install.sh create mode 100644 src/mise/mise-minisign.pub create mode 100755 src/mise/smoke_test.sh diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md new file mode 100644 index 0000000..85ba0f3 --- /dev/null +++ b/src/mise/NOTES.md @@ -0,0 +1,154 @@ +The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) +is installed as `/usr/local/bin/mise`, and the directories mise installs tools into +and caches downloads in are placed on volumes the feature declares. + +## Requirements + +- A `linux-x64` or `linux-arm64` image. The glibc build is installed where the + image's glibc is new enough to run it, and the statically linked musl build + otherwise, so any Debian or Ubuntu release works. +- `wget` (or `curl`), `minisign`, `sha256sum` and a CA bundle, for the download and + the signature check. Any of those the image is missing are installed with + `apt-get`, so an image without them has to be Debian or Ubuntu based; an image + that already has them needs no package manager at all. + +## Usage + +The defaults install the newest release. To pin a release instead: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/mise:1": { + "version": "2026.9.10" + } +} +``` + +`version` accepts `latest` or an exact version such as `2026.9.10`. `latest` is +resolved at install time, so a rebuild picks up newer releases. + +Not every project needs this feature: on the +[`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) +image, mise is already there. The feature is for adding mise to any other image, such +as a language image that needs a second toolchain or a linter the project pins. + +## Installed software + +- mise, installed as `/usr/local/bin/mise`, owned by root. + +Nothing else: no tool is installed until the project asks for it. `mise install` +installs everything a project's `mise.toml` declares, and `mise exec @ +-- ` runs a one-off without declaring anything. To install the project's tools +when the container is created rather than on first use, run `mise install` from a +`postCreateCommand`. + +The shims directory is on `PATH`, so a tool resolves as soon as mise installs it, +for editors and other programs that do not load the shell configuration as well as +in the terminal. `mise activate` can be added to the shell configuration for the +[features shims do not cover](https://mise.jdx.dev/dev-tools/shims.html), such as +environment variables declared in `mise.toml`. + +## Tool installs and download cache + +mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in +`MISE_CACHE_DIR`. The feature declares a volume for each, so a rebuild neither +reinstalls the tools nor downloads them again, and points mise at them: + +```json +"containerEnv": { + "MISE_DATA_DIR": "/var/lib/mise", + "MISE_CACHE_DIR": "/var/cache/mise", + "PATH": "/var/lib/mise/shims:${PATH}" +}, +"mounts": [ + { + "source": "${devcontainerId}-mise-data", + "target": "/var/lib/mise", + "type": "volume" + }, + { + "source": "${devcontainerId}-mise-cache", + "target": "/var/cache/mise", + "type": "volume" + } +] +``` + +A feature's mount target is static and cannot reference the remote user's home +directory, so the volumes are mounted under `/var` and the environment variables +move the directories there. That keeps the mounts working whatever the image uses +as its remote user. The shims directory moves with `MISE_DATA_DIR`, which is why +`PATH` names it under `/var/lib/mise`. + +The volumes are seeded from the image on first use, ownership included. The Dev +Containers CLI renumbers the remote user to the host user's UID and GID on Linux, +but only chowns the home directory, so a directory owned by a build-time UID would +end up unreachable. The install script therefore creates a `mise` system group, +adds the remote user to it, and makes both directories group-writable and setgid: +group membership is recorded by name and is unaffected by the renumbering. + +One case is not covered. If an existing volume is reused after the host user's UID +changed, files written under the old UID keep their owner-only modes and mise cannot +replace them; clear them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this +removes the installed tools) or remove the volumes. Files created afterwards are +unaffected. + +The volumes are per dev container (`${devcontainerId}`) and are not shared between +projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`, +but `containerEnv` there takes precedence over a feature's, so setting +`MISE_DATA_DIR` or `MISE_CACHE_DIR` points mise somewhere else and leaves the volume +mounted and unused. `PATH` then needs the new shims directory as well. + +mise's configuration (`~/.config/mise`) and state, including which `mise.toml` +files have been trusted (`~/.local/state/mise`), stay in the remote user's home +directory and are recreated by a rebuild. + +## Not installed + +- **No language runtime or tool.** mise resolves the versions the project declares + in `mise.toml` or in idiomatic per-language files such as `.node-version`. +- **No development headers beyond libc.** A backend that downloads prebuilt + binaries works as-is; one that builds a runtime from source needs the `-dev` + packages of the libraries it links against. +- **No shell activation.** The shell configuration is left untouched; the shims + directory on `PATH` is what makes installed tools resolve. + +## Supply chain + +The binary is downloaded from `https://github.com/jdx/mise/releases/`, and its +checksum is verified against `SHASUMS256.txt`, whose minisign signature is verified +with mise's public key. The key is vendored with the feature and read from there, +so the signature is checked against a key reviewed in this repository rather than +one fetched at install time. It is a copy of +[`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise +repository, refreshed by this repository's `Update Trusted Material` workflow. + +To see the key before pinning the feature: + +```sh +cat src/mise/mise-minisign.pub +``` + +This covers the mise binary only. Tools that mise installs at runtime are fetched +from their own upstreams under mise's own verification, which +[`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. + +Everything downloaded during installation goes to a temporary directory that is +removed when the script exits. + +## Tips + +- For the tags this feature is published under, see + [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). +- To have mise re-verify the provenance of the tools it installs on every install + and refuse untrusted configuration, set `MISE_PARANOID` in `containerEnv`: + + ```json + "containerEnv": { + "MISE_PARANOID": "1" + } + ``` + +- The project's `mise.toml` has to be trusted before mise reads it, and a rebuild + clears that trust. Set `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in + `containerEnv` to trust it up front. diff --git a/src/mise/README.md b/src/mise/README.md new file mode 100644 index 0000000..730209e --- /dev/null +++ b/src/mise/README.md @@ -0,0 +1,178 @@ + +# mise (mise) + +Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against the mise minisign key, with its tool installs and download cache kept on volumes. + +## Example Usage + +```json +"features": { + "ghcr.io/bare-devcontainer/features/mise:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | mise version to install. Use "latest" for the newest release, or an exact version such as "2026.9.10". | string | latest | + +The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) +is installed as `/usr/local/bin/mise`, and the directories mise installs tools into +and caches downloads in are placed on volumes the feature declares. + +## Requirements + +- A `linux-x64` or `linux-arm64` image. The glibc build is installed where the + image's glibc is new enough to run it, and the statically linked musl build + otherwise, so any Debian or Ubuntu release works. +- `wget` (or `curl`), `minisign`, `sha256sum` and a CA bundle, for the download and + the signature check. Any of those the image is missing are installed with + `apt-get`, so an image without them has to be Debian or Ubuntu based; an image + that already has them needs no package manager at all. + +## Usage + +The defaults install the newest release. To pin a release instead: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/mise:1": { + "version": "2026.9.10" + } +} +``` + +`version` accepts `latest` or an exact version such as `2026.9.10`. `latest` is +resolved at install time, so a rebuild picks up newer releases. + +Not every project needs this feature: on the +[`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) +image, mise is already there. The feature is for adding mise to any other image, such +as a language image that needs a second toolchain or a linter the project pins. + +## Installed software + +- mise, installed as `/usr/local/bin/mise`, owned by root. + +Nothing else: no tool is installed until the project asks for it. `mise install` +installs everything a project's `mise.toml` declares, and `mise exec @ +-- ` runs a one-off without declaring anything. To install the project's tools +when the container is created rather than on first use, run `mise install` from a +`postCreateCommand`. + +The shims directory is on `PATH`, so a tool resolves as soon as mise installs it, +for editors and other programs that do not load the shell configuration as well as +in the terminal. `mise activate` can be added to the shell configuration for the +[features shims do not cover](https://mise.jdx.dev/dev-tools/shims.html), such as +environment variables declared in `mise.toml`. + +## Tool installs and download cache + +mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in +`MISE_CACHE_DIR`. The feature declares a volume for each, so a rebuild neither +reinstalls the tools nor downloads them again, and points mise at them: + +```json +"containerEnv": { + "MISE_DATA_DIR": "/var/lib/mise", + "MISE_CACHE_DIR": "/var/cache/mise", + "PATH": "/var/lib/mise/shims:${PATH}" +}, +"mounts": [ + { + "source": "${devcontainerId}-mise-data", + "target": "/var/lib/mise", + "type": "volume" + }, + { + "source": "${devcontainerId}-mise-cache", + "target": "/var/cache/mise", + "type": "volume" + } +] +``` + +A feature's mount target is static and cannot reference the remote user's home +directory, so the volumes are mounted under `/var` and the environment variables +move the directories there. That keeps the mounts working whatever the image uses +as its remote user. The shims directory moves with `MISE_DATA_DIR`, which is why +`PATH` names it under `/var/lib/mise`. + +The volumes are seeded from the image on first use, ownership included. The Dev +Containers CLI renumbers the remote user to the host user's UID and GID on Linux, +but only chowns the home directory, so a directory owned by a build-time UID would +end up unreachable. The install script therefore creates a `mise` system group, +adds the remote user to it, and makes both directories group-writable and setgid: +group membership is recorded by name and is unaffected by the renumbering. + +One case is not covered. If an existing volume is reused after the host user's UID +changed, files written under the old UID keep their owner-only modes and mise cannot +replace them; clear them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this +removes the installed tools) or remove the volumes. Files created afterwards are +unaffected. + +The volumes are per dev container (`${devcontainerId}`) and are not shared between +projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`, +but `containerEnv` there takes precedence over a feature's, so setting +`MISE_DATA_DIR` or `MISE_CACHE_DIR` points mise somewhere else and leaves the volume +mounted and unused. `PATH` then needs the new shims directory as well. + +mise's configuration (`~/.config/mise`) and state, including which `mise.toml` +files have been trusted (`~/.local/state/mise`), stay in the remote user's home +directory and are recreated by a rebuild. + +## Not installed + +- **No language runtime or tool.** mise resolves the versions the project declares + in `mise.toml` or in idiomatic per-language files such as `.node-version`. +- **No development headers beyond libc.** A backend that downloads prebuilt + binaries works as-is; one that builds a runtime from source needs the `-dev` + packages of the libraries it links against. +- **No shell activation.** The shell configuration is left untouched; the shims + directory on `PATH` is what makes installed tools resolve. + +## Supply chain + +The binary is downloaded from `https://github.com/jdx/mise/releases/`, and its +checksum is verified against `SHASUMS256.txt`, whose minisign signature is verified +with mise's public key. The key is vendored with the feature and read from there, +so the signature is checked against a key reviewed in this repository rather than +one fetched at install time. It is a copy of +[`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise +repository, refreshed by this repository's `Update Trusted Material` workflow. + +To see the key before pinning the feature: + +```sh +cat src/mise/mise-minisign.pub +``` + +This covers the mise binary only. Tools that mise installs at runtime are fetched +from their own upstreams under mise's own verification, which +[`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. + +Everything downloaded during installation goes to a temporary directory that is +removed when the script exits. + +## Tips + +- For the tags this feature is published under, see + [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). +- To have mise re-verify the provenance of the tools it installs on every install + and refuse untrusted configuration, set `MISE_PARANOID` in `containerEnv`: + + ```json + "containerEnv": { + "MISE_PARANOID": "1" + } + ``` + +- The project's `mise.toml` has to be trusted before mise reads it, and a rebuild + clears that trust. Set `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in + `containerEnv` to trust it up front. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/bare-devcontainer/features/blob/main/src/mise/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/mise/devcontainer-feature.json b/src/mise/devcontainer-feature.json new file mode 100644 index 0000000..8e7d446 --- /dev/null +++ b/src/mise/devcontainer-feature.json @@ -0,0 +1,37 @@ +{ + "id": "mise", + "version": "1.0.0", + "name": "mise", + "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/mise", + "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against the mise minisign key, with its tool installs and download cache kept on volumes.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest" + ], + "default": "latest", + "description": "mise version to install. Use \"latest\" for the newest release, or an exact version such as \"2026.9.10\"." + } + }, + "containerEnv": { + "MISE_DATA_DIR": "/var/lib/mise", + "MISE_CACHE_DIR": "/var/cache/mise", + "PATH": "/var/lib/mise/shims:${PATH}" + }, + "mounts": [ + { + "source": "${devcontainerId}-mise-data", + "target": "/var/lib/mise", + "type": "volume" + }, + { + "source": "${devcontainerId}-mise-cache", + "target": "/var/cache/mise", + "type": "volume" + } + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/mise/install.sh b/src/mise/install.sh new file mode 100755 index 0000000..e729626 --- /dev/null +++ b/src/mise/install.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# +# Installs mise into /usr/local/bin from the official GitHub release, mirroring +# the setup of the ghcr.io/bare-devcontainer/mise image: the release binary is +# checked against SHASUMS256.txt, whose minisign signature is verified with the +# vendored mise public key. The directories mise installs tools into and caches +# downloads in are prepared for the remote user, at the paths the feature +# mounts volumes on and points MISE_DATA_DIR and MISE_CACHE_DIR at. +# +# Expected environment variables, from this feature's own options: +# +# VERSION mise version to install: "latest", or an exact version +# such as "2026.9.10". +# +# and from the Dev Container specification, injected by the CLI: +# +# _REMOTE_USER The account the container is attached as, and therefore +# the one mise installs tools as. + +set -euo pipefail + +VERSION="${VERSION:-latest}" + +FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUBLIC_KEY="${FEATURE_DIR}/mise-minisign.pub" +PREFIX="/usr/local" +RELEASES_URL="https://github.com/jdx/mise/releases" +# Coupled to containerEnv and mounts in devcontainer-feature.json. +DATA_DIR="/var/lib/mise" +CACHE_DIR="/var/cache/mise" +GROUP="mise" + +if [ "$(id -u)" -ne 0 ]; then + echo "(!) This feature must be installed as root." >&2 + exit 1 +fi + +# Every download lands here, so nothing the install fetches is left in the +# image, whichever path the script exits by. +tmpdir="$(mktemp -d)" +trap 'rm -rf "${tmpdir}"' EXIT + +# Debian package providing each command the installation needs. +package_for() { + case "$1" in + wget) echo "wget" ;; + minisign) echo "minisign" ;; + sha256sum) echo "coreutils" ;; + *) echo "$1" ;; + esac +} + +# Installs whatever the base image is missing, and nothing it already has. +install_prerequisites() { + local required=(minisign sha256sum) missing=() cmd + + # Either downloader will do, so one is only pulled in when neither is there. + if ! command -v curl >/dev/null 2>&1; then + required+=(wget) + fi + + for cmd in "${required[@]}"; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + missing+=("$(package_for "${cmd}")") + fi + done + + # HTTPS access to github.com needs a CA bundle. + if [ ! -e /etc/ssl/certs/ca-certificates.crt ]; then + missing+=(ca-certificates) + fi + + if [ "${#missing[@]}" -eq 0 ]; then + return + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "(!) Missing prerequisites and no apt-get to install them with: ${missing[*]}" >&2 + exit 1 + fi + + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y --no-install-recommends "${missing[@]}" + rm -rf /var/lib/apt/lists/* +} + +download() { + local url="$1" destination="$2" + + if command -v wget >/dev/null 2>&1; then + wget -q -T 30 -t 3 -O "${destination}" "${url}" + else + curl -fsSL --connect-timeout 30 --retry 3 -o "${destination}" "${url}" + fi +} + +# Prints the Location header a URL redirects to, without following it. +redirect_target() { + local url="$1" + + if command -v wget >/dev/null 2>&1; then + # wget treats the redirect it was told not to follow as a failure, but + # has printed the response headers by then. + (wget -q -T 30 -t 3 --max-redirect=0 -S -O /dev/null "${url}" 2>&1 || true) \ + | sed -n 's/^ *Location: *//p' | head -n1 + else + curl -fsSI --connect-timeout 30 --retry 3 -o /dev/null -w '%{redirect_url}' "${url}" + fi +} + +# Turns the requested version into the exact "vYYYY.M.N" the release is +# published under. "latest" is resolved through the redirect GitHub serves for +# a repository's latest release, which lands on that release's tag page. +resolve_version() { + local requested="${1#v}" location resolved + + if [[ "${requested}" =~ ^[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then + echo "v${requested}" + return + fi + + if [ "${requested}" != "latest" ]; then + echo "(!) Unrecognised version '$1'. Use \"latest\" or an exact version such as \"2026.9.10\"." >&2 + exit 1 + fi + + location="$(redirect_target "${RELEASES_URL}/latest")" + resolved="${location##*/tag/}" + + if [ -z "${location}" ] || [[ ! "${resolved}" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then + echo "(!) Could not resolve the latest mise release from ${RELEASES_URL}/latest (got '${location}')." >&2 + exit 1 + fi + + echo "${resolved}" +} + +mise_arch() { + case "$(uname -m)" in + x86_64) echo "x64" ;; + aarch64 | arm64) echo "arm64" ;; + *) + echo "(!) Unsupported architecture: $(uname -m)." >&2 + exit 1 + ;; + esac +} + +# Downloads the named release binary, checks it against the verified +# SHASUMS256.txt and installs it as ${PREFIX}/bin/mise. +install_binary() { + local binary="$1" + + download "${RELEASES_URL}/download/${mise_version}/${binary}" "${tmpdir}/${binary}" + + # SHASUMS256.txt names each file as ./, so the entry is rewritten to + # point at the copy just downloaded. + grep " \\./${binary}\$" "${tmpdir}/SHASUMS256.txt" \ + | sed "s| \\./${binary}\$| ${tmpdir}/${binary}|" \ + | sha256sum -c - + + install -m 755 "${tmpdir}/${binary}" "${PREFIX}/bin/mise" +} + +# Runs the installed mise once, with every directory it might write to pointed +# into the temporary directory so the check leaves nothing behind. +mise_runs() { + env HOME="${tmpdir}/home" \ + MISE_DATA_DIR="${tmpdir}/home/data" \ + MISE_CACHE_DIR="${tmpdir}/home/cache" \ + MISE_CONFIG_DIR="${tmpdir}/home/config" \ + MISE_STATE_DIR="${tmpdir}/home/state" \ + "${PREFIX}/bin/mise" --version +} + +install_prerequisites + +mise_version="$(resolve_version "${VERSION}")" +arch="$(mise_arch)" + +echo "Installing mise ${mise_version} (linux-${arch}) into ${PREFIX}/bin..." +download "${RELEASES_URL}/download/${mise_version}/SHASUMS256.txt" "${tmpdir}/SHASUMS256.txt" +download "${RELEASES_URL}/download/${mise_version}/SHASUMS256.txt.minisig" "${tmpdir}/SHASUMS256.txt.minisig" + +# The checksums are signed with mise's minisign key. The public key is vendored +# with the feature, so the signature is checked against the key reviewed in +# this repository rather than one fetched at install time. +minisign -V -m "${tmpdir}/SHASUMS256.txt" -p "${PUBLIC_KEY}" + +# The glibc build is what upstream's installer picks on a glibc image, but a +# release can require a newer glibc than the image provides, in which case it +# fails to start at all. The statically linked musl build runs on any image, so +# it is installed in that case, verified against the same signed checksums. +install_binary "mise-${mise_version}-linux-${arch}" +if ! output="$(mise_runs 2>&1)"; then + echo "(*) The glibc build of mise ${mise_version} does not run on this image; installing the musl build instead." >&2 + echo "${output}" | sed 's/^/ /' >&2 + install_binary "mise-${mise_version}-linux-${arch}-musl" + mise_runs >/dev/null +fi + +username="${_REMOTE_USER:-root}" +if ! getent passwd "${username}" >/dev/null; then + echo "(!) Remote user '${username}' was not found in the password database." >&2 + exit 1 +fi + +# The volumes are seeded from these directories on first use, ownership +# included, but the Dev Containers CLI renumbers the remote user to the host +# user's UID without touching anything outside the home directory. A dedicated +# group keeps the directories writable through that: membership is recorded by +# name, and the setgid bit keeps entries created later in the group. +if ! getent group "${GROUP}" >/dev/null; then + groupadd --system "${GROUP}" +fi +usermod -aG "${GROUP}" "${username}" + +mkdir -p "${DATA_DIR}" "${CACHE_DIR}" +chown "${username}:${GROUP}" "${DATA_DIR}" "${CACHE_DIR}" +chmod 2775 "${DATA_DIR}" "${CACHE_DIR}" + +echo "Installed mise $("${PREFIX}/bin/mise" --version)." diff --git a/src/mise/mise-minisign.pub b/src/mise/mise-minisign.pub new file mode 100644 index 0000000..d76941d --- /dev/null +++ b/src/mise/mise-minisign.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 64113EDF160FDEC2 +RWTC3g8W3z4RZK3V3qv7fa1QY4JEWyBtqIHW+85QlJpZc5yG+uNYNBSZ diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh new file mode 100755 index 0000000..0d16ad8 --- /dev/null +++ b/src/mise/smoke_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +failures=0 + +# Runs a command and records the outcome instead of aborting, so a single run +# reports every broken check rather than only the first one. +check() { + local label="$1" + shift + + local output + local status=0 + # The `||` keeps the assignment out of `set -e`'s reach, which would + # otherwise abort the whole run on the first failing check. + output="$("$@" 2>&1)" || status=$? + + if [ "${status}" -eq 0 ]; then + echo "ok - ${label}" + else + echo "FAIL - ${label} (exit ${status})" + if [ -n "${output}" ]; then + echo "${output}" | sed 's/^/ /' + fi + failures=$((failures + 1)) + fi +} + +check "mise is on PATH" command -v mise +check "mise reports a version" mise --version +check "mise is installed under /usr/local" \ + bash -c 'test "$(command -v mise)" = /usr/local/bin/mise' +check "the installed binary is owned by root" \ + bash -c 'test "$(stat -c %u /usr/local/bin/mise)" = 0' +check "MISE_DATA_DIR points at the mounted volume" \ + bash -c 'test "${MISE_DATA_DIR:-}" = /var/lib/mise' +check "MISE_CACHE_DIR points at the mounted volume" \ + bash -c 'test "${MISE_CACHE_DIR:-}" = /var/cache/mise' +check "the shims directory is on PATH" \ + bash -c 'tr ":" "\n" <<< "${PATH}" | grep -qx /var/lib/mise/shims' +check "remote user belongs to the mise group" \ + bash -c 'id -nG | tr " " "\n" | grep -qx mise' +check "data and cache directories are group-writable and setgid" \ + bash -c 'test "$(stat -c "%A %G" /var/lib/mise)" = "drwxrwsr-x mise" + test "$(stat -c "%A %G" /var/cache/mise)" = "drwxrwsr-x mise"' +check "data and cache directories are writable by the remote user" \ + bash -c 'touch /var/lib/mise/.write-test /var/cache/mise/.write-test + rm /var/lib/mise/.write-test /var/cache/mise/.write-test' +check "files created there are owned by the remote user" \ + bash -c 'touch /var/lib/mise/.owner-test + owner="$(stat -c %u /var/lib/mise/.owner-test)" + rm /var/lib/mise/.owner-test + test "${owner}" = "$(id -u)"' +check "mise installs and runs a tool" \ + mise exec python@3 -- python -c 'print("Hello, world!")' +check "the tool was installed into the data directory" \ + bash -c 'test -d /var/lib/mise/installs/python' +check "the tool's shim was written to the shims directory" \ + bash -c 'test -x /var/lib/mise/shims/python' + +if [ "${failures}" -ne 0 ]; then + echo "${failures} check(s) failed." + exit 1 +fi + +echo "All checks passed." From 990a943eb0103d57419040a8958b2ab43d7cefb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:32:13 +0000 Subject: [PATCH 02/16] feature(mise): verify the download against committed checksums 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- .github/workflows/ci.yml | 21 +++++ README.md | 18 ++++- scripts/pin-checksums.sh | 69 +++++++++++++++++ scripts/signed-material.json | 9 +++ scripts/update-material.sh | 8 ++ scripts/verify-material.sh | 35 +++++++++ src/mise/NOTES.md | 68 ++++++++++++----- src/mise/README.md | 74 ++++++++++++------ src/mise/SHASUMS256.txt | 36 +++++++++ src/mise/SHASUMS256.txt.minisig | 4 + src/mise/devcontainer-feature.json | 12 +-- src/mise/install.sh | 118 +++++++++++++---------------- 12 files changed, 352 insertions(+), 120 deletions(-) create mode 100755 scripts/pin-checksums.sh create mode 100644 scripts/signed-material.json create mode 100755 scripts/verify-material.sh create mode 100644 src/mise/SHASUMS256.txt create mode 100644 src/mise/SHASUMS256.txt.minisig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2678470..11c884c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,27 @@ defaults: shell: bash -euo pipefail {0} jobs: + # The install scripts verify their downloads against checksums committed to + # this repository, so that nothing has to be installed into a container just + # to check a signature. Proving those checksums are upstream's is this job. + verify-material: + name: Verify vendored checksums + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install minisign + run: | + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends minisign + + - name: Verify vendored checksums + run: bash scripts/verify-material.sh + discover: name: Discover features runs-on: ubuntu-latest diff --git a/README.md b/README.md index 22fe7d8..cdc6138 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ signing material committed to this repository, and requires no other Feature. | Feature | What you get | Source and verification | |---------|--------------|-------------------------| | [claude-code](src/claude-code)
`ghcr.io/bare-devcontainer/features/claude-code` | Claude Code, Anthropic's agentic coding CLI, with the VS Code extension requested alongside it. Credentials, settings and history live on a volume, so a rebuild does not mean signing in again. | Anthropic's APT repository. The package signature is checked with a key committed to this repository. | +| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself. The tools it installs and its download cache live on volumes, so a rebuild does not reinstall them. | [GitHub Releases](https://github.com/jdx/mise/releases). Upstream's signed checksum file is committed here, so the install verifies the download with `sha256sum` alone and CI checks that file's minisign signature. | | [node](src/node)
`ghcr.io/bare-devcontainer/features/node` | Node.js, with Corepack in place of npm, so the package manager comes from the project's `packageManager` field rather than from the image. | [nodejs.org](https://nodejs.org/dist/). The release checksums are checked against the Node.js release keys committed to this repository. | Each Feature's README documents its options, requirements, what it deliberately leaves @@ -97,14 +98,25 @@ no key is fetched while the container is built, and no other Feature's install s runs on its behalf. **What you can check before pinning.** Every input is a file in this repository: one -install script per Feature, and the signing material sitting next to it. Each Feature's -README shows how to inspect its key. +install script per Feature, and the signing material sitting next to it — a signing key, or +upstream's own checksum file with the signature it published for it. Each Feature's README +shows how to inspect what it verifies against. + +**Where verification happens.** Wherever it costs a container nothing. A Feature whose +upstream signs its checksums ships those checksums rather than the tooling to check a +signature, so the install verifies the download with `sha256sum` and installs no package in +order to do it; the `CI` workflow verifies the committed checksums against upstream's +signature on every change instead +([`scripts/verify-material.sh`](scripts/verify-material.sh)). **How the trusted material changes.** Only through a pull request. The `Update Trusted Material` workflow ([`.github/workflows/update-material.yml`](.github/workflows/update-material.yml)) checks upstream weekly and opens one when a key has changed, so every rotation is visible in -the history of this repository rather than picked up silently at build time. +the history of this repository rather than picked up silently at build time. Vendored +release checksums move only when the release they pin does, through +[`scripts/pin-checksums.sh`](scripts/pin-checksums.sh), which writes what it downloaded +only once upstream's signature over it verifies. **What is not covered.** The upstream projects themselves are still trusted: verification proves a download came from them unaltered, not that what they published is sound. These diff --git a/scripts/pin-checksums.sh b/scripts/pin-checksums.sh new file mode 100755 index 0000000..04194e8 --- /dev/null +++ b/scripts/pin-checksums.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# pin-checksums.sh — re-pin a feature's vendored release checksums to a named +# upstream release. Downloads the checksum file and the signature upstream +# published for it, and writes both into the working tree only once the +# signature verifies against the vendored public key, so a failed, truncated or +# tampered download cannot land in the repository. Performs no git operations. +# +# Which file belongs to which feature is listed in signed-material.json next to +# this script. The release is named rather than looked up, so what gets pinned +# is always what the caller asked for. +# +# Bumping a feature's mise version is this script plus a version bump in the +# feature's devcontainer-feature.json, since the checksums decide which release +# the feature installs. +# +# Requires: minisign, jq, wget +# +# Usage: +# pin-checksums.sh +# +# Example: +# pin-checksums.sh mise v2026.9.11 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MATERIAL="${SCRIPT_DIR}/signed-material.json" + +if [ "$#" -ne 2 ]; then + sed -n '/^# Usage:/,/^# *pin-checksums.sh /p' "${BASH_SOURCE[0]}" >&2 + exit 2 +fi +feature="$1" +tag="$2" + +entry="$(jq -e --arg feature "$feature" '.[] | select(.feature == $feature)' "$MATERIAL")" || { + echo "(!) ${MATERIAL} has no entry for '${feature}'. Known: $(jq -r '[.[].feature] | join(", ")' "$MATERIAL")" >&2 + exit 1 +} + +repository="$(jq -r '.repository' <<< "$entry")" +checksums="$(jq -r '.checksums' <<< "$entry")" +signature="$(jq -r '.signature' <<< "$entry")" +key="$(jq -r '.key' <<< "$entry")" +releases="https://github.com/${repository}/releases/download/${tag}" + +tmp_checksums=$(mktemp) +tmp_signature=$(mktemp) +trap 'rm -f "$tmp_checksums" "$tmp_signature"' EXIT + +echo "Downloading ${repository} ${tag} checksums" >&2 +wget -q -T 30 -t 3 -O "$tmp_checksums" "${releases}/$(basename "$checksums")" +wget -q -T 30 -t 3 -O "$tmp_signature" "${releases}/$(basename "$signature")" + +echo "Verifying the signature against ${key}" >&2 +minisign -V -m "$tmp_checksums" -x "$tmp_signature" -p "$key" + +if cmp -s "$tmp_checksums" "$checksums" && cmp -s "$tmp_signature" "$signature"; then + echo "${checksums} already pins ${tag}; nothing to do." >&2 + exit 0 +fi + +chmod 644 "$tmp_checksums" "$tmp_signature" +# Moved out of the trap's reach, since the files are now the working tree's. +mv "$tmp_checksums" "$checksums" +mv "$tmp_signature" "$signature" +trap - EXIT + +echo "Pinned ${checksums} to ${tag}. Bump the feature version to publish it." >&2 diff --git a/scripts/signed-material.json b/scripts/signed-material.json new file mode 100644 index 0000000..b76b1b7 --- /dev/null +++ b/scripts/signed-material.json @@ -0,0 +1,9 @@ +[ + { + "feature": "mise", + "repository": "jdx/mise", + "checksums": "src/mise/SHASUMS256.txt", + "signature": "src/mise/SHASUMS256.txt.minisig", + "key": "src/mise/mise-minisign.pub" + } +] diff --git a/scripts/update-material.sh b/scripts/update-material.sh index 807a4d3..0e92fa2 100755 --- a/scripts/update-material.sh +++ b/scripts/update-material.sh @@ -5,6 +5,10 @@ # to stdout depending on whether anything changed; per-file download and # comparison progress goes to stderr. Performs no git or GitHub operations. # +# Covers material published at a fixed URL, which is every signing key. The +# release checksums pinned per feature move with a release rather than on their +# own, so they are re-pinned by pin-checksums.sh instead. +# # Usage: # update-material.sh set -euo pipefail @@ -15,6 +19,10 @@ MATERIALS='[ "path": "src/claude-code/claude-code.asc", "url": "https://downloads.claude.ai/keys/claude-code.asc" }, + { + "path": "src/mise/mise-minisign.pub", + "url": "https://raw.githubusercontent.com/jdx/mise/main/minisign.pub" + }, { "path": "src/node/node-keyring.kbx", "url": "https://github.com/nodejs/release-keys/raw/HEAD/gpg/pubring.kbx" diff --git a/scripts/verify-material.sh b/scripts/verify-material.sh new file mode 100755 index 0000000..e7bb69c --- /dev/null +++ b/scripts/verify-material.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# verify-material.sh — check that the release checksums a feature's install +# script verifies downloads against are the ones upstream signed. Reads the +# working tree only: it downloads nothing, writes nothing and needs no network, +# so what it proves holds for whatever the repository currently has committed. +# Exits non-zero on the first file that does not verify. +# +# The features it covers, and the signature and key for each, are listed in +# signed-material.json next to this script; pin-checksums.sh writes the files +# this reads. +# +# Requires: minisign, jq +# +# Usage: +# verify-material.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MATERIAL="${SCRIPT_DIR}/signed-material.json" + +count=0 +while IFS=$'\t' read -r feature checksums signature key; do + echo "${feature}: verifying ${checksums} against ${signature} with ${key}" >&2 + minisign -V -m "$checksums" -x "$signature" -p "$key" + count=$((count + 1)) +done < <(jq -r '.[] | [.feature, .checksums, .signature, .key] | @tsv' "$MATERIAL") + +# An empty table would otherwise report success without having checked anything. +if [ "$count" -eq 0 ]; then + echo "(!) ${MATERIAL} lists no material to verify." >&2 + exit 1 +fi + +echo "Verified vendored checksums for ${count} feature(s)." >&2 diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 85ba0f3..368e8f7 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -7,25 +7,36 @@ and caches downloads in are placed on volumes the feature declares. - A `linux-x64` or `linux-arm64` image. The glibc build is installed where the image's glibc is new enough to run it, and the statically linked musl build otherwise, so any Debian or Ubuntu release works. -- `wget` (or `curl`), `minisign`, `sha256sum` and a CA bundle, for the download and - the signature check. Any of those the image is missing are installed with - `apt-get`, so an image without them has to be Debian or Ubuntu based; an image - that already has them needs no package manager at all. +- `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum + check. Any of those the image is missing are installed with `apt-get`, so an image + without them has to be Debian or Ubuntu based; an image that already has them + needs no package manager at all, and in practice that is every image with a + downloader. Nothing is installed in order to verify the download — see + [Supply chain](#supply-chain). ## Usage -The defaults install the newest release. To pin a release instead: +The feature takes no options: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1": { - "version": "2026.9.10" - } + "ghcr.io/bare-devcontainer/features/mise:1": {} +} +``` + +Which mise release gets installed is decided by the checksums committed with the +feature, so the feature's own version is what selects it. Pin the reference to hold +a release, and read the release notes to see which mise a version carries: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/mise:1.0.0": {} } ``` -`version` accepts `latest` or an exact version such as `2026.9.10`. `latest` is -resolved at install time, so a rebuild picks up newer releases. +A shorter tag such as `:1` resolves to the newest release when the container is +built, so it picks up newer mise releases on a rebuild. See +[Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). Not every project needs this feature: on the [`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) @@ -115,20 +126,43 @@ directory and are recreated by a rebuild. ## Supply chain -The binary is downloaded from `https://github.com/jdx/mise/releases/`, and its -checksum is verified against `SHASUMS256.txt`, whose minisign signature is verified -with mise's public key. The key is vendored with the feature and read from there, -so the signature is checked against a key reviewed in this repository rather than -one fetched at install time. It is a copy of +The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked +against `SHASUMS256.txt`, which is vendored with the feature. That file is +upstream's own checksum file, committed verbatim together with the minisign +signature upstream published for it, so the expected hash of every release binary +is a reviewed line in this repository rather than something fetched while the +container is built. + +Checking the download therefore needs `sha256sum` and nothing else. The signature +is verified where the tooling for it is free rather than in the container: the +`CI` workflow runs +[`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) +on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` +using the vendored public key. A hand-edited checksum fails that job, and an +install can never be talked into skipping it, because the install has no signature +step to skip. + +The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise -repository, refreshed by this repository's `Update Trusted Material` workflow. +repository, refreshed by this repository's `Update Trusted Material` workflow. The +checksums move only when the pinned release does: +[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh) +re-pins them to a named release and writes what it downloaded only once the signature +verifies, so a newer mise reaches this feature through a pull request that bumps the +feature version alongside the checksums. -To see the key before pinning the feature: +To check the material yourself before pinning the feature: ```sh cat src/mise/mise-minisign.pub +minisign -V -m src/mise/SHASUMS256.txt \ + -x src/mise/SHASUMS256.txt.minisig \ + -p src/mise/mise-minisign.pub ``` +Because the checksums cover one release, the version they pin is the version the +feature installs; `grep linux-x64 src/mise/SHASUMS256.txt` shows which. + This covers the mise binary only. Tools that mise installs at runtime are fetched from their own upstreams under mise's own verification, which [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. diff --git a/src/mise/README.md b/src/mise/README.md index 730209e..60d6bc5 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -1,7 +1,7 @@ # mise (mise) -Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against the mise minisign key, with its tool installs and download cache kept on volumes. +Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its tool installs and download cache kept on volumes. ## Example Usage @@ -11,11 +11,7 @@ Installs mise, the polyglot tool and runtime manager, from its GitHub release, v } ``` -## Options -| Options Id | Description | Type | Default Value | -|-----|-----|-----|-----| -| version | mise version to install. Use "latest" for the newest release, or an exact version such as "2026.9.10". | string | latest | The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) is installed as `/usr/local/bin/mise`, and the directories mise installs tools into @@ -26,25 +22,36 @@ and caches downloads in are placed on volumes the feature declares. - A `linux-x64` or `linux-arm64` image. The glibc build is installed where the image's glibc is new enough to run it, and the statically linked musl build otherwise, so any Debian or Ubuntu release works. -- `wget` (or `curl`), `minisign`, `sha256sum` and a CA bundle, for the download and - the signature check. Any of those the image is missing are installed with - `apt-get`, so an image without them has to be Debian or Ubuntu based; an image - that already has them needs no package manager at all. +- `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum + check. Any of those the image is missing are installed with `apt-get`, so an image + without them has to be Debian or Ubuntu based; an image that already has them + needs no package manager at all, and in practice that is every image with a + downloader. Nothing is installed in order to verify the download — see + [Supply chain](#supply-chain). ## Usage -The defaults install the newest release. To pin a release instead: +The feature takes no options: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1": { - "version": "2026.9.10" - } + "ghcr.io/bare-devcontainer/features/mise:1": {} } ``` -`version` accepts `latest` or an exact version such as `2026.9.10`. `latest` is -resolved at install time, so a rebuild picks up newer releases. +Which mise release gets installed is decided by the checksums committed with the +feature, so the feature's own version is what selects it. Pin the reference to hold +a release, and read the release notes to see which mise a version carries: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/mise:1.0.0": {} +} +``` + +A shorter tag such as `:1` resolves to the newest release when the container is +built, so it picks up newer mise releases on a rebuild. See +[Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). Not every project needs this feature: on the [`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) @@ -134,20 +141,43 @@ directory and are recreated by a rebuild. ## Supply chain -The binary is downloaded from `https://github.com/jdx/mise/releases/`, and its -checksum is verified against `SHASUMS256.txt`, whose minisign signature is verified -with mise's public key. The key is vendored with the feature and read from there, -so the signature is checked against a key reviewed in this repository rather than -one fetched at install time. It is a copy of +The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked +against `SHASUMS256.txt`, which is vendored with the feature. That file is +upstream's own checksum file, committed verbatim together with the minisign +signature upstream published for it, so the expected hash of every release binary +is a reviewed line in this repository rather than something fetched while the +container is built. + +Checking the download therefore needs `sha256sum` and nothing else. The signature +is verified where the tooling for it is free rather than in the container: the +`CI` workflow runs +[`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) +on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` +using the vendored public key. A hand-edited checksum fails that job, and an +install can never be talked into skipping it, because the install has no signature +step to skip. + +The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise -repository, refreshed by this repository's `Update Trusted Material` workflow. +repository, refreshed by this repository's `Update Trusted Material` workflow. The +checksums move only when the pinned release does: +[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh) +re-pins them to a named release and writes what it downloaded only once the signature +verifies, so a newer mise reaches this feature through a pull request that bumps the +feature version alongside the checksums. -To see the key before pinning the feature: +To check the material yourself before pinning the feature: ```sh cat src/mise/mise-minisign.pub +minisign -V -m src/mise/SHASUMS256.txt \ + -x src/mise/SHASUMS256.txt.minisig \ + -p src/mise/mise-minisign.pub ``` +Because the checksums cover one release, the version they pin is the version the +feature installs; `grep linux-x64 src/mise/SHASUMS256.txt` shows which. + This covers the mise binary only. Tools that mise installs at runtime are fetched from their own upstreams under mise's own verification, which [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. diff --git a/src/mise/SHASUMS256.txt b/src/mise/SHASUMS256.txt new file mode 100644 index 0000000..642984d --- /dev/null +++ b/src/mise/SHASUMS256.txt @@ -0,0 +1,36 @@ +1b46a14314c18f9bbce4bf6f88cc1fb3b31be8dd2b23327a851555f4e144fc61 ./mise-v2026.9.10-linux-arm64 +c78bfdebe1e3523eebd386f6dd07af5a1e8a9e39582b05f4a6cd8d1afcd0c82f ./mise-v2026.9.10-linux-arm64-musl +f96a6fb39eea42d565bb2c622ff89b0568ea73cd3d09418a383955457a962505 ./mise-v2026.9.10-linux-arm64-musl.tar.gz +5c7cee4e1ba20194d34776856fb515e5be14f04aefa0d946736f50a5a0ced94c ./mise-v2026.9.10-linux-arm64-musl.tar.xz +cedd7f6ef0e12f40f1b1fcf6c3375d6f6d726995f55e98fb6abd4d3f4b9a2183 ./mise-v2026.9.10-linux-arm64-musl.tar.zst +efbfab6beaac07f933b63a95cb8746bbb7b2d48b70f636c19eb03a38f5786361 ./mise-v2026.9.10-linux-arm64.tar.gz +21697d5388315a5271cd7e3b56e12b1a3603796640f8f5760b3e7698d18a49f8 ./mise-v2026.9.10-linux-arm64.tar.xz +911bc64be0ae9591bea0e01bb3e69a4249491c6b439a3b0f9eb3227003961394 ./mise-v2026.9.10-linux-arm64.tar.zst +c59b3ea53648cf0fb0a04bbe5253b78eff0f5f13bca2a0e8de38cc8b70eb5117 ./mise-v2026.9.10-linux-armv7 +233b494da2d7e69c093101b658eb08beca949e35e44b6f755b3a5cc7599d9f1b ./mise-v2026.9.10-linux-armv7-musl +c509939e2cc10d7f43b6d593a3e53dbce4c7e7b203b64c9d821bb45144c0d6c9 ./mise-v2026.9.10-linux-armv7-musl.tar.gz +2d3c3fc218e982f6009c8b1689c00e46dc735ba00fc625018e2312b8704ec27f ./mise-v2026.9.10-linux-armv7-musl.tar.xz +22579c3507037f4810d0acedb9d079326073449234e8629f62decd2ba01f1ecf ./mise-v2026.9.10-linux-armv7-musl.tar.zst +08c5f17eec5f4d74d450ed60e834e384600570707face6c91454ce60c8f1bd3e ./mise-v2026.9.10-linux-armv7.tar.gz +193812b887b1bb5f65c41e77e938c56dcaa2142ad3c72210435a753d0859029d ./mise-v2026.9.10-linux-armv7.tar.xz +ef43add9fb6cc1c7d21a9856165802b61effd7af5c975969c70091c155b5ad5b ./mise-v2026.9.10-linux-armv7.tar.zst +f917e52216924ef0a8b4eca3f7004dfcff3b94665716ac5685fd53006a491eee ./mise-v2026.9.10-linux-x64 +cf2f561e9195f035367c75cfce794a75ce6f8595cf4b4d7d398b891aafac0b8d ./mise-v2026.9.10-linux-x64-musl +33b553909fcbe41f962daed66527fe5753980c126d44a7135f7a07de306465aa ./mise-v2026.9.10-linux-x64-musl.tar.gz +2ac22d8b516ce1c4de6b2c0d4310d45759a2fb25574b5792352cc659b995cebe ./mise-v2026.9.10-linux-x64-musl.tar.xz +e0b65cec82f44d634034e3ec395392b8f17dccab18ca0fce94a0c5b1e67c6794 ./mise-v2026.9.10-linux-x64-musl.tar.zst +cf6c0d4713932cf47da67f4f753348bc1ccf9a22d4d1e3c76d3c23a6187a853c ./mise-v2026.9.10-linux-x64.tar.gz +c5e4b03cb8266d3114e1c9322924608652fbb5bbcc3347928dbe86cd8b2fa654 ./mise-v2026.9.10-linux-x64.tar.xz +dc61935d7db50f08c8a61867a3b35ab71ef1a0f02be01789d28ae43fcb5a0a38 ./mise-v2026.9.10-linux-x64.tar.zst +2d1b5a2a210c172af7d58b017478e0b5d5cdea541916e80a495cd078a92d8706 ./mise-v2026.9.10-macos-arm64 +7d0c48e10a46a9cd6cf466303827cec34af6e5ed526c34d9348944eca9bcec99 ./mise-v2026.9.10-macos-arm64.tar.gz +354e189c82353ad840528a6a724d35a41f9ae61033340ab8461e9d46b2f38022 ./mise-v2026.9.10-macos-arm64.tar.xz +98c511c364f0806d6cb47225998ed5a197df5738f935b7bbb04c16e1a301d1e6 ./mise-v2026.9.10-macos-arm64.tar.zst +9a566ce20e984abaf8b02ecca56458f864919fbc979720839956465a5cd28900 ./mise-v2026.9.10-macos-x64 +6704c8986e60d1b03e3b2e95e88bc24b20e94bb762f26d6ec88755115638fc53 ./mise-v2026.9.10-macos-x64.tar.gz +1d7fab23f420c141ed1ca6fe893bfb7c2ed34c2a57cd061750339a888d7e263c ./mise-v2026.9.10-macos-x64.tar.xz +597a6d196887ef3c2ca3864e033d3518559d5dd38a9b7b6d429a26f6f879520f ./mise-v2026.9.10-macos-x64.tar.zst +f81de03d8fd293f9401ed083c8b673bfb00404494d11e25fc74cba8224844e77 ./mise-v2026.9.10-windows-arm64.exe +384c027c2814691eac8d5d45557b089518f7226ba7296eba509522256dc098ce ./mise-v2026.9.10-windows-arm64.zip +98f2b199c6b283547b66c8ab6d721aab977ff5e084f0e034b71596c7d2aba2e1 ./mise-v2026.9.10-windows-x64.exe +ad0ec08cfcf7614f30e0cfb3b403fa09454389d982d6c41368fb1ba50a08e835 ./mise-v2026.9.10-windows-x64.zip diff --git a/src/mise/SHASUMS256.txt.minisig b/src/mise/SHASUMS256.txt.minisig new file mode 100644 index 0000000..341771b --- /dev/null +++ b/src/mise/SHASUMS256.txt.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUTC3g8W3z4RZEsrHPKJlKspVrnB0OP6RsEwRkyBgzg28IPH0knKKBNl0KRnJUqbKvX71lvog2YH1yJpnKxOlUtd1DGEbc1a3QA= +trusted comment: timestamp:1789579033 file:SHASUMS256.txt hashed +gDg10N17Z+EiQ/49KggtXzTtqnrfStJ7kMl9iYTdcjdv24ztN46uUDrHMBxeutiMz3OFTKF6UDM3Y+4jD6zEAg== diff --git a/src/mise/devcontainer-feature.json b/src/mise/devcontainer-feature.json index 8e7d446..fcbfb05 100644 --- a/src/mise/devcontainer-feature.json +++ b/src/mise/devcontainer-feature.json @@ -3,17 +3,7 @@ "version": "1.0.0", "name": "mise", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/mise", - "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against the mise minisign key, with its tool installs and download cache kept on volumes.", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest" - ], - "default": "latest", - "description": "mise version to install. Use \"latest\" for the newest release, or an exact version such as \"2026.9.10\"." - } - }, + "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its tool installs and download cache kept on volumes.", "containerEnv": { "MISE_DATA_DIR": "/var/lib/mise", "MISE_CACHE_DIR": "/var/cache/mise", diff --git a/src/mise/install.sh b/src/mise/install.sh index e729626..b19deec 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -1,28 +1,32 @@ #!/usr/bin/env bash # -# Installs mise into /usr/local/bin from the official GitHub release, mirroring -# the setup of the ghcr.io/bare-devcontainer/mise image: the release binary is -# checked against SHASUMS256.txt, whose minisign signature is verified with the -# vendored mise public key. The directories mise installs tools into and caches -# downloads in are prepared for the remote user, at the paths the feature -# mounts volumes on and points MISE_DATA_DIR and MISE_CACHE_DIR at. +# Installs mise into /usr/local/bin from the official GitHub release. Both the +# version to install and the checksum to expect come from SHASUMS256.txt, +# vendored with the feature, so the download is verified with sha256sum alone +# and no signature tooling is installed into the container to check it. # -# Expected environment variables, from this feature's own options: +# That file's authenticity is established in this repository rather than at +# install time: it is upstream's own checksum file, committed together with the +# minisign signature upstream published for it, and scripts/verify-material.sh +# checks the two against the vendored mise public key on every change. # -# VERSION mise version to install: "latest", or an exact version -# such as "2026.9.10". +# The directories mise installs tools into and caches downloads in are prepared +# for the remote user, at the paths the feature mounts volumes on and points +# MISE_DATA_DIR and MISE_CACHE_DIR at. # -# and from the Dev Container specification, injected by the CLI: +# This feature has no options: the vendored checksums cover one mise release, +# so the feature's own version is what selects which mise gets installed. +# +# Expected environment variables, from the Dev Container specification, +# injected by the CLI: # # _REMOTE_USER The account the container is attached as, and therefore # the one mise installs tools as. set -euo pipefail -VERSION="${VERSION:-latest}" - FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PUBLIC_KEY="${FEATURE_DIR}/mise-minisign.pub" +SHASUMS="${FEATURE_DIR}/SHASUMS256.txt" PREFIX="/usr/local" RELEASES_URL="https://github.com/jdx/mise/releases" # Coupled to containerEnv and mounts in devcontainer-feature.json. @@ -35,6 +39,11 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +if [ ! -f "${SHASUMS}" ]; then + echo "(!) ${SHASUMS} is missing; the feature cannot verify a download without it." >&2 + exit 1 +fi + # Every download lands here, so nothing the install fetches is left in the # image, whichever path the script exits by. tmpdir="$(mktemp -d)" @@ -44,7 +53,6 @@ trap 'rm -rf "${tmpdir}"' EXIT package_for() { case "$1" in wget) echo "wget" ;; - minisign) echo "minisign" ;; sha256sum) echo "coreutils" ;; *) echo "$1" ;; esac @@ -52,7 +60,7 @@ package_for() { # Installs whatever the base image is missing, and nothing it already has. install_prerequisites() { - local required=(minisign sha256sum) missing=() cmd + local required=(sha256sum) missing=() cmd # Either downloader will do, so one is only pulled in when neither is there. if ! command -v curl >/dev/null 2>&1; then @@ -95,45 +103,23 @@ download() { fi } -# Prints the Location header a URL redirects to, without following it. -redirect_target() { - local url="$1" +# Reads the release to install out of the vendored checksums, which are the +# only place it is recorded: every entry names the release it belongs to, so +# the version cannot drift from the checksums the download is verified against. +pinned_version() { + local versions count - if command -v wget >/dev/null 2>&1; then - # wget treats the redirect it was told not to follow as a failure, but - # has printed the response headers by then. - (wget -q -T 30 -t 3 --max-redirect=0 -S -O /dev/null "${url}" 2>&1 || true) \ - | sed -n 's/^ *Location: *//p' | head -n1 - else - curl -fsSI --connect-timeout 30 --retry 3 -o /dev/null -w '%{redirect_url}' "${url}" - fi -} + versions="$(grep -oE 'mise-v[0-9]{4}\.[0-9]+\.[0-9]+-' "${SHASUMS}" \ + | sed 's/^mise-//; s/-$//' \ + | sort -u)" + count="$(printf '%s' "${versions}" | grep -c . || true)" -# Turns the requested version into the exact "vYYYY.M.N" the release is -# published under. "latest" is resolved through the redirect GitHub serves for -# a repository's latest release, which lands on that release's tag page. -resolve_version() { - local requested="${1#v}" location resolved - - if [[ "${requested}" =~ ^[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then - echo "v${requested}" - return - fi - - if [ "${requested}" != "latest" ]; then - echo "(!) Unrecognised version '$1'. Use \"latest\" or an exact version such as \"2026.9.10\"." >&2 - exit 1 - fi - - location="$(redirect_target "${RELEASES_URL}/latest")" - resolved="${location##*/tag/}" - - if [ -z "${location}" ] || [[ ! "${resolved}" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then - echo "(!) Could not resolve the latest mise release from ${RELEASES_URL}/latest (got '${location}')." >&2 + if [ "${count}" -ne 1 ]; then + echo "(!) ${SHASUMS} names ${count} mise versions; expected exactly one." >&2 exit 1 fi - echo "${resolved}" + printf '%s\n' "${versions}" } mise_arch() { @@ -147,18 +133,23 @@ mise_arch() { esac } -# Downloads the named release binary, checks it against the verified -# SHASUMS256.txt and installs it as ${PREFIX}/bin/mise. +# Downloads the named release binary, checks it against the vendored checksums +# and installs it as ${PREFIX}/bin/mise. install_binary() { - local binary="$1" + local binary="$1" expected + + # Looked up before downloading, so a binary the vendored checksums do not + # cover fails without fetching anything. Entries read " ./", and + # the name is compared as a string so the dots in a version cannot act as + # wildcards and match a neighbouring release. + expected="$(awk -v name="./${binary}" '$2 == name { print $1 }' "${SHASUMS}")" + if [ -z "${expected}" ]; then + echo "(!) ${SHASUMS} has no checksum for ${binary}." >&2 + exit 1 + fi download "${RELEASES_URL}/download/${mise_version}/${binary}" "${tmpdir}/${binary}" - - # SHASUMS256.txt names each file as ./, so the entry is rewritten to - # point at the copy just downloaded. - grep " \\./${binary}\$" "${tmpdir}/SHASUMS256.txt" \ - | sed "s| \\./${binary}\$| ${tmpdir}/${binary}|" \ - | sha256sum -c - + printf '%s %s\n' "${expected}" "${tmpdir}/${binary}" | sha256sum -c - install -m 755 "${tmpdir}/${binary}" "${PREFIX}/bin/mise" } @@ -176,22 +167,15 @@ mise_runs() { install_prerequisites -mise_version="$(resolve_version "${VERSION}")" +mise_version="$(pinned_version)" arch="$(mise_arch)" echo "Installing mise ${mise_version} (linux-${arch}) into ${PREFIX}/bin..." -download "${RELEASES_URL}/download/${mise_version}/SHASUMS256.txt" "${tmpdir}/SHASUMS256.txt" -download "${RELEASES_URL}/download/${mise_version}/SHASUMS256.txt.minisig" "${tmpdir}/SHASUMS256.txt.minisig" - -# The checksums are signed with mise's minisign key. The public key is vendored -# with the feature, so the signature is checked against the key reviewed in -# this repository rather than one fetched at install time. -minisign -V -m "${tmpdir}/SHASUMS256.txt" -p "${PUBLIC_KEY}" # The glibc build is what upstream's installer picks on a glibc image, but a # release can require a newer glibc than the image provides, in which case it # fails to start at all. The statically linked musl build runs on any image, so -# it is installed in that case, verified against the same signed checksums. +# it is installed in that case, verified against the same vendored checksums. install_binary "mise-${mise_version}-linux-${arch}" if ! output="$(mise_runs 2>&1)"; then echo "(*) The glibc build of mise ${mise_version} does not run on this image; installing the musl build instead." >&2 From e849f3921f951e412eba09e8845a4eb9e59a5fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:35:22 +0000 Subject: [PATCH 03/16] test(mise): install Node.js rather than Python in the smoke test 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/smoke_test.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh index 0d16ad8..c41bef9 100755 --- a/src/mise/smoke_test.sh +++ b/src/mise/smoke_test.sh @@ -52,12 +52,18 @@ check "files created there are owned by the remote user" \ owner="$(stat -c %u /var/lib/mise/.owner-test)" rm /var/lib/mise/.owner-test test "${owner}" = "$(id -u)"' +# Node.js, because mise's node backend takes both the version list and the +# tarball from nodejs.org. Backends that resolve a version or verify an +# artifact through the GitHub API cannot be used here: the API's unauthenticated +# rate limit is per IP, which a GitHub-hosted runner shares with every other +# runner behind the same address, so such a check fails whenever that quota is +# spent (mise's core:python is one, via its artifact attestation check). check "mise installs and runs a tool" \ - mise exec python@3 -- python -c 'print("Hello, world!")' + mise exec node@24 -- node -e 'console.log("Hello, world!")' check "the tool was installed into the data directory" \ - bash -c 'test -d /var/lib/mise/installs/python' + bash -c 'test -d /var/lib/mise/installs/node' check "the tool's shim was written to the shims directory" \ - bash -c 'test -x /var/lib/mise/shims/python' + bash -c 'test -x /var/lib/mise/shims/node' if [ "${failures}" -ne 0 ]; then echo "${failures} check(s) failed." From d40052ff26be9b813d03e870dc8ded1d0b55822c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 03:37:50 +0000 Subject: [PATCH 04/16] feature(mise): install the musl build unconditionally 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/NOTES.md | 10 ++++++---- src/mise/README.md | 10 ++++++---- src/mise/install.sh | 23 ++++++++++------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 368e8f7..aaea6c1 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -4,9 +4,10 @@ and caches downloads in are placed on volumes the feature declares. ## Requirements -- A `linux-x64` or `linux-arm64` image. The glibc build is installed where the - image's glibc is new enough to run it, and the statically linked musl build - otherwise, so any Debian or Ubuntu release works. +- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the + one installed, so mise itself depends on no library in the image and any + Debian or Ubuntu release works. Which libc the tools mise installs are built + for is detected from the image, not from that choice. - `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum check. Any of those the image is missing are installed with `apt-get`, so an image without them has to be Debian or Ubuntu based; an image that already has them @@ -45,7 +46,8 @@ as a language image that needs a second toolchain or a linter the project pins. ## Installed software -- mise, installed as `/usr/local/bin/mise`, owned by root. +- mise, the statically linked `linux-*-musl` build, installed as + `/usr/local/bin/mise` and owned by root. Nothing else: no tool is installed until the project asks for it. `mise install` installs everything a project's `mise.toml` declares, and `mise exec @ diff --git a/src/mise/README.md b/src/mise/README.md index 60d6bc5..2690199 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -19,9 +19,10 @@ and caches downloads in are placed on volumes the feature declares. ## Requirements -- A `linux-x64` or `linux-arm64` image. The glibc build is installed where the - image's glibc is new enough to run it, and the statically linked musl build - otherwise, so any Debian or Ubuntu release works. +- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the + one installed, so mise itself depends on no library in the image and any + Debian or Ubuntu release works. Which libc the tools mise installs are built + for is detected from the image, not from that choice. - `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum check. Any of those the image is missing are installed with `apt-get`, so an image without them has to be Debian or Ubuntu based; an image that already has them @@ -60,7 +61,8 @@ as a language image that needs a second toolchain or a linter the project pins. ## Installed software -- mise, installed as `/usr/local/bin/mise`, owned by root. +- mise, the statically linked `linux-*-musl` build, installed as + `/usr/local/bin/mise` and owned by root. Nothing else: no tool is installed until the project asks for it. `mise install` installs everything a project's `mise.toml` declares, and `mise exec @ diff --git a/src/mise/install.sh b/src/mise/install.sh index b19deec..bf9515a 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -170,19 +170,16 @@ install_prerequisites mise_version="$(pinned_version)" arch="$(mise_arch)" -echo "Installing mise ${mise_version} (linux-${arch}) into ${PREFIX}/bin..." - -# The glibc build is what upstream's installer picks on a glibc image, but a -# release can require a newer glibc than the image provides, in which case it -# fails to start at all. The statically linked musl build runs on any image, so -# it is installed in that case, verified against the same vendored checksums. -install_binary "mise-${mise_version}-linux-${arch}" -if ! output="$(mise_runs 2>&1)"; then - echo "(*) The glibc build of mise ${mise_version} does not run on this image; installing the musl build instead." >&2 - echo "${output}" | sed 's/^/ /' >&2 - install_binary "mise-${mise_version}-linux-${arch}-musl" - mise_runs >/dev/null -fi +echo "Installing mise ${mise_version} (linux-${arch}-musl) into ${PREFIX}/bin..." + +# The statically linked musl build, not the glibc build upstream's installer +# picks on a glibc host. It runs whatever the image's glibc version is, and it +# starts an order of magnitude faster, which the shims on PATH pay on every +# tool invocation because each one re-execs this binary. The libc mise installs +# *tools* for is detected from the image rather than from this build, so the +# choice stops at mise itself. +install_binary "mise-${mise_version}-linux-${arch}-musl" +mise_runs >/dev/null username="${_REMOTE_USER:-root}" if ! getent passwd "${username}" >/dev/null; then From 56e07dd5ec6d7215a497e3cbf6e625db5332adf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:00:51 +0000 Subject: [PATCH 05/16] docs(mise): correct how a consumer overrides the feature's volumes 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/NOTES.md | 24 ++++++++++++++++++++---- src/mise/README.md | 24 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index aaea6c1..723bce1 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -107,10 +107,26 @@ removes the installed tools) or remove the volumes. Files created afterwards are unaffected. The volumes are per dev container (`${devcontainerId}`) and are not shared between -projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`, -but `containerEnv` there takes precedence over a feature's, so setting -`MISE_DATA_DIR` or `MISE_CACHE_DIR` points mise somewhere else and leaves the volume -mounted and unused. `PATH` then needs the new shims directory as well. +projects. To share one across projects, or to put a directory somewhere else +entirely, declare a mount on the same target in `devcontainer.json`: mounts are +merged by target with the last one winning, and `devcontainer.json` is merged +last, so it replaces the feature's rather than being added alongside it. + +```json +"mounts": [ + { + "source": "mise-data-shared", + "target": "/var/lib/mise", + "type": "volume" + } +] +``` + +Pointing mise at a different path works the same way, since `containerEnv` is +merged per variable with `devcontainer.json` last: setting `MISE_DATA_DIR` or +`MISE_CACHE_DIR` there overrides the feature's. The feature's mount then stays +in place and unused unless a mount on the same target replaces it, and `PATH` +needs the new shims directory as well. mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files have been trusted (`~/.local/state/mise`), stay in the remote user's home diff --git a/src/mise/README.md b/src/mise/README.md index 2690199..1c96612 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -122,10 +122,26 @@ removes the installed tools) or remove the volumes. Files created afterwards are unaffected. The volumes are per dev container (`${devcontainerId}`) and are not shared between -projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`, -but `containerEnv` there takes precedence over a feature's, so setting -`MISE_DATA_DIR` or `MISE_CACHE_DIR` points mise somewhere else and leaves the volume -mounted and unused. `PATH` then needs the new shims directory as well. +projects. To share one across projects, or to put a directory somewhere else +entirely, declare a mount on the same target in `devcontainer.json`: mounts are +merged by target with the last one winning, and `devcontainer.json` is merged +last, so it replaces the feature's rather than being added alongside it. + +```json +"mounts": [ + { + "source": "mise-data-shared", + "target": "/var/lib/mise", + "type": "volume" + } +] +``` + +Pointing mise at a different path works the same way, since `containerEnv` is +merged per variable with `devcontainer.json` last: setting `MISE_DATA_DIR` or +`MISE_CACHE_DIR` there overrides the feature's. The feature's mount then stays +in place and unused unless a mount on the same target replaces it, and `PATH` +needs the new shims directory as well. mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files have been trusted (`~/.local/state/mise`), stay in the remote user's home From 765609ce0a4b5809b0d8de4d9d36b49d357c0db1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:25:17 +0000 Subject: [PATCH 06/16] docs(mise): cut rationale and upstream basics from the notes and comments 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- .github/workflows/ci.yml | 5 +- README.md | 19 ++--- scripts/pin-checksums.sh | 12 +-- scripts/update-material.sh | 5 +- scripts/verify-material.sh | 10 +-- src/mise/NOTES.md | 155 +++++++++++++------------------------ src/mise/README.md | 155 +++++++++++++------------------------ src/mise/install.sh | 35 +++------ src/mise/smoke_test.sh | 9 +-- 9 files changed, 141 insertions(+), 264 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11c884c..904899a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,8 @@ defaults: shell: bash -euo pipefail {0} jobs: - # The install scripts verify their downloads against checksums committed to - # this repository, so that nothing has to be installed into a container just - # to check a signature. Proving those checksums are upstream's is this job. + # Proves the checksums the install scripts verify their downloads against are + # the ones upstream signed. verify-material: name: Verify vendored checksums runs-on: ubuntu-latest diff --git a/README.md b/README.md index cdc6138..d0caa60 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ signing material committed to this repository, and requires no other Feature. | Feature | What you get | Source and verification | |---------|--------------|-------------------------| | [claude-code](src/claude-code)
`ghcr.io/bare-devcontainer/features/claude-code` | Claude Code, Anthropic's agentic coding CLI, with the VS Code extension requested alongside it. Credentials, settings and history live on a volume, so a rebuild does not mean signing in again. | Anthropic's APT repository. The package signature is checked with a key committed to this repository. | -| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself. The tools it installs and its download cache live on volumes, so a rebuild does not reinstall them. | [GitHub Releases](https://github.com/jdx/mise/releases). Upstream's signed checksum file is committed here, so the install verifies the download with `sha256sum` alone and CI checks that file's minisign signature. | +| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself. The tools it installs and its download cache live on volumes, so a rebuild does not reinstall them. | [GitHub Releases](https://github.com/jdx/mise/releases). The download is checked against upstream's own checksum file, committed to this repository with the minisign signature published for it. | | [node](src/node)
`ghcr.io/bare-devcontainer/features/node` | Node.js, with Corepack in place of npm, so the package manager comes from the project's `packageManager` field rather than from the image. | [nodejs.org](https://nodejs.org/dist/). The release checksums are checked against the Node.js release keys committed to this repository. | Each Feature's README documents its options, requirements, what it deliberately leaves @@ -99,14 +99,12 @@ runs on its behalf. **What you can check before pinning.** Every input is a file in this repository: one install script per Feature, and the signing material sitting next to it — a signing key, or -upstream's own checksum file with the signature it published for it. Each Feature's README -shows how to inspect what it verifies against. - -**Where verification happens.** Wherever it costs a container nothing. A Feature whose -upstream signs its checksums ships those checksums rather than the tooling to check a -signature, so the install verifies the download with `sha256sum` and installs no package in -order to do it; the `CI` workflow verifies the committed checksums against upstream's -signature on every change instead +upstream's own checksum file with its signature. Each Feature's README shows how to +inspect what it verifies against. + +**Where verification happens.** A Feature whose upstream signs its checksums ships those +checksums, so its install verifies the download with `sha256sum` alone; the `CI` workflow +checks them against upstream's signature on every change ([`scripts/verify-material.sh`](scripts/verify-material.sh)). **How the trusted material changes.** Only through a pull request. The @@ -115,8 +113,7 @@ signature on every change instead upstream weekly and opens one when a key has changed, so every rotation is visible in the history of this repository rather than picked up silently at build time. Vendored release checksums move only when the release they pin does, through -[`scripts/pin-checksums.sh`](scripts/pin-checksums.sh), which writes what it downloaded -only once upstream's signature over it verifies. +[`scripts/pin-checksums.sh`](scripts/pin-checksums.sh). **What is not covered.** The upstream projects themselves are still trusted: verification proves a download came from them unaltered, not that what they published is sound. These diff --git a/scripts/pin-checksums.sh b/scripts/pin-checksums.sh index 04194e8..e3349d5 100755 --- a/scripts/pin-checksums.sh +++ b/scripts/pin-checksums.sh @@ -3,16 +3,12 @@ # pin-checksums.sh — re-pin a feature's vendored release checksums to a named # upstream release. Downloads the checksum file and the signature upstream # published for it, and writes both into the working tree only once the -# signature verifies against the vendored public key, so a failed, truncated or -# tampered download cannot land in the repository. Performs no git operations. +# signature verifies against the vendored public key. Performs no git +# operations. # # Which file belongs to which feature is listed in signed-material.json next to -# this script. The release is named rather than looked up, so what gets pinned -# is always what the caller asked for. -# -# Bumping a feature's mise version is this script plus a version bump in the -# feature's devcontainer-feature.json, since the checksums decide which release -# the feature installs. +# this script. The checksums decide which release the feature installs, so +# moving them also needs a version bump in its devcontainer-feature.json. # # Requires: minisign, jq, wget # diff --git a/scripts/update-material.sh b/scripts/update-material.sh index 0e92fa2..68ba360 100755 --- a/scripts/update-material.sh +++ b/scripts/update-material.sh @@ -5,9 +5,8 @@ # to stdout depending on whether anything changed; per-file download and # comparison progress goes to stderr. Performs no git or GitHub operations. # -# Covers material published at a fixed URL, which is every signing key. The -# release checksums pinned per feature move with a release rather than on their -# own, so they are re-pinned by pin-checksums.sh instead. +# Covers material published at a fixed URL, which is every signing key. Release +# checksums move with the release they pin, so pin-checksums.sh handles those. # # Usage: # update-material.sh diff --git a/scripts/verify-material.sh b/scripts/verify-material.sh index e7bb69c..a207e08 100755 --- a/scripts/verify-material.sh +++ b/scripts/verify-material.sh @@ -2,13 +2,11 @@ # # verify-material.sh — check that the release checksums a feature's install # script verifies downloads against are the ones upstream signed. Reads the -# working tree only: it downloads nothing, writes nothing and needs no network, -# so what it proves holds for whatever the repository currently has committed. -# Exits non-zero on the first file that does not verify. +# working tree only, so it needs no network. Exits non-zero on the first file +# that does not verify. # -# The features it covers, and the signature and key for each, are listed in -# signed-material.json next to this script; pin-checksums.sh writes the files -# this reads. +# The files covered, per feature, are listed in signed-material.json next to +# this script; pin-checksums.sh writes them. # # Requires: minisign, jq # diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 723bce1..4448688 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -4,16 +4,12 @@ and caches downloads in are placed on volumes the feature declares. ## Requirements -- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the - one installed, so mise itself depends on no library in the image and any - Debian or Ubuntu release works. Which libc the tools mise installs are built - for is detected from the image, not from that choice. +- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the one + installed, so it runs whatever the image's libc is. The libc that tools mise + installs are built for is detected from the image, not from that build. - `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum check. Any of those the image is missing are installed with `apt-get`, so an image - without them has to be Debian or Ubuntu based; an image that already has them - needs no package manager at all, and in practice that is every image with a - downloader. Nothing is installed in order to verify the download — see - [Supply chain](#supply-chain). + without them has to be Debian or Ubuntu based. ## Usage @@ -25,9 +21,9 @@ The feature takes no options: } ``` -Which mise release gets installed is decided by the checksums committed with the -feature, so the feature's own version is what selects it. Pin the reference to hold -a release, and read the release notes to see which mise a version carries: +The checksums committed with the feature cover one mise release, so the feature +version is what selects which mise is installed. A tag such as `:1` resolves to the +newest feature release when the container is built; pin it to hold a mise release: ```json "features": { @@ -35,37 +31,19 @@ a release, and read the release notes to see which mise a version carries: } ``` -A shorter tag such as `:1` resolves to the newest release when the container is -built, so it picks up newer mise releases on a rebuild. See -[Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). - -Not every project needs this feature: on the -[`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) -image, mise is already there. The feature is for adding mise to any other image, such -as a language image that needs a second toolchain or a linter the project pins. - ## Installed software - mise, the statically linked `linux-*-musl` build, installed as `/usr/local/bin/mise` and owned by root. -Nothing else: no tool is installed until the project asks for it. `mise install` -installs everything a project's `mise.toml` declares, and `mise exec @ --- ` runs a one-off without declaring anything. To install the project's tools -when the container is created rather than on first use, run `mise install` from a -`postCreateCommand`. - -The shims directory is on `PATH`, so a tool resolves as soon as mise installs it, -for editors and other programs that do not load the shell configuration as well as -in the terminal. `mise activate` can be added to the shell configuration for the -[features shims do not cover](https://mise.jdx.dev/dev-tools/shims.html), such as -environment variables declared in `mise.toml`. +Nothing else: no tool is installed until the project asks for it. The shims +directory is on `PATH`, so a tool resolves as soon as mise installs it, including +for programs that do not load the shell configuration. ## Tool installs and download cache mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in -`MISE_CACHE_DIR`. The feature declares a volume for each, so a rebuild neither -reinstalls the tools nor downloads them again, and points mise at them: +`MISE_CACHE_DIR`. The feature declares a volume for each and points mise at them: ```json "containerEnv": { @@ -88,29 +66,24 @@ reinstalls the tools nor downloads them again, and points mise at them: ``` A feature's mount target is static and cannot reference the remote user's home -directory, so the volumes are mounted under `/var` and the environment variables -move the directories there. That keeps the mounts working whatever the image uses -as its remote user. The shims directory moves with `MISE_DATA_DIR`, which is why -`PATH` names it under `/var/lib/mise`. +directory, so the volumes sit under `/var` and the environment variables move the +directories there. The shims directory moves with `MISE_DATA_DIR`. The volumes are seeded from the image on first use, ownership included. The Dev -Containers CLI renumbers the remote user to the host user's UID and GID on Linux, -but only chowns the home directory, so a directory owned by a build-time UID would -end up unreachable. The install script therefore creates a `mise` system group, -adds the remote user to it, and makes both directories group-writable and setgid: -group membership is recorded by name and is unaffected by the renumbering. - -One case is not covered. If an existing volume is reused after the host user's UID -changed, files written under the old UID keep their owner-only modes and mise cannot -replace them; clear them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this -removes the installed tools) or remove the volumes. Files created afterwards are -unaffected. - -The volumes are per dev container (`${devcontainerId}`) and are not shared between -projects. To share one across projects, or to put a directory somewhere else -entirely, declare a mount on the same target in `devcontainer.json`: mounts are -merged by target with the last one winning, and `devcontainer.json` is merged -last, so it replaces the feature's rather than being added alongside it. +Containers CLI renumbers the remote user to the host user's UID and GID on Linux +but only chowns the home directory, so the install script creates a `mise` system +group, adds the remote user to it, and makes both directories group-writable and +setgid: group membership is recorded by name and survives the renumbering. + +If an existing volume is reused after the host user's UID changed, files written +under the old UID keep their owner-only modes and mise cannot replace them; clear +them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this removes the installed +tools) or remove the volumes. + +The volumes are per dev container (`${devcontainerId}`). To share one across +projects, or to put a directory somewhere else, declare a mount on the same target +in `devcontainer.json`: mounts are merged by target with the last one winning, and +`devcontainer.json` is merged last, so it replaces the feature's. ```json "mounts": [ @@ -122,68 +95,54 @@ last, so it replaces the feature's rather than being added alongside it. ] ``` -Pointing mise at a different path works the same way, since `containerEnv` is -merged per variable with `devcontainer.json` last: setting `MISE_DATA_DIR` or -`MISE_CACHE_DIR` there overrides the feature's. The feature's mount then stays -in place and unused unless a mount on the same target replaces it, and `PATH` -needs the new shims directory as well. +`containerEnv` is merged the same way, so setting `MISE_DATA_DIR` or +`MISE_CACHE_DIR` there points mise elsewhere and leaves the feature's mount in place +and unused; `PATH` then needs the new shims directory as well. -mise's configuration (`~/.config/mise`) and state, including which `mise.toml` -files have been trusted (`~/.local/state/mise`), stay in the remote user's home -directory and are recreated by a rebuild. +mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files +have been trusted (`~/.local/state/mise`), stay in the remote user's home directory +and are recreated by a rebuild. ## Not installed -- **No language runtime or tool.** mise resolves the versions the project declares - in `mise.toml` or in idiomatic per-language files such as `.node-version`. -- **No development headers beyond libc.** A backend that downloads prebuilt - binaries works as-is; one that builds a runtime from source needs the `-dev` - packages of the libraries it links against. +- **No language runtime or tool.** mise resolves the versions the project declares. +- **No development headers beyond libc.** A backend that downloads prebuilt binaries + works as-is; one that builds from source needs the `-dev` packages of the + libraries it links against. - **No shell activation.** The shell configuration is left untouched; the shims directory on `PATH` is what makes installed tools resolve. ## Supply chain The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked -against `SHASUMS256.txt`, which is vendored with the feature. That file is -upstream's own checksum file, committed verbatim together with the minisign -signature upstream published for it, so the expected hash of every release binary -is a reviewed line in this repository rather than something fetched while the -container is built. - -Checking the download therefore needs `sha256sum` and nothing else. The signature -is verified where the tooling for it is free rather than in the container: the -`CI` workflow runs +against `SHASUMS256.txt`, vendored with the feature: upstream's own checksum file, +committed verbatim together with the minisign signature upstream published for it. +Checking the download therefore needs `sha256sum` and nothing else. + +The signature is verified in CI rather than in the container. The `CI` workflow runs [`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` -using the vendored public key. A hand-edited checksum fails that job, and an -install can never be talked into skipping it, because the install has no signature -step to skip. +using the vendored public key. The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise repository, refreshed by this repository's `Update Trusted Material` workflow. The -checksums move only when the pinned release does: -[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh) -re-pins them to a named release and writes what it downloaded only once the signature -verifies, so a newer mise reaches this feature through a pull request that bumps the -feature version alongside the checksums. +checksums move only when the pinned release does, through +[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh), +which writes what it downloaded only once the signature verifies. To check the material yourself before pinning the feature: ```sh -cat src/mise/mise-minisign.pub minisign -V -m src/mise/SHASUMS256.txt \ -x src/mise/SHASUMS256.txt.minisig \ -p src/mise/mise-minisign.pub ``` -Because the checksums cover one release, the version they pin is the version the -feature installs; `grep linux-x64 src/mise/SHASUMS256.txt` shows which. +`grep linux-x64 src/mise/SHASUMS256.txt` shows the release they pin. This covers the mise binary only. Tools that mise installs at runtime are fetched -from their own upstreams under mise's own verification, which -[`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. +from their own upstreams under mise's own verification. Everything downloaded during installation goes to a temporary directory that is removed when the script exits. @@ -192,15 +151,9 @@ removed when the script exits. - For the tags this feature is published under, see [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). -- To have mise re-verify the provenance of the tools it installs on every install - and refuse untrusted configuration, set `MISE_PARANOID` in `containerEnv`: - - ```json - "containerEnv": { - "MISE_PARANOID": "1" - } - ``` - -- The project's `mise.toml` has to be trusted before mise reads it, and a rebuild - clears that trust. Set `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in - `containerEnv` to trust it up front. +- [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise + re-verify the provenance of the tools it installs and refuse untrusted + configuration. +- A rebuild clears which `mise.toml` files mise has trusted. Set + `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in `containerEnv` to trust the + project's up front. diff --git a/src/mise/README.md b/src/mise/README.md index 1c96612..1d85fd9 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -19,16 +19,12 @@ and caches downloads in are placed on volumes the feature declares. ## Requirements -- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the - one installed, so mise itself depends on no library in the image and any - Debian or Ubuntu release works. Which libc the tools mise installs are built - for is detected from the image, not from that choice. +- A `linux-x64` or `linux-arm64` image. The statically linked musl build is the one + installed, so it runs whatever the image's libc is. The libc that tools mise + installs are built for is detected from the image, not from that build. - `wget` (or `curl`), `sha256sum` and a CA bundle, for the download and the checksum check. Any of those the image is missing are installed with `apt-get`, so an image - without them has to be Debian or Ubuntu based; an image that already has them - needs no package manager at all, and in practice that is every image with a - downloader. Nothing is installed in order to verify the download — see - [Supply chain](#supply-chain). + without them has to be Debian or Ubuntu based. ## Usage @@ -40,9 +36,9 @@ The feature takes no options: } ``` -Which mise release gets installed is decided by the checksums committed with the -feature, so the feature's own version is what selects it. Pin the reference to hold -a release, and read the release notes to see which mise a version carries: +The checksums committed with the feature cover one mise release, so the feature +version is what selects which mise is installed. A tag such as `:1` resolves to the +newest feature release when the container is built; pin it to hold a mise release: ```json "features": { @@ -50,37 +46,19 @@ a release, and read the release notes to see which mise a version carries: } ``` -A shorter tag such as `:1` resolves to the newest release when the container is -built, so it picks up newer mise releases on a rebuild. See -[Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). - -Not every project needs this feature: on the -[`ghcr.io/bare-devcontainer/mise`](https://github.com/bare-devcontainer/images/tree/main/mise) -image, mise is already there. The feature is for adding mise to any other image, such -as a language image that needs a second toolchain or a linter the project pins. - ## Installed software - mise, the statically linked `linux-*-musl` build, installed as `/usr/local/bin/mise` and owned by root. -Nothing else: no tool is installed until the project asks for it. `mise install` -installs everything a project's `mise.toml` declares, and `mise exec @ --- ` runs a one-off without declaring anything. To install the project's tools -when the container is created rather than on first use, run `mise install` from a -`postCreateCommand`. - -The shims directory is on `PATH`, so a tool resolves as soon as mise installs it, -for editors and other programs that do not load the shell configuration as well as -in the terminal. `mise activate` can be added to the shell configuration for the -[features shims do not cover](https://mise.jdx.dev/dev-tools/shims.html), such as -environment variables declared in `mise.toml`. +Nothing else: no tool is installed until the project asks for it. The shims +directory is on `PATH`, so a tool resolves as soon as mise installs it, including +for programs that do not load the shell configuration. ## Tool installs and download cache mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in -`MISE_CACHE_DIR`. The feature declares a volume for each, so a rebuild neither -reinstalls the tools nor downloads them again, and points mise at them: +`MISE_CACHE_DIR`. The feature declares a volume for each and points mise at them: ```json "containerEnv": { @@ -103,29 +81,24 @@ reinstalls the tools nor downloads them again, and points mise at them: ``` A feature's mount target is static and cannot reference the remote user's home -directory, so the volumes are mounted under `/var` and the environment variables -move the directories there. That keeps the mounts working whatever the image uses -as its remote user. The shims directory moves with `MISE_DATA_DIR`, which is why -`PATH` names it under `/var/lib/mise`. +directory, so the volumes sit under `/var` and the environment variables move the +directories there. The shims directory moves with `MISE_DATA_DIR`. The volumes are seeded from the image on first use, ownership included. The Dev -Containers CLI renumbers the remote user to the host user's UID and GID on Linux, -but only chowns the home directory, so a directory owned by a build-time UID would -end up unreachable. The install script therefore creates a `mise` system group, -adds the remote user to it, and makes both directories group-writable and setgid: -group membership is recorded by name and is unaffected by the renumbering. - -One case is not covered. If an existing volume is reused after the host user's UID -changed, files written under the old UID keep their owner-only modes and mise cannot -replace them; clear them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this -removes the installed tools) or remove the volumes. Files created afterwards are -unaffected. - -The volumes are per dev container (`${devcontainerId}`) and are not shared between -projects. To share one across projects, or to put a directory somewhere else -entirely, declare a mount on the same target in `devcontainer.json`: mounts are -merged by target with the last one winning, and `devcontainer.json` is merged -last, so it replaces the feature's rather than being added alongside it. +Containers CLI renumbers the remote user to the host user's UID and GID on Linux +but only chowns the home directory, so the install script creates a `mise` system +group, adds the remote user to it, and makes both directories group-writable and +setgid: group membership is recorded by name and survives the renumbering. + +If an existing volume is reused after the host user's UID changed, files written +under the old UID keep their owner-only modes and mise cannot replace them; clear +them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this removes the installed +tools) or remove the volumes. + +The volumes are per dev container (`${devcontainerId}`). To share one across +projects, or to put a directory somewhere else, declare a mount on the same target +in `devcontainer.json`: mounts are merged by target with the last one winning, and +`devcontainer.json` is merged last, so it replaces the feature's. ```json "mounts": [ @@ -137,68 +110,54 @@ last, so it replaces the feature's rather than being added alongside it. ] ``` -Pointing mise at a different path works the same way, since `containerEnv` is -merged per variable with `devcontainer.json` last: setting `MISE_DATA_DIR` or -`MISE_CACHE_DIR` there overrides the feature's. The feature's mount then stays -in place and unused unless a mount on the same target replaces it, and `PATH` -needs the new shims directory as well. +`containerEnv` is merged the same way, so setting `MISE_DATA_DIR` or +`MISE_CACHE_DIR` there points mise elsewhere and leaves the feature's mount in place +and unused; `PATH` then needs the new shims directory as well. -mise's configuration (`~/.config/mise`) and state, including which `mise.toml` -files have been trusted (`~/.local/state/mise`), stay in the remote user's home -directory and are recreated by a rebuild. +mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files +have been trusted (`~/.local/state/mise`), stay in the remote user's home directory +and are recreated by a rebuild. ## Not installed -- **No language runtime or tool.** mise resolves the versions the project declares - in `mise.toml` or in idiomatic per-language files such as `.node-version`. -- **No development headers beyond libc.** A backend that downloads prebuilt - binaries works as-is; one that builds a runtime from source needs the `-dev` - packages of the libraries it links against. +- **No language runtime or tool.** mise resolves the versions the project declares. +- **No development headers beyond libc.** A backend that downloads prebuilt binaries + works as-is; one that builds from source needs the `-dev` packages of the + libraries it links against. - **No shell activation.** The shell configuration is left untouched; the shims directory on `PATH` is what makes installed tools resolve. ## Supply chain The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked -against `SHASUMS256.txt`, which is vendored with the feature. That file is -upstream's own checksum file, committed verbatim together with the minisign -signature upstream published for it, so the expected hash of every release binary -is a reviewed line in this repository rather than something fetched while the -container is built. - -Checking the download therefore needs `sha256sum` and nothing else. The signature -is verified where the tooling for it is free rather than in the container: the -`CI` workflow runs +against `SHASUMS256.txt`, vendored with the feature: upstream's own checksum file, +committed verbatim together with the minisign signature upstream published for it. +Checking the download therefore needs `sha256sum` and nothing else. + +The signature is verified in CI rather than in the container. The `CI` workflow runs [`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` -using the vendored public key. A hand-edited checksum fails that job, and an -install can never be talked into skipping it, because the install has no signature -step to skip. +using the vendored public key. The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise repository, refreshed by this repository's `Update Trusted Material` workflow. The -checksums move only when the pinned release does: -[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh) -re-pins them to a named release and writes what it downloaded only once the signature -verifies, so a newer mise reaches this feature through a pull request that bumps the -feature version alongside the checksums. +checksums move only when the pinned release does, through +[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh), +which writes what it downloaded only once the signature verifies. To check the material yourself before pinning the feature: ```sh -cat src/mise/mise-minisign.pub minisign -V -m src/mise/SHASUMS256.txt \ -x src/mise/SHASUMS256.txt.minisig \ -p src/mise/mise-minisign.pub ``` -Because the checksums cover one release, the version they pin is the version the -feature installs; `grep linux-x64 src/mise/SHASUMS256.txt` shows which. +`grep linux-x64 src/mise/SHASUMS256.txt` shows the release they pin. This covers the mise binary only. Tools that mise installs at runtime are fetched -from their own upstreams under mise's own verification, which -[`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) makes stricter. +from their own upstreams under mise's own verification. Everything downloaded during installation goes to a temporary directory that is removed when the script exits. @@ -207,18 +166,12 @@ removed when the script exits. - For the tags this feature is published under, see [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). -- To have mise re-verify the provenance of the tools it installs on every install - and refuse untrusted configuration, set `MISE_PARANOID` in `containerEnv`: - - ```json - "containerEnv": { - "MISE_PARANOID": "1" - } - ``` - -- The project's `mise.toml` has to be trusted before mise reads it, and a rebuild - clears that trust. Set `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in - `containerEnv` to trust it up front. +- [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise + re-verify the provenance of the tools it installs and refuse untrusted + configuration. +- A rebuild clears which `mise.toml` files mise has trusted. Set + `MISE_TRUSTED_CONFIG_PATHS` to the workspace folder in `containerEnv` to trust the + project's up front. --- diff --git a/src/mise/install.sh b/src/mise/install.sh index bf9515a..ddb30e7 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -1,22 +1,13 @@ #!/usr/bin/env bash # -# Installs mise into /usr/local/bin from the official GitHub release. Both the -# version to install and the checksum to expect come from SHASUMS256.txt, -# vendored with the feature, so the download is verified with sha256sum alone -# and no signature tooling is installed into the container to check it. -# -# That file's authenticity is established in this repository rather than at -# install time: it is upstream's own checksum file, committed together with the -# minisign signature upstream published for it, and scripts/verify-material.sh -# checks the two against the vendored mise public key on every change. +# Installs mise into /usr/local/bin from the official GitHub release. The +# release to install and the checksum to expect both come from SHASUMS256.txt, +# vendored with the feature; scripts/verify-material.sh checks its signature. # # The directories mise installs tools into and caches downloads in are prepared # for the remote user, at the paths the feature mounts volumes on and points # MISE_DATA_DIR and MISE_CACHE_DIR at. # -# This feature has no options: the vendored checksums cover one mise release, -# so the feature's own version is what selects which mise gets installed. -# # Expected environment variables, from the Dev Container specification, # injected by the CLI: # @@ -103,9 +94,8 @@ download() { fi } -# Reads the release to install out of the vendored checksums, which are the -# only place it is recorded: every entry names the release it belongs to, so -# the version cannot drift from the checksums the download is verified against. +# Reads the release to install out of the vendored checksums, the only place it +# is recorded. pinned_version() { local versions count @@ -138,10 +128,8 @@ mise_arch() { install_binary() { local binary="$1" expected - # Looked up before downloading, so a binary the vendored checksums do not - # cover fails without fetching anything. Entries read " ./", and - # the name is compared as a string so the dots in a version cannot act as - # wildcards and match a neighbouring release. + # Entries read " ./". The name is compared as a string so the + # dots in a version cannot act as wildcards and match a neighbouring release. expected="$(awk -v name="./${binary}" '$2 == name { print $1 }' "${SHASUMS}")" if [ -z "${expected}" ]; then echo "(!) ${SHASUMS} has no checksum for ${binary}." >&2 @@ -172,12 +160,9 @@ arch="$(mise_arch)" echo "Installing mise ${mise_version} (linux-${arch}-musl) into ${PREFIX}/bin..." -# The statically linked musl build, not the glibc build upstream's installer -# picks on a glibc host. It runs whatever the image's glibc version is, and it -# starts an order of magnitude faster, which the shims on PATH pay on every -# tool invocation because each one re-execs this binary. The libc mise installs -# *tools* for is detected from the image rather than from this build, so the -# choice stops at mise itself. +# The musl build is statically linked, so it neither depends on the image's +# glibc nor pays dynamic relocation at startup — a cost the shims on PATH would +# carry on every tool invocation, since each one re-execs this binary. install_binary "mise-${mise_version}-linux-${arch}-musl" mise_runs >/dev/null diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh index c41bef9..7fe7afe 100755 --- a/src/mise/smoke_test.sh +++ b/src/mise/smoke_test.sh @@ -52,12 +52,9 @@ check "files created there are owned by the remote user" \ owner="$(stat -c %u /var/lib/mise/.owner-test)" rm /var/lib/mise/.owner-test test "${owner}" = "$(id -u)"' -# Node.js, because mise's node backend takes both the version list and the -# tarball from nodejs.org. Backends that resolve a version or verify an -# artifact through the GitHub API cannot be used here: the API's unauthenticated -# rate limit is per IP, which a GitHub-hosted runner shares with every other -# runner behind the same address, so such a check fails whenever that quota is -# spent (mise's core:python is one, via its artifact attestation check). +# Node.js, whose backend takes the version list and the tarball from nodejs.org. +# A backend that reaches the GitHub API cannot be used here: its unauthenticated +# rate limit is per IP, and GitHub-hosted runners share one. check "mise installs and runs a tool" \ mise exec node@24 -- node -e 'console.log("Hello, world!")' check "the tool was installed into the data directory" \ From 24360270c2b4b48cf8a942a1db1f3718ba1171f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:43:34 +0000 Subject: [PATCH 07/16] docs(mise): state why the musl build is installed, not how fast it is 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/install.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mise/install.sh b/src/mise/install.sh index ddb30e7..01b2ee9 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -160,9 +160,9 @@ arch="$(mise_arch)" echo "Installing mise ${mise_version} (linux-${arch}-musl) into ${PREFIX}/bin..." -# The musl build is statically linked, so it neither depends on the image's -# glibc nor pays dynamic relocation at startup — a cost the shims on PATH would -# carry on every tool invocation, since each one re-execs this binary. +# The musl build is statically linked, so it runs whatever the image's glibc +# version is. The glibc build does not, and a release can require a newer glibc +# than the image provides. install_binary "mise-${mise_version}-linux-${arch}-musl" mise_runs >/dev/null From fe41f88ad3d87827cd7d63f7938a7b612d4a0aa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:49:06 +0000 Subject: [PATCH 08/16] docs(mise): drop comments that restate the code they sit on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- .github/workflows/ci.yml | 2 -- scripts/pin-checksums.sh | 2 -- scripts/verify-material.sh | 3 +-- src/mise/NOTES.md | 12 +++--------- src/mise/README.md | 12 +++--------- src/mise/install.sh | 31 ++++++++----------------------- src/mise/smoke_test.sh | 2 -- 7 files changed, 15 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 904899a..efd9b98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,6 @@ defaults: shell: bash -euo pipefail {0} jobs: - # Proves the checksums the install scripts verify their downloads against are - # the ones upstream signed. verify-material: name: Verify vendored checksums runs-on: ubuntu-latest diff --git a/scripts/pin-checksums.sh b/scripts/pin-checksums.sh index e3349d5..7442a8d 100755 --- a/scripts/pin-checksums.sh +++ b/scripts/pin-checksums.sh @@ -57,9 +57,7 @@ if cmp -s "$tmp_checksums" "$checksums" && cmp -s "$tmp_signature" "$signature"; fi chmod 644 "$tmp_checksums" "$tmp_signature" -# Moved out of the trap's reach, since the files are now the working tree's. mv "$tmp_checksums" "$checksums" mv "$tmp_signature" "$signature" -trap - EXIT echo "Pinned ${checksums} to ${tag}. Bump the feature version to publish it." >&2 diff --git a/scripts/verify-material.sh b/scripts/verify-material.sh index a207e08..441a482 100755 --- a/scripts/verify-material.sh +++ b/scripts/verify-material.sh @@ -2,8 +2,7 @@ # # verify-material.sh — check that the release checksums a feature's install # script verifies downloads against are the ones upstream signed. Reads the -# working tree only, so it needs no network. Exits non-zero on the first file -# that does not verify. +# working tree only, so it needs no network. # # The files covered, per feature, are listed in signed-material.json next to # this script; pin-checksums.sh writes them. diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 4448688..984ee59 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -65,15 +65,9 @@ mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in ] ``` -A feature's mount target is static and cannot reference the remote user's home -directory, so the volumes sit under `/var` and the environment variables move the -directories there. The shims directory moves with `MISE_DATA_DIR`. - -The volumes are seeded from the image on first use, ownership included. The Dev -Containers CLI renumbers the remote user to the host user's UID and GID on Linux -but only chowns the home directory, so the install script creates a `mise` system -group, adds the remote user to it, and makes both directories group-writable and -setgid: group membership is recorded by name and survives the renumbering. +Both directories belong to a `mise` system group the remote user is added to, and +are group-writable and setgid, so they stay writable after the Dev Containers CLI +renumbers the remote user's UID. If an existing volume is reused after the host user's UID changed, files written under the old UID keep their owner-only modes and mise cannot replace them; clear diff --git a/src/mise/README.md b/src/mise/README.md index 1d85fd9..aa387a3 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -80,15 +80,9 @@ mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in ] ``` -A feature's mount target is static and cannot reference the remote user's home -directory, so the volumes sit under `/var` and the environment variables move the -directories there. The shims directory moves with `MISE_DATA_DIR`. - -The volumes are seeded from the image on first use, ownership included. The Dev -Containers CLI renumbers the remote user to the host user's UID and GID on Linux -but only chowns the home directory, so the install script creates a `mise` system -group, adds the remote user to it, and makes both directories group-writable and -setgid: group membership is recorded by name and survives the renumbering. +Both directories belong to a `mise` system group the remote user is added to, and +are group-writable and setgid, so they stay writable after the Dev Containers CLI +renumbers the remote user's UID. If an existing volume is reused after the host user's UID changed, files written under the old UID keep their owner-only modes and mise cannot replace them; clear diff --git a/src/mise/install.sh b/src/mise/install.sh index 01b2ee9..79d72b9 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -4,15 +4,10 @@ # release to install and the checksum to expect both come from SHASUMS256.txt, # vendored with the feature; scripts/verify-material.sh checks its signature. # -# The directories mise installs tools into and caches downloads in are prepared -# for the remote user, at the paths the feature mounts volumes on and points -# MISE_DATA_DIR and MISE_CACHE_DIR at. -# # Expected environment variables, from the Dev Container specification, # injected by the CLI: # -# _REMOTE_USER The account the container is attached as, and therefore -# the one mise installs tools as. +# _REMOTE_USER The account the container is attached as. set -euo pipefail @@ -35,12 +30,9 @@ if [ ! -f "${SHASUMS}" ]; then exit 1 fi -# Every download lands here, so nothing the install fetches is left in the -# image, whichever path the script exits by. tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir}"' EXIT -# Debian package providing each command the installation needs. package_for() { case "$1" in wget) echo "wget" ;; @@ -49,11 +41,10 @@ package_for() { esac } -# Installs whatever the base image is missing, and nothing it already has. install_prerequisites() { local required=(sha256sum) missing=() cmd - # Either downloader will do, so one is only pulled in when neither is there. + # Either downloader will do, so wget is only pulled in when curl is absent. if ! command -v curl >/dev/null 2>&1; then required+=(wget) fi @@ -64,7 +55,6 @@ install_prerequisites() { fi done - # HTTPS access to github.com needs a CA bundle. if [ ! -e /etc/ssl/certs/ca-certificates.crt ]; then missing+=(ca-certificates) fi @@ -94,8 +84,6 @@ download() { fi } -# Reads the release to install out of the vendored checksums, the only place it -# is recorded. pinned_version() { local versions count @@ -123,8 +111,6 @@ mise_arch() { esac } -# Downloads the named release binary, checks it against the vendored checksums -# and installs it as ${PREFIX}/bin/mise. install_binary() { local binary="$1" expected @@ -142,8 +128,8 @@ install_binary() { install -m 755 "${tmpdir}/${binary}" "${PREFIX}/bin/mise" } -# Runs the installed mise once, with every directory it might write to pointed -# into the temporary directory so the check leaves nothing behind. +# Every directory mise might write to is pointed into the temporary directory, +# so running it here leaves nothing in the image. mise_runs() { env HOME="${tmpdir}/home" \ MISE_DATA_DIR="${tmpdir}/home/data" \ @@ -172,11 +158,10 @@ if ! getent passwd "${username}" >/dev/null; then exit 1 fi -# The volumes are seeded from these directories on first use, ownership -# included, but the Dev Containers CLI renumbers the remote user to the host -# user's UID without touching anything outside the home directory. A dedicated -# group keeps the directories writable through that: membership is recorded by -# name, and the setgid bit keeps entries created later in the group. +# The volumes are seeded from these directories, ownership included, and the +# Dev Containers CLI renumbers the remote user's UID without touching anything +# outside the home directory. Group membership is recorded by name and survives +# that, and the setgid bit keeps entries created later in the group. if ! getent group "${GROUP}" >/dev/null; then groupadd --system "${GROUP}" fi diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh index 7fe7afe..17251e4 100755 --- a/src/mise/smoke_test.sh +++ b/src/mise/smoke_test.sh @@ -4,8 +4,6 @@ set -euo pipefail failures=0 -# Runs a command and records the outcome instead of aborting, so a single run -# reports every broken check rather than only the first one. check() { local label="$1" shift From 84bda1a2a3a55209c1542793fb27187004a8ee91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:56:56 +0000 Subject: [PATCH 09/16] feature(mise): keep package lists the image shipped 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/install.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/mise/install.sh b/src/mise/install.sh index 79d72b9..6e2d081 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -68,10 +68,22 @@ install_prerequisites() { exit 1 fi + # An image that ships package lists means them to be there, and this script + # runs after it was built, so only lists its own apt-get update fetched are + # removed. "lock" and "partial" are left by a cleaned image and do not count. + local lists_shipped=false + if find /var/lib/apt/lists -maxdepth 1 -type f ! -name lock -print -quit 2>/dev/null \ + | grep -q .; then + lists_shipped=true + fi + export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y --no-install-recommends "${missing[@]}" - rm -rf /var/lib/apt/lists/* + + if [ "${lists_shipped}" = false ]; then + rm -rf /var/lib/apt/lists/* + fi } download() { From 587d23cc938b3d46a397f43f3f327358995c5e0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:04:34 +0000 Subject: [PATCH 10/16] feature(mise): leave the tool directories in the remote user's home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- README.md | 2 +- src/mise/NOTES.md | 84 +++++++++++------------------ src/mise/README.md | 86 +++++++++++------------------- src/mise/devcontainer-feature.json | 18 +------ src/mise/install.sh | 45 ++++++++++------ src/mise/smoke_test.sh | 35 ++++++------ 6 files changed, 109 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index d0caa60..2b6efd8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ signing material committed to this repository, and requires no other Feature. | Feature | What you get | Source and verification | |---------|--------------|-------------------------| | [claude-code](src/claude-code)
`ghcr.io/bare-devcontainer/features/claude-code` | Claude Code, Anthropic's agentic coding CLI, with the VS Code extension requested alongside it. Credentials, settings and history live on a volume, so a rebuild does not mean signing in again. | Anthropic's APT repository. The package signature is checked with a key committed to this repository. | -| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself. The tools it installs and its download cache live on volumes, so a rebuild does not reinstall them. | [GitHub Releases](https://github.com/jdx/mise/releases). The download is checked against upstream's own checksum file, committed to this repository with the minisign signature published for it. | +| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself, with its shims on `PATH` so an editor resolves them too. | [GitHub Releases](https://github.com/jdx/mise/releases). The download is checked against upstream's own checksum file, committed to this repository with the minisign signature published for it. | | [node](src/node)
`ghcr.io/bare-devcontainer/features/node` | Node.js, with Corepack in place of npm, so the package manager comes from the project's `packageManager` field rather than from the image. | [nodejs.org](https://nodejs.org/dist/). The release checksums are checked against the Node.js release keys committed to this repository. | Each Feature's README documents its options, requirements, what it deliberately leaves diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 984ee59..9cdc846 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -1,6 +1,5 @@ The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) -is installed as `/usr/local/bin/mise`, and the directories mise installs tools into -and caches downloads in are placed on volumes the feature declares. +is installed as `/usr/local/bin/mise`, and its shims directory is put on `PATH`. ## Requirements @@ -42,60 +41,19 @@ for programs that do not load the shell configuration. ## Tool installs and download cache -mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in -`MISE_CACHE_DIR`. The feature declares a volume for each and points mise at them: +mise keeps its tool installs in `~/.local/share/mise` and its download cache in +`~/.cache/mise`, its own defaults. Both are created for the remote user when the +feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` +above them. -```json -"containerEnv": { - "MISE_DATA_DIR": "/var/lib/mise", - "MISE_CACHE_DIR": "/var/cache/mise", - "PATH": "/var/lib/mise/shims:${PATH}" -}, -"mounts": [ - { - "source": "${devcontainerId}-mise-data", - "target": "/var/lib/mise", - "type": "volume" - }, - { - "source": "${devcontainerId}-mise-cache", - "target": "/var/cache/mise", - "type": "volume" - } -] -``` - -Both directories belong to a `mise` system group the remote user is added to, and -are group-writable and setgid, so they stay writable after the Dev Containers CLI -renumbers the remote user's UID. - -If an existing volume is reused after the host user's UID changed, files written -under the old UID keep their owner-only modes and mise cannot replace them; clear -them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this removes the installed -tools) or remove the volumes. - -The volumes are per dev container (`${devcontainerId}`). To share one across -projects, or to put a directory somewhere else, declare a mount on the same target -in `devcontainer.json`: mounts are merged by target with the last one winning, and -`devcontainer.json` is merged last, so it replaces the feature's. - -```json -"mounts": [ - { - "source": "mise-data-shared", - "target": "/var/lib/mise", - "type": "volume" - } -] -``` - -`containerEnv` is merged the same way, so setting `MISE_DATA_DIR` or -`MISE_CACHE_DIR` there points mise elsewhere and leaves the feature's mount in place -and unused; `PATH` then needs the new shims directory as well. +`PATH` cannot name a path in the remote user's home directory, because a feature's +`containerEnv` is fixed text and the remote user differs between images. The +feature therefore symlinks `/usr/local/share/mise` to the data directory and puts +`/usr/local/share/mise/shims` on `PATH`. Pointing `MISE_DATA_DIR` somewhere else +leaves that symlink behind, so `PATH` then needs the new shims directory. -mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files -have been trusted (`~/.local/state/mise`), stay in the remote user's home directory -and are recreated by a rebuild. +Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping +them on a volume. ## Not installed @@ -145,6 +103,24 @@ removed when the script exits. - For the tags this feature is published under, see [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). +- To keep the installed tools and the download cache across rebuilds, add named + volumes to `mounts` in `devcontainer.json`, with the targets under the remote + user's home directory: + + ```json + "mounts": [ + { + "source": "${devcontainerId}-mise-data", + "target": "/home/dev/.local/share/mise", + "type": "volume" + }, + { + "source": "${devcontainerId}-mise-cache", + "target": "/home/dev/.cache/mise", + "type": "volume" + } + ] + ``` - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. diff --git a/src/mise/README.md b/src/mise/README.md index aa387a3..e8aa42f 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -1,7 +1,7 @@ # mise (mise) -Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its tool installs and download cache kept on volumes. +Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its shims on PATH. ## Example Usage @@ -14,8 +14,7 @@ Installs mise, the polyglot tool and runtime manager, from its GitHub release, v The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) -is installed as `/usr/local/bin/mise`, and the directories mise installs tools into -and caches downloads in are placed on volumes the feature declares. +is installed as `/usr/local/bin/mise`, and its shims directory is put on `PATH`. ## Requirements @@ -57,60 +56,19 @@ for programs that do not load the shell configuration. ## Tool installs and download cache -mise keeps its tool installs in `MISE_DATA_DIR` and its download cache in -`MISE_CACHE_DIR`. The feature declares a volume for each and points mise at them: +mise keeps its tool installs in `~/.local/share/mise` and its download cache in +`~/.cache/mise`, its own defaults. Both are created for the remote user when the +feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` +above them. -```json -"containerEnv": { - "MISE_DATA_DIR": "/var/lib/mise", - "MISE_CACHE_DIR": "/var/cache/mise", - "PATH": "/var/lib/mise/shims:${PATH}" -}, -"mounts": [ - { - "source": "${devcontainerId}-mise-data", - "target": "/var/lib/mise", - "type": "volume" - }, - { - "source": "${devcontainerId}-mise-cache", - "target": "/var/cache/mise", - "type": "volume" - } -] -``` - -Both directories belong to a `mise` system group the remote user is added to, and -are group-writable and setgid, so they stay writable after the Dev Containers CLI -renumbers the remote user's UID. - -If an existing volume is reused after the host user's UID changed, files written -under the old UID keep their owner-only modes and mise cannot replace them; clear -them with `rm -rf /var/lib/mise/* /var/cache/mise/*` (this removes the installed -tools) or remove the volumes. - -The volumes are per dev container (`${devcontainerId}`). To share one across -projects, or to put a directory somewhere else, declare a mount on the same target -in `devcontainer.json`: mounts are merged by target with the last one winning, and -`devcontainer.json` is merged last, so it replaces the feature's. - -```json -"mounts": [ - { - "source": "mise-data-shared", - "target": "/var/lib/mise", - "type": "volume" - } -] -``` - -`containerEnv` is merged the same way, so setting `MISE_DATA_DIR` or -`MISE_CACHE_DIR` there points mise elsewhere and leaves the feature's mount in place -and unused; `PATH` then needs the new shims directory as well. +`PATH` cannot name a path in the remote user's home directory, because a feature's +`containerEnv` is fixed text and the remote user differs between images. The +feature therefore symlinks `/usr/local/share/mise` to the data directory and puts +`/usr/local/share/mise/shims` on `PATH`. Pointing `MISE_DATA_DIR` somewhere else +leaves that symlink behind, so `PATH` then needs the new shims directory. -mise's configuration (`~/.config/mise`) and state, including which `mise.toml` files -have been trusted (`~/.local/state/mise`), stay in the remote user's home directory -and are recreated by a rebuild. +Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping +them on a volume. ## Not installed @@ -160,6 +118,24 @@ removed when the script exits. - For the tags this feature is published under, see [Versions and pinning](https://github.com/bare-devcontainer/features#versions-and-pinning). +- To keep the installed tools and the download cache across rebuilds, add named + volumes to `mounts` in `devcontainer.json`, with the targets under the remote + user's home directory: + + ```json + "mounts": [ + { + "source": "${devcontainerId}-mise-data", + "target": "/home/dev/.local/share/mise", + "type": "volume" + }, + { + "source": "${devcontainerId}-mise-cache", + "target": "/home/dev/.cache/mise", + "type": "volume" + } + ] + ``` - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. diff --git a/src/mise/devcontainer-feature.json b/src/mise/devcontainer-feature.json index fcbfb05..5204475 100644 --- a/src/mise/devcontainer-feature.json +++ b/src/mise/devcontainer-feature.json @@ -3,24 +3,10 @@ "version": "1.0.0", "name": "mise", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/mise", - "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its tool installs and download cache kept on volumes.", + "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its shims on PATH.", "containerEnv": { - "MISE_DATA_DIR": "/var/lib/mise", - "MISE_CACHE_DIR": "/var/cache/mise", - "PATH": "/var/lib/mise/shims:${PATH}" + "PATH": "/usr/local/share/mise/shims:${PATH}" }, - "mounts": [ - { - "source": "${devcontainerId}-mise-data", - "target": "/var/lib/mise", - "type": "volume" - }, - { - "source": "${devcontainerId}-mise-cache", - "target": "/var/cache/mise", - "type": "volume" - } - ], "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" ] diff --git a/src/mise/install.sh b/src/mise/install.sh index 6e2d081..d60f147 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -8,6 +8,7 @@ # injected by the CLI: # # _REMOTE_USER The account the container is attached as. +# _REMOTE_USER_HOME That account's home directory. set -euo pipefail @@ -15,10 +16,12 @@ FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SHASUMS="${FEATURE_DIR}/SHASUMS256.txt" PREFIX="/usr/local" RELEASES_URL="https://github.com/jdx/mise/releases" -# Coupled to containerEnv and mounts in devcontainer-feature.json. -DATA_DIR="/var/lib/mise" -CACHE_DIR="/var/cache/mise" -GROUP="mise" +# mise's own defaults, relative to the remote user's home directory. +DATA_DIR=".local/share/mise" +CACHE_DIR=".cache/mise" +# Coupled to PATH in devcontainer-feature.json: a fixed path is the only kind +# containerEnv can name, and it resolves to DATA_DIR through a symlink. +SHIMS_PARENT="/usr/local/share/mise" if [ "$(id -u)" -ne 0 ]; then echo "(!) This feature must be installed as root." >&2 @@ -169,18 +172,30 @@ if ! getent passwd "${username}" >/dev/null; then echo "(!) Remote user '${username}' was not found in the password database." >&2 exit 1 fi +home="${_REMOTE_USER_HOME:-$(getent passwd "${username}" | cut -d: -f6)}" +group="$(id -gn "${username}")" + +# Gives every level it creates to the remote user too. ~/.local and ~/.cache +# are shared with everything else the account runs, so one left owned by root +# would lock the user out of directories this feature has nothing to do with. +make_user_dir() { + local path="$1" current="" part + while IFS= read -r part; do + [ -n "${part}" ] || continue + current="${current}/${part}" + if [ ! -e "${current}" ]; then + mkdir "${current}" + chown "${username}:${group}" "${current}" + fi + done <<< "${path//\//$'\n'}" +} -# The volumes are seeded from these directories, ownership included, and the -# Dev Containers CLI renumbers the remote user's UID without touching anything -# outside the home directory. Group membership is recorded by name and survives -# that, and the setgid bit keeps entries created later in the group. -if ! getent group "${GROUP}" >/dev/null; then - groupadd --system "${GROUP}" -fi -usermod -aG "${GROUP}" "${username}" +# Created here rather than left to mise so that a volume mounted over either of +# them is seeded with the remote user's ownership instead of root's. +make_user_dir "${home}/${DATA_DIR}" +make_user_dir "${home}/${CACHE_DIR}" -mkdir -p "${DATA_DIR}" "${CACHE_DIR}" -chown "${username}:${GROUP}" "${DATA_DIR}" "${CACHE_DIR}" -chmod 2775 "${DATA_DIR}" "${CACHE_DIR}" +mkdir -p "$(dirname "${SHIMS_PARENT}")" +ln -sfn "${home}/${DATA_DIR}" "${SHIMS_PARENT}" echo "Installed mise $("${PREFIX}/bin/mise" --version)." diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh index 17251e4..7bf175a 100755 --- a/src/mise/smoke_test.sh +++ b/src/mise/smoke_test.sh @@ -31,34 +31,29 @@ check "mise is installed under /usr/local" \ bash -c 'test "$(command -v mise)" = /usr/local/bin/mise' check "the installed binary is owned by root" \ bash -c 'test "$(stat -c %u /usr/local/bin/mise)" = 0' -check "MISE_DATA_DIR points at the mounted volume" \ - bash -c 'test "${MISE_DATA_DIR:-}" = /var/lib/mise' -check "MISE_CACHE_DIR points at the mounted volume" \ - bash -c 'test "${MISE_CACHE_DIR:-}" = /var/cache/mise' check "the shims directory is on PATH" \ - bash -c 'tr ":" "\n" <<< "${PATH}" | grep -qx /var/lib/mise/shims' -check "remote user belongs to the mise group" \ - bash -c 'id -nG | tr " " "\n" | grep -qx mise' -check "data and cache directories are group-writable and setgid" \ - bash -c 'test "$(stat -c "%A %G" /var/lib/mise)" = "drwxrwsr-x mise" - test "$(stat -c "%A %G" /var/cache/mise)" = "drwxrwsr-x mise"' -check "data and cache directories are writable by the remote user" \ - bash -c 'touch /var/lib/mise/.write-test /var/cache/mise/.write-test - rm /var/lib/mise/.write-test /var/cache/mise/.write-test' -check "files created there are owned by the remote user" \ - bash -c 'touch /var/lib/mise/.owner-test - owner="$(stat -c %u /var/lib/mise/.owner-test)" - rm /var/lib/mise/.owner-test - test "${owner}" = "$(id -u)"' + bash -c 'tr ":" "\n" <<< "${PATH}" | grep -qx /usr/local/share/mise/shims' +check "the shims path resolves into the remote user's home" \ + bash -c 'test "$(readlink -f /usr/local/share/mise)" = "${HOME}/.local/share/mise"' +check "the data and cache directories are owned by the remote user" \ + bash -c 'test "$(stat -c %u "${HOME}/.local/share/mise")" = "$(id -u)" + test "$(stat -c %u "${HOME}/.cache/mise")" = "$(id -u)"' +check "the directories above them are owned by the remote user" \ + bash -c 'for d in "${HOME}/.local" "${HOME}/.local/share" "${HOME}/.cache"; do + test "$(stat -c %u "${d}")" = "$(id -u)" || exit 1 + done' +check "the data directory is writable by the remote user" \ + bash -c 'touch "${HOME}/.local/share/mise/.write-test" + rm "${HOME}/.local/share/mise/.write-test"' # Node.js, whose backend takes the version list and the tarball from nodejs.org. # A backend that reaches the GitHub API cannot be used here: its unauthenticated # rate limit is per IP, and GitHub-hosted runners share one. check "mise installs and runs a tool" \ mise exec node@24 -- node -e 'console.log("Hello, world!")' check "the tool was installed into the data directory" \ - bash -c 'test -d /var/lib/mise/installs/node' + bash -c 'test -d "${HOME}/.local/share/mise/installs/node"' check "the tool's shim was written to the shims directory" \ - bash -c 'test -x /var/lib/mise/shims/node' + bash -c 'test -x /usr/local/share/mise/shims/node' if [ "${failures}" -ne 0 ]; then echo "${failures} check(s) failed." From 8da6de4a91da5da34629cf18d7f9b0151a5afd55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:17:25 +0000 Subject: [PATCH 11/16] feature(mise): create the tool directories with a loop, not a helper 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/install.sh | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/mise/install.sh b/src/mise/install.sh index d60f147..9f9fdac 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -175,25 +175,18 @@ fi home="${_REMOTE_USER_HOME:-$(getent passwd "${username}" | cut -d: -f6)}" group="$(id -gn "${username}")" -# Gives every level it creates to the remote user too. ~/.local and ~/.cache -# are shared with everything else the account runs, so one left owned by root -# would lock the user out of directories this feature has nothing to do with. -make_user_dir() { - local path="$1" current="" part - while IFS= read -r part; do - [ -n "${part}" ] || continue - current="${current}/${part}" - if [ ! -e "${current}" ]; then - mkdir "${current}" - chown "${username}:${group}" "${current}" - fi - done <<< "${path//\//$'\n'}" -} - # Created here rather than left to mise so that a volume mounted over either of -# them is seeded with the remote user's ownership instead of root's. -make_user_dir "${home}/${DATA_DIR}" -make_user_dir "${home}/${CACHE_DIR}" +# them is seeded with the remote user's ownership instead of root's. Every level +# is handed over, not just the last: ~/.local and ~/.cache are shared with the +# rest of the account, and one left owned by root would lock the user out of +# directories this feature has nothing to do with. +for dir in "${DATA_DIR}" "${CACHE_DIR}"; do + mkdir -p "${home}/${dir}" + while [ "${dir}" != "." ]; do + chown "${username}:${group}" "${home}/${dir}" + dir="$(dirname "${dir}")" + done +done mkdir -p "$(dirname "${SHIMS_PARENT}")" ln -sfn "${home}/${DATA_DIR}" "${SHIMS_PARENT}" From 722882a4b713bad5f71eb2f569a4df0d915f100c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:25:10 +0000 Subject: [PATCH 12/16] feature(mise): create the tool directories with one install -d 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/install.sh | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/mise/install.sh b/src/mise/install.sh index 9f9fdac..e07fd10 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -16,11 +16,9 @@ FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SHASUMS="${FEATURE_DIR}/SHASUMS256.txt" PREFIX="/usr/local" RELEASES_URL="https://github.com/jdx/mise/releases" -# mise's own defaults, relative to the remote user's home directory. -DATA_DIR=".local/share/mise" -CACHE_DIR=".cache/mise" # Coupled to PATH in devcontainer-feature.json: a fixed path is the only kind -# containerEnv can name, and it resolves to DATA_DIR through a symlink. +# containerEnv can name, and it resolves to mise's data directory through a +# symlink. SHIMS_PARENT="/usr/local/share/mise" if [ "$(id -u)" -ne 0 ]; then @@ -175,20 +173,15 @@ fi home="${_REMOTE_USER_HOME:-$(getent passwd "${username}" | cut -d: -f6)}" group="$(id -gn "${username}")" -# Created here rather than left to mise so that a volume mounted over either of -# them is seeded with the remote user's ownership instead of root's. Every level -# is handed over, not just the last: ~/.local and ~/.cache are shared with the -# rest of the account, and one left owned by root would lock the user out of -# directories this feature has nothing to do with. -for dir in "${DATA_DIR}" "${CACHE_DIR}"; do - mkdir -p "${home}/${dir}" - while [ "${dir}" != "." ]; do - chown "${username}:${group}" "${home}/${dir}" - dir="$(dirname "${dir}")" - done -done +# mise's own defaults, created here rather than left to mise so that a volume +# mounted over either is seeded with the remote user's ownership instead of +# root's. The levels above them are listed too: ~/.local and ~/.cache are shared +# with the rest of the account, so one left owned by root would lock the user +# out of directories this feature has nothing to do with. +install -d -o "${username}" -g "${group}" \ + "${home}/.local" "${home}/.local/share" "${home}/.local/share/mise" \ + "${home}/.cache" "${home}/.cache/mise" -mkdir -p "$(dirname "${SHIMS_PARENT}")" -ln -sfn "${home}/${DATA_DIR}" "${SHIMS_PARENT}" +ln -sfn "${home}/.local/share/mise" "${SHIMS_PARENT}" echo "Installed mise $("${PREFIX}/bin/mise" --version)." From 19a5de930fd7c9916df17b5468621d73c15a44fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:41:21 +0000 Subject: [PATCH 13/16] docs(mise): trim the comments the last two commits left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- src/mise/install.sh | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/mise/install.sh b/src/mise/install.sh index e07fd10..9399b22 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -16,9 +16,7 @@ FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SHASUMS="${FEATURE_DIR}/SHASUMS256.txt" PREFIX="/usr/local" RELEASES_URL="https://github.com/jdx/mise/releases" -# Coupled to PATH in devcontainer-feature.json: a fixed path is the only kind -# containerEnv can name, and it resolves to mise's data directory through a -# symlink. +# Coupled to PATH in devcontainer-feature.json. SHIMS_PARENT="/usr/local/share/mise" if [ "$(id -u)" -ne 0 ]; then @@ -69,9 +67,8 @@ install_prerequisites() { exit 1 fi - # An image that ships package lists means them to be there, and this script - # runs after it was built, so only lists its own apt-get update fetched are - # removed. "lock" and "partial" are left by a cleaned image and do not count. + # Only the lists this script's apt-get update fetched are removed; an image + # that ships its own means them to be kept. "lock" and "partial" do not count. local lists_shipped=false if find /var/lib/apt/lists -maxdepth 1 -type f ! -name lock -print -quit 2>/dev/null \ | grep -q .; then @@ -173,11 +170,9 @@ fi home="${_REMOTE_USER_HOME:-$(getent passwd "${username}" | cut -d: -f6)}" group="$(id -gn "${username}")" -# mise's own defaults, created here rather than left to mise so that a volume -# mounted over either is seeded with the remote user's ownership instead of -# root's. The levels above them are listed too: ~/.local and ~/.cache are shared -# with the rest of the account, so one left owned by root would lock the user -# out of directories this feature has nothing to do with. +# Created rather than left to mise, so a volume mounted over one is seeded with +# this owner. The parents are listed too, or root keeps the rest of ~/.local +# and ~/.cache. install -d -o "${username}" -g "${group}" \ "${home}/.local" "${home}/.local/share" "${home}/.local/share/mise" \ "${home}/.cache" "${home}/.cache/mise" From 2e2c6b842c5f1dab8c6f2fc7e863e5d0492a5b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 08:56:17 +0000 Subject: [PATCH 14/16] feature(mise): drop the PATH entry and the symlink it needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- README.md | 2 +- src/mise/NOTES.md | 28 ++++++++++++++++------------ src/mise/README.md | 30 +++++++++++++++++------------- src/mise/devcontainer-feature.json | 5 +---- src/mise/install.sh | 4 ---- src/mise/smoke_test.sh | 6 +----- 6 files changed, 36 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2b6efd8..4744415 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ signing material committed to this repository, and requires no other Feature. | Feature | What you get | Source and verification | |---------|--------------|-------------------------| | [claude-code](src/claude-code)
`ghcr.io/bare-devcontainer/features/claude-code` | Claude Code, Anthropic's agentic coding CLI, with the VS Code extension requested alongside it. Credentials, settings and history live on a volume, so a rebuild does not mean signing in again. | Anthropic's APT repository. The package signature is checked with a key committed to this repository. | -| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself, with its shims on `PATH` so an editor resolves them too. | [GitHub Releases](https://github.com/jdx/mise/releases). The download is checked against upstream's own checksum file, committed to this repository with the minisign signature published for it. | +| [mise](src/mise)
`ghcr.io/bare-devcontainer/features/mise` | mise, the polyglot tool and runtime manager, for the toolchains and CLIs a project pins itself. | [GitHub Releases](https://github.com/jdx/mise/releases). The download is checked against upstream's own checksum file, committed to this repository with the minisign signature published for it. | | [node](src/node)
`ghcr.io/bare-devcontainer/features/node` | Node.js, with Corepack in place of npm, so the package manager comes from the project's `packageManager` field rather than from the image. | [nodejs.org](https://nodejs.org/dist/). The release checksums are checked against the Node.js release keys committed to this repository. | Each Feature's README documents its options, requirements, what it deliberately leaves diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 9cdc846..6eedc61 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -1,5 +1,5 @@ The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) -is installed as `/usr/local/bin/mise`, and its shims directory is put on `PATH`. +is installed as `/usr/local/bin/mise`. ## Requirements @@ -35,9 +35,7 @@ newest feature release when the container is built; pin it to hold a mise releas - mise, the statically linked `linux-*-musl` build, installed as `/usr/local/bin/mise` and owned by root. -Nothing else: no tool is installed until the project asks for it. The shims -directory is on `PATH`, so a tool resolves as soon as mise installs it, including -for programs that do not load the shell configuration. +Nothing else: no tool is installed until the project asks for it. ## Tool installs and download cache @@ -46,12 +44,6 @@ mise keeps its tool installs in `~/.local/share/mise` and its download cache in feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` above them. -`PATH` cannot name a path in the remote user's home directory, because a feature's -`containerEnv` is fixed text and the remote user differs between images. The -feature therefore symlinks `/usr/local/share/mise` to the data directory and puts -`/usr/local/share/mise/shims` on `PATH`. Pointing `MISE_DATA_DIR` somewhere else -leaves that symlink behind, so `PATH` then needs the new shims directory. - Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping them on a volume. @@ -61,8 +53,8 @@ them on a volume. - **No development headers beyond libc.** A backend that downloads prebuilt binaries works as-is; one that builds from source needs the `-dev` packages of the libraries it links against. -- **No shell activation.** The shell configuration is left untouched; the shims - directory on `PATH` is what makes installed tools resolve. +- **No `PATH` entry and no shell activation.** `mise exec` and `mise run` work as + they are; see [Tips](#tips) for making tools resolve by name. ## Supply chain @@ -121,6 +113,18 @@ removed when the script exits. } ] ``` +- To have tools resolve by name, put the shims directory in `remoteEnv` in + `devcontainer.json`, with the path under the remote user's home directory: + + ```json + "remoteEnv": { + "PATH": "/home/dev/.local/share/mise/shims:${containerEnv:PATH}" + } + ``` + + `remoteEnv` applies to the remote user rather than to every process in the + container, which a feature's `containerEnv` cannot express. `mise activate` in + the shell configuration covers interactive shells instead. - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. diff --git a/src/mise/README.md b/src/mise/README.md index e8aa42f..c732b03 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -1,7 +1,7 @@ # mise (mise) -Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its shims on PATH. +Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository. ## Example Usage @@ -14,7 +14,7 @@ Installs mise, the polyglot tool and runtime manager, from its GitHub release, v The official release binary from [GitHub Releases](https://github.com/jdx/mise/releases) -is installed as `/usr/local/bin/mise`, and its shims directory is put on `PATH`. +is installed as `/usr/local/bin/mise`. ## Requirements @@ -50,9 +50,7 @@ newest feature release when the container is built; pin it to hold a mise releas - mise, the statically linked `linux-*-musl` build, installed as `/usr/local/bin/mise` and owned by root. -Nothing else: no tool is installed until the project asks for it. The shims -directory is on `PATH`, so a tool resolves as soon as mise installs it, including -for programs that do not load the shell configuration. +Nothing else: no tool is installed until the project asks for it. ## Tool installs and download cache @@ -61,12 +59,6 @@ mise keeps its tool installs in `~/.local/share/mise` and its download cache in feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` above them. -`PATH` cannot name a path in the remote user's home directory, because a feature's -`containerEnv` is fixed text and the remote user differs between images. The -feature therefore symlinks `/usr/local/share/mise` to the data directory and puts -`/usr/local/share/mise/shims` on `PATH`. Pointing `MISE_DATA_DIR` somewhere else -leaves that symlink behind, so `PATH` then needs the new shims directory. - Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping them on a volume. @@ -76,8 +68,8 @@ them on a volume. - **No development headers beyond libc.** A backend that downloads prebuilt binaries works as-is; one that builds from source needs the `-dev` packages of the libraries it links against. -- **No shell activation.** The shell configuration is left untouched; the shims - directory on `PATH` is what makes installed tools resolve. +- **No `PATH` entry and no shell activation.** `mise exec` and `mise run` work as + they are; see [Tips](#tips) for making tools resolve by name. ## Supply chain @@ -136,6 +128,18 @@ removed when the script exits. } ] ``` +- To have tools resolve by name, put the shims directory in `remoteEnv` in + `devcontainer.json`, with the path under the remote user's home directory: + + ```json + "remoteEnv": { + "PATH": "/home/dev/.local/share/mise/shims:${containerEnv:PATH}" + } + ``` + + `remoteEnv` applies to the remote user rather than to every process in the + container, which a feature's `containerEnv` cannot express. `mise activate` in + the shell configuration covers interactive shells instead. - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. diff --git a/src/mise/devcontainer-feature.json b/src/mise/devcontainer-feature.json index 5204475..e59e47e 100644 --- a/src/mise/devcontainer-feature.json +++ b/src/mise/devcontainer-feature.json @@ -3,10 +3,7 @@ "version": "1.0.0", "name": "mise", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/mise", - "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository, with its shims on PATH.", - "containerEnv": { - "PATH": "/usr/local/share/mise/shims:${PATH}" - }, + "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository.", "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" ] diff --git a/src/mise/install.sh b/src/mise/install.sh index 9399b22..6d6fc37 100755 --- a/src/mise/install.sh +++ b/src/mise/install.sh @@ -16,8 +16,6 @@ FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SHASUMS="${FEATURE_DIR}/SHASUMS256.txt" PREFIX="/usr/local" RELEASES_URL="https://github.com/jdx/mise/releases" -# Coupled to PATH in devcontainer-feature.json. -SHIMS_PARENT="/usr/local/share/mise" if [ "$(id -u)" -ne 0 ]; then echo "(!) This feature must be installed as root." >&2 @@ -177,6 +175,4 @@ install -d -o "${username}" -g "${group}" \ "${home}/.local" "${home}/.local/share" "${home}/.local/share/mise" \ "${home}/.cache" "${home}/.cache/mise" -ln -sfn "${home}/.local/share/mise" "${SHIMS_PARENT}" - echo "Installed mise $("${PREFIX}/bin/mise" --version)." diff --git a/src/mise/smoke_test.sh b/src/mise/smoke_test.sh index 7bf175a..5656f02 100755 --- a/src/mise/smoke_test.sh +++ b/src/mise/smoke_test.sh @@ -31,10 +31,6 @@ check "mise is installed under /usr/local" \ bash -c 'test "$(command -v mise)" = /usr/local/bin/mise' check "the installed binary is owned by root" \ bash -c 'test "$(stat -c %u /usr/local/bin/mise)" = 0' -check "the shims directory is on PATH" \ - bash -c 'tr ":" "\n" <<< "${PATH}" | grep -qx /usr/local/share/mise/shims' -check "the shims path resolves into the remote user's home" \ - bash -c 'test "$(readlink -f /usr/local/share/mise)" = "${HOME}/.local/share/mise"' check "the data and cache directories are owned by the remote user" \ bash -c 'test "$(stat -c %u "${HOME}/.local/share/mise")" = "$(id -u)" test "$(stat -c %u "${HOME}/.cache/mise")" = "$(id -u)"' @@ -53,7 +49,7 @@ check "mise installs and runs a tool" \ check "the tool was installed into the data directory" \ bash -c 'test -d "${HOME}/.local/share/mise/installs/node"' check "the tool's shim was written to the shims directory" \ - bash -c 'test -x /usr/local/share/mise/shims/node' + bash -c 'test -x "${HOME}/.local/share/mise/shims/node"' if [ "${failures}" -ne 0 ]; then echo "${failures} check(s) failed." From ea4291119cb3b8d17766c4e7ce9bce0655bda6f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:57:03 +0000 Subject: [PATCH 15/16] docs: drop maintainer mechanics from the user-facing READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- README.md | 9 +-------- src/mise/NOTES.md | 18 +++--------------- src/mise/README.md | 18 +++--------------- 3 files changed, 7 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4744415..3e4347b 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,11 @@ install script per Feature, and the signing material sitting next to it — a si upstream's own checksum file with its signature. Each Feature's README shows how to inspect what it verifies against. -**Where verification happens.** A Feature whose upstream signs its checksums ships those -checksums, so its install verifies the download with `sha256sum` alone; the `CI` workflow -checks them against upstream's signature on every change -([`scripts/verify-material.sh`](scripts/verify-material.sh)). - **How the trusted material changes.** Only through a pull request. The `Update Trusted Material` workflow ([`.github/workflows/update-material.yml`](.github/workflows/update-material.yml)) checks upstream weekly and opens one when a key has changed, so every rotation is visible in -the history of this repository rather than picked up silently at build time. Vendored -release checksums move only when the release they pin does, through -[`scripts/pin-checksums.sh`](scripts/pin-checksums.sh). +the history of this repository rather than picked up silently at build time. **What is not covered.** The upstream projects themselves are still trusted: verification proves a download came from them unaltered, not that what they published is sound. These diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 6eedc61..66171f7 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -41,8 +41,7 @@ Nothing else: no tool is installed until the project asks for it. mise keeps its tool installs in `~/.local/share/mise` and its download cache in `~/.cache/mise`, its own defaults. Both are created for the remote user when the -feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` -above them. +feature is installed. Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping them on a volume. @@ -61,19 +60,10 @@ them on a volume. The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked against `SHASUMS256.txt`, vendored with the feature: upstream's own checksum file, committed verbatim together with the minisign signature upstream published for it. -Checking the download therefore needs `sha256sum` and nothing else. - -The signature is verified in CI rather than in the container. The `CI` workflow runs -[`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) -on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` -using the vendored public key. The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise -repository, refreshed by this repository's `Update Trusted Material` workflow. The -checksums move only when the pinned release does, through -[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh), -which writes what it downloaded only once the signature verifies. +repository, refreshed by this repository's `Update Trusted Material` workflow. To check the material yourself before pinning the feature: @@ -122,9 +112,7 @@ removed when the script exits. } ``` - `remoteEnv` applies to the remote user rather than to every process in the - container, which a feature's `containerEnv` cannot express. `mise activate` in - the shell configuration covers interactive shells instead. + `mise activate` in the shell configuration covers interactive shells instead. - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. diff --git a/src/mise/README.md b/src/mise/README.md index c732b03..982842e 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -56,8 +56,7 @@ Nothing else: no tool is installed until the project asks for it. mise keeps its tool installs in `~/.local/share/mise` and its download cache in `~/.cache/mise`, its own defaults. Both are created for the remote user when the -feature is installed, along with the `~/.local`, `~/.local/share` and `~/.cache` -above them. +feature is installed. Neither directory survives a rebuild on its own; see [Tips](#tips) for keeping them on a volume. @@ -76,19 +75,10 @@ them on a volume. The binary is downloaded from `https://github.com/jdx/mise/releases/` and checked against `SHASUMS256.txt`, vendored with the feature: upstream's own checksum file, committed verbatim together with the minisign signature upstream published for it. -Checking the download therefore needs `sha256sum` and nothing else. - -The signature is verified in CI rather than in the container. The `CI` workflow runs -[`scripts/verify-material.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/verify-material.sh) -on every change, which checks `SHASUMS256.txt` against `SHASUMS256.txt.minisig` -using the vendored public key. The public key is a copy of [`minisign.pub`](https://github.com/jdx/mise/blob/main/minisign.pub) from the mise -repository, refreshed by this repository's `Update Trusted Material` workflow. The -checksums move only when the pinned release does, through -[`scripts/pin-checksums.sh`](https://github.com/bare-devcontainer/features/blob/main/scripts/pin-checksums.sh), -which writes what it downloaded only once the signature verifies. +repository, refreshed by this repository's `Update Trusted Material` workflow. To check the material yourself before pinning the feature: @@ -137,9 +127,7 @@ removed when the script exits. } ``` - `remoteEnv` applies to the remote user rather than to every process in the - container, which a feature's `containerEnv` cannot express. `mise activate` in - the shell configuration covers interactive shells instead. + `mise activate` in the shell configuration covers interactive shells instead. - [`MISE_PARANOID`](https://mise.jdx.dev/paranoid.html) in `containerEnv` makes mise re-verify the provenance of the tools it installs and refuse untrusted configuration. From 284a15a47ee89da6b2e07b6853598f835550da00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:13:57 +0000 Subject: [PATCH 16/16] chore(mise): version the feature as the mise release it installs 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 Claude-Session: https://claude.ai/code/session_01Ti123BqzFGF4RaFf4qM5XJ --- scripts/pin-checksums.sh | 16 ++++++++++------ src/mise/NOTES.md | 10 +++++----- src/mise/README.md | 12 ++++++------ src/mise/devcontainer-feature.json | 2 +- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/scripts/pin-checksums.sh b/scripts/pin-checksums.sh index 7442a8d..a8155f3 100755 --- a/scripts/pin-checksums.sh +++ b/scripts/pin-checksums.sh @@ -3,12 +3,11 @@ # pin-checksums.sh — re-pin a feature's vendored release checksums to a named # upstream release. Downloads the checksum file and the signature upstream # published for it, and writes both into the working tree only once the -# signature verifies against the vendored public key. Performs no git -# operations. +# signature verifies against the vendored public key, and sets the feature +# version to the release version. Performs no git operations. # # Which file belongs to which feature is listed in signed-material.json next to -# this script. The checksums decide which release the feature installs, so -# moving them also needs a version bump in its devcontainer-feature.json. +# this script. # # Requires: minisign, jq, wget # @@ -42,7 +41,8 @@ releases="https://github.com/${repository}/releases/download/${tag}" tmp_checksums=$(mktemp) tmp_signature=$(mktemp) -trap 'rm -f "$tmp_checksums" "$tmp_signature"' EXIT +tmp_metadata=$(mktemp) +trap 'rm -f "$tmp_checksums" "$tmp_signature" "$tmp_metadata"' EXIT echo "Downloading ${repository} ${tag} checksums" >&2 wget -q -T 30 -t 3 -O "$tmp_checksums" "${releases}/$(basename "$checksums")" @@ -60,4 +60,8 @@ chmod 644 "$tmp_checksums" "$tmp_signature" mv "$tmp_checksums" "$checksums" mv "$tmp_signature" "$signature" -echo "Pinned ${checksums} to ${tag}. Bump the feature version to publish it." >&2 +metadata="src/${feature}/devcontainer-feature.json" +jq --indent 4 --arg version "${tag#v}" '.version = $version' "$metadata" > "$tmp_metadata" +cat "$tmp_metadata" > "$metadata" + +echo "Pinned ${checksums} and ${metadata} to ${tag}." >&2 diff --git a/src/mise/NOTES.md b/src/mise/NOTES.md index 66171f7..acb6bc8 100644 --- a/src/mise/NOTES.md +++ b/src/mise/NOTES.md @@ -16,17 +16,17 @@ The feature takes no options: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1": {} + "ghcr.io/bare-devcontainer/features/mise:latest": {} } ``` -The checksums committed with the feature cover one mise release, so the feature -version is what selects which mise is installed. A tag such as `:1` resolves to the -newest feature release when the container is built; pin it to hold a mise release: +The feature version is the mise version it installs. `:latest` resolves to the +newest feature release when the container is built; pin the exact version to hold +a mise release: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1.0.0": {} + "ghcr.io/bare-devcontainer/features/mise:2026.9.10": {} } ``` diff --git a/src/mise/README.md b/src/mise/README.md index 982842e..1d15e7c 100644 --- a/src/mise/README.md +++ b/src/mise/README.md @@ -7,7 +7,7 @@ Installs mise, the polyglot tool and runtime manager, from its GitHub release, v ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1": {} + "ghcr.io/bare-devcontainer/features/mise:2026": {} } ``` @@ -31,17 +31,17 @@ The feature takes no options: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1": {} + "ghcr.io/bare-devcontainer/features/mise:latest": {} } ``` -The checksums committed with the feature cover one mise release, so the feature -version is what selects which mise is installed. A tag such as `:1` resolves to the -newest feature release when the container is built; pin it to hold a mise release: +The feature version is the mise version it installs. `:latest` resolves to the +newest feature release when the container is built; pin the exact version to hold +a mise release: ```json "features": { - "ghcr.io/bare-devcontainer/features/mise:1.0.0": {} + "ghcr.io/bare-devcontainer/features/mise:2026.9.10": {} } ``` diff --git a/src/mise/devcontainer-feature.json b/src/mise/devcontainer-feature.json index e59e47e..957964d 100644 --- a/src/mise/devcontainer-feature.json +++ b/src/mise/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "mise", - "version": "1.0.0", + "version": "2026.9.10", "name": "mise", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/mise", "description": "Installs mise, the polyglot tool and runtime manager, from its GitHub release, verified against checksums committed to this repository.",