diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml index 202a10d..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" @@ -180,6 +207,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,21 +249,39 @@ 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 && [[ "$(git rev-parse 'HEAD^{tree}')" == "$(git rev-parse "$remote_ref^{tree}")" ]]; then echo "The automation branch already contains the desired update." @@ -245,15 +298,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" 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/**" 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. 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 diff --git a/docs/runtime-locks.md b/docs/runtime-locks.md new file mode 100644 index 0000000..a15dab0 --- /dev/null +++ b/docs/runtime-locks.md @@ -0,0 +1,47 @@ +# 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 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 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. 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 +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 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 workflow is restricted to `main`, serialized to prevent competing writers and uses a force-with-lease update for its dedicated automation branch. + +## Recovery + +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. diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 4e8b6c6..4233704 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,19 +148,19 @@ 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 \ && 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 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" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..fe00c07 --- /dev/null +++ b/mise.toml @@ -0,0 +1,9 @@ +[settings] +lockfile = true +locked_verify_provenance = true +lockfile_platforms = ["linux-x64", "linux-arm64"] + +[tools] +python = "3.14.6" +node = "24.18.0" +uv = "0.11.32" diff --git a/scripts/regenerate-mise-lock.sh b/scripts/regenerate-mise-lock.sh new file mode 100644 index 0000000..a2d7ff0 --- /dev/null +++ b/scripts/regenerate-mise-lock.sh @@ -0,0 +1,98 @@ +#!/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}" +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 +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 + cat >&2 <&2 + exit 1 + fi +done + +scratch="$(mktemp -d)" +replacement="" +cleanup() { + if [[ -n "$replacement" ]]; then + rm -f -- "$replacement" + fi + rm -rf -- "${scratch:?}" +} +trap cleanup 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 -d '' name _; do + if [[ "$name" == MISE_* ]]; then + env_args+=(-u "$name") + fi +done < <(env -0) + +( + 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_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" +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." diff --git a/scripts/test-regenerate-mise-lock.sh b/scripts/test-regenerate-mise-lock.sh new file mode 100644 index 0000000..1f605e4 --- /dev/null +++ b/scripts/test-regenerate-mise-lock.sh @@ -0,0 +1,207 @@ +#!/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:-}" == "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; } +[[ -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/" +} + +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" \ + 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=7s \ + MISE_LOCK_TIMEOUT=5s \ + 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" + +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; } +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" + +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; } +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 \ + 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; } +assert_no_temp_lock "$bad_http_root" +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; } +assert_no_temp_lock "$bad_lock_root" +echo "OK reject option-like lock timeout" + +echo "All isolated mise lock regeneration tests passed." diff --git a/scripts/test-validate-mise-lock.py b/scripts/test-validate-mise-lock.py new file mode 100644 index 0000000..5a99a11 --- /dev/null +++ b/scripts/test-validate-mise-lock.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Exercise fail-closed mise configuration and lockfile validation cases.""" + +from __future__ import annotations + +import argparse +import re +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 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 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( + [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 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 malformed 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]\nUNEXPECTED = "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_regex_once( + root / "mise.toml", r'^python = "[^"]+"$', 'python = "0.0.0"' + ), + "does not match versions.env", + ) + expect_failure( + source_root, + validator, + "unexpected lockfile section", + 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_regex_once( + root / "mise.lock", + r'^(backend = "core:node")$', + r'\1\n"platforms.linux-x64-musl" = "malformed"', + ), + "mise.lock tools.node keys must be exactly", + ) + expect_failure( + source_root, + validator, + "required platform scalar", + lambda root: replace_table_with_scalar( + root / "mise.lock", + '[tools.node."platforms.linux-arm64"]', + '[tools.node."platforms.linux-x64"]', + '"platforms.linux-arm64" = "malformed"', + ), + "no valid node artifact mapping for linux-arm64", + ) + expect_failure( + source_root, + validator, + "unexpected artifact field", + lambda root: append_to_table( + root / "mise.lock", + '[tools.node."platforms.linux-arm64"]', + "size = 1\n", + ), + "mise.lock tools.node.linux-arm64 keys must be exactly", + ) + expect_failure( + source_root, + validator, + "untrusted uv API URL", + lambda root: replace_regex_once( + root / "mise.lock", + 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", + ) + expect_failure( + source_root, + validator, + "missing provenance", + lambda root: replace_regex_once( + root / "mise.lock", r'^provenance = "github-attestations"\n', "" + ), + "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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-mise-lock.py b/scripts/validate-mise-lock.py new file mode 100644 index 0000000..e2a9a55 --- /dev/null +++ b/scripts/validate-mise-lock.py @@ -0,0 +1,324 @@ +#!/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, 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: + """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() + 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]: + """Load a TOML document or fail with the original parse error.""" + 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 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) + 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]: + """Return one locked platform artifact mapping for a managed tool.""" + key = f"platforms.{platform}" + value = entry.get(key) + if not isinstance(value, dict): + 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) -> 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" + 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 None + + 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$" + ) + match = pattern.fullmatch(url) + if match is None: + fail(f"mise.lock {tool} URL for {platform} is unexpected: {url}") + return match.group("date") + + 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 None + + fail(f"no URL policy defined for mise tool {tool}") + + +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", ""), + "uv": env.get("UV_VERSION", ""), + } + for tool, version in expected_versions.items(): + if not version: + fail(f"versions.env has no version for {tool}") + return expected_versions + + +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: + fail("mise.toml must enable settings.locked_verify_provenance") + 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]") + configured_tools = cast(dict[str, Any], configured_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}" + ) + + +def validate_artifact( + tool: str, version: str, platform: str, artifact: dict[str, Any] +) -> str | None: + """Validate one artifact and return its cross-platform build identifier.""" + 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( + 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}") + build_identifier = 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}") + + return build_identifier + + +def validate_tool_entry( + tool: str, + expected_version: str, + entries: object, +) -> None: + """Validate one managed tool entry and all required platform artifacts.""" + if not isinstance(entries, list) or len(entries) != 1: + fail(f"mise.lock must contain exactly one {tool} entry") + 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: + 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 in entry + if key.startswith("platforms.") + } + if locked_platforms != set(PLATFORMS): + fail( + f"mise.lock {tool} platforms must be exactly {sorted(PLATFORMS)}, " + f"got {sorted(locked_platforms)}" + ) + + build_identifiers: list[str] = [] + uv_api_urls: list[str] = [] + for platform in PLATFORMS: + 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: + """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 " + f"{sorted(expected_versions)}, got {sorted(lock_tools)}" + ) + + for tool, expected_version in expected_versions.items(): + validate_tool_entry(tool, expected_version, lock_tools.get(tool)) + + +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 " + + ", ".join(f"{tool} {version}" for tool, version in expected_versions.items()) + + " on linux-x64 and linux-arm64, with locked provenance re-verification enabled." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-version-pins.sh b/scripts/validate-version-pins.sh index ba7f7ac..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,28 @@ 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 + 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" +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 @@ -191,4 +222,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."