feat(oci): build OCI images from mise.toml with per-tool layers#9273
feat(oci): build OCI images from mise.toml with per-tool layers#9273
Conversation
Each installed tool becomes its own content-addressable OCI layer. Because mise tools install into isolated per-version directories, bumping any one tool's version invalidates exactly one blob — unlike a Dockerfile where changing an early `RUN` invalidates every layer above it. The command: - Writes an OCI image layout (oci-layout, index.json, blobs/sha256/...) that skopeo, crane, and podman consume natively. - Supports a configurable base image via `--from`, `[oci].from` in mise.toml, or the new `oci.default_from` setting (defaults to debian:bookworm-slim). Anonymous registry v2 pulls are supported. - Bakes env (config [env] + tool exec_env), PATH (each tool's bin dir), and labels into the image config. - Embeds the currently-running mise binary at /usr/local/bin/mise by default (disable with --no-mise). - Produces reproducible layer digests on the same machine; honors SOURCE_DATE_EPOCH for cross-invocation reproducibility. - Rejects asdf/vfox backends (external plugin scripts can escape the per-version install dir, breaking the one-layer-per-tool invariant). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds a new All issues raised in earlier review rounds have been addressed: Confidence Score: 5/5Safe to merge — all previous P0/P1 concerns have been resolved and no new critical issues were found. Every blocking concern from prior review rounds is addressed: single timestamp capture, path-traversal + content-verification guards on blob writes, correct env insertion order, per-registry token auth, Windows OS normalization, symlink rebasing, and proper multi-file OciConfig merging. The new code is well-tested and experimentally gated. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[mise oci build] --> B[Load Config + Toolset]
B --> C{from ref?}
C -- yes --> D[registry::pull_base_image\nvalidate digests\nwrite base blobs]
C -- no / scratch --> E[empty base layers]
D --> F
E --> F[Per-tool layers\nlayer::build_layer_from_dir\none blob per tool]
F --> G{include_mise?}
G -- yes --> H[mise binary layer\nbuild_layer_from_files]
G -- no --> I
H --> I[Config layer\netc/mise/config.toml]
I --> J[build_image_config\nenv + PATH rebased\nMISE_DATA_DIR last]
J --> K[layout::write_blob\nconfig + manifest + index.json]
K --> L[OCI image layout on disk]
L --> M{subcommand}
M -- oci build --> N[print manifest digest]
M -- oci run --> O[load into podman/docker\nrun container\ncleanup image]
M -- oci push --> P[skopeo/crane push\nto registry]
Reviews (19): Last reviewed commit: "refactor(oci): explicit in-image vs tar-..." | Re-trigger Greptile |
There was a problem hiding this comment.
Code Review
This pull request introduces the mise oci build command, enabling the creation of OCI container images where each tool version is isolated into its own content-addressable layer. The implementation prioritizes reproducibility by normalizing file permissions and timestamps within the generated layers. Review feedback highlights several areas for improvement, including the need to normalize version strings in the image's PATH to match the filesystem layout, ensuring proper escaping when synthesizing the embedded config.toml, and capturing timestamps once to maintain consistency across the manifest and labels. Additionally, suggestions were made to optimize network usage by reusing the existing HTTP client and to dynamically resolve tool binary paths instead of assuming a standard bin directory.
| "{}/installs/{}/{}/bin", | ||
| mount_point, | ||
| tv.ba().short.replace([':', '/'], "-"), | ||
| tv.version |
There was a problem hiding this comment.
| } | ||
| // Build a request and add headers manually since HTTP::get_bytes doesn't | ||
| // currently expose a headers variant. | ||
| let client = reqwest::Client::new(); |
| let short = &tv.ba().short; | ||
| let ver = &tv.version; | ||
| // Quote values to be safe against special chars. | ||
| s.push_str(&format!("\"{short}\" = \"{ver}\"\n")); |
| rfc3339_now(), | ||
| ); | ||
| labels.insert( | ||
| "org.opencontainers.image.source".to_string(), | ||
| "mise oci build".to_string(), | ||
| ); | ||
| labels.insert( | ||
| "dev.mise.version".to_string(), | ||
| crate::cli::version::VERSION_PLAIN.to_string(), | ||
| ); | ||
| for (_, tv) in versions { | ||
| labels.insert( | ||
| format!("dev.mise.tools.{}", sanitize_label(&tv.ba().short)), | ||
| tv.version.clone(), | ||
| ); | ||
| } | ||
| for (k, v) in &self.oci.labels { | ||
| labels.insert(k.clone(), v.clone()); | ||
| } | ||
|
|
||
| let config = ImgConfig { | ||
| env: env_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect(), | ||
| cmd, | ||
| entrypoint, | ||
| working_dir, | ||
| user, | ||
| labels, | ||
| exposed_ports: Default::default(), | ||
| volumes: Default::default(), | ||
| stop_signal: None, | ||
| }; | ||
|
|
||
| let (arch, os) = if let Some(p) = platform { | ||
| (p.architecture.clone(), p.os.clone()) | ||
| } else { | ||
| ( | ||
| normalize_arch(std::env::consts::ARCH).to_string(), | ||
| normalize_os(std::env::consts::OS).to_string(), | ||
| ) | ||
| }; | ||
|
|
||
| ImageConfig { | ||
| created: Some(rfc3339_now()), |
There was a problem hiding this comment.
| // Synthesize an in-image bin path: `<mount_point>/installs/<plugin>/<version>/bin`. | ||
| // This assumes the default layout (matches `list_bin_paths`'s default). | ||
| path_entries.push(format!( | ||
| "{}/installs/{}/{}/bin", | ||
| mount_point, | ||
| tv.ba().short.replace([':', '/'], "-"), | ||
| tv.version | ||
| )); |
- registry: use shared HTTP client (with new `get_bytes_with_headers` method) instead of a raw reqwest::Client so blob fetches inherit the timeout, retry, proxy, and TLS configuration. Drop the HEAD probe for auth; use the known-good anonymous realms for Docker Hub / GHCR instead. - layout: verify `sha256(bytes) == digest` in `write_blob_with_digest` so a corrupted or tampered registry response surfaces immediately with a clear message, not later as a confusing skopeo/podman mismatch. - builder: capture the RFC3339 build timestamp once so the `org.opencontainers.image.created` label and the image config `created` field can never disagree. - builder: construct PATH from each backend's `list_bin_paths()` rebased to the in-image mount point, instead of hardcoding `<install>/bin`. This makes single-binary tools (e.g. jq, whose install root *is* the bin path) discoverable inside the container. - cli: merge `[oci]` sections across all loaded config files field-by- field (most-specific wins) via a new `OciConfig::overlay` helper, so users can split the section between global and project configs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- layout: reject blob digests that aren't a literal `sha256:` followed by 64 lowercase hex chars. A malicious registry could otherwise return a digest like `sha256:../../etc/passwd`, and we'd write attacker-controlled bytes to an arbitrary filesystem path via the unfiltered `blob_path` join. Adds unit tests for the traversal cases. - builder: use the `toml` crate to serialize the embedded `/etc/mise/config.toml`, so tool names or versions containing `"` or `\` can't produce invalid TOML. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- builder: warn when `include_mise` is set and the host OS isn't linux. Images are linux-targeted in v1 (we normalize `os` to "linux"), so embedding a darwin/windows mise binary produced a valid-looking image that exploded with `Exec format error` at first `mise` invocation inside the container. The warning points to `--no-mise` as the escape hatch. - registry: add a quay.io case to `fetch_token_if_needed` so anonymous pulls of public quay.io images (which require a bearer token from `https://quay.io/v2/auth`) actually succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…helpers - builder: tool_prefix now uses `BackendArg::tool_dir_name()` and `ToolVersion::tv_pathname()` so the in-image path exactly matches mise's on-disk convention. The previous `short.replace([':','/'], "-")` diverged from the real `to_kebab_case()` transform (which lowercases, splits camelCase, strips non-alphanumerics), so e.g. `npm:@vue/cli` landed at `npm-@vue-cli` in the image while mise looks for it at `npm-vue-cli` — breaking resolution inside the container. - layer: collapse the gzip header rewrite to a single `len >= 10` guard. Gzip output is always ≥10 bytes (magic + CM + FLG + MTIME + XFL + OS), so the nested `>= 8` / `>= 10` check was split and the XFL write at byte 8 wasn't actually guarded by the inner check. - mod: hoist `normalize_arch` / `normalize_os` to `src/oci/mod.rs` as the single source of truth; builder and registry now both call the shared helpers so platform resolution can't drift between them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- builder: insert MISE_DATA_DIR / MISE_CONFIG_DIR *after* user [env] and [oci].env, so a `MISE_DATA_DIR = "..."` entry in the project config can't accidentally shadow the in-image value. Without this the embedded mise would look for tools at a host path that doesn't exist in the container. - builder: warn loudly when any [env] entries get baked into the image config — values from .env files can contain secrets that become visible via `docker inspect` / `skopeo inspect`. Point users at [oci].env for image-only values and runtime injection for secrets. - registry: split digest references (`name@sha256:…`) before the tag-parse step so the `:` inside the digest isn't mistaken for a tag separator. `ubuntu@sha256:…` now resolves correctly instead of producing a bogus `/v2/library/ubuntu@sha256/manifests/<hex>` URL. Added unit tests covering tag, digest, and custom-registry forms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- layer: gate `std::os::unix::fs::MetadataExt` behind `cfg(unix)` and replace the `md.mode() & 0o111` exec-bit check with a cross-platform `file_is_executable` helper. On Windows we fall back to extension heuristics (.exe, .bat, .cmd, .ps1, .com) since there's no POSIX exec bit on NTFS. - layer: normalize `\` → `/` in tar entry paths so Windows builds emit POSIX-style paths as the tar spec requires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows hosts were passing `"windows"` straight through, which broke both base-image pulls (multi-arch index lookups fail: no windows/amd64 variant of `debian:bookworm-slim` exists) and scratch builds (the image config's `os` field ended up `"windows"`, making the output unrunnable as a Linux container). Map windows the same way we already map macos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.18 x -- echo |
18.0 ± 0.2 | 17.5 | 20.4 | 1.00 |
mise x -- echo |
18.7 ± 0.2 | 18.2 | 19.6 | 1.04 ± 0.02 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.18 env |
17.5 ± 0.5 | 17.0 | 23.7 | 1.00 |
mise env |
18.4 ± 0.4 | 17.7 | 21.6 | 1.05 ± 0.04 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.18 hook-env |
18.2 ± 0.3 | 17.7 | 21.3 | 1.00 |
mise hook-env |
18.7 ± 0.3 | 18.2 | 20.9 | 1.03 ± 0.02 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.18 ls |
15.9 ± 0.2 | 15.5 | 16.8 | 1.00 |
mise ls |
16.5 ± 0.2 | 16.0 | 18.0 | 1.04 ± 0.02 |
xtasks/test/perf
| Command | mise-2026.4.18 | mise | Variance |
|---|---|---|---|
| install (cached) | 114ms | 120ms | -5% |
| ls (cached) | 60ms | 63ms | -4% |
| bin-paths (cached) | 64ms | 67ms | -4% |
| task-ls (cached) | 623ms | 618ms | +0% |
The `blob_path().exists()` skip-download shortcut in the layer-pull loop bypassed `write_blob_with_digest`'s format check. A malicious registry returning a path-traversal digest (`sha256:../../…`) would have `blob_path` resolve outside the layout, and if that arbitrary path happened to exist the blob was silently "cached" with the malicious digest propagating into the final manifest. Validate every manifest-supplied digest (config + all layers) up front, before any filesystem operations use them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If `config.env().await` failed (template error, missing env_file, circular reference, etc.) the builder silently skipped the entire [env] section and still produced a "successful" image — but without the user's expected env vars. Wire the error through `build` so the user sees the actual cause instead of a mysteriously broken image. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous check used `BackendType::guess(short.split(':').next())`,
which only matches literal "asdf" / "vfox" prefixes. Third-party vfox
plugins (whose tools use a custom plugin name as the prefix, e.g.
`my-plugin:tool`) have the same out-of-tree-write behavior but slipped
through unchecked, producing potentially broken one-layer-per-tool
images.
Ask the `Backend` trait object's `get_type()` instead, and include the
`VfoxBackend(_)` variant in the reject set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- builder: call `Backend::exec_env()` for every tool in the toolset and merge the result into the image config env. Paths in the returned values (JAVA_HOME, GOROOT, GEM_HOME, …) point at the host install dir, so rebase them onto the in-image tool root via a new `rebase_path_value` helper. Tools that rely on these vars now work inside the container even with `--no-mise`. - builder: validate that `mount_point` is an absolute path. A relative value would make MISE_DATA_DIR resolve against the container's workdir while the synthesized PATH entries are absolute, leaving mise unable to find its tools. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- registry: determine whether a manifest response is an index using three signals instead of one — (1) the body's `mediaType` field, (2) the response Content-Type header (previously ignored), and (3) structural fallback (body contains a `manifests` array and no mediaType). Per OCI spec `mediaType` is SHOULD not MUST, so some registries omit it — without this, an index manifest fell through to `parse_single_manifest` and failed with a confusing serde error. - builder: warn up front when building tool layers on a non-linux host. The existing `--no-mise` + mise-binary warning only covered the embedded mise; host-native tool binaries have the same `Exec format error` failure mode and `--no-mise` doesn't help. Run `mise oci build` on a linux host for a working image. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Gate `mise oci build` behind `Settings::ensure_experimental`, matching the pattern used by conda / dotnet / s3 / spm / swift / hooks. Running the command without `experimental=true` now errors with a clear "enable with `mise settings experimental=true`" message. - Label the command group and `build` subcommand as `[experimental]` in their doc comments so `--help` and the generated docs reflect it. - Add /dev-tools/mise-oci.md: a user guide covering the layering model, CLI + [oci] section + settings, env precedence and the secret-leak caveat, supported backends / base registries, digest refs, reproducibility, cross-platform caveats, and v1 limitations. - Link the new page from the docs sidebar (docs/.vitepress/config.ts). - Update the e2e test to set MISE_EXPERIMENTAL=1. - Regenerate usage.kdl, docs/cli/, completions, and fig autocomplete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ch-0 Previously `s.parse::<u64>().unwrap_or(0)` silently fell back to 1970-01-01 when SOURCE_DATE_EPOCH was set to a non-numeric value — indistinguishable from an intentional `SOURCE_DATE_EPOCH=0`, leaving users wondering why their reproducibility setting looked wrong. Warn and fall back to the system clock instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `mise oci run`: builds (or reuses via `--image-dir`) an OCI layout, loads it into a container engine, and runs a user command. Prefers `podman` (native OCI-layout support); falls back to `docker + skopeo`. Passes `-i`, `-t`, `-e`, `--volume`, `-w`, `--rm`/`--keep` through. No `-v` short for volume (mise reserves `-v` for --verbose); use `--volume` or `--mount`. - `mise oci push`: builds (or reuses via `--image-dir`) an OCI layout and pushes it via `skopeo copy` (preferred) or `crane push`. Auth is handled by the underlying tool. Validates the destination looks fully-qualified before doing anything expensive. - Both commands are gated behind `Settings::ensure_experimental` and produce clear errors when required external tools aren't on PATH. - Factor shared build setup into `src/cli/oci/common.rs` so `build`, `run`, and `push` assemble `BuildOptions` the same way. - Export `BuildOutput` from `crate::oci`. - e2e: new `e2e/oci/test_oci_run_push_errors_slow` covering argument validation and tool-detection error paths. - docs: per-command sections for `run` and `push` in `docs/dev-tools/mise-oci.md`; regenerated `docs/cli/oci/` and `mise.usage.kdl`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`get_bytes_with_headers` was merging `host_auth_headers` (which injects a GitHub / GitLab / Forgejo token when the host matches) on top of the caller's headers. The sibling `json_headers_with_headers` does not do that — the caller supplies the complete header set. For hosts that match both a host-auth rule and a registry flow (e.g. `ghcr.io`), the inconsistency produced conflicting auth: manifest JSON fetches used only the OCI Bearer token the caller built, while blob byte fetches got both that Bearer and the GitHub Authorization header injected by host_auth_headers — enough to confuse the registry's auth logic. Make `get_bytes_with_headers` match `json_headers_with_headers`: pass the caller's headers straight through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`cli::tests::test_subcommands_are_sorted` fails fast on any subcommand whose long-only flags are out of alphabetical order. Move `--from` ahead of `--image-dir` in both commands, and reorder `oci run` so all long-only flags (`engine`, `from`, `image-dir`, `keep`, `mount-point`, `no-mise`, `volume`) sit in order at the top of the struct, followed by the short-flagged ones (`-e`, `-i`, `-t`, `-w`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `oci run`: `podman tag oci:<dir> mise-oci:run` never worked — `podman
tag` takes an image name/ID, not a transport reference — and the
fallback warning lied about switching to the oci: ref. Instead,
capture the image ID that `podman pull --quiet` prints on stdout and
pass it directly to `podman run`. Deterministic across podman
versions and no longer relies on the layout's ref.name annotation.
Docker path is unchanged: `skopeo copy oci:<dir> docker-daemon:tag`
gives us a predictable tag.
- `oci run` / `oci push`: replace hand-rolled `std::env::temp_dir()
.join(format!("mise-oci-{pid}"))` with `tempfile::TempDir`. The
previous layout left potentially hundreds of megabytes of base-image
layers + tool layers + mise binary in /tmp after every invocation
(the `remove_dir_all` guard only fired on same-PID re-runs). The
`TempDir` guard is held for the duration of the command and cleans
up on drop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ll tree
A tool install often contains symlinks like
/<data_dir>/installs/node/20/bin/npm
→ /<data_dir>/installs/node/20/lib/node_modules/npm/bin/npm-cli.js
where the target is stored as an absolute host path. If we wrote that
verbatim into the tar, the symlink would dangle at runtime because the
container extracts the layer under /mise/installs/node/20 (or whatever
`mount_point` is), not the host's data dir.
For symlinks whose target is absolute AND falls under the tool's
install tree, rewrite the target to a *relative* path (`../lib/…`) so
it stays correct regardless of where the layer mounts inside the image.
Absolute targets outside the tree are left verbatim with a warning —
they'd be dangling anyway and rewriting needs information we don't
have at layer-build time.
Added a Unix-only unit test covering both cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocations The docker path in `load_image` always used the fixed tag `mise-oci:run`, so two concurrent `mise oci run` calls could have the second skopeo copy overwrite the image the first one was about to execute — producing "wrong image" surprises. Derive a per-invocation tag from the PID + nanosecond timestamp so each run operates on its own image ref. (The podman path already avoids this because it uses the pulled image ID, not a tag.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alid image
When pulling a base via `--from`, we were silently tolerating three
shapes of broken registry config:
1. No `rootfs.diff_ids` at all → base_diff_ids empty while
base_layers populated.
2. Non-string entries in the array → silently dropped by `filter_map`.
3. Count mismatch vs the manifest's layers → written anyway.
The OCI spec requires `rootfs.diff_ids.len() == manifest.layers.len()`;
any of the above produced an image that podman / skopeo reject with
a confusing downstream error. Fail at pull-time with a clear message
instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The `--keep`/`--rm` wording implied images were cleaned up by default, but `docker run --rm` / `podman run --rm` only removes the *container*. The loaded image stayed in engine storage forever, so every `mise oci run` accumulated another image. Now we `rmi --force` the per-invocation image (docker tag / podman image ID) after the run completes, regardless of success. `--keep` opts out. - The help text showed `mise oci run -e DEBUG=1 -v $PWD:/work …`, but `-v` is mise's global `--verbose` flag — the example would silently enable verbose mode and drop the volume. Updated to `--volume`. - Regenerated docs/cli/oci/run.md and mise.usage.kdl. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c008ec9. Configure here.
…nfig merge
- Replace the single `tool_prefix` helper (which returned a
tar-relative path that every absolute-path caller had to re-prepend
`/` to) with two named helpers:
* `tool_in_image_path(mount_point, tv)` — `/mise/installs/<p>/<v>`
* `tool_tar_prefix(mount_point, tv)` — `mise/installs/<p>/<v>`
Each caller picks the form it actually wants instead of round-
tripping the leading `/`. Cleans up the three exec_env / PATH /
layer-builder sites.
- Rework `OciConfig` merge from `overlay` ("later wins") to
`fill_defaults_from` ("first-Some wins"), and iterate
`config_files` in its natural order. The previous version depended
on a specific interpretation of mise's `config_files` insertion
ordering + `.rev()`; if that assumption ever inverted, fields
silently flipped their precedence. The new shape is stable: the
first value encountered for each field wins, and only *which*
config that is depends on iteration order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Summary
Adds a new
mise oci buildcommand that builds OCI container images directly from amise.toml. The key design choice: each tool version gets its own content-addressable OCI layer, so bumping any single tool invalidates exactly one blob — unlike a Dockerfile where changing an earlyRUNinvalidates every layer above it.This works because mise installs tools to isolated, non-overlapping directories (
$MISE_DATA_DIR/installs/<plugin>/<version>/), so layer ordering is semantically irrelevant. Swapping a version swaps one layer; everything else (base image, other tools, mise binary, config) stays identical and gets reused from the registry / local cache.What's in this PR
mise oci build— reads currentmise.toml, produces an OCI image layout in./mise-oci/(configurable via-o). Output is consumable byskopeo,crane, andpodman loaddirectly.[oci]section inmise.tomlforfrom,tag,workdir,entrypoint,cmd,user,mount_point,env,labels.oci.default_from(defaultdebian:bookworm-slim— a glibc-based image, so most mise-installed prebuilt binaries actually run; alpine/musl is a footgun we explicitly don't recommend) andoci.default_mount_point(default/mise).--fromCLI flag overrides base image. Anonymous OCI registry v2 pulls (Docker Hub / GHCR / quay) — no auth, no login flow.--from scratchor empty builds without a base.[env]+ per-toolexec_env()), PATH (each tool's bin dir), and per-tool labels (dev.mise.tools.<plugin>=<version>) into the image config./usr/local/bin/miseby default (--no-miseopts out).SOURCE_DATE_EPOCHfor thecreatedtimestamp so manifests themselves can be fully reproducible.Module layout
src/oci/—manifest(hand-rolled serde structs matching the OCI image-spec),layer(reproducible tar builder),layout(image-layout writer),registry(anonymous base pull),builder(orchestrator).src/cli/oci/— command group dispatcher +buildsubcommand.src/config/config_file/mise_toml.rs— newoci: Option<OciConfig>field.Test plan
src/oci/layer.rsassert the tar builder is byte-identical across runs and digests differ for different prefixes.e2e/oci/test_oci_build_slowcovers: basic build, reproducibility (same inputs → same digests underSOURCE_DATE_EPOCH), delta behavior (bumping a version only changes that tool's layer digest), image config PATH/labels, asdf/vfox rejection.mise run lint-fixandcargo test --bin misepass (719 tests).mise run render.What's next (not in this PR)
mise oci run— convenience wrapper to build +docker run.mise oci push— native registry push (v1 users go throughskopeo copy oci:./mise-oci docker://...).🤖 Generated with Claude Code
Note
Medium Risk
Adds a new experimental CLI surface that pulls base images over HTTP, writes OCI image layouts, and shells out to container/registry tooling; despite gating, it introduces new security- and correctness-sensitive paths (digest validation, env baking, external command execution).
Overview
Adds an experimental
mise ocicommand group (build,run,push) that can generate an OCI image-layout directory from the currentmise.toml, optionally pull a base image from public registries, and then either run it viapodman/docker+skopeoor push it viaskopeo/crane.Implements a new
src/ocimodule that builds reproducible, per-tool OCI layers, synthesizes an embedded/etc/mise/config.toml, bakes env/PATH/labels into the image config (with warnings about secrets), and rejectsasdf/vfoxbackends; also adds[oci]config support inmise.toml, new settings defaults (oci.default_from,oci.default_mount_point), and updates generated docs/manpages/schema/completions plus new e2e coverage for build determinism and error handling.Reviewed by Cursor Bugbot for commit 1ed597a. Bugbot is set up for automated code reviews on this repo. Configure here.