diff --git a/.github/workflows/mise-toolchain.yml b/.github/workflows/mise-toolchain.yml new file mode 100644 index 0000000..0900dc9 --- /dev/null +++ b/.github/workflows/mise-toolchain.yml @@ -0,0 +1,170 @@ +name: mise toolchain + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + mise-toolchain: + name: mise toolchain (Linux x64) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install verified mise and locked tools + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 + with: + version: 2026.10.25 + sha256: 5d362ecc578dc7c146679b841dda949d6472cbf86ff5635db7afdca1b6b3fd87 + install_args: --locked + cache: false + env: false + - name: Print selected tool versions + run: mise run toolchain + - name: Confirm installation did not change configuration + run: git diff --exit-code -- mise.toml mise.lock + + # ADR-0010 M-01 and M-02. mise.toml binds the repository; this job binds the claim. + # A declaration nobody has watched fail is a hypothesis, so every leg below has an + # opposite leg: a guard that only asserts absence also passes when the command + # printed nothing at all. + # + # Portable by construction. Nothing here names a tool or a version: the declared + # set is read out of mise.toml, so the same file is correct in every repository + # that adopts this pattern. Two measured reasons it is written this way: + # * mise abbreviates HOME in `mise config` output, so a grep for + # "$GITHUB_WORKSPACE/mise.toml" matches nothing while that very file is loaded. + # The first version of this job failed for exactly that reason, and its failure + # output was the proof of the inheritance. Assertions therefore match on the + # fixture's own tool name and on the versions the repository declares. + # * jolarca-control's tests/test_ci_gates_are_real.py forbids a shell fallback in + # any run step, and it named four steps here. Handing a status to a fallback + # reports an unreadable input as a clean scan, so every step tests status with + # `if` and exits 1 when it cannot read its own input. + mise-config-scope: + name: mise config scope guard (Linux x64) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install verified mise and locked tools + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 + with: + version: 2026.10.25 + sha256: 5d362ecc578dc7c146679b841dda949d6472cbf86ff5635db7afdca1b6b3fd87 + install_args: --locked + cache: false + env: false + - name: Show what mise loaded and what it trusts + run: | + set -euo pipefail + mise --version + echo '--- mise trust --show ---' + if ! mise trust --show; then + echo 'note: trust listing exited non-zero' + fi + echo '--- mise config ---' + if ! mise config; then + echo 'note: config listing exited non-zero' + fi + - name: Plant a decoy config one directory above the repository + run: | + set -euo pipefail + parent="$(dirname "$GITHUB_WORKSPACE")" + printf '[tools]\nbidi-decoy = "9.9.9"\n' > "$parent/mise.toml" + echo "planted: $parent/mise.toml" + ls -l "$parent" + - name: Leg 1 - unbounded search MUST see the decoy (so leg 2 is not vacuous) + env: + MISE_CEILING_PATHS: "" + run: | + set -euo pipefail + if ! out="$(mise config 2>&1)"; then + printf '%s\n' "$out" + echo '::error::mise config could not be read; the guard refuses to grade an input it cannot see' + exit 1 + fi + printf '%s\n' "$out" + if ! printf '%s' "$out" | grep -q 'bidi-decoy'; then + echo '::error::decoy invisible even with no ceiling: this runner cannot test ceiling_paths' + exit 1 + fi + echo 'OK: with no ceiling mise discovers the parent config and adopts its tool.' + - name: Leg 2 - the committed .miserc.toml MUST hide the decoy and keep every declared pin + run: | + set -euo pipefail + if ! out="$(mise config 2>&1)"; then + printf '%s\n' "$out" + echo '::error::mise config could not be read' + exit 1 + fi + printf '%s\n' "$out" + if printf '%s' "$out" | grep -q 'bidi-decoy'; then + echo '::error::ceiling_paths failed open: mise still loads a config above the repository' + exit 1 + fi + if ! vers="$(mise ls --current 2>&1)"; then + printf '%s\n' "$vers" + echo '::error::mise ls --current could not be read' + exit 1 + fi + printf '%s\n' "$vers" + declared="$(awk '/^\[tools\]/{f=1; next} /^\[/{f=0} f && /=/ { + gsub(/[ "]/, ""); split($0, a, "="); if (a[2] ~ /^[0-9]/) print a[1] " " a[2] }' mise.toml)" + if [ -z "$declared" ]; then + echo '::error::mise.toml declares no tools, so a silent refusal would be read as a bound holding' + exit 1 + fi + printf 'declared tools:\n%s\n' "$declared" + while read -r name version; do + if [ -z "$name" ]; then + continue + fi + if ! printf '%s' "$vers" | grep -qF "$version"; then + echo "::error::$name $version is declared but not selected, so this repository's config is not in effect" + exit 1 + fi + echo "OK: $name $version is the selected version." + done <<< "$declared" + echo 'OK: decoy excluded and every declared pin is the one mise actually selects.' + - name: Leg 3 - plain "mise install" succeeds from the committed lockfile + run: | + set -euo pipefail + mise install + echo 'OK: no --locked flag was passed anywhere and the lock still governed resolution. Leg 4 is meaningful.' + - name: Leg 4 - with no lockfile, plain "mise install" MUST be refused + run: | + set -euo pipefail + work="$(mktemp -d)" + cp mise.toml .miserc.toml "$work"/ + cd "$work" + if ! mise trust .; then + echo 'note: mise trust exited non-zero on the scratch copy' + fi + first="$(awk '/^\[tools\]/{f=1; next} /^\[/{f=0} f && /=/ { + gsub(/[ "]/, ""); split($0, a, "="); if (a[2] ~ /^[0-9]/) print a[1]; exit }' mise.toml)" + if [ -z "$first" ]; then + echo '::error::no tool to test, so this leg would prove nothing' + exit 1 + fi + echo "testing: mise install $first with mise.lock deliberately absent" + data="$(mktemp -d)" + if out="$(MISE_DATA_DIR="$data" mise install "$first" 2>&1)"; then + printf '%s\n' "$out" + echo "::error::mise install ACCEPTED $first with no lockfile: locked mode is not a repo property" + exit 1 + fi + printf '%s\n' "$out" + if ! printf '%s' "$out" | grep -qi 'lock'; then + echo '::error::install failed for a reason other than the lockfile, so this leg proves nothing' + exit 1 + fi + echo 'OK: refused, and the refusal names the lockfile.' diff --git a/.miserc.toml b/.miserc.toml new file mode 100644 index 0000000..21a9217 --- /dev/null +++ b/.miserc.toml @@ -0,0 +1,30 @@ +# Bounds mise's config discovery to this repository. Closes ADR-0010 M-01. +# +# WHY THIS FILE AND NOT [settings] IN mise.toml +# mise reads .miserc.toml BEFORE any mise.toml. Setting ceiling_paths under +# [settings] in mise.toml has no effect on config discovery, which is precisely +# the mistake a reader of mise.toml alone would make. +# +# WHY THE PARENT, NOT THE REPO ROOT +# The ceiling directory is not searched itself: with a ceiling of /home/user, +# /home/user/projects/app/mise.toml loads but /home/user/mise.toml does not. +# config_root is the directory holding this file, so dirname(config_root) is the +# repo's parent. Naming the repo root would stop mise loading this repository's +# own mise.toml -- a bound that passes by disabling the thing it guards. +# +# WHY THIS FAILS OPEN, AND WHAT ACTUALLY ENFORCES IT +# If the template does not render, mise uses the file's raw, unrendered content as +# a literal path. That path matches nothing, discovery stays unbounded, and no +# warning is printed because mise reads this before logging starts. So this file +# is a declaration, not a control. The `mise config scope guard` job in +# .github/workflows/mise-toolchain.yml is the control: it plants a decoy config in +# the parent directory and fails the run if mise loads it. +# +# WHAT IT CANNOT BOUND +# A project .miserc.toml cannot ignore global or system config -- documented, not +# inferred from this repo's silence. So ~/.config/mise/config.toml and /etc/mise +# still merge into every project here, and env/get_env() are empty in a project +# miserc so this file cannot even read the environment to compensate. That is +# ADR-0010 M-04: the remaining half of the bound is a machine baseline, and it is +# not enforced by anything in this repository. +ceiling_paths = ["{{ config_root | dirname }}"] diff --git a/MISE-ADOPTION.md b/MISE-ADOPTION.md new file mode 100644 index 0000000..6707ca8 --- /dev/null +++ b/MISE-ADOPTION.md @@ -0,0 +1,76 @@ +# mise adoption: .github + +Status: review proposal. Linux x64 locked installation and version diagnostics passed locally; CI execution and application compatibility remain to be reviewed. Base commit: `48b96656c6779788cf84444fc0973f9697d18dcb`. + +This file introduces exact runtime selections and a diagnostic task only. Python and Node patch versions were resolved from their official release indexes on 2026-11-03; they need application compatibility testing. Terraform selections preserve this repository's existing CI selection. ShellCheck 0.11.0 installs successfully; lint compatibility still requires the existing repository checks. + +## First use + +1. Install an approved, verified, exact mise release once on your workstation. +2. Read `mise.toml` before trusting it. Repository configuration and tasks can execute code. +3. Run `mise trust` in this repository only after review. +4. Run `mise install --locked` to download the declared tools. +5. Run `mise run toolchain` to inspect versions. Success proves version availability, not application correctness. +6. Follow this repository's existing dependency setup in an isolated environment. mise does not replace Python requirements, npm package locks, Terraform provider locks, system packages, or database services. +7. Run the existing repository tests and required CI checks. Do not rename required check contexts or enable deployment as part of installation. +8. The supplied `mise.lock` was generated by mise 2026.10.25 for Linux x64 and installed successfully. Additional platforms require generated locks and validation before support is claimed. +9. Migrate CI in a separate reviewed change using the same configuration. Pin the mise installer action to a reviewed full commit SHA and the mise binary to an exact version; retain all existing gates and permissions. + +## Boundaries + +No automatic hooks, environment-file loading, production credentials, deployment tasks, or cross-repository downloads are added. `mise run toolchain` is diagnostic only. The additive mise-toolchain workflow verifies locked installation on Linux x64. Existing application/deployment workflows are unchanged and do not yet use mise. The new smoke check is not configured as a required branch-protection check. Package dependencies and additional CLI tools still require their own inventory and pinning. + +## Evidence inspected + +- `.github/workflows/ci-base.yml` +- `.github/workflows/compliance-base.yml` +- `.github/workflows/security-scan.yml` + +## Installer provenance + +The smoke workflow pins mise-action to its reviewed commit and mise to 2026.10.25, verifies the extracted Linux x64 binary SHA-256, disables shared caching, and uses a read-only token on a hosted runner. The archive digest was checked against the release API before deriving the binary digest. This is integrity verification, not an independent publisher identity audit. + +## Repository-level bounds added 2026-11-03 (ADR-0010 M-01 and M-02) + +Two files the original proposal did not carry: + +- `.miserc.toml` bounds mise's config discovery to this repository by setting + `ceiling_paths` to the **parent** of the repo root. mise reads `.miserc.toml` + before any `mise.toml`, so the same setting under `[settings]` in `mise.toml` has + no effect on discovery at all. A ceiling directory is not searched itself, which + is why the value names the parent: naming this directory would stop mise loading + the very config the bound exists to protect. +- `mise.toml` gains `[tool_config] locked = true`, making strict lockfile mode a + property of this repository rather than of one CI invocation. + `install_args: --locked` in the workflow binds the job; a developer running plain + `mise install` was never bound by it. `[tool_config]` is not merged and covers + only tools declared in this config root, so it does nothing about tools inherited + from a parent directory -- which is why it has to ship with `.miserc.toml`, not + instead of it. + +`mise-config-scope` in the workflow is a four-leg refusal test, each negative paired +with a positive, because a guard that only asserts absence also passes when the +command printed nothing: + +1. with no ceiling, `mise config` must list a decoy planted one directory above the + checkout and adopt its tool -- proving the next leg is testing something; +2. with the committed `.miserc.toml`, the decoy must be absent **and** every version + this repository declares must still be the one mise actually selects; +3. plain `mise install`, no `--locked` anywhere, must succeed from the committed + lockfile; +4. the same plain install must be refused when `mise.lock` is absent. + +The job reads the declared tool set out of `mise.toml` instead of naming versions, +so one file is correct in every repository that adopts the pattern. + +### Limits, stated rather than implied + +Linux x64 only, in CI, on a hosted runner with `permissions: contents: read`, no +secrets and no deployment path. It is not a required status check. A project +`.miserc.toml` **cannot** ignore global or system config, so `~/.config/mise` and +`/etc/mise` still merge into every project on the machine (ADR-0010 M-04 stays +open). `ceiling_paths` **fails open**: an unrendered template becomes a literal path +matching nothing, silently, because mise reads the file before logging starts -- leg 2 +is what turns the declaration into a control, and deleting that leg deletes the +control. The printed verdicts on this pull request's checks are the evidence; a green +badge is not, and nothing here certifies any version as vulnerability-free. diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..fb0455d --- /dev/null +++ b/mise.lock @@ -0,0 +1,22 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 3 + +[[tools.node]] +version = "22.23.4" +backend = "core:node" +specifiers = ["22.23.4"] + +[tools.node."platforms.linux-x64"] +checksum = "sha256:b8492284c76a54baf58d61668dd0e41ee0fc98adabf9503d30f05d08f55b4406" +url = "https://nodejs.org/dist/v22.23.4/node-v22.23.4-linux-x64.tar.gz" + +[[tools.python]] +version = "3.12.16" +backend = "core:python" +specifiers = ["3.12.16"] + +[tools.python."platforms.linux-x64"] +checksum = "sha256:ce4f6d0a6cfdb3bed6e44c2f9affd85943c76a42e25ad3a259002f31d6fb8a20" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20261026/cpython-3.12.16+20261026-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..70eb432 --- /dev/null +++ b/mise.toml @@ -0,0 +1,18 @@ +# Initial local toolchain adoption; see MISE-ADOPTION.md for validation limits. +# Strict lockfile mode for the tools declared in this config root (ADR-0010 M-02). +# install_args: --locked in the workflow binds the job, not the repository; a plain +# `mise install` was never bound by it. [tool_config] is not merged and reaches only +# tools declared here, so .miserc.toml must ship with it, not after it. +[tool_config] +locked = true + +[tools] +python = "3.12.16" +node = "22.23.4" + +[tasks.toolchain] +description = "Print selected tool versions; this is not a compliance gate" +run = [ + "python --version", + "node --version", +]