From fc6d7e04bf72f05753b27468ff243f96d3676043 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:05:07 +0200 Subject: [PATCH 01/38] security: add locked mise runtime config --- mise.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..eafa693 --- /dev/null +++ b/mise.toml @@ -0,0 +1,9 @@ +[settings] +experimental = true +lockfile = true +lockfile_platforms = ["linux-x64", "linux-arm64"] + +[tools] +python = "3.14.6" +node = "24.18.0" +uv = "0.11.32" From ecc8f22b43724501a934c12e6aa940ca95200e4c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:05:30 +0200 Subject: [PATCH 02/38] security: commit mise runtime artifacts --- mise.lock | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 mise.lock diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..767a6fa --- /dev/null +++ b/mise.lock @@ -0,0 +1,43 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.node]] +version = "24.18.0" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508" +url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:783130984963db7ba9cbd01089eaf2c2efb055c7c1693c943174b967b3050cb8" +url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-x64.tar.gz" + +[[tools.python]] +version = "3.14.6" +backend = "core:python" + +[tools.python."platforms.linux-arm64"] +checksum = "sha256:f177d40ca931df03f660fc006f86ad8cd2ac6e7d6b5d54edbc625103464fc4aa" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.14.6+20260623-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64"] +checksum = "sha256:c172314f4a8ec137a8f605289010c3d19c8b56867d968f0095074cc68efa1d29" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.14.6+20260623-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[[tools.uv]] +version = "0.11.32" +backend = "aqua:astral-sh/uv" + +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:d70cdae687feb6aad9a09fe8d686df8c8efaf69a1007fa581379a2025adc10a5" +url = "https://github.com/astral-sh/uv/releases/download/0.11.32/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/487747547" +provenance = "github-attestations" + +[tools.uv."platforms.linux-x64"] +checksum = "sha256:1fd052f196108d87e61fc3d98fe06b4ec758c9a1eb1466a6fd1a436fe45885f2" +url = "https://github.com/astral-sh/uv/releases/download/0.11.32/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/487747669" +provenance = "github-attestations" From 01fa1937c1f97ddd55d26f82679b1d76c264fb57 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:06:14 +0200 Subject: [PATCH 03/38] security: validate mise runtime lock --- scripts/validate-mise-lock.py | 213 ++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 scripts/validate-mise-lock.py diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py new file mode 100644 index 0000000..a7a4124 --- /dev/null +++ b/scripts/validate-mise-lock.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Validate committed mise runtime configuration and artifact lock data.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path +from typing import Any, NoReturn + +ASSIGNMENT_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$") +SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +PLATFORMS = ("linux-x64", "linux-arm64") + + +def fail(message: str) -> NoReturn: + print(f"ERROR: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + match = ASSIGNMENT_RE.fullmatch(line) + if not match: + fail(f"{path}:{number} is not a simple NAME=value assignment") + values[match.group(1)] = match.group(2) + return values + + +def load_toml(path: Path) -> dict[str, Any]: + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as exc: + fail(f"cannot read valid TOML from {path}: {exc}") + + +def expect_string(mapping: dict[str, Any], key: str, context: str) -> str: + value = mapping.get(key) + if not isinstance(value, str) or not value: + fail(f"{context}.{key} must be a non-empty string") + return value + + +def platform_info(entry: dict[str, Any], platform: str, tool: str) -> dict[str, Any]: + key = f"platforms.{platform}" + value = entry.get(key) + if not isinstance(value, dict): + fail(f"mise.lock has no {tool} artifact entry for {platform}") + return value + + +def validate_url(tool: str, version: str, platform: str, url: str) -> None: + arch = {"linux-x64": "x86_64", "linux-arm64": "aarch64"}[platform] + if tool == "node": + node_arch = "x64" if platform == "linux-x64" else "arm64" + expected = ( + f"https://nodejs.org/dist/v{version}/" + f"node-v{version}-linux-{node_arch}.tar.gz" + ) + if url != expected: + fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") + return + + if tool == "python": + pattern = re.compile( + rf"^https://github\.com/astral-sh/python-build-standalone/releases/download/" + rf"(?P[0-9]{{8}})/cpython-{re.escape(version)}\+(?P=date)-{arch}-unknown-linux-gnu-" + rf"install_only_stripped\.tar\.gz$" + ) + if not pattern.fullmatch(url): + fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") + return + + if tool == "uv": + expected = ( + f"https://github.com/astral-sh/uv/releases/download/{version}/" + f"uv-{arch}-unknown-linux-musl.tar.gz" + ) + if url != expected: + fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") + return + + fail(f"no URL policy defined for mise tool {tool}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the parent of scripts/)", + ) + args = parser.parse_args() + root = args.root.resolve() + + env = load_env(root / "versions.env") + expected_versions = { + "python": env.get("PYTHON_VERSION", ""), + "node": env.get("NODE_VERSION", ""), + "uv": env.get("UV_VERSION", ""), + } + for tool, version in expected_versions.items(): + if not version: + fail(f"versions.env has no version for {tool}") + + config = load_toml(root / "mise.toml") + settings = config.get("settings") + if not isinstance(settings, dict): + fail("mise.toml must contain [settings]") + if settings.get("lockfile") is not True: + fail("mise.toml must enable settings.lockfile") + configured_platforms = settings.get("lockfile_platforms") + if configured_platforms != list(PLATFORMS): + fail( + "mise.toml settings.lockfile_platforms must be exactly " + f"{list(PLATFORMS)!r}, got {configured_platforms!r}" + ) + + configured_tools = config.get("tools") + if not isinstance(configured_tools, dict): + fail("mise.toml must contain [tools]") + if set(configured_tools) != set(expected_versions): + fail( + "mise.toml must define exactly the managed runtimes " + f"{sorted(expected_versions)}, got {sorted(configured_tools)}" + ) + for tool, expected_version in expected_versions.items(): + if configured_tools.get(tool) != expected_version: + fail( + f"mise.toml {tool} version {configured_tools.get(tool)!r} does not match " + f"versions.env {expected_version!r}" + ) + + lock = load_toml(root / "mise.lock") + lock_tools = lock.get("tools") + if not isinstance(lock_tools, dict): + fail("mise.lock must contain tool entries") + if set(lock_tools) != set(expected_versions): + fail( + "mise.lock must contain exactly the managed runtimes " + f"{sorted(expected_versions)}, got {sorted(lock_tools)}" + ) + + expected_backends = { + "python": "core:python", + "node": "core:node", + "uv": "aqua:astral-sh/uv", + } + for tool, expected_version in expected_versions.items(): + entries = lock_tools.get(tool) + if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict): + fail(f"mise.lock must contain exactly one {tool} entry") + entry = entries[0] + version = expect_string(entry, "version", f"mise.lock tools.{tool}") + backend = expect_string(entry, "backend", f"mise.lock tools.{tool}") + if version != expected_version: + fail( + f"mise.lock {tool} version {version!r} does not match " + f"versions.env {expected_version!r}" + ) + if backend != expected_backends[tool]: + fail( + f"mise.lock {tool} backend {backend!r} does not match " + f"{expected_backends[tool]!r}" + ) + + locked_platforms = { + key.removeprefix("platforms.") + for key, value in entry.items() + if key.startswith("platforms.") and isinstance(value, dict) + } + if locked_platforms != set(PLATFORMS): + fail( + f"mise.lock {tool} platforms must be exactly {sorted(PLATFORMS)}, " + f"got {sorted(locked_platforms)}" + ) + + for platform in PLATFORMS: + artifact = platform_info(entry, platform, tool) + checksum = expect_string( + artifact, "checksum", f"mise.lock tools.{tool}.{platform}" + ) + if not SHA256_RE.fullmatch(checksum): + fail( + f"mise.lock {tool} checksum for {platform} must be an exact " + f"lowercase SHA-256: {checksum}" + ) + url = expect_string(artifact, "url", f"mise.lock tools.{tool}.{platform}") + validate_url(tool, version, platform, url) + if tool in {"python", "uv"} and artifact.get("provenance") != "github-attestations": + fail( + f"mise.lock {tool} artifact for {platform} must require " + "GitHub artifact attestations" + ) + + print( + "mise runtime lock is coherent for " + + ", ".join(f"{tool} {version}" for tool, version in expected_versions.items()) + + " on linux-x64 and linux-arm64." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d6fae663a4e82be10cdcd11934fefac62c611df7 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:06:33 +0200 Subject: [PATCH 04/38] security: add mise lock regeneration helper --- scripts/regenerate-mise-lock.sh | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/regenerate-mise-lock.sh diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh new file mode 100644 index 0000000..18673b4 --- /dev/null +++ b/scripts/regenerate-mise-lock.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck disable=SC1091 +source "$ROOT/versions.env" + +MISE_BIN="${MISE_BIN:-mise}" +if ! command -v "$MISE_BIN" >/dev/null 2>&1; then + echo "ERROR: mise is required to regenerate mise.lock" >&2 + exit 1 +fi + +installed_version="$("$MISE_BIN" --version | awk '{print $1}')" +if [[ "$installed_version" != "$MISE_VERSION" ]]; then + cat >&2 < Date: Tue, 28 Jul 2026 09:07:41 +0200 Subject: [PATCH 05/38] security: install mise runtimes from lockfile --- images/base/Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 4e8b6c6..fbc5361 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -39,7 +39,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ MISE_DATA_DIR=/opt/remote-dev/mise \ MISE_CACHE_DIR=/opt/remote-dev/mise-cache \ MISE_CONFIG_DIR=/etc/mise \ - MISE_GLOBAL_CONFIG_FILE=/etc/mise/config.toml \ + MISE_GLOBAL_CONFIG_FILE=/etc/mise/mise.toml \ PATH=/opt/remote-dev/mise/shims:/opt/remote-dev/mise/bin:/root/.local/bin:${PATH} \ GH_CONFIG_DIR=/root/.config/gh \ GH_HOST=github.com \ @@ -148,13 +148,13 @@ RUN case "${TARGETARCH}" in \ && cd / \ && rm -rf "$workdir" +# Runtime config and resolved artifacts are immutable build inputs. Strict locked +# installation refuses missing URLs and verifies every committed checksum. +COPY --chmod=0444 mise.toml mise.lock /etc/mise/ + # One current runtime per language. Other runtimes are deliberately not bundled. RUN mkdir -p "$MISE_DATA_DIR" "$MISE_CACHE_DIR" "$MISE_CONFIG_DIR" \ - && mise settings set experimental true \ - && mise use --global \ - "python@${PYTHON_VERSION}" \ - "node@${NODE_VERSION}" \ - "uv@${UV_VERSION}" \ + && mise install --locked \ && npm install --global --ignore-scripts --no-audit --no-fund "npm@${NPM_VERSION}" \ && python --version \ && node --version \ From 1eb6c0c0d75f6323b6ef6ce825d4503db1915aac Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:08:31 +0200 Subject: [PATCH 06/38] security: validate locked mise build inputs --- scripts/validate-version-pins.sh | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/validate-version-pins.sh b/scripts/validate-version-pins.sh index ba7f7ac..7f118fa 100644 --- a/scripts/validate-version-pins.sh +++ b/scripts/validate-version-pins.sh @@ -100,6 +100,24 @@ if ! grep -Fxq 'FROM ubuntu:${UBUNTU_VERSION}@${UBUNTU_DIGEST}' "$base_dockerfil fi require_action_shas +if ! grep -Fq 'MISE_GLOBAL_CONFIG_FILE=/etc/mise/mise.toml' "$base_dockerfile"; then + echo "ERROR: base Dockerfile must use the committed mise.toml as its global config" >&2 + exit 1 +fi +if ! grep -Fq 'COPY --chmod=0444 mise.toml mise.lock /etc/mise/' "$base_dockerfile"; then + echo "ERROR: base Dockerfile must copy immutable mise config and lock inputs" >&2 + exit 1 +fi +if ! grep -Fq 'mise install --locked' "$base_dockerfile"; then + echo "ERROR: base Dockerfile must install mise runtimes in locked mode" >&2 + exit 1 +fi +if grep -Fq 'mise use --global' "$base_dockerfile"; then + echo "ERROR: base Dockerfile must not resolve mise runtimes dynamically" >&2 + exit 1 +fi +python3 "$ROOT/scripts/validate-mise-lock.py" --root "$ROOT" + if [[ ! "$UBUNTU_VERSION" =~ ^[0-9]*[02468]\.04$ ]]; then echo "ERROR: UBUNTU_VERSION must be an explicit Ubuntu LTS release tag: $UBUNTU_VERSION" >&2 exit 1 @@ -191,4 +209,4 @@ printf 'Python release pin: %s\n' "$PYTHON_VERSION" printf 'Node LTS release pin: %s\n' "$NODE_VERSION" printf 'npm release pin: %s\n' "$NPM_VERSION" printf 'uv release pin: %s\n' "$UV_VERSION" -echo "Release asset SHA-256 pins are present and synchronized." +echo "Release asset SHA-256 pins and mise runtime lock data are present and synchronized." From ed21ab9c3929e21dc977d03bbe9f6709f071f3ab Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:10:28 +0200 Subject: [PATCH 07/38] security: regenerate mise lock in upstream automation --- .github/workflows/check-upstream.yml | 43 ++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml index 202a10d..1fa6f58 100644 --- a/.github/workflows/check-upstream.yml +++ b/.github/workflows/check-upstream.yml @@ -180,6 +180,14 @@ jobs: sed -i "s|^ARG ${name}=.*|ARG ${name}=${value}|" "$file" } + replace_mise_tool() { + local name="$1" + local value="$2" + grep -q "^${name} = \"" mise.toml \ + || { echo "ERROR: mise.toml has no ${name} tool pin" >&2; exit 1; } + sed -i "s|^${name} = .*|${name} = \"${value}\"|" mise.toml + } + replace_env CODEX_RELEASE_TAG "$latest_codex" replace_env CODEX_AMD64_SHA256 "$codex_amd64_sha256" replace_env CODEX_ARM64_SHA256 "$codex_arm64_sha256" @@ -214,19 +222,38 @@ jobs: replace_arg images/base/Dockerfile NPM_VERSION "$latest_npm" replace_arg images/base/Dockerfile UV_VERSION "$latest_uv" + replace_mise_tool python "$latest_python" + replace_mise_tool node "$latest_node" + replace_mise_tool uv "$latest_uv" + + mise_bin="$workdir/mise" + curl "${curl_args[@]}" \ + "https://github.com/jdx/mise/releases/download/v${latest_mise}/mise-v${latest_mise}-linux-x64" \ + -o "$mise_bin" + printf '%s %s\n' "$mise_amd64_sha256" "$mise_bin" | sha256sum -c - + chmod 0755 "$mise_bin" + MISE_BIN="$mise_bin" bash scripts/regenerate-mise-lock.sh + bash scripts/validate-version-pins.sh - if git diff --quiet -- versions.env images/base/Dockerfile images/codex/Dockerfile; then - echo "No upstream release or digest changes." + tracked_files=( + versions.env + mise.toml + mise.lock + images/base/Dockerfile + images/codex/Dockerfile + ) + if git diff --quiet -- "${tracked_files[@]}"; then + echo "No upstream release, digest or runtime artifact changes." if [[ -n "$existing_pr" ]]; then gh pr close "$existing_pr" \ - --comment "Closing because main already contains the latest tracked stable releases and asset digests." + --comment "Closing because main already contains the latest tracked stable releases, digests and locked runtime artifacts." fi exit 0 fi - git add versions.env images/base/Dockerfile images/codex/Dockerfile - git commit -m "chore: update upstream versions and digests" + git add "${tracked_files[@]}" + git commit -m "chore: update upstream versions, digests, and runtime lock" remote_ref="refs/remotes/origin/$branch" if git rev-parse --verify "$remote_ref" >/dev/null 2>&1 && @@ -245,15 +272,15 @@ jobs: --json number \ --jq '.[0].number // empty')" - body="Automated stable upstream release and digest update. Tracks final Codex, GitHub CLI, ttyd, mise and uv releases plus maintenance updates within Python 3.14, Node 24 LTS and npm 12. Merge only after the required AMD64 build, image vulnerability scans, runtime smoke tests and review pass. Build AMD64 is dispatched explicitly because pull-request runs created with GITHUB_TOKEN otherwise require manual approval. Merging publishes a new public edge image; stable image tags are not changed." + body="Automated stable upstream release, digest and mise runtime-lock update. Tracks final Codex, GitHub CLI, ttyd, mise and uv releases plus maintenance updates within Python 3.14, Node 24 LTS and npm 12. The same PR regenerates exact AMD64 and ARM64 runtime artifact URLs, SHA-256 values and provenance with the verified pinned mise binary. Merge only after the required AMD64 build, image vulnerability scans, runtime smoke tests and review pass. Build AMD64 is dispatched explicitly because pull-request runs created with GITHUB_TOKEN otherwise require manual approval. Merging publishes a new public edge image; stable image tags are not changed." if [[ -n "$existing_pr" ]]; then gh pr edit "$existing_pr" \ - --title "chore: update stable upstream versions and digests" \ + --title "chore: update stable upstream versions and runtime lock" \ --body "$body" else gh pr create \ - --title "chore: update stable upstream versions and digests" \ + --title "chore: update stable upstream versions and runtime lock" \ --body "$body" \ --base main \ --head "$branch" From 0a7a0f4a3fc89fd5ce9a051975ee50dfde2c6713 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:11:03 +0200 Subject: [PATCH 08/38] docs: explain locked runtime maintenance --- docs/runtime-locks.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/runtime-locks.md diff --git a/docs/runtime-locks.md b/docs/runtime-locks.md new file mode 100644 index 0000000..4678655 --- /dev/null +++ b/docs/runtime-locks.md @@ -0,0 +1,43 @@ +# Locked mise runtimes + +Python, Node.js and uv are installed by mise, but their build inputs are committed rather than resolved dynamically during the image build. + +## Source of truth + +The runtime pins are represented in three places for different purposes: + +- `versions.env` supplies reviewed repository and build arguments. +- `mise.toml` declares the exact mise-managed runtime versions. +- `mise.lock` records the resolved Linux AMD64 and ARM64 artifact URLs, SHA-256 checksums and available provenance requirements. + +`scripts/validate-version-pins.sh` fails when these files or the base Dockerfile disagree. The Dockerfile copies `mise.toml` and `mise.lock` as read-only inputs and runs `mise install --locked`; a missing artifact entry, dynamic-resolution requirement, provenance failure or checksum mismatch stops the build. + +npm is intentionally excluded from `mise.lock` because the image installs it separately from the npm registry. + +## Regenerate the lockfile + +Use the exact mise release pinned by `MISE_VERSION` in `versions.env`. The helper rejects any other mise version and isolates the command from user-global mise configuration. + +```bash +source versions.env +mise --version +bash scripts/regenerate-mise-lock.sh +bash scripts/validate-version-pins.sh +``` + +When changing Python, Node.js or uv: + +1. Update the version in `versions.env`. +2. Update the matching `ARG` default in `images/base/Dockerfile`. +3. Update the matching tool in `mise.toml`. +4. Run `scripts/regenerate-mise-lock.sh` with the pinned mise release. +5. Review every changed URL, SHA-256 and provenance field for both `linux-x64` and `linux-arm64`. +6. Run `make validate` and build the AMD64 images so mise verifies the downloaded artifacts. + +The daily upstream workflow follows the same procedure with a freshly downloaded mise binary whose SHA-256 is verified before it regenerates the lock. It may therefore propose a lock-only change when an upstream provider publishes a newer artifact for an unchanged runtime version. + +## Recovery + +Do not remove `--locked`, delete `mise.lock` or fall back to `mise use` to work around a stale lock. Regenerate the lock with the exact pinned mise version, review the artifact changes, and keep the version/config/lock updates in one pull request. + +If a checksum has changed unexpectedly for an artifact URL that should be immutable, stop the update and investigate upstream before merging. From d9a78d269e2b8fa1345e6f0b2822e30560e086fc Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:11:37 +0200 Subject: [PATCH 09/38] docs: link runtime lock maintenance guide --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a2c6e2e..0cac1e7 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Read `CONTRIBUTING.md` before proposing changes. Pull requests use the repositor - `docs/security.md` - `docs/decisions.md` - `docs/releases.md` +- `docs/runtime-locks.md` - `docs/roadmap.md` ## Upstream references From 3e837785703cd1d3ecca57909f7a4ccf7c9abe51 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:12:13 +0200 Subject: [PATCH 10/38] docs: record locked mise runtime security --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ba149..85aceae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Persistent credential permission hardening for Codex, GitHub CLI, Git and SSH state. - Embedded image channel and source revision metadata exposed in the menu, diagnostics and `remote-dev-version`, together with the installed Codex CLI version reported at runtime. - Trivy JSON reports for all critical findings in locally built images and exact publication candidates; only findings with a known fixed version fail the gate. +- Committed mise runtime configuration and lock data for Linux AMD64 and ARM64, plus validation and a documented regeneration helper. ### Changed @@ -45,6 +46,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Assigned npm updates exclusively to the grouped upstream workflow to avoid competing Renovate pull requests. - Added an official `SHA256SUMS` fallback for upstream releases such as ttyd that do not expose GitHub asset digest metadata. - Centralized the fixable-critical Trivy gate so build, edge and stable workflows share the same enforcement logic. +- Extended upstream automation to regenerate and review the mise lock whenever runtime versions or resolved artifacts change. ### Security @@ -60,4 +62,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Third-party GitHub Actions are pinned to immutable commit SHAs. - The Ubuntu base image is pinned to an immutable OCI digest. - Downloaded Codex, GitHub CLI, ttyd and mise assets are verified against repository-controlled architecture-specific SHA-256 values. +- Python, Node.js and uv install from committed artifact URLs and SHA-256 values in strict mise locked mode, with GitHub artifact attestations required where supported. - Publication workflows scan exact pushed digests before promoting public tags and use only the permissions required to read source and write packages. From 6cda75ae8491d8f5f85dfba647594723fd3c48e9 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:18:24 +0200 Subject: [PATCH 11/38] security: reverify locked runtime provenance --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index eafa693..b9bcb29 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,7 @@ [settings] experimental = true lockfile = true +locked_verify_provenance = true lockfile_platforms = ["linux-x64", "linux-arm64"] [tools] From ca310fef6b0153c70cea4a6383b7a57c1306d9e4 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:19:12 +0200 Subject: [PATCH 12/38] security: validate provenance re-verification --- scripts/validate-mise-lock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py index a7a4124..5e84c6e 100644 --- a/scripts/validate-mise-lock.py +++ b/scripts/validate-mise-lock.py @@ -117,6 +117,8 @@ def main() -> int: fail("mise.toml must contain [settings]") if settings.get("lockfile") is not True: fail("mise.toml must enable settings.lockfile") + if settings.get("locked_verify_provenance") is not True: + fail("mise.toml must enable settings.locked_verify_provenance") configured_platforms = settings.get("lockfile_platforms") if configured_platforms != list(PLATFORMS): fail( @@ -204,7 +206,7 @@ def main() -> int: print( "mise runtime lock is coherent for " + ", ".join(f"{tool} {version}" for tool, version in expected_versions.items()) - + " on linux-x64 and linux-arm64." + + " on linux-x64 and linux-arm64, with locked provenance re-verification enabled." ) return 0 From 662ab235c9e49893c5b11d9a15f48c551e4f54bd Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:19:35 +0200 Subject: [PATCH 13/38] docs: explain locked provenance verification --- docs/runtime-locks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/runtime-locks.md b/docs/runtime-locks.md index 4678655..6b36924 100644 --- a/docs/runtime-locks.md +++ b/docs/runtime-locks.md @@ -7,10 +7,10 @@ Python, Node.js and uv are installed by mise, but their build inputs are committ The runtime pins are represented in three places for different purposes: - `versions.env` supplies reviewed repository and build arguments. -- `mise.toml` declares the exact mise-managed runtime versions. +- `mise.toml` declares the exact mise-managed runtime versions and enables provenance re-verification for locked installs. - `mise.lock` records the resolved Linux AMD64 and ARM64 artifact URLs, SHA-256 checksums and available provenance requirements. -`scripts/validate-version-pins.sh` fails when these files or the base Dockerfile disagree. The Dockerfile copies `mise.toml` and `mise.lock` as read-only inputs and runs `mise install --locked`; a missing artifact entry, dynamic-resolution requirement, provenance failure or checksum mismatch stops the build. +`scripts/validate-version-pins.sh` fails when these files or the base Dockerfile disagree. The Dockerfile copies `mise.toml` and `mise.lock` as read-only inputs and runs `mise install --locked`; a missing artifact entry, dynamic-resolution requirement, provenance failure or checksum mismatch stops the build. `locked_verify_provenance = true` ensures that Python and uv GitHub artifact attestations are checked during the build instead of trusting only the provenance marker already stored in the lockfile. npm is intentionally excluded from `mise.lock` because the image installs it separately from the npm registry. @@ -32,12 +32,12 @@ When changing Python, Node.js or uv: 3. Update the matching tool in `mise.toml`. 4. Run `scripts/regenerate-mise-lock.sh` with the pinned mise release. 5. Review every changed URL, SHA-256 and provenance field for both `linux-x64` and `linux-arm64`. -6. Run `make validate` and build the AMD64 images so mise verifies the downloaded artifacts. +6. Run `make validate` and build the AMD64 images so mise verifies the downloaded artifacts and supported provenance. -The daily upstream workflow follows the same procedure with a freshly downloaded mise binary whose SHA-256 is verified before it regenerates the lock. It may therefore propose a lock-only change when an upstream provider publishes a newer artifact for an unchanged runtime version. +The daily upstream workflow follows the same procedure with a freshly downloaded mise binary whose SHA-256 is verified before it regenerates the lock. A plain `mise lock` refreshes artifact metadata for the already pinned versions, so the workflow may propose a lock-only change when an upstream provider publishes a newer artifact for an unchanged runtime version. ## Recovery -Do not remove `--locked`, delete `mise.lock` or fall back to `mise use` to work around a stale lock. Regenerate the lock with the exact pinned mise version, review the artifact changes, and keep the version/config/lock updates in one pull request. +Do not remove `--locked`, disable `locked_verify_provenance`, delete `mise.lock` or fall back to `mise use` to work around a stale lock. Regenerate the lock with the exact pinned mise version, review the artifact changes, and keep the version/config/lock updates in one pull request. If a checksum has changed unexpectedly for an artifact URL that should be immutable, stop the update and investigate upstream before merging. From 69832a05e7804d7e4125f00f9c4ff1c9ce9e47eb Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:49:46 +0200 Subject: [PATCH 14/38] security: disable unused mise experimental features --- mise.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/mise.toml b/mise.toml index b9bcb29..fe00c07 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,4 @@ [settings] -experimental = true lockfile = true locked_verify_provenance = true lockfile_platforms = ["linux-x64", "linux-arm64"] From 2c5b3a588d07519d2104b0a30ea07c437baefae2 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:50:04 +0200 Subject: [PATCH 15/38] ci: bound mise lock regeneration time --- scripts/regenerate-mise-lock.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh index 18673b4..4f46bc9 100644 --- a/scripts/regenerate-mise-lock.sh +++ b/scripts/regenerate-mise-lock.sh @@ -10,6 +10,10 @@ if ! command -v "$MISE_BIN" >/dev/null 2>&1; then echo "ERROR: mise is required to regenerate mise.lock" >&2 exit 1 fi +if ! command -v timeout >/dev/null 2>&1; then + echo "ERROR: GNU timeout is required to bound mise.lock regeneration" >&2 + exit 1 +fi installed_version="$("$MISE_BIN" --version | awk '{print $1}')" if [[ "$installed_version" != "$MISE_VERSION" ]]; then @@ -29,6 +33,8 @@ trap 'rm -f "$empty_global_config"' EXIT cd "$ROOT" MISE_GLOBAL_CONFIG_FILE="$empty_global_config" \ MISE_SAFE=1 \ + MISE_HTTP_TIMEOUT="${MISE_HTTP_TIMEOUT:-60}" \ + timeout --signal=TERM --kill-after=30s "${MISE_LOCK_TIMEOUT:-10m}" \ "$MISE_BIN" lock --platform linux-x64,linux-arm64 ) From 63c2e4931e9053b3132bf0eafdaac4ebc646a74d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:50:59 +0200 Subject: [PATCH 16/38] security: guard mise cache cleanup path --- images/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/base/Dockerfile b/images/base/Dockerfile index fbc5361..4233704 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -160,7 +160,7 @@ RUN mkdir -p "$MISE_DATA_DIR" "$MISE_CACHE_DIR" "$MISE_CONFIG_DIR" \ && node --version \ && test "$(npm --version)" = "$NPM_VERSION" \ && uv --version \ - && rm -rf "$MISE_CACHE_DIR"/* + && rm -rf "${MISE_CACHE_DIR:?}"/* COPY config/tmux.conf /etc/tmux.conf COPY scripts/base-verify.sh /usr/local/bin/remote-dev-base-verify From a0f66bb65d6060fa35d5f5f7887e1b4054969572 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:51:55 +0200 Subject: [PATCH 17/38] refactor: split mise lock validation helpers --- scripts/validate-mise-lock.py | 157 ++++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 62 deletions(-) diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py index 5e84c6e..1096b35 100644 --- a/scripts/validate-mise-lock.py +++ b/scripts/validate-mise-lock.py @@ -13,14 +13,21 @@ ASSIGNMENT_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$") SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") PLATFORMS = ("linux-x64", "linux-arm64") +EXPECTED_BACKENDS = { + "python": "core:python", + "node": "core:node", + "uv": "aqua:astral-sh/uv", +} def fail(message: str) -> NoReturn: + """Exit with a consistent validation error message.""" print(f"ERROR: {message}", file=sys.stderr) raise SystemExit(1) def load_env(path: Path) -> dict[str, str]: + """Load a simple NAME=value environment file without shell evaluation.""" values: dict[str, str] = {} for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): line = raw_line.strip() @@ -34,6 +41,7 @@ def load_env(path: Path) -> dict[str, str]: def load_toml(path: Path) -> dict[str, Any]: + """Load a TOML document or fail with the original parse error.""" try: with path.open("rb") as handle: return tomllib.load(handle) @@ -42,6 +50,7 @@ def load_toml(path: Path) -> dict[str, Any]: def expect_string(mapping: dict[str, Any], key: str, context: str) -> str: + """Return a required non-empty string from a TOML mapping.""" value = mapping.get(key) if not isinstance(value, str) or not value: fail(f"{context}.{key} must be a non-empty string") @@ -49,6 +58,7 @@ def expect_string(mapping: dict[str, Any], key: str, context: str) -> str: def platform_info(entry: dict[str, Any], platform: str, tool: str) -> dict[str, Any]: + """Return one locked platform artifact mapping for a managed tool.""" key = f"platforms.{platform}" value = entry.get(key) if not isinstance(value, dict): @@ -57,6 +67,7 @@ def platform_info(entry: dict[str, Any], platform: str, tool: str) -> dict[str, def validate_url(tool: str, version: str, platform: str, url: str) -> None: + """Require an artifact URL that matches the approved upstream layout.""" arch = {"linux-x64": "x86_64", "linux-arm64": "aarch64"}[platform] if tool == "node": node_arch = "x64" if platform == "linux-x64" else "arm64" @@ -90,18 +101,8 @@ def validate_url(tool: str, version: str, platform: str, url: str) -> None: fail(f"no URL policy defined for mise tool {tool}") -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "--root", - type=Path, - default=Path(__file__).resolve().parents[1], - help="repository root (defaults to the parent of scripts/)", - ) - args = parser.parse_args() - root = args.root.resolve() - - env = load_env(root / "versions.env") +def expected_versions_from_env(env: dict[str, str]) -> dict[str, str]: + """Extract required managed-runtime versions from versions.env.""" expected_versions = { "python": env.get("PYTHON_VERSION", ""), "node": env.get("NODE_VERSION", ""), @@ -110,8 +111,11 @@ def main() -> int: for tool, version in expected_versions.items(): if not version: fail(f"versions.env has no version for {tool}") + return expected_versions - config = load_toml(root / "mise.toml") + +def validate_mise_config(config: dict[str, Any], expected_versions: dict[str, str]) -> None: + """Validate mise settings, managed tools, and version coherence.""" settings = config.get("settings") if not isinstance(settings, dict): fail("mise.toml must contain [settings]") @@ -141,7 +145,65 @@ def main() -> int: f"versions.env {expected_version!r}" ) - lock = load_toml(root / "mise.lock") + +def validate_artifact( + tool: str, version: str, platform: str, artifact: dict[str, Any] +) -> None: + """Validate one platform-specific checksum, URL, and provenance record.""" + checksum = expect_string(artifact, "checksum", f"mise.lock tools.{tool}.{platform}") + if not SHA256_RE.fullmatch(checksum): + fail( + f"mise.lock {tool} checksum for {platform} must be an exact " + f"lowercase SHA-256: {checksum}" + ) + url = expect_string(artifact, "url", f"mise.lock tools.{tool}.{platform}") + validate_url(tool, version, platform, url) + if tool in {"python", "uv"} and artifact.get("provenance") != "github-attestations": + fail( + f"mise.lock {tool} artifact for {platform} must require " + "GitHub artifact attestations" + ) + + +def validate_tool_entry( + tool: str, + expected_version: str, + entries: Any, +) -> None: + """Validate one managed tool entry and all required platform artifacts.""" + if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict): + fail(f"mise.lock must contain exactly one {tool} entry") + entry = entries[0] + version = expect_string(entry, "version", f"mise.lock tools.{tool}") + backend = expect_string(entry, "backend", f"mise.lock tools.{tool}") + if version != expected_version: + fail( + f"mise.lock {tool} version {version!r} does not match " + f"versions.env {expected_version!r}" + ) + if backend != EXPECTED_BACKENDS[tool]: + fail( + f"mise.lock {tool} backend {backend!r} does not match " + f"{EXPECTED_BACKENDS[tool]!r}" + ) + + locked_platforms = { + key.removeprefix("platforms.") + for key, value in entry.items() + if key.startswith("platforms.") and isinstance(value, dict) + } + if locked_platforms != set(PLATFORMS): + fail( + f"mise.lock {tool} platforms must be exactly {sorted(PLATFORMS)}, " + f"got {sorted(locked_platforms)}" + ) + + for platform in PLATFORMS: + validate_artifact(tool, version, platform, platform_info(entry, platform, tool)) + + +def validate_lock(lock: dict[str, Any], expected_versions: dict[str, str]) -> None: + """Validate the lockfile tool set and each locked runtime entry.""" lock_tools = lock.get("tools") if not isinstance(lock_tools, dict): fail("mise.lock must contain tool entries") @@ -151,57 +213,28 @@ def main() -> int: f"{sorted(expected_versions)}, got {sorted(lock_tools)}" ) - expected_backends = { - "python": "core:python", - "node": "core:node", - "uv": "aqua:astral-sh/uv", - } for tool, expected_version in expected_versions.items(): - entries = lock_tools.get(tool) - if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict): - fail(f"mise.lock must contain exactly one {tool} entry") - entry = entries[0] - version = expect_string(entry, "version", f"mise.lock tools.{tool}") - backend = expect_string(entry, "backend", f"mise.lock tools.{tool}") - if version != expected_version: - fail( - f"mise.lock {tool} version {version!r} does not match " - f"versions.env {expected_version!r}" - ) - if backend != expected_backends[tool]: - fail( - f"mise.lock {tool} backend {backend!r} does not match " - f"{expected_backends[tool]!r}" - ) + validate_tool_entry(tool, expected_version, lock_tools.get(tool)) - locked_platforms = { - key.removeprefix("platforms.") - for key, value in entry.items() - if key.startswith("platforms.") and isinstance(value, dict) - } - if locked_platforms != set(PLATFORMS): - fail( - f"mise.lock {tool} platforms must be exactly {sorted(PLATFORMS)}, " - f"got {sorted(locked_platforms)}" - ) - for platform in PLATFORMS: - artifact = platform_info(entry, platform, tool) - checksum = expect_string( - artifact, "checksum", f"mise.lock tools.{tool}.{platform}" - ) - if not SHA256_RE.fullmatch(checksum): - fail( - f"mise.lock {tool} checksum for {platform} must be an exact " - f"lowercase SHA-256: {checksum}" - ) - url = expect_string(artifact, "url", f"mise.lock tools.{tool}.{platform}") - validate_url(tool, version, platform, url) - if tool in {"python", "uv"} and artifact.get("provenance") != "github-attestations": - fail( - f"mise.lock {tool} artifact for {platform} must require " - "GitHub artifact attestations" - ) +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for repository-root selection.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the parent of scripts/)", + ) + return parser.parse_args() + + +def main() -> int: + """Load repository inputs, run all lock validations, and report success.""" + root = parse_args().root.resolve() + expected_versions = expected_versions_from_env(load_env(root / "versions.env")) + validate_mise_config(load_toml(root / "mise.toml"), expected_versions) + validate_lock(load_toml(root / "mise.lock"), expected_versions) print( "mise runtime lock is coherent for " From 15f929e4eb085cf15aa8a76bb2d6928f4029b615 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:52:18 +0200 Subject: [PATCH 18/38] chore: include mise lock in CodeRabbit review --- .coderabbit.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 17d3285..539da5c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,11 +14,20 @@ reviews: in_progress_fortune: false enable_prompt_for_ai_agents: true + path_filters: + - "mise.lock" + auto_review: enabled: true drafts: false path_instructions: + - path: "mise.lock" + instructions: | + Review every locked runtime entry as a security-sensitive supply-chain input. + Verify exact versions, expected backends, AMD64 and ARM64 platform coverage, + trusted upstream URLs, lowercase SHA-256 checksums and required provenance metadata. + - path: "images/**/Dockerfile" instructions: | Review for reproducibility, supply-chain security and minimal image growth. From d0bc2594ff35821c5b205ed4ee98067ccf6ec787 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:05:58 +0200 Subject: [PATCH 19/38] fix: preserve full CodeRabbit review scope --- .coderabbit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 539da5c..1f7a9a3 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -15,6 +15,7 @@ reviews: enable_prompt_for_ai_agents: true path_filters: + - "**/*" - "mise.lock" auto_review: From 332c55edfc167e666e1438e6cc15341e35141ac7 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:56:37 +0200 Subject: [PATCH 20/38] security: harden mise lock schema validation --- scripts/validate-mise-lock.py | 72 +++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py index 1096b35..a269b29 100644 --- a/scripts/validate-mise-lock.py +++ b/scripts/validate-mise-lock.py @@ -8,16 +8,36 @@ import sys import tomllib from pathlib import Path -from typing import Any, NoReturn +from typing import Any, NoReturn, cast ASSIGNMENT_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$") SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +UV_API_URL_RE = re.compile( + r"^https://api\.github\.com/repos/astral-sh/uv/releases/assets/[1-9][0-9]*$" +) PLATFORMS = ("linux-x64", "linux-arm64") EXPECTED_BACKENDS = { "python": "core:python", "node": "core:node", "uv": "aqua:astral-sh/uv", } +CONFIG_TOP_LEVEL_KEYS = {"settings", "tools"} +CONFIG_SETTING_KEYS = { + "lockfile", + "locked_verify_provenance", + "lockfile_platforms", +} +LOCK_TOP_LEVEL_KEYS = {"tools"} +TOOL_ENTRY_KEYS = { + "version", + "backend", + *(f"platforms.{platform}" for platform in PLATFORMS), +} +ARTIFACT_KEYS = { + "node": {"checksum", "url"}, + "python": {"checksum", "url", "provenance"}, + "uv": {"checksum", "url", "url_api", "provenance"}, +} def fail(message: str) -> NoReturn: @@ -49,6 +69,18 @@ def load_toml(path: Path) -> dict[str, Any]: fail(f"cannot read valid TOML from {path}: {exc}") +def require_exact_keys( + mapping: dict[str, Any], expected: set[str], context: str +) -> None: + """Reject missing or unknown keys in a security-sensitive TOML mapping.""" + actual = set(mapping) + if actual != expected: + fail( + f"{context} keys must be exactly {sorted(expected)}, " + f"got {sorted(actual)}" + ) + + def expect_string(mapping: dict[str, Any], key: str, context: str) -> str: """Return a required non-empty string from a TOML mapping.""" value = mapping.get(key) @@ -62,8 +94,8 @@ def platform_info(entry: dict[str, Any], platform: str, tool: str) -> dict[str, key = f"platforms.{platform}" value = entry.get(key) if not isinstance(value, dict): - fail(f"mise.lock has no {tool} artifact entry for {platform}") - return value + fail(f"mise.lock has no valid {tool} artifact mapping for {platform}") + return cast(dict[str, Any], value) def validate_url(tool: str, version: str, platform: str, url: str) -> None: @@ -116,9 +148,13 @@ def expected_versions_from_env(env: dict[str, str]) -> dict[str, str]: def validate_mise_config(config: dict[str, Any], expected_versions: dict[str, str]) -> None: """Validate mise settings, managed tools, and version coherence.""" + require_exact_keys(config, CONFIG_TOP_LEVEL_KEYS, "mise.toml top-level") + settings = config.get("settings") if not isinstance(settings, dict): fail("mise.toml must contain [settings]") + settings = cast(dict[str, Any], settings) + require_exact_keys(settings, CONFIG_SETTING_KEYS, "mise.toml settings") if settings.get("lockfile") is not True: fail("mise.toml must enable settings.lockfile") if settings.get("locked_verify_provenance") is not True: @@ -133,6 +169,7 @@ def validate_mise_config(config: dict[str, Any], expected_versions: dict[str, st configured_tools = config.get("tools") if not isinstance(configured_tools, dict): fail("mise.toml must contain [tools]") + configured_tools = cast(dict[str, Any], configured_tools) if set(configured_tools) != set(expected_versions): fail( "mise.toml must define exactly the managed runtimes " @@ -150,6 +187,11 @@ def validate_artifact( tool: str, version: str, platform: str, artifact: dict[str, Any] ) -> None: """Validate one platform-specific checksum, URL, and provenance record.""" + require_exact_keys( + artifact, + ARTIFACT_KEYS[tool], + f"mise.lock tools.{tool}.{platform}", + ) checksum = expect_string(artifact, "checksum", f"mise.lock tools.{tool}.{platform}") if not SHA256_RE.fullmatch(checksum): fail( @@ -158,22 +200,34 @@ def validate_artifact( ) url = expect_string(artifact, "url", f"mise.lock tools.{tool}.{platform}") validate_url(tool, version, platform, url) + if tool in {"python", "uv"} and artifact.get("provenance") != "github-attestations": fail( f"mise.lock {tool} artifact for {platform} must require " "GitHub artifact attestations" ) + if tool == "uv": + url_api = expect_string( + artifact, "url_api", f"mise.lock tools.{tool}.{platform}" + ) + if not UV_API_URL_RE.fullmatch(url_api): + fail(f"mise.lock uv API URL for {platform} is unexpected: {url_api}") def validate_tool_entry( tool: str, expected_version: str, - entries: Any, + entries: object, ) -> None: """Validate one managed tool entry and all required platform artifacts.""" - if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict): + if not isinstance(entries, list) or len(entries) != 1: fail(f"mise.lock must contain exactly one {tool} entry") - entry = entries[0] + raw_entry = entries[0] + if not isinstance(raw_entry, dict): + fail(f"mise.lock must contain exactly one {tool} entry") + entry = cast(dict[str, Any], raw_entry) + require_exact_keys(entry, TOOL_ENTRY_KEYS, f"mise.lock tools.{tool}") + version = expect_string(entry, "version", f"mise.lock tools.{tool}") backend = expect_string(entry, "backend", f"mise.lock tools.{tool}") if version != expected_version: @@ -189,8 +243,8 @@ def validate_tool_entry( locked_platforms = { key.removeprefix("platforms.") - for key, value in entry.items() - if key.startswith("platforms.") and isinstance(value, dict) + for key in entry + if key.startswith("platforms.") } if locked_platforms != set(PLATFORMS): fail( @@ -204,9 +258,11 @@ def validate_tool_entry( def validate_lock(lock: dict[str, Any], expected_versions: dict[str, str]) -> None: """Validate the lockfile tool set and each locked runtime entry.""" + require_exact_keys(lock, LOCK_TOP_LEVEL_KEYS, "mise.lock top-level") lock_tools = lock.get("tools") if not isinstance(lock_tools, dict): fail("mise.lock must contain tool entries") + lock_tools = cast(dict[str, Any], lock_tools) if set(lock_tools) != set(expected_versions): fail( "mise.lock must contain exactly the managed runtimes " From c75e87dbc014a936d9f38bfeaf1b4fa75ff60683 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:57:54 +0200 Subject: [PATCH 21/38] test: cover adversarial mise lock inputs --- scripts/test-validate-mise-lock.py | 186 +++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 scripts/test-validate-mise-lock.py diff --git a/scripts/test-validate-mise-lock.py b/scripts/test-validate-mise-lock.py new file mode 100644 index 0000000..9a2bb50 --- /dev/null +++ b/scripts/test-validate-mise-lock.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Exercise fail-closed mise configuration and lockfile validation cases.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +INPUT_FILES = ("versions.env", "mise.toml", "mise.lock") + + +def replace_once(path: Path, old: str, new: str) -> None: + """Replace one required text fragment or fail the test setup.""" + content = path.read_text(encoding="utf-8") + if old not in content: + raise AssertionError(f"test fixture fragment not found in {path}: {old!r}") + path.write_text(content.replace(old, new, 1), encoding="utf-8") + + +def append_text(path: Path, text: str) -> None: + """Append text to one copied fixture file.""" + with path.open("a", encoding="utf-8") as handle: + handle.write(text) + + +def run_validator(validator: Path, root: Path) -> subprocess.CompletedProcess[str]: + """Run the validator against one isolated fixture root.""" + return subprocess.run( + [sys.executable, str(validator), "--root", str(root)], + check=False, + capture_output=True, + text=True, + ) + + +def copied_fixture(source_root: Path, destination: Path) -> None: + """Copy the repository-controlled validator inputs into a temporary root.""" + for relative_path in INPUT_FILES: + shutil.copy2(source_root / relative_path, destination / relative_path) + + +def expect_failure( + source_root: Path, + validator: Path, + name: str, + mutate: Callable[[Path], None], + expected_message: str, +) -> None: + """Require one malicious or malformed fixture to be rejected.""" + with tempfile.TemporaryDirectory(prefix="mise-lock-test-") as temp_dir: + fixture_root = Path(temp_dir) + copied_fixture(source_root, fixture_root) + mutate(fixture_root) + result = run_validator(validator, fixture_root) + if result.returncode == 0: + raise AssertionError(f"{name}: validator unexpectedly accepted the fixture") + if expected_message not in result.stderr: + raise AssertionError( + f"{name}: expected {expected_message!r} in stderr, got:\n{result.stderr}" + ) + print(f"OK reject {name}") + + +def parse_args() -> argparse.Namespace: + """Parse an optional repository root for CI and local execution.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the parent of scripts/)", + ) + return parser.parse_args() + + +def main() -> int: + """Validate the real inputs and a set of adversarial mutations.""" + source_root = parse_args().root.resolve() + validator = source_root / "scripts/validate-mise-lock.py" + + baseline = run_validator(validator, source_root) + if baseline.returncode != 0: + raise AssertionError(f"baseline validation failed:\n{baseline.stderr}") + print("OK accept committed mise inputs") + + expect_failure( + source_root, + validator, + "unexpected mise.toml section", + lambda root: append_text(root / "mise.toml", '\n[env]\nDANGEROUS = "1"\n'), + "mise.toml top-level keys must be exactly", + ) + expect_failure( + source_root, + validator, + "unexpected mise setting", + lambda root: replace_once( + root / "mise.toml", + "lockfile = true\n", + "lockfile = true\nexperimental = true\n", + ), + "mise.toml settings keys must be exactly", + ) + expect_failure( + source_root, + validator, + "runtime version drift", + lambda root: replace_once( + root / "mise.toml", 'python = "3.14.6"', 'python = "3.14.5"' + ), + "does not match versions.env", + ) + expect_failure( + source_root, + validator, + "unexpected lockfile section", + lambda root: append_text(root / "mise.lock", '\n[metadata]\nowner = "attacker"\n'), + "mise.lock top-level keys must be exactly", + ) + expect_failure( + source_root, + validator, + "extra platform scalar", + lambda root: replace_once( + root / "mise.lock", + 'backend = "core:node"\n\n', + 'backend = "core:node"\n"platforms.linux-x64-musl" = "malformed"\n\n', + ), + "mise.lock tools.node keys must be exactly", + ) + expect_failure( + source_root, + validator, + "required platform scalar", + lambda root: replace_once( + root / "mise.lock", + '[tools.node."platforms.linux-arm64"]\n' + 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\n' + 'url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-arm64.tar.gz"\n', + '"platforms.linux-arm64" = "malformed"\n', + ), + "no valid node artifact mapping for linux-arm64", + ) + expect_failure( + source_root, + validator, + "unexpected artifact field", + lambda root: replace_once( + root / "mise.lock", + 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\n', + 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\nsize = 1\n', + ), + "mise.lock tools.node.linux-arm64 keys must be exactly", + ) + expect_failure( + source_root, + validator, + "untrusted uv API URL", + lambda root: replace_once( + root / "mise.lock", + "https://api.github.com/repos/astral-sh/uv/releases/assets/487747547", + "https://example.invalid/releases/assets/487747547", + ), + "mise.lock uv API URL for linux-arm64 is unexpected", + ) + expect_failure( + source_root, + validator, + "missing provenance", + lambda root: replace_once( + root / "mise.lock", 'provenance = "github-attestations"\n', "" + ), + "mise.lock tools.python.linux-arm64 keys must be exactly", + ) + + print("All mise lock validation tests passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 32d761ecd3a108ec1f07596c695779872ca8f976 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:59:03 +0200 Subject: [PATCH 22/38] test: run mise lock validation cases in CI --- scripts/validate-version-pins.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validate-version-pins.sh b/scripts/validate-version-pins.sh index 7f118fa..98ea211 100644 --- a/scripts/validate-version-pins.sh +++ b/scripts/validate-version-pins.sh @@ -117,6 +117,7 @@ if grep -Fq 'mise use --global' "$base_dockerfile"; then exit 1 fi python3 "$ROOT/scripts/validate-mise-lock.py" --root "$ROOT" +python3 "$ROOT/scripts/test-validate-mise-lock.py" --root "$ROOT" if [[ ! "$UBUNTU_VERSION" =~ ^[0-9]*[02468]\.04$ ]]; then echo "ERROR: UBUNTU_VERSION must be an explicit Ubuntu LTS release tag: $UBUNTU_VERSION" >&2 From 850a5605ba7bd5a3244891b7b93cded46bf49886 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:59:40 +0200 Subject: [PATCH 23/38] chore: narrow CodeRabbit lockfile review override --- .coderabbit.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1f7a9a3..334ca79 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,9 +14,12 @@ reviews: in_progress_fortune: false enable_prompt_for_ai_agents: true + # CodeRabbit excludes generic *.lock files by default. Explicitly include this + # security-sensitive generated input without force-including every other + # generated, binary, media, or dependency file covered by its default filters. path_filters: - - "**/*" - "mise.lock" + - "**/mise.lock" auto_review: enabled: true @@ -29,6 +32,12 @@ reviews: Verify exact versions, expected backends, AMD64 and ARM64 platform coverage, trusted upstream URLs, lowercase SHA-256 checksums and required provenance metadata. + - path: "scripts/**/*.py" + instructions: | + Review security validators as fail-closed parsers. Flag unknown fields that are accepted, + malformed TOML shapes that bypass checks, incomplete URL allowlists, broad typing that hides + validation gaps, and missing adversarial tests for every rejected input class. + - path: "images/**/Dockerfile" instructions: | Review for reproducibility, supply-chain security and minimal image growth. From 6a7e156422da42851c4933c3eab861829c4107ec Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:02:50 +0200 Subject: [PATCH 24/38] test: make mise lock mutations version-independent --- scripts/test-validate-mise-lock.py | 80 ++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/scripts/test-validate-mise-lock.py b/scripts/test-validate-mise-lock.py index 9a2bb50..1030779 100644 --- a/scripts/test-validate-mise-lock.py +++ b/scripts/test-validate-mise-lock.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import re import shutil import subprocess import sys @@ -22,12 +23,48 @@ def replace_once(path: Path, old: str, new: str) -> None: path.write_text(content.replace(old, new, 1), encoding="utf-8") +def replace_regex_once(path: Path, pattern: str, replacement: str) -> None: + """Replace one required regular-expression match in a fixture file.""" + content = path.read_text(encoding="utf-8") + updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) + if count != 1: + raise AssertionError(f"test fixture pattern not found in {path}: {pattern!r}") + path.write_text(updated, encoding="utf-8") + + def append_text(path: Path, text: str) -> None: """Append text to one copied fixture file.""" with path.open("a", encoding="utf-8") as handle: handle.write(text) +def replace_table_with_scalar( + path: Path, table_header: str, next_table_header: str, scalar: str +) -> None: + """Replace one complete TOML table with a literal scalar key.""" + content = path.read_text(encoding="utf-8") + start = content.find(table_header) + end = content.find(next_table_header, start + len(table_header)) + if start < 0 or end < 0: + raise AssertionError( + f"test fixture table range not found in {path}: " + f"{table_header!r} to {next_table_header!r}" + ) + path.write_text(content[:start] + scalar + "\n\n" + content[end:], encoding="utf-8") + + +def append_to_table(path: Path, table_header: str, text: str) -> None: + """Append one field before the next TOML table header.""" + content = path.read_text(encoding="utf-8") + start = content.find(table_header) + if start < 0: + raise AssertionError(f"test fixture table not found in {path}: {table_header!r}") + end = content.find("\n[", start + len(table_header)) + if end < 0: + end = len(content) + path.write_text(content[:end] + "\n" + text + content[end:], encoding="utf-8") + + def run_validator(validator: Path, root: Path) -> subprocess.CompletedProcess[str]: """Run the validator against one isolated fixture root.""" return subprocess.run( @@ -51,7 +88,7 @@ def expect_failure( mutate: Callable[[Path], None], expected_message: str, ) -> None: - """Require one malicious or malformed fixture to be rejected.""" + """Require one malformed fixture to be rejected.""" with tempfile.TemporaryDirectory(prefix="mise-lock-test-") as temp_dir: fixture_root = Path(temp_dir) copied_fixture(source_root, fixture_root) @@ -79,7 +116,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: - """Validate the real inputs and a set of adversarial mutations.""" + """Validate the real inputs and a set of malformed mutations.""" source_root = parse_args().root.resolve() validator = source_root / "scripts/validate-mise-lock.py" @@ -92,7 +129,7 @@ def main() -> int: source_root, validator, "unexpected mise.toml section", - lambda root: append_text(root / "mise.toml", '\n[env]\nDANGEROUS = "1"\n'), + lambda root: append_text(root / "mise.toml", '\n[env]\nUNEXPECTED = "1"\n'), "mise.toml top-level keys must be exactly", ) expect_failure( @@ -110,8 +147,8 @@ def main() -> int: source_root, validator, "runtime version drift", - lambda root: replace_once( - root / "mise.toml", 'python = "3.14.6"', 'python = "3.14.5"' + lambda root: replace_regex_once( + root / "mise.toml", r'^python = "[^"]+"$', 'python = "0.0.0"' ), "does not match versions.env", ) @@ -119,17 +156,17 @@ def main() -> int: source_root, validator, "unexpected lockfile section", - lambda root: append_text(root / "mise.lock", '\n[metadata]\nowner = "attacker"\n'), + lambda root: append_text(root / "mise.lock", '\n[metadata]\nlabel = "unexpected"\n'), "mise.lock top-level keys must be exactly", ) expect_failure( source_root, validator, "extra platform scalar", - lambda root: replace_once( + lambda root: replace_regex_once( root / "mise.lock", - 'backend = "core:node"\n\n', - 'backend = "core:node"\n"platforms.linux-x64-musl" = "malformed"\n\n', + r'^(backend = "core:node")$', + r'\1\n"platforms.linux-x64-musl" = "malformed"', ), "mise.lock tools.node keys must be exactly", ) @@ -137,12 +174,11 @@ def main() -> int: source_root, validator, "required platform scalar", - lambda root: replace_once( + lambda root: replace_table_with_scalar( root / "mise.lock", - '[tools.node."platforms.linux-arm64"]\n' - 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\n' - 'url = "https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-arm64.tar.gz"\n', - '"platforms.linux-arm64" = "malformed"\n', + '[tools.node."platforms.linux-arm64"]', + '[tools.node."platforms.linux-x64"]', + '"platforms.linux-arm64" = "malformed"', ), "no valid node artifact mapping for linux-arm64", ) @@ -150,10 +186,10 @@ def main() -> int: source_root, validator, "unexpected artifact field", - lambda root: replace_once( + lambda root: append_to_table( root / "mise.lock", - 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\n', - 'checksum = "sha256:6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508"\nsize = 1\n', + '[tools.node."platforms.linux-arm64"]', + "size = 1\n", ), "mise.lock tools.node.linux-arm64 keys must be exactly", ) @@ -161,10 +197,10 @@ def main() -> int: source_root, validator, "untrusted uv API URL", - lambda root: replace_once( + lambda root: replace_regex_once( root / "mise.lock", - "https://api.github.com/repos/astral-sh/uv/releases/assets/487747547", - "https://example.invalid/releases/assets/487747547", + r'^url_api = "https://api\.github\.com/repos/astral-sh/uv/releases/assets/[0-9]+"$', + 'url_api = "https://example.invalid/releases/assets/1"', ), "mise.lock uv API URL for linux-arm64 is unexpected", ) @@ -172,8 +208,8 @@ def main() -> int: source_root, validator, "missing provenance", - lambda root: replace_once( - root / "mise.lock", 'provenance = "github-attestations"\n', "" + lambda root: replace_regex_once( + root / "mise.lock", r'^provenance = "github-attestations"\n', "" ), "mise.lock tools.python.linux-arm64 keys must be exactly", ) From aad84e7702d4ec9543f758cebe1a50e641e222cf Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:34 +0200 Subject: [PATCH 25/38] chore: keep review policy changes separate --- .coderabbit.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 334ca79..17d3285 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,30 +14,11 @@ reviews: in_progress_fortune: false enable_prompt_for_ai_agents: true - # CodeRabbit excludes generic *.lock files by default. Explicitly include this - # security-sensitive generated input without force-including every other - # generated, binary, media, or dependency file covered by its default filters. - path_filters: - - "mise.lock" - - "**/mise.lock" - auto_review: enabled: true drafts: false path_instructions: - - path: "mise.lock" - instructions: | - Review every locked runtime entry as a security-sensitive supply-chain input. - Verify exact versions, expected backends, AMD64 and ARM64 platform coverage, - trusted upstream URLs, lowercase SHA-256 checksums and required provenance metadata. - - - path: "scripts/**/*.py" - instructions: | - Review security validators as fail-closed parsers. Flag unknown fields that are accepted, - malformed TOML shapes that bypass checks, incomplete URL allowlists, broad typing that hides - validation gaps, and missing adversarial tests for every rejected input class. - - path: "images/**/Dockerfile" instructions: | Review for reproducibility, supply-chain security and minimal image growth. From ab3c8a94bd5c642d2ccafec1aaf2a44e177ff31d Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:13:08 +0200 Subject: [PATCH 26/38] security: isolate mise lock regeneration inputs --- scripts/regenerate-mise-lock.sh | 52 ++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh index 4f46bc9..d2d7528 100644 --- a/scripts/regenerate-mise-lock.sh +++ b/scripts/regenerate-mise-lock.sh @@ -6,6 +6,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" source "$ROOT/versions.env" MISE_BIN="${MISE_BIN:-mise}" +MISE_HTTP_TIMEOUT_VALUE="${MISE_HTTP_TIMEOUT:-60}" +MISE_LOCK_TIMEOUT_VALUE="${MISE_LOCK_TIMEOUT:-10m}" + if ! command -v "$MISE_BIN" >/dev/null 2>&1; then echo "ERROR: mise is required to regenerate mise.lock" >&2 exit 1 @@ -26,17 +29,52 @@ EOF exit 1 fi -empty_global_config="$(mktemp)" -trap 'rm -f "$empty_global_config"' EXIT +for required_file in versions.env mise.toml mise.lock; do + if [[ ! -f "$ROOT/$required_file" ]]; then + echo "ERROR: required lock input is missing: $ROOT/$required_file" >&2 + exit 1 + fi +done + +scratch="$(mktemp -d)" +trap 'rm -rf "${scratch:?}"' EXIT +workspace="$scratch/workspace" +mkdir -p \ + "$workspace" \ + "$scratch/cache" \ + "$scratch/config" \ + "$scratch/data" \ + "$scratch/system" \ + "$scratch/tmp" +cp "$ROOT/versions.env" "$ROOT/mise.toml" "$ROOT/mise.lock" "$workspace/" +: > "$scratch/config/global.toml" + +# Clear caller-provided MISE_* settings before applying the small controlled set +# below. The lock is generated in a temporary config root so parent/profile files, +# user caches, installed plugins and partial writes cannot influence the result. +env_args=() +while IFS='=' read -r name _; do + if [[ "$name" == MISE_* ]]; then + env_args+=(-u "$name") + fi +done < <(env) ( - cd "$ROOT" - MISE_GLOBAL_CONFIG_FILE="$empty_global_config" \ + cd "$workspace" + env "${env_args[@]}" \ + MISE_CACHE_DIR="$scratch/cache" \ + MISE_CONFIG_DIR="$scratch/config" \ + MISE_DATA_DIR="$scratch/data" \ + MISE_GLOBAL_CONFIG_FILE="$scratch/config/global.toml" \ + MISE_HTTP_TIMEOUT="$MISE_HTTP_TIMEOUT_VALUE" \ MISE_SAFE=1 \ - MISE_HTTP_TIMEOUT="${MISE_HTTP_TIMEOUT:-60}" \ - timeout --signal=TERM --kill-after=30s "${MISE_LOCK_TIMEOUT:-10m}" \ + MISE_SYSTEM_DIR="$scratch/system" \ + MISE_TMP_DIR="$scratch/tmp" \ + timeout --signal=TERM --kill-after=30s "$MISE_LOCK_TIMEOUT_VALUE" \ "$MISE_BIN" lock --platform linux-x64,linux-arm64 ) +python3 "$ROOT/scripts/validate-mise-lock.py" --root "$workspace" +install -m 0644 "$workspace/mise.lock" "$ROOT/mise.lock" python3 "$ROOT/scripts/validate-mise-lock.py" --root "$ROOT" -echo "Regenerated mise.lock with mise $MISE_VERSION." +echo "Regenerated mise.lock with mise $MISE_VERSION from isolated inputs." From ceef08f03e68eb40ed8c567b3d56d533d2f169c6 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:14:55 +0200 Subject: [PATCH 27/38] test: exercise isolated mise lock regeneration --- scripts/test-regenerate-mise-lock.sh | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 scripts/test-regenerate-mise-lock.sh diff --git a/scripts/test-regenerate-mise-lock.sh b/scripts/test-regenerate-mise-lock.sh new file mode 100644 index 0000000..7fdca5c --- /dev/null +++ b/scripts/test-regenerate-mise-lock.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck disable=SC1091 +source "$ROOT/versions.env" + +scratch="$(mktemp -d)" +trap 'rm -rf "${scratch:?}"' EXIT +fake_mise="$scratch/fake-mise" + +cat > "$fake_mise" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "--version" ]]; then + printf '%s linux-x64 (test)\n' "$FAKE_MISE_VERSION" + exit 0 +fi + +if [[ "$#" -ne 3 || "$1" != "lock" || "$2" != "--platform" || "$3" != "linux-x64,linux-arm64" ]]; then + echo "unexpected fake mise arguments: $*" >&2 + exit 80 +fi + +[[ "$PWD" == */workspace ]] || { echo "lock did not run in isolated workspace: $PWD" >&2; exit 81; } +[[ "${MISE_SAFE:-}" == "1" ]] || { echo "MISE_SAFE was not forced" >&2; exit 82; } +[[ "${MISE_HTTP_TIMEOUT:-}" == "7" ]] || { echo "HTTP timeout override was not preserved" >&2; exit 83; } +[[ -z "${MISE_ENV+x}" ]] || { echo "MISE_ENV leaked into regeneration" >&2; exit 84; } +[[ -z "${MISE_AQUA_REGISTRY_URL+x}" ]] || { echo "registry override leaked into regeneration" >&2; exit 85; } +[[ "${MISE_CACHE_DIR:-}" != "$FORBIDDEN_MISE_CACHE_DIR" ]] || { echo "caller cache directory was reused" >&2; exit 86; } +[[ -f "${MISE_GLOBAL_CONFIG_FILE:-}" && ! -s "$MISE_GLOBAL_CONFIG_FILE" ]] \ + || { echo "global config was not isolated" >&2; exit 87; } +for directory in MISE_CACHE_DIR MISE_CONFIG_DIR MISE_DATA_DIR MISE_SYSTEM_DIR MISE_TMP_DIR; do + value="${!directory:-}" + [[ -n "$value" && "$value" == */* ]] || { echo "$directory was not isolated" >&2; exit 88; } +done + +printf '%s\n' "$(dirname "$MISE_CACHE_DIR")" > "$FAKE_SCRATCH_RECORD" +case "$FAKE_MISE_MODE" in + success) + printf '\n# fake regeneration marker\n' >> mise.lock + ;; + invalid) + printf '%s\n' 'not valid toml = [' > mise.lock + ;; + fail) + printf '%s\n' 'partial output' > mise.lock + exit 42 + ;; + *) + echo "unknown FAKE_MISE_MODE: $FAKE_MISE_MODE" >&2 + exit 89 + ;; +esac +EOF +chmod 0755 "$fake_mise" + +copy_fixture() { + local destination="$1" + mkdir -p "$destination/scripts" + cp \ + "$ROOT/versions.env" \ + "$ROOT/mise.toml" \ + "$ROOT/mise.lock" \ + "$destination/" + cp \ + "$ROOT/scripts/regenerate-mise-lock.sh" \ + "$ROOT/scripts/validate-mise-lock.py" \ + "$destination/scripts/" +} + +run_helper() { + local fixture_root="$1" + local mode="$2" + local record="$3" + local forbidden_cache="$scratch/forbidden-cache" + mkdir -p "$forbidden_cache" + + env \ + FAKE_MISE_MODE="$mode" \ + FAKE_MISE_VERSION="$MISE_VERSION" \ + FAKE_SCRATCH_RECORD="$record" \ + FORBIDDEN_MISE_CACHE_DIR="$forbidden_cache" \ + MISE_AQUA_REGISTRY_URL="https://example.invalid/registry" \ + MISE_BIN="$fake_mise" \ + MISE_CACHE_DIR="$forbidden_cache" \ + MISE_ENV="unexpected-profile" \ + MISE_HTTP_TIMEOUT=7 \ + MISE_LOCK_TIMEOUT=5s \ + bash "$fixture_root/scripts/regenerate-mise-lock.sh" +} + +success_root="$scratch/success-repo" +success_record="$scratch/success-record" +copy_fixture "$success_root" +run_helper "$success_root" success "$success_record" +grep -Fq '# fake regeneration marker' "$success_root/mise.lock" +success_scratch="$(cat "$success_record")" +[[ ! -e "$success_scratch" ]] || { echo "successful regeneration scratch was not removed" >&2; exit 1; } +echo "OK isolated successful regeneration" + +invalid_root="$scratch/invalid-repo" +invalid_record="$scratch/invalid-record" +copy_fixture "$invalid_root" +if run_helper "$invalid_root" invalid "$invalid_record"; then + echo "ERROR: invalid generated lock was accepted" >&2 + exit 1 +fi +cmp -s "$ROOT/mise.lock" "$invalid_root/mise.lock" \ + || { echo "ERROR: invalid generated lock replaced the committed fixture" >&2; exit 1; } +invalid_scratch="$(cat "$invalid_record")" +[[ ! -e "$invalid_scratch" ]] || { echo "invalid regeneration scratch was not removed" >&2; exit 1; } +echo "OK reject invalid generated lock without partial write" + +failure_root="$scratch/failure-repo" +failure_record="$scratch/failure-record" +copy_fixture "$failure_root" +if run_helper "$failure_root" fail "$failure_record"; then + echo "ERROR: failed mise command was treated as success" >&2 + exit 1 +fi +cmp -s "$ROOT/mise.lock" "$failure_root/mise.lock" \ + || { echo "ERROR: failed regeneration replaced the committed fixture" >&2; exit 1; } +failure_scratch="$(cat "$failure_record")" +[[ ! -e "$failure_scratch" ]] || { echo "failed regeneration scratch was not removed" >&2; exit 1; } +echo "OK preserve lock after failed regeneration" + +echo "All isolated mise lock regeneration tests passed." From 8efe75e7a4b7281ead1f41929cb93b923522a088 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:16:09 +0200 Subject: [PATCH 28/38] test: run isolated lock regeneration cases in CI --- scripts/validate-version-pins.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validate-version-pins.sh b/scripts/validate-version-pins.sh index 98ea211..b72535e 100644 --- a/scripts/validate-version-pins.sh +++ b/scripts/validate-version-pins.sh @@ -118,6 +118,7 @@ if grep -Fq 'mise use --global' "$base_dockerfile"; then fi python3 "$ROOT/scripts/validate-mise-lock.py" --root "$ROOT" python3 "$ROOT/scripts/test-validate-mise-lock.py" --root "$ROOT" +bash "$ROOT/scripts/test-regenerate-mise-lock.sh" if [[ ! "$UBUNTU_VERSION" =~ ^[0-9]*[02468]\.04$ ]]; then echo "ERROR: UBUNTU_VERSION must be an explicit Ubuntu LTS release tag: $UBUNTU_VERSION" >&2 From 93ab5c6004c07c67755dbcab8f6e5327cf1d0e57 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:17:34 +0200 Subject: [PATCH 29/38] fix: validate mise regeneration timeouts --- scripts/regenerate-mise-lock.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh index d2d7528..0de0174 100644 --- a/scripts/regenerate-mise-lock.sh +++ b/scripts/regenerate-mise-lock.sh @@ -6,9 +6,17 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" source "$ROOT/versions.env" MISE_BIN="${MISE_BIN:-mise}" -MISE_HTTP_TIMEOUT_VALUE="${MISE_HTTP_TIMEOUT:-60}" +MISE_HTTP_TIMEOUT_VALUE="${MISE_HTTP_TIMEOUT:-60s}" MISE_LOCK_TIMEOUT_VALUE="${MISE_LOCK_TIMEOUT:-10m}" +if [[ ! "$MISE_HTTP_TIMEOUT_VALUE" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]]; then + echo "ERROR: MISE_HTTP_TIMEOUT must be a positive simple duration such as 60s: $MISE_HTTP_TIMEOUT_VALUE" >&2 + exit 1 +fi +if [[ ! "$MISE_LOCK_TIMEOUT_VALUE" =~ ^[1-9][0-9]*(s|m|h|d)$ ]]; then + echo "ERROR: MISE_LOCK_TIMEOUT must be a positive GNU timeout duration such as 10m: $MISE_LOCK_TIMEOUT_VALUE" >&2 + exit 1 +fi if ! command -v "$MISE_BIN" >/dev/null 2>&1; then echo "ERROR: mise is required to regenerate mise.lock" >&2 exit 1 @@ -53,11 +61,11 @@ cp "$ROOT/versions.env" "$ROOT/mise.toml" "$ROOT/mise.lock" "$workspace/" # below. The lock is generated in a temporary config root so parent/profile files, # user caches, installed plugins and partial writes cannot influence the result. env_args=() -while IFS='=' read -r name _; do +while IFS='=' read -r -d '' name _; do if [[ "$name" == MISE_* ]]; then env_args+=(-u "$name") fi -done < <(env) +done < <(env -0) ( cd "$workspace" From f1b44eb4b79153ba26ea55762405aa34f131b377 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:18:37 +0200 Subject: [PATCH 30/38] test: cover regeneration timeout validation --- scripts/test-regenerate-mise-lock.sh | 32 ++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/test-regenerate-mise-lock.sh b/scripts/test-regenerate-mise-lock.sh index 7fdca5c..976a803 100644 --- a/scripts/test-regenerate-mise-lock.sh +++ b/scripts/test-regenerate-mise-lock.sh @@ -25,7 +25,7 @@ fi [[ "$PWD" == */workspace ]] || { echo "lock did not run in isolated workspace: $PWD" >&2; exit 81; } [[ "${MISE_SAFE:-}" == "1" ]] || { echo "MISE_SAFE was not forced" >&2; exit 82; } -[[ "${MISE_HTTP_TIMEOUT:-}" == "7" ]] || { echo "HTTP timeout override was not preserved" >&2; exit 83; } +[[ "${MISE_HTTP_TIMEOUT:-}" == "7s" ]] || { echo "HTTP timeout override was not preserved" >&2; exit 83; } [[ -z "${MISE_ENV+x}" ]] || { echo "MISE_ENV leaked into regeneration" >&2; exit 84; } [[ -z "${MISE_AQUA_REGISTRY_URL+x}" ]] || { echo "registry override leaked into regeneration" >&2; exit 85; } [[ "${MISE_CACHE_DIR:-}" != "$FORBIDDEN_MISE_CACHE_DIR" ]] || { echo "caller cache directory was reused" >&2; exit 86; } @@ -86,7 +86,7 @@ run_helper() { MISE_BIN="$fake_mise" \ MISE_CACHE_DIR="$forbidden_cache" \ MISE_ENV="unexpected-profile" \ - MISE_HTTP_TIMEOUT=7 \ + MISE_HTTP_TIMEOUT=7s \ MISE_LOCK_TIMEOUT=5s \ bash "$fixture_root/scripts/regenerate-mise-lock.sh" } @@ -126,4 +126,32 @@ failure_scratch="$(cat "$failure_record")" [[ ! -e "$failure_scratch" ]] || { echo "failed regeneration scratch was not removed" >&2; exit 1; } echo "OK preserve lock after failed regeneration" +bad_http_root="$scratch/bad-http-repo" +copy_fixture "$bad_http_root" +if env \ + MISE_BIN="$fake_mise" \ + MISE_HTTP_TIMEOUT=60 \ + MISE_LOCK_TIMEOUT=5s \ + bash "$bad_http_root/scripts/regenerate-mise-lock.sh"; then + echo "ERROR: unitless HTTP timeout was accepted" >&2 + exit 1 +fi +cmp -s "$ROOT/mise.lock" "$bad_http_root/mise.lock" \ + || { echo "ERROR: bad HTTP timeout changed the lock" >&2; exit 1; } +echo "OK reject unitless HTTP timeout" + +bad_lock_root="$scratch/bad-lock-repo" +copy_fixture "$bad_lock_root" +if env \ + MISE_BIN="$fake_mise" \ + MISE_HTTP_TIMEOUT=7s \ + MISE_LOCK_TIMEOUT=--help \ + bash "$bad_lock_root/scripts/regenerate-mise-lock.sh"; then + echo "ERROR: option-like lock timeout was accepted" >&2 + exit 1 +fi +cmp -s "$ROOT/mise.lock" "$bad_lock_root/mise.lock" \ + || { echo "ERROR: bad lock timeout changed the lock" >&2; exit 1; } +echo "OK reject option-like lock timeout" + echo "All isolated mise lock regeneration tests passed." From db84742accd7865e9464ba12bfaca7c98096d24e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:20:42 +0200 Subject: [PATCH 31/38] security: require cross-platform lock coherence --- scripts/validate-mise-lock.py | 40 ++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py index a269b29..e2a9a55 100644 --- a/scripts/validate-mise-lock.py +++ b/scripts/validate-mise-lock.py @@ -98,8 +98,8 @@ def platform_info(entry: dict[str, Any], platform: str, tool: str) -> dict[str, return cast(dict[str, Any], value) -def validate_url(tool: str, version: str, platform: str, url: str) -> None: - """Require an artifact URL that matches the approved upstream layout.""" +def validate_url(tool: str, version: str, platform: str, url: str) -> str | None: + """Require an approved artifact URL and return Python's build date when present.""" arch = {"linux-x64": "x86_64", "linux-arm64": "aarch64"}[platform] if tool == "node": node_arch = "x64" if platform == "linux-x64" else "arm64" @@ -109,7 +109,7 @@ def validate_url(tool: str, version: str, platform: str, url: str) -> None: ) if url != expected: fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") - return + return None if tool == "python": pattern = re.compile( @@ -117,9 +117,10 @@ def validate_url(tool: str, version: str, platform: str, url: str) -> None: rf"(?P[0-9]{{8}})/cpython-{re.escape(version)}\+(?P=date)-{arch}-unknown-linux-gnu-" rf"install_only_stripped\.tar\.gz$" ) - if not pattern.fullmatch(url): + match = pattern.fullmatch(url) + if match is None: fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") - return + return match.group("date") if tool == "uv": expected = ( @@ -128,7 +129,7 @@ def validate_url(tool: str, version: str, platform: str, url: str) -> None: ) if url != expected: fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") - return + return None fail(f"no URL policy defined for mise tool {tool}") @@ -185,8 +186,8 @@ def validate_mise_config(config: dict[str, Any], expected_versions: dict[str, st def validate_artifact( tool: str, version: str, platform: str, artifact: dict[str, Any] -) -> None: - """Validate one platform-specific checksum, URL, and provenance record.""" +) -> str | None: + """Validate one artifact and return its cross-platform build identifier.""" require_exact_keys( artifact, ARTIFACT_KEYS[tool], @@ -199,7 +200,7 @@ def validate_artifact( f"lowercase SHA-256: {checksum}" ) url = expect_string(artifact, "url", f"mise.lock tools.{tool}.{platform}") - validate_url(tool, version, platform, url) + build_identifier = validate_url(tool, version, platform, url) if tool in {"python", "uv"} and artifact.get("provenance") != "github-attestations": fail( @@ -213,6 +214,8 @@ def validate_artifact( if not UV_API_URL_RE.fullmatch(url_api): fail(f"mise.lock uv API URL for {platform} is unexpected: {url_api}") + return build_identifier + def validate_tool_entry( tool: str, @@ -252,8 +255,25 @@ def validate_tool_entry( f"got {sorted(locked_platforms)}" ) + build_identifiers: list[str] = [] + uv_api_urls: list[str] = [] for platform in PLATFORMS: - validate_artifact(tool, version, platform, platform_info(entry, platform, tool)) + artifact = platform_info(entry, platform, tool) + build_identifier = validate_artifact(tool, version, platform, artifact) + if build_identifier is not None: + build_identifiers.append(build_identifier) + if tool == "uv": + uv_api_urls.append( + expect_string(artifact, "url_api", f"mise.lock tools.{tool}.{platform}") + ) + + if tool == "python" and len(set(build_identifiers)) != 1: + fail( + "mise.lock python artifacts must use one cross-platform build date, " + f"got {sorted(set(build_identifiers))}" + ) + if tool == "uv" and len(set(uv_api_urls)) != len(uv_api_urls): + fail("mise.lock uv artifacts must use distinct GitHub release asset API URLs") def validate_lock(lock: dict[str, Any], expected_versions: dict[str, str]) -> None: From 4087e71d3ccb035bec4b90c2577b5fb70f767d08 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:22:08 +0200 Subject: [PATCH 32/38] test: cover cross-platform lock coherence --- scripts/test-validate-mise-lock.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/scripts/test-validate-mise-lock.py b/scripts/test-validate-mise-lock.py index 1030779..5a99a11 100644 --- a/scripts/test-validate-mise-lock.py +++ b/scripts/test-validate-mise-lock.py @@ -65,6 +65,43 @@ def append_to_table(path: Path, table_header: str, text: str) -> None: path.write_text(content[:end] + "\n" + text + content[end:], encoding="utf-8") +def change_python_x64_build_date(path: Path) -> None: + """Keep the Python URL valid while making its x64 build date inconsistent.""" + content = path.read_text(encoding="utf-8") + pattern = re.compile( + r'(?Purl = "https://github\.com/astral-sh/python-build-standalone/' + r'releases/download/)(?P[0-9]{8})(?P/cpython-[^"]+\+)' + r'(?P=date)(?P-x86_64-unknown-linux-gnu-install_only_stripped\.tar\.gz")' + ) + match = pattern.search(content) + if match is None: + raise AssertionError("test fixture Python x64 URL not found") + new_date = "19990101" if match.group("date") != "19990101" else "19990102" + replacement = ( + match.group("prefix") + + new_date + + match.group("middle") + + new_date + + match.group("suffix") + ) + path.write_text(content[: match.start()] + replacement + content[match.end() :], encoding="utf-8") + + +def duplicate_uv_asset_api_url(path: Path) -> None: + """Make both uv platforms refer to the same GitHub release asset API URL.""" + content = path.read_text(encoding="utf-8") + pattern = re.compile( + r'url_api = "(?Phttps://api\.github\.com/repos/astral-sh/uv/releases/assets/[0-9]+)"' + ) + matches = list(pattern.finditer(content)) + if len(matches) != 2: + raise AssertionError(f"expected two uv API URLs, found {len(matches)}") + first_url = matches[0].group("url") + second = matches[1] + replacement = f'url_api = "{first_url}"' + path.write_text(content[: second.start()] + replacement + content[second.end() :], encoding="utf-8") + + def run_validator(validator: Path, root: Path) -> subprocess.CompletedProcess[str]: """Run the validator against one isolated fixture root.""" return subprocess.run( @@ -213,6 +250,20 @@ def main() -> int: ), "mise.lock tools.python.linux-arm64 keys must be exactly", ) + expect_failure( + source_root, + validator, + "mixed Python build dates", + lambda root: change_python_x64_build_date(root / "mise.lock"), + "must use one cross-platform build date", + ) + expect_failure( + source_root, + validator, + "duplicate uv asset API URL", + lambda root: duplicate_uv_asset_api_url(root / "mise.lock"), + "must use distinct GitHub release asset API URLs", + ) print("All mise lock validation tests passed.") return 0 From 42b70b8e827a1bb284a221cc2378a5bef97c8e83 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:26:05 +0200 Subject: [PATCH 33/38] security: serialize upstream update writes --- .github/workflows/check-upstream.yml | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml index 1fa6f58..3925c15 100644 --- a/.github/workflows/check-upstream.yml +++ b/.github/workflows/check-upstream.yml @@ -10,6 +10,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: check-upstream + cancel-in-progress: false + jobs: check: runs-on: ubuntu-latest @@ -21,6 +25,13 @@ jobs: with: fetch-depth: 0 + - name: Require the main branch + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "ERROR: upstream maintenance may only run from main; got $GITHUB_REF" >&2 + exit 1 + fi + - name: Check stable upstream releases env: GH_TOKEN: ${{ github.token }} @@ -158,7 +169,23 @@ jobs: --json number \ --jq '.[0].number // empty')" - git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" 2>/dev/null || true + remote_ref="refs/remotes/origin/$branch" + set +e + git ls-remote --exit-code --heads origin "$branch" >/dev/null + remote_status=$? + set -e + case "$remote_status" in + 0) + git fetch origin "refs/heads/$branch:$remote_ref" + ;; + 2) + ;; + *) + echo "ERROR: could not determine whether remote automation branch exists" >&2 + exit "$remote_status" + ;; + esac + git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com git checkout -B "$branch" @@ -255,7 +282,6 @@ jobs: git add "${tracked_files[@]}" git commit -m "chore: update upstream versions, digests, and runtime lock" - remote_ref="refs/remotes/origin/$branch" if git rev-parse --verify "$remote_ref" >/dev/null 2>&1 && [[ "$(git rev-parse 'HEAD^{tree}')" == "$(git rev-parse "$remote_ref^{tree}")" ]]; then echo "The automation branch already contains the desired update." From 25e445f059be846f8dfab536261b80130a18412f Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:26:59 +0200 Subject: [PATCH 34/38] docs: describe fail-closed lock validation --- docs/runtime-locks.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/runtime-locks.md b/docs/runtime-locks.md index 6b36924..a15dab0 100644 --- a/docs/runtime-locks.md +++ b/docs/runtime-locks.md @@ -10,13 +10,17 @@ The runtime pins are represented in three places for different purposes: - `mise.toml` declares the exact mise-managed runtime versions and enables provenance re-verification for locked installs. - `mise.lock` records the resolved Linux AMD64 and ARM64 artifact URLs, SHA-256 checksums and available provenance requirements. -`scripts/validate-version-pins.sh` fails when these files or the base Dockerfile disagree. The Dockerfile copies `mise.toml` and `mise.lock` as read-only inputs and runs `mise install --locked`; a missing artifact entry, dynamic-resolution requirement, provenance failure or checksum mismatch stops the build. `locked_verify_provenance = true` ensures that Python and uv GitHub artifact attestations are checked during the build instead of trusting only the provenance marker already stored in the lockfile. +`scripts/validate-version-pins.sh` fails when these files or the base Dockerfile disagree. The lock validator treats both TOML files as security-sensitive schemas: unknown sections or fields, malformed platform values, unexpected backends or URLs, invalid checksums, missing provenance, mixed Python build dates and reused uv asset IDs are rejected. Adversarial fixtures exercise these rejection paths on every validation run. + +The Dockerfile copies `mise.toml` and `mise.lock` as read-only inputs and runs `mise install --locked`; a missing artifact entry, dynamic-resolution requirement, provenance failure or checksum mismatch stops the build. `locked_verify_provenance = true` ensures that Python and uv GitHub artifact attestations are checked during installation instead of trusting only the provenance marker already stored in the lockfile. + +The current CI builds Linux AMD64, so it downloads, checksums, installs and re-verifies provenance for the AMD64 artifacts. ARM64 entries are checked for exact schema, platform, backend, URL, checksum and provenance metadata coherence, but are not executed by the current AMD64 job. A future ARM64 image build will use the same locked installation and re-verification path before ARM64 publication. npm is intentionally excluded from `mise.lock` because the image installs it separately from the npm registry. ## Regenerate the lockfile -Use the exact mise release pinned by `MISE_VERSION` in `versions.env`. The helper rejects any other mise version and isolates the command from user-global mise configuration. +Use the exact mise release pinned by `MISE_VERSION` in `versions.env`. The helper rejects any other mise version. It copies only `versions.env`, `mise.toml` and the existing `mise.lock` into a temporary workspace, clears inherited `MISE_*` settings, uses isolated config/data/cache/system/tmp directories, bounds network and command time, validates the generated lock and replaces the repository lock only after validation succeeds. A failed or malformed regeneration leaves the previous lock untouched. ```bash source versions.env @@ -32,9 +36,9 @@ When changing Python, Node.js or uv: 3. Update the matching tool in `mise.toml`. 4. Run `scripts/regenerate-mise-lock.sh` with the pinned mise release. 5. Review every changed URL, SHA-256 and provenance field for both `linux-x64` and `linux-arm64`. -6. Run `make validate` and build the AMD64 images so mise verifies the downloaded artifacts and supported provenance. +6. Run `make validate` and build the AMD64 images so mise verifies the current-platform downloaded artifacts and supported provenance. -The daily upstream workflow follows the same procedure with a freshly downloaded mise binary whose SHA-256 is verified before it regenerates the lock. A plain `mise lock` refreshes artifact metadata for the already pinned versions, so the workflow may propose a lock-only change when an upstream provider publishes a newer artifact for an unchanged runtime version. +The daily upstream workflow follows the same procedure with a freshly downloaded mise binary whose SHA-256 is verified before it regenerates the lock. A plain `mise lock` refreshes artifact metadata for the already pinned versions, so the workflow may propose a lock-only change when an upstream provider publishes a newer artifact for an unchanged runtime version. The workflow is restricted to `main`, serialized to prevent competing writers and uses a force-with-lease update for its dedicated automation branch. ## Recovery From c7da89f968759de074033eb2c326cb81c76b3b54 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:41:36 +0200 Subject: [PATCH 35/38] fix: replace regenerated mise lock atomically --- scripts/regenerate-mise-lock.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh index 0de0174..a2d7ff0 100644 --- a/scripts/regenerate-mise-lock.sh +++ b/scripts/regenerate-mise-lock.sh @@ -45,7 +45,14 @@ for required_file in versions.env mise.toml mise.lock; do done scratch="$(mktemp -d)" -trap 'rm -rf "${scratch:?}"' EXIT +replacement="" +cleanup() { + if [[ -n "$replacement" ]]; then + rm -f -- "$replacement" + fi + rm -rf -- "${scratch:?}" +} +trap cleanup EXIT workspace="$scratch/workspace" mkdir -p \ "$workspace" \ @@ -83,6 +90,9 @@ done < <(env -0) ) python3 "$ROOT/scripts/validate-mise-lock.py" --root "$workspace" -install -m 0644 "$workspace/mise.lock" "$ROOT/mise.lock" +replacement="$(mktemp "$ROOT/.mise.lock.tmp.XXXXXX")" +install -m 0644 "$workspace/mise.lock" "$replacement" +mv -f -- "$replacement" "$ROOT/mise.lock" +replacement="" python3 "$ROOT/scripts/validate-mise-lock.py" --root "$ROOT" echo "Regenerated mise.lock with mise $MISE_VERSION from isolated inputs." From a09af103dfe7e015b322e0ef636e1e41cf9a565a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:42:29 +0200 Subject: [PATCH 36/38] fix: publish edge for mise lock changes --- .github/workflows/publish-edge-amd64.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-edge-amd64.yml b/.github/workflows/publish-edge-amd64.yml index fa36216..21eb066 100644 --- a/.github/workflows/publish-edge-amd64.yml +++ b/.github/workflows/publish-edge-amd64.yml @@ -7,6 +7,8 @@ on: paths: - ".dockerignore" - "versions.env" + - "mise.toml" + - "mise.lock" - "images/**" - "scripts/**" - "config/**" From 3537e4c615b0a253acf9117cfa7db2e2c4cb17ac Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:43:24 +0200 Subject: [PATCH 37/38] test: cover atomic mise lock replacement --- scripts/test-regenerate-mise-lock.sh | 52 +++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/scripts/test-regenerate-mise-lock.sh b/scripts/test-regenerate-mise-lock.sh index 976a803..1f605e4 100644 --- a/scripts/test-regenerate-mise-lock.sh +++ b/scripts/test-regenerate-mise-lock.sh @@ -70,14 +70,26 @@ copy_fixture() { "$destination/scripts/" } -run_helper() { +assert_no_temp_lock() { + local fixture_root="$1" + local leftover="" + leftover="$(find "$fixture_root" -maxdepth 1 -type f -name '.mise.lock.tmp.*' -print -quit)" + if [[ -n "$leftover" ]]; then + echo "ERROR: temporary lock replacement was not removed: $leftover" >&2 + exit 1 + fi +} + +run_helper_with_path() { local fixture_root="$1" local mode="$2" local record="$3" + local path_value="$4" local forbidden_cache="$scratch/forbidden-cache" mkdir -p "$forbidden_cache" env \ + PATH="$path_value" \ FAKE_MISE_MODE="$mode" \ FAKE_MISE_VERSION="$MISE_VERSION" \ FAKE_SCRATCH_RECORD="$record" \ @@ -91,11 +103,16 @@ run_helper() { bash "$fixture_root/scripts/regenerate-mise-lock.sh" } +run_helper() { + run_helper_with_path "$1" "$2" "$3" "$PATH" +} + success_root="$scratch/success-repo" success_record="$scratch/success-record" copy_fixture "$success_root" run_helper "$success_root" success "$success_record" grep -Fq '# fake regeneration marker' "$success_root/mise.lock" +assert_no_temp_lock "$success_root" success_scratch="$(cat "$success_record")" [[ ! -e "$success_scratch" ]] || { echo "successful regeneration scratch was not removed" >&2; exit 1; } echo "OK isolated successful regeneration" @@ -109,6 +126,7 @@ if run_helper "$invalid_root" invalid "$invalid_record"; then fi cmp -s "$ROOT/mise.lock" "$invalid_root/mise.lock" \ || { echo "ERROR: invalid generated lock replaced the committed fixture" >&2; exit 1; } +assert_no_temp_lock "$invalid_root" invalid_scratch="$(cat "$invalid_record")" [[ ! -e "$invalid_scratch" ]] || { echo "invalid regeneration scratch was not removed" >&2; exit 1; } echo "OK reject invalid generated lock without partial write" @@ -122,10 +140,40 @@ if run_helper "$failure_root" fail "$failure_record"; then fi cmp -s "$ROOT/mise.lock" "$failure_root/mise.lock" \ || { echo "ERROR: failed regeneration replaced the committed fixture" >&2; exit 1; } +assert_no_temp_lock "$failure_root" failure_scratch="$(cat "$failure_record")" [[ ! -e "$failure_scratch" ]] || { echo "failed regeneration scratch was not removed" >&2; exit 1; } echo "OK preserve lock after failed regeneration" +fake_tools="$scratch/fake-tools" +mkdir -p "$fake_tools" +cat > "$fake_tools/install" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +destination="${!#}" +printf '%s\n' 'partial replacement' > "$destination" +exit 43 +EOF +chmod 0755 "$fake_tools/install" + +install_failure_root="$scratch/install-failure-repo" +install_failure_record="$scratch/install-failure-record" +copy_fixture "$install_failure_root" +if run_helper_with_path \ + "$install_failure_root" \ + success \ + "$install_failure_record" \ + "$fake_tools:$PATH"; then + echo "ERROR: failed replacement copy was treated as success" >&2 + exit 1 +fi +cmp -s "$ROOT/mise.lock" "$install_failure_root/mise.lock" \ + || { echo "ERROR: failed replacement copy changed the committed fixture" >&2; exit 1; } +assert_no_temp_lock "$install_failure_root" +install_failure_scratch="$(cat "$install_failure_record")" +[[ ! -e "$install_failure_scratch" ]] || { echo "copy-failure scratch was not removed" >&2; exit 1; } +echo "OK preserve lock after replacement copy failure" + bad_http_root="$scratch/bad-http-repo" copy_fixture "$bad_http_root" if env \ @@ -138,6 +186,7 @@ if env \ fi cmp -s "$ROOT/mise.lock" "$bad_http_root/mise.lock" \ || { echo "ERROR: bad HTTP timeout changed the lock" >&2; exit 1; } +assert_no_temp_lock "$bad_http_root" echo "OK reject unitless HTTP timeout" bad_lock_root="$scratch/bad-lock-repo" @@ -152,6 +201,7 @@ if env \ fi cmp -s "$ROOT/mise.lock" "$bad_lock_root/mise.lock" \ || { echo "ERROR: bad lock timeout changed the lock" >&2; exit 1; } +assert_no_temp_lock "$bad_lock_root" echo "OK reject option-like lock timeout" echo "All isolated mise lock regeneration tests passed." From 50b4269f0181631d0b495be27f0703f8edc815c3 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:44:14 +0200 Subject: [PATCH 38/38] test: require edge publication for lock inputs --- scripts/validate-version-pins.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/validate-version-pins.sh b/scripts/validate-version-pins.sh index b72535e..57de659 100644 --- a/scripts/validate-version-pins.sh +++ b/scripts/validate-version-pins.sh @@ -45,6 +45,7 @@ require_sha256() { base_dockerfile="$ROOT/images/base/Dockerfile" codex_dockerfile="$ROOT/images/codex/Dockerfile" +edge_workflow="$ROOT/.github/workflows/publish-edge-amd64.yml" require_frontend_pin() { local file="$1" @@ -88,6 +89,14 @@ require_action_shas() { done < <(find "$ROOT/.github/workflows" -type f \( -name '*.yml' -o -name '*.yaml' \) -print) } +require_edge_path_trigger() { + local path="$1" + if ! grep -Fxq " - \"$path\"" "$edge_workflow"; then + echo "ERROR: edge publication must trigger when $path changes" >&2 + exit 1 + fi +} + base_frontend="$(require_frontend_pin "$base_dockerfile")" codex_frontend="$(require_frontend_pin "$codex_dockerfile")" if [[ "$base_frontend" != "$codex_frontend" ]]; then @@ -99,6 +108,8 @@ if ! grep -Fxq 'FROM ubuntu:${UBUNTU_VERSION}@${UBUNTU_DIGEST}' "$base_dockerfil exit 1 fi require_action_shas +require_edge_path_trigger mise.toml +require_edge_path_trigger mise.lock if ! grep -Fq 'MISE_GLOBAL_CONFIG_FILE=/etc/mise/mise.toml' "$base_dockerfile"; then echo "ERROR: base Dockerfile must use the committed mise.toml as its global config" >&2