Skip to content

feat(oci): build OCI images from mise.toml with per-tool layers#9273

Merged
jdx merged 27 commits intomainfrom
claude/gracious-elgamal-bdfb63
Apr 22, 2026
Merged

feat(oci): build OCI images from mise.toml with per-tool layers#9273
jdx merged 27 commits intomainfrom
claude/gracious-elgamal-bdfb63

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Apr 20, 2026

Summary

Adds a new mise oci build command that builds OCI container images directly from a mise.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 early RUN invalidates 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 current mise.toml, produces an OCI image layout in ./mise-oci/ (configurable via -o). Output is consumable by skopeo, crane, and podman load directly.
  • [oci] section in mise.toml for from, tag, workdir, entrypoint, cmd, user, mount_point, env, labels.
  • New settings: oci.default_from (default debian:bookworm-slim — a glibc-based image, so most mise-installed prebuilt binaries actually run; alpine/musl is a footgun we explicitly don't recommend) and oci.default_mount_point (default /mise).
  • --from CLI flag overrides base image. Anonymous OCI registry v2 pulls (Docker Hub / GHCR / quay) — no auth, no login flow. --from scratch or empty builds without a base.
  • Bakes env (mise [env] + per-tool exec_env()), PATH (each tool's bin dir), and per-tool labels (dev.mise.tools.<plugin>=<version>) into the image config.
  • Embeds the currently-running mise binary at /usr/local/bin/mise by default (--no-mise opts out).
  • Same-host reproducible layer digests: normalized tar headers (mtime=0, uid/gid=0, sorted entries, deterministic perms), gzip header mtime zeroed. Honors SOURCE_DATE_EPOCH for the created timestamp so manifests themselves can be fully reproducible.
  • asdf/vfox backends rejected — their install scripts can write outside the per-version dir, which would break the one-layer-per-tool invariant. Clear error. Re-enabling these via a sandboxed install contract is future work.

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 + build subcommand.
  • src/config/config_file/mise_toml.rs — new oci: Option<OciConfig> field.

Test plan

  • Unit tests in src/oci/layer.rs assert the tar builder is byte-identical across runs and digests differ for different prefixes.
  • E2E test at e2e/oci/test_oci_build_slow covers: basic build, reproducibility (same inputs → same digests under SOURCE_DATE_EPOCH), delta behavior (bumping a version only changes that tool's layer digest), image config PATH/labels, asdf/vfox rejection.
  • mise run lint-fix and cargo test --bin mise pass (719 tests).
  • Generated docs/completions/schema regenerated via 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 through skopeo copy oci:./mise-oci docker://...).
  • Authenticated registry pulls (private base images).
  • Cross-platform builds (build a linux/amd64 image on darwin/arm64).

🤖 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 oci command group (build, run, push) that can generate an OCI image-layout directory from the current mise.toml, optionally pull a base image from public registries, and then either run it via podman/docker+skopeo or push it via skopeo/crane.

Implements a new src/oci module 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 rejects asdf/vfox backends; also adds [oci] config support in mise.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.

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 20, 2026

Greptile Summary

This PR adds a new mise oci build command (plus oci run and oci push) that turns a mise.toml toolset into an OCI image layout with one content-addressable layer per tool. It introduces src/oci/ (manifest, layer, layout, registry, builder modules), src/cli/oci/, a new [oci] config section in mise.toml, and two new settings (oci.default_from, oci.default_mount_point).

All issues raised in earlier review rounds have been addressed: rfc3339_now() is captured once, path-traversal digest validation is in place with content verification, MISE_DATA_DIR/MISE_CONFIG_DIR are inserted last so user [env] cannot shadow them, the [oci] config now merges across layered config files via fill_defaults_from, normalize_os maps windowslinux, quay.io token auth is handled, absolute intra-tree symlinks are rebased to relative paths, and list_bin_paths is used for the PATH instead of a hardcoded /bin.

Confidence Score: 5/5

Safe 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

Filename Overview
src/oci/builder.rs Orchestrates full image build: base pull, per-tool layers, mise binary layer, config synthesis, env/PATH rebasing. Timestamp captured once, MISE_DATA_DIR inserted last, secrets warning present.
src/oci/registry.rs Anonymous OCI registry pull with Bearer token auth. Now uses shared HTTP client; quay.io token endpoint added; digest references parsed correctly; config/layer digests validated before any filesystem writes.
src/oci/layer.rs Reproducible tar builder with mtime/uid/gid normalization, deterministic entry ordering, and zeroed gzip header mtime. Absolute intra-tree symlinks are now rebased to relative paths.
src/oci/layout.rs OCI image layout writer. write_blob_with_digest now validates sha256 hex format (path traversal guard) and verifies sha256(bytes)==digest before writing.
src/oci/mod.rs Module root with normalize_arch/normalize_os helpers and OciConfig struct with fill_defaults_from merge logic. normalize_os now maps both "macos" and "windows" to "linux".
src/cli/oci/common.rs Shared perform_build helper and merged_oci_config that iterates all config files using fill_defaults_from (most-specific-first wins per field).
src/cli/oci/run.rs Build-and-run via podman or docker+skopeo. TempDir guard keeps layout alive during run; per-invocation docker tag avoids concurrent collisions; loaded image cleaned up post-run unless --keep.
src/cli/oci/push.rs Shells out to skopeo or crane for authenticated registry push; validates reference contains a slash before building; TempDir guard prevents layout accumulation.

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]
Loading

Reviews (19): Last reviewed commit: "refactor(oci): explicit in-image vs tar-..." | Re-trigger Greptile

Comment thread src/oci/registry.rs Outdated
Comment thread src/oci/layout.rs
Comment thread src/oci/builder.rs Outdated
Comment thread src/cli/oci/build.rs Outdated
Comment thread src/oci/builder.rs Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/oci/builder.rs Outdated
"{}/installs/{}/{}/bin",
mount_point,
tv.ba().short.replace([':', '/'], "-"),
tv.version
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The tool version is not being normalized here, but it is normalized in tool_prefix (line 476). This will cause the PATH in the image config to point to non-existent directories if the version string contains characters like : or /.

Suggested change
tv.version
tv.version.replace([':', '/'], "-")

Comment thread src/oci/registry.rs Outdated
}
// Build a request and add headers manually since HTTP::get_bytes doesn't
// currently expose a headers variant.
let client = reqwest::Client::new();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new reqwest::Client for every layer download is inefficient as it bypasses connection pooling. You should reuse the existing crate::http::HTTP client instead.

Comment thread src/oci/builder.rs Outdated
let short = &tv.ba().short;
let ver = &tv.version;
// Quote values to be safe against special chars.
s.push_str(&format!("\"{short}\" = \"{ver}\"\n"));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The tool name and version are inserted directly into a TOML string without escaping. If a tool name or version contains a double quote, the generated /etc/mise/config.toml will be invalid.

Comment thread src/oci/builder.rs Outdated
Comment on lines +390 to +432
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()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

rfc3339_now() is called multiple times during image configuration building. If the system clock ticks between these calls, the created timestamp in the manifest and the labels might differ slightly, which is inconsistent. It's better to capture the timestamp once and reuse it.

Comment thread src/oci/builder.rs Outdated
Comment on lines +350 to +357
// 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
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic assumes that every tool's binary directory is named bin. While common, some tools might have different or multiple binary paths. It would be more robust to use backend.list_bin_paths(tv) and relativize them against the install path.

Comment thread src/oci/layout.rs
autofix-ci Bot and others added 3 commits April 20, 2026 22:22
- 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>
Comment thread src/oci/builder.rs
Comment thread src/oci/registry.rs
Comment thread src/oci/builder.rs
Comment thread src/oci/layer.rs
Comment thread src/oci/builder.rs Outdated
- 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>
Comment thread src/oci/builder.rs Outdated
Comment thread src/oci/registry.rs Outdated
Comment thread src/oci/builder.rs Outdated
jdx and others added 3 commits April 20, 2026 17:41
…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>
Comment thread src/oci/mod.rs
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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

Hyperfine Performance

mise x -- echo

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%

Comment thread src/oci/registry.rs
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>
Comment thread src/oci/builder.rs
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>
Comment thread src/oci/builder.rs
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>
Comment thread src/oci/builder.rs
Comment thread src/oci/builder.rs
- 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>
Comment thread src/oci/registry.rs
Comment thread src/oci/builder.rs
jdx and others added 2 commits April 20, 2026 19:09
- 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>
Comment thread src/oci/builder.rs
…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>
Comment thread src/http.rs
jdx and others added 2 commits April 20, 2026 20:07
- `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>
Comment thread src/cli/oci/run.rs
Comment thread src/cli/oci/push.rs Outdated
jdx and others added 2 commits April 20, 2026 20:15
`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>
Comment thread src/oci/layer.rs
autofix-ci Bot and others added 2 commits April 21, 2026 01:24
…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>
Comment thread src/cli/oci/run.rs Outdated
…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>
Comment thread src/oci/builder.rs
…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>
Comment thread src/cli/oci/run.rs Outdated
Comment thread src/cli/oci/run.rs
- 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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread src/oci/builder.rs Outdated
Comment thread src/cli/oci/common.rs
…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>
@jdx jdx merged commit 2fcc84d into main Apr 22, 2026
37 checks passed
@jdx jdx deleted the claude/gracious-elgamal-bdfb63 branch April 22, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant