Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions .github/workflows/mise-toolchain.yml
Original file line number Diff line number Diff line change
@@ -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.'
30 changes: 30 additions & 0 deletions .miserc.toml
Original file line number Diff line number Diff line change
@@ -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 }}"]
76 changes: 76 additions & 0 deletions MISE-ADOPTION.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions mise.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -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",
]
Loading