From c27075778dda1da9606fa81d01646cec49b2ba76 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 7 Sep 2026 12:51:31 +0000 Subject: [PATCH] fix: reject a system toolchain older than the revision's pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `demo.sh --system typescript` used whatever `bun` PATH offered. Bun 1.3 cannot parse this repository's lockfileVersion 2 `bun.lock`, so it warned "Ignoring lockfile" and then failed the frozen install with "lockfile had changes, but lockfile is frozen" — a message about the repository, not about the caller's Bun. The launcher already reads each revision's pinned version out of mise.toml for `--mise`. In system mode it now also probes the resolved driver and compares, failing before any install or build with both versions and the `--mise` way out. A newer toolchain still passes. The hermetic updater tests gain the TypeScript path, which nothing covered, plus the version gate across all three probe shapes (`bun --version`, `go version`, Perl's `$^V`). The Python fixture now pins the caller's own python3, so those tests stay independent of how new the host interpreter is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TbGYCRHZsZUsQwRCLafhMF --- apps/demo/scripts/test-updater.py | 87 ++++++++++++++++++++++++++++--- apps/web/app/docs/page.tsx | 3 +- apps/web/public/demo.sh | 28 ++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/apps/demo/scripts/test-updater.py b/apps/demo/scripts/test-updater.py index 831af24..d08884f 100644 --- a/apps/demo/scripts/test-updater.py +++ b/apps/demo/scripts/test-updater.py @@ -40,8 +40,12 @@ def git(self, *args): return subprocess.check_output(["git", *args], cwd=self.repo, stderr=subprocess.DEVNULL, text=True).strip() def commit(self, label): - (self.repo / "mise.toml").write_text('[tools]\nbun = "1.4.0"\npython = "3.12.13"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\ncmake = "4.4.3"\nruby = "4.0.6"\n"conda:php" = "8.5.9"\nperl = "5.44.0.0"\n') - (self.repo / ".gitignore").write_text('__pycache__/\n') + # The Python demo runs the caller's own python3, so pin that exact + # version: these tests cover updating and caching, not the version gate, + # and must not depend on how new the host interpreter happens to be. + host = subprocess.check_output(["python3", "--version"], text=True).split()[-1] + (self.repo / "mise.toml").write_text(f'[tools]\nbun = "1.4.0"\npython = "{host}"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\ncmake = "4.4.3"\nruby = "4.0.6"\n"conda:php" = "8.5.9"\nperl = "5.44.0.0"\n') + (self.repo / ".gitignore").write_text('__pycache__/\ndist/\n') path = self.repo / "ports/python/examples" path.mkdir(parents=True, exist_ok=True) (path / "__init__.py").write_text("") @@ -49,7 +53,7 @@ def commit(self, label): 'import json, os, sys\n' f'print(json.dumps({{"revision": {label!r}, "args": sys.argv[1:], "cwd": os.getcwd(), "tty": os.isatty(0), "tmpdir": os.environ.get("TMPDIR")}}), flush=True)\n' 'if "--wait" in sys.argv: input()\n') - for language in ("rust", "go", "zig", "cpp", "ruby", "php", "perl"): + for language in ("typescript", "rust", "go", "zig", "cpp", "ruby", "php", "perl"): (self.repo / "ports" / language).mkdir(exist_ok=True) (self.repo / "ports" / language / "source").write_text(label) self.git("add", ".") @@ -111,6 +115,17 @@ def stub_compilers(self): tool=pathlib.Path(sys.argv[0]).name args=sys.argv[1:] with open(os.environ["UPDATER_TEST_LOG"],"a") as log: log.write(json.dumps([tool,*args])+"\\n") +pins={'bun':'1.4.0','cargo':'1.97.1','go':'1.26.0','zig':'0.16.0','cmake':'4.4.3','ruby':'4.0.6','perl':'5.44.0.0'} +reported=os.environ.get('UPDATER_'+tool.upper()+'_VERSION',pins.get(tool,'')) +# The launcher probes each system tool before building. Answer in the shape the +# real tool answers: a bare number, or a number buried in a sentence. +if tool!='php-config' and (args[:1]==['--version'] or (tool in ('go','zig') and args[:1]==['version'])): + print({'cargo':'cargo %s (fixture 2026-01-01)','go':'go version go%s linux/amd64', + 'cmake':'cmake version %s','ruby':'ruby %s (fixture revision)'}.get(tool,'%s')%reported) + sys.exit(0) +if tool=='perl' and args[:1]==['-e'] and '$^V' in args[1]: + print('.'.join(reported.split('.')[:3]),end='') # Perl reports three fields; mise pins four. + sys.exit(0) if tool in ('cmake','cargo','go','zig'): expected=pathlib.Path(os.environ['HQTUI_DEMO_CACHE'])/'v1/tmp' assert pathlib.Path(os.environ['TMPDIR'])==expected, 'compiler scratch escaped demo cache' @@ -132,6 +147,21 @@ def stub_compilers(self): print(json.dumps(dict(revision=label,args=args[1:]))) sys.exit(0) elif tool=="php-config": print(os.environ.get('UPDATER_PHP_CONFIG_VERSION',os.environ.get('UPDATER_PHP_VERSION','8.5.9')));sys.exit(0) +elif tool=="bun": + scratch=pathlib.Path(os.environ['HQTUI_DEMO_CACHE'])/'v1/tmp' + if args[0]=="install": + assert args==["install","--frozen-lockfile","--ignore-scripts"] + assert pathlib.Path(os.environ['TMPDIR'])==scratch, 'install scratch escaped demo cache' + sys.exit(0) + if args[0]=="run": + assert args==["run","--bun","build"] + assert pathlib.Path(os.environ['TMPDIR'])==scratch, 'build scratch escaped demo cache' + built=pathlib.Path("apps/demo/dist/main.js") + built.parent.mkdir(parents=True,exist_ok=True) + built.write_text(pathlib.Path("ports/typescript/source").read_text()) + sys.exit(0) + print(json.dumps(dict(revision=pathlib.Path(args[0]).read_text(),args=args[1:]))) + sys.exit(0) elif tool=="cargo": assert args==["build","--release","--example","dashboard"] output=pathlib.Path(os.environ["CARGO_TARGET_DIR"])/"release/examples/dashboard" @@ -160,7 +190,7 @@ def stub_compilers(self): output.write_text("#!/usr/bin/env python3\\nimport json,sys\\nprint(json.dumps(dict(revision="+repr(label)+",args=sys.argv[1:])))\\n") output.chmod(0o700) ''' - for name in ("mise", "cargo", "go", "zig", "cmake", "ruby", "php", "perl", "php-config"): + for name in ("mise", "bun", "cargo", "go", "zig", "cmake", "ruby", "php", "perl", "php-config"): path = self.bin / name path.write_text(code) path.chmod(0o700) @@ -174,7 +204,7 @@ def test_vanilla_and_mise_build_latest_and_reuse_only_same_revision(self): self.assertEqual(json.loads(result.stdout)["revision"], "first") self.assertEqual(json.loads(result.stdout)["args"][-1], "argument with spaces") log = [json.loads(line) for line in (self.root / "tools.jsonl").read_text().splitlines()] - self.assertEqual(sum(row[0] in ("cargo", "go", "zig") or row[:2]==["cmake","--build"] for row in log), 8) + self.assertEqual(sum(row[:2] in (["cargo","build"],["go","build"],["zig","build"],["cmake","--build"]) for row in log), 8) for language in ("rust", "go", "zig", "cpp"): self.assertEqual(self.run_demo("--mise", language, "--snapshot").returncode, 0) self.commit("second") @@ -183,7 +213,52 @@ def test_vanilla_and_mise_build_latest_and_reuse_only_same_revision(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["revision"], "second") log = [json.loads(line) for line in (self.root / "tools.jsonl").read_text().splitlines()] - self.assertEqual(sum(row[0] in ("cargo", "go", "zig") or row[:2]==["cmake","--build"] for row in log), 12) + self.assertEqual(sum(row[:2] in (["cargo","build"],["go","build"],["zig","build"],["cmake","--build"]) for row in log), 12) + + def test_typescript_installs_and_builds_once_per_revision(self): + self.stub_compilers() + def installs(): + return sum(json.loads(line)[:2] == ["bun", "install"] + for line in (self.root / "tools.jsonl").read_text().splitlines()) + for manager in ("--system", "--mise"): + result = self.run_demo(manager, "typescript", "--snapshot", "literal argument") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), + dict(revision="first", args=["--snapshot", "literal argument"])) + self.assertEqual(installs(), 2) + self.assertEqual(self.run_demo("--mise", "typescript", "--snapshot").returncode, 0) + self.assertEqual(installs(), 2) # Same revision: the earlier build is reused. + self.commit("second") + result = self.run_demo("--mise", "typescript", "--snapshot") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["revision"], "second") + self.assertEqual(installs(), 3) + + def test_system_tool_older_than_the_pin_fails_before_any_build(self): + self.stub_compilers() + # Each entry is a different probe shape: `bun --version`, `go version`, + # and Perl's own $^V. Bun 1.3 is the real case: it cannot read this + # repository's lockfile and would otherwise report frozen-lockfile drift. + for language, tool, old, pinned in (("typescript", "BUN", "1.3.14", "bun 1.4.0"), + ("go", "GO", "1.20.0", "go 1.26.0"), + ("perl", "PERL", "5.40.0.0", "perl 5.44.0.0")): + result = self.run_demo("--system", language, "--snapshot", + env={**self.env, f"UPDATER_{tool}_VERSION": old}) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"pins {pinned}", result.stderr) + self.assertIn(f"is {old.rsplit('.', 1)[0] if tool == 'PERL' else old}", result.stderr) + self.assertIn("--mise", result.stderr) + self.assertEqual(result.stdout, "") + log = (self.root / "tools.jsonl").read_text() + self.assertNotIn('"install"', log) + self.assertNotIn('"build"', log) + # A newer system tool is fine, and --mise remains the documented way out. + newer = self.run_demo("--system", "typescript", "--snapshot", + env={**self.env, "UPDATER_BUN_VERSION": "1.5.0"}) + self.assertEqual(newer.returncode, 0, newer.stderr) + pinned = self.run_demo("--mise", "typescript", "--snapshot", + env={**self.env, "UPDATER_BUN_VERSION": "1.3.14"}) + self.assertEqual(pinned.returncode, 0, pinned.stderr) def test_bindings_update_both_managers_and_keep_builds_outside_source(self): self.stub_compilers() diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 85d1673..fff2f96 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -119,7 +119,8 @@ export default async function Docs() {

Vanilla commands require Git, curl and the language's installed toolchain - (Bun for TypeScript). The mise + (Bun for TypeScript), at least as new as the version that revision pins; the launcher + compares the two before building and names both if yours is older. The mise alternatives install/use the selected pinned runtime and required build tools without loading project hooks. The launcher prints the full Git revision, reuses completed builds for that revision, and fails rather than silently launching stale code if fetching fails. diff --git a/apps/web/public/demo.sh b/apps/web/public/demo.sh index ffd80ca..168be9e 100644 --- a/apps/web/public/demo.sh +++ b/apps/web/public/demo.sh @@ -146,6 +146,34 @@ main() ( tool_spec=$tool # Prebuilt PHP includes development headers for our small native adapter. [ "$language" != php ] || tool_spec=conda:php + # A system toolchain older than this revision's pin does not fail here; it + # fails later, describing the wrong problem. Bun 1.3 cannot read a + # lockfileVersion 2 bun.lock, so it drops the lockfile and then reports + # frozen-lockfile drift, which reads as a broken repository. Compare the + # resolved driver against the pin up front and name both versions. + if [ "$manager" = system ]; then + case "$language" in + typescript|rust|python|cpp|ruby) installed=$("$driver_path" --version 2>/dev/null) ;; + go|zig) installed=$("$driver_path" version 2>/dev/null) ;; + php) installed=$("$driver_path" -r 'echo PHP_VERSION;' 2>/dev/null) ;; + perl) installed=$("$driver_path" -e 'printf "%vd", $^V' 2>/dev/null) ;; + esac + # Every driver above prints its version as the first number on the first + # line, whatever surrounds it ("go version go1.26.0", "cmake version 4.4.3"). + installed=$(printf '%s\n' "$installed" | sed -n '1s/[^0-9]*\([0-9][0-9.]*\).*/\1/p' | sed 's/\.*$//') + [ -n "$installed" ] || fail "Cannot read the version of $driver_path. Use --mise to build against the pinned toolchain." + # Field-wise numeric comparison; absent trailing fields count as zero. + if ! awk -v have="$installed" -v want="$version" 'BEGIN { + n = split(have, a, "."); m = split(want, b, "."); if (m > n) n = m + for (i = 1; i <= n; i++) { + if (a[i] + 0 > b[i] + 0) exit 0 + if (a[i] + 0 < b[i] + 0) exit 1 + } + exit 0 + }'; then + fail "This revision pins $tool $version, but $driver_path is $installed. Install $driver $version or newer, or rerun with --mise to build against the pinned toolchain." + fi + fi run_tool() { if [ "$manager" = mise ]; then mise --no-config exec "$tool_spec@$version" -- "$@"