diff --git a/apps/demo/scripts/test-updater.py b/apps/demo/scripts/test-updater.py index d08884f..0cd6a8e 100644 --- a/apps/demo/scripts/test-updater.py +++ b/apps/demo/scripts/test-updater.py @@ -7,6 +7,7 @@ import os from pathlib import Path import pty +import re import select import signal import subprocess @@ -234,18 +235,18 @@ def installs(): 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): + def test_system_tool_below_the_floor_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")): + for language, tool, old, need in (("typescript", "BUN", "1.3.14", "bun 1.4.0"), + ("go", "GO", "1.20.0", "go 1.22"), + ("perl", "PERL", "5.18.0.0", "perl 5.20")): 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"needs {need} or newer", 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, "") @@ -260,6 +261,53 @@ def test_system_tool_older_than_the_pin_fails_before_any_build(self): env={**self.env, "UPDATER_BUN_VERSION": "1.3.14"}) self.assertEqual(pinned.returncode, 0, pinned.stderr) + def test_system_tool_between_the_floor_and_the_pin_builds_with_a_note(self): + # The regression this guards: treating the mise pin as a requirement + # refused every stock Mac, where Perl is 5.34 and Ruby 3.3 against pins + # of 5.44 and 4.0. Those build the bindings perfectly well. + self.stub_compilers() + for language, tool, have, pin in (("perl", "PERL", "5.34.0.0", "perl 5.44.0.0"), + ("ruby", "RUBY", "3.3.8", "ruby 4.0.6"), + ("go", "GO", "1.23.0", "go 1.26.0")): + result = self.run_demo("--system", language, "--snapshot", + env={**self.env, f"UPDATER_{tool}_VERSION": have}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"pins {pin}", result.stderr) + self.assertIn("Rerun with --mise if the build fails", result.stderr) + + def test_declared_floors_match_the_ports_own_manifests(self): + """The floors in demo.sh are copies. This is what stops them drifting. + + Each one is stated by the port itself; if a port raises its requirement + and nobody updates the launcher, the launcher will happily start a build + that cannot succeed. + """ + launcher = LAUNCHER.read_text() + declared = dict(re.findall(r"^\s+(\w+)\) minimum=([0-9.]+) ;;", launcher, re.M)) + manifests = { + "go": (ROOT / "ports/go/go.mod", r"^go\s+([0-9.]+)"), + "rust": (ROOT / "ports/rust/Cargo.toml", r'rust-version\s*=\s*"([0-9.]+)"'), + "python": (ROOT / "ports/python/pyproject.toml", r'requires-python\s*=\s*">=\s*([0-9.]+)"'), + "ruby": (ROOT / "ports/ruby/hqtui.gemspec", r"required_ruby_version\s*=\s*'>=\s*([0-9.]+)'"), + "php": (ROOT / "ports/php/composer.json", r'"php":\s*">=\s*([0-9.]+)"'), + "cpp": (ROOT / "ports/cpp/CMakeLists.txt", r"cmake_minimum_required\(VERSION ([0-9.]+)"), + } + for language, (path, pattern) in manifests.items(): + match = re.search(pattern, path.read_text(), re.M) + self.assertIsNotNone(match, f"no version found in {path}") + self.assertEqual(declared.get(language), match.group(1), + f"demo.sh says {language} needs {declared.get(language)}, " + f"but {path.name} says {match.group(1)}") + # Perl's manifest writes 5.020, which is 5.20. + perl = re.search(r"MIN_PERL_VERSION\s*=>\s*'([0-9.]+)'", + (ROOT / "ports/perl/Makefile.PL").read_text()) + self.assertIsNotNone(perl) + self.assertEqual(declared.get("perl"), + re.sub(r"\.0*(\d)", r".\1", perl.group(1))) + # Bun's floor is not a preference; it is what can read a v2 lockfile. + self.assertEqual(declared.get("typescript"), "1.4.0") + self.assertIn('"lockfileVersion": 2', (ROOT / "bun.lock").read_text()) + def test_bindings_update_both_managers_and_keep_builds_outside_source(self): self.stub_compilers() for manager in ('--system', '--mise'): diff --git a/apps/web/public/demo.sh b/apps/web/public/demo.sh index 168be9e..7b5e092 100644 --- a/apps/web/public/demo.sh +++ b/apps/web/public/demo.sh @@ -146,11 +146,41 @@ 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. + # What --mise installs is a pin, not a floor. Perl 5.40 and Ruby 3.3 build + # these bindings perfectly well, and treating the pin as a requirement makes + # --system useless on any machine that is not already pinned, which is most + # of them. So fail only below the version the port itself says it needs, and + # merely mention anything between that and the pin. + # + # Each floor is the one declared in that port's own manifest: go.mod, + # Cargo.toml rust-version, pyproject.toml requires-python, hqtui.gemspec, + # composer.json, Makefile.PL MIN_PERL_VERSION, CMakeLists.txt. Bun's is not + # a preference: 1.3 cannot parse a lockfileVersion 2 bun.lock, drops the + # lockfile, and then blames the repository for frozen-lockfile drift. + # apps/demo/scripts/test-updater.py checks this table against those files. + minimum= + case "$language" in + typescript) minimum=1.4.0 ;; + rust) minimum=1.75 ;; + go) minimum=1.22 ;; + python) minimum=3.10 ;; + zig) minimum=0.16.0 ;; + cpp) minimum=3.20 ;; + ruby) minimum=3.1 ;; + php) minimum=8.1 ;; + perl) minimum=5.20 ;; + esac + # Field-wise numeric comparison; absent trailing fields count as zero. + version_ge() { + awk -v have="$1" -v want="$2" '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 + }' + } if [ "$manager" = system ]; then case "$language" in typescript|rust|python|cpp|ruby) installed=$("$driver_path" --version 2>/dev/null) ;; @@ -162,16 +192,11 @@ main() ( # 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." + if ! version_ge "$installed" "$minimum"; then + fail "$driver_path is $installed, and this demo needs $driver $minimum or newer. Upgrade $driver, or rerun with --mise to build against the pinned $tool $version." + fi + if ! version_ge "$installed" "$version"; then + note "Building with your $driver $installed; this revision pins $tool $version. Rerun with --mise if the build fails." fi fi run_tool() { @@ -203,12 +228,30 @@ main() ( cmake_version=$(awk '$1 == "cmake" && $2 == "=" {gsub(/"/, "", $3); print $3; exit}' "$source/mise.toml") case "$cmake_version" in ''|*[!0-9.]*) fail 'Invalid CMake pin.' ;; esac cmake_driver=cmake + cmake_via_mise=0 if [ "$manager" = system ]; then - cmake_driver=$(command -v cmake) || fail 'CMake is required. Install it or use --mise.' - case "$cmake_driver" in */mise/shims/*) cmake_driver=$(mise which cmake) || fail 'Activate CMake or use --mise.' ;; esac + if cmake_driver=$(command -v cmake 2>/dev/null); then + case "$cmake_driver" in */mise/shims/*) cmake_driver=$(mise which cmake) || fail 'Activate CMake or use --mise.' ;; esac + elif command -v mise >/dev/null 2>&1; then + # CMake is a build tool for the native binding, not the + # language runtime this flag is about, and the C++ compiler + # still comes from the host. Borrowing the pinned CMake beats + # refusing to run on a machine that has everything else. + note "No system CMake; borrowing the pinned CMake $cmake_version through mise." + cmake_via_mise=1 + else + case "$(uname -s)" in + Darwin) fail 'CMake is needed to build the native binding. Install it with "brew install cmake", or install mise and rerun.' ;; + *) fail 'CMake is needed to build the native binding. Install it with your package manager (for example "apt install cmake"), or install mise and rerun.' ;; + esac + fi fi binding_cmake() { - if [ "$manager" = mise ]; then mise --no-config exec "cmake@$cmake_version" -- cmake "$@"; else "$cmake_driver" "$@"; fi + if [ "$manager" = mise ] || [ "$cmake_via_mise" -eq 1 ]; then + mise --no-config exec "cmake@$cmake_version" -- cmake "$@" + else + "$cmake_driver" "$@" + fi } binding_identity=$language-$version php_config= @@ -254,7 +297,31 @@ main() ( PERL5LIB=$perl_deps/lib/perl5${PERL5LIB:+:$PERL5LIB}; export PERL5LIB if ! run_tool perl -MFFI::Platypus=2.11 -e '' 2>/dev/null; then note 'Installing Perl FFI::Platypus into the private demo cache…' - run_tool cpanm --local-lib-contained "$perl_deps" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11 >&2 || fail 'Install cpanm and native build tools, then retry. No global Perl modules were modified.' + if command -v cpanm >/dev/null 2>&1; then + run_tool cpanm --local-lib-contained "$perl_deps" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11 >&2 \ + || fail 'FFI::Platypus did not build. A C compiler and make are required. No global Perl modules were modified.' + else + # macOS ships Perl but no cpanm, which stopped + # --system perl before it built anything. Fetch the + # standalone installer into the private cache rather + # than asking for a manual step; nothing global and + # nothing outside the cache is touched. + cpanm_script=$cache/deps/perl/cpanm + if [ ! -f "$cpanm_script" ]; then + mkdir -p "$cache/deps/perl" + note 'No cpanm found; fetching the standalone installer from cpanmin.us into the demo cache…' + if command -v curl >/dev/null 2>&1; then + curl -fsSL https://cpanmin.us -o "$cpanm_script.pending" || fail 'Could not download cpanm. Install cpanm, or use --mise.' + elif command -v wget >/dev/null 2>&1; then + wget -qO "$cpanm_script.pending" https://cpanmin.us || fail 'Could not download cpanm. Install cpanm, or use --mise.' + else + fail 'Fetching cpanm needs curl or wget. Install cpanm, or use --mise.' + fi + mv "$cpanm_script.pending" "$cpanm_script" + fi + run_tool perl "$cpanm_script" --local-lib-contained "$perl_deps" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11 >&2 \ + || fail 'FFI::Platypus did not build. A C compiler and make are required. No global Perl modules were modified.' + fi fi launch perl "$source/ports/perl/examples/dashboard.pl" "$@" ;;