feat: nix builds the managed runtime dir from runtime-pins.json

nix/npm-12-0-2.nix pinned npm 12.0.2 with an SRI hash while
runtime-pins.json pinned the same npm with a hex digest, and nothing
connected them: two files to bump, and a devShell free to ship a
different npm than every user's install. Nix is now a consumer of the
pin table, not a second table.

Shape: one derivation per pinned tool, `extends` in the table becoming a
real Nix dependency (npm's derivation takes node's, so Nix orders the
builds and neither side restates "npm needs node"), and a bundle that
symlinks them into a runtime dir.

That bundle is deliberately not a set of specially-wrapped programs. It
is the directory layout runtime_registry.py already describes, and its
runtimes.json is written by the registry's own code, so
`hermes_cli.runtime_env` derives PATH order, GIT_EXEC_PATH and
npm_config_cache from it exactly as on any other install kind. Nothing
nix-specific: an earlier draft grew per-tool wrappers for each of those
and every one duplicated behaviour that already existed and was tested.

Sealed installs now fail loudly on drift. A git checkout provisions on
demand, so a mismatch there is transient and raising would break the run
that fixes it; a nix/docker/desktop tree cannot provision at all, so a
mismatch means the artifact was assembled against a different pin table
than the code it ships. `require_current_runtimes` refuses at that point
and `hermes doctor` reports drift as an error rather than a warning,
both keyed off the existing runtime_tree Sealed/GitCheckout split.

Also fixes a real packaging bug this uncovered: runtime-pins.json lived
only at the repo root and was never packaged, so any sealed venv install
(uv2nix, docker, the desktop payload's site-packages) could not read the
pin table it was built from. It ships inside hermes_cli too now, via a
symlink so there is still one table, with pins_path() preferring the
repo copy and a test asserting the two agree.
This commit is contained in:
ethernet 2026-08-13 14:38:28 -04:00
parent b812c34fb9
commit d92d174ca4
10 changed files with 627 additions and 42 deletions

View File

@ -213,18 +213,36 @@ def _check_managed_runtimes() -> None:
them rather than re-deriving existence by probing paths (hermes-home
lifetime split, phase 3.10). Tools the registry does not know about
are still reported from PATH so a system copy is visible.
Drift is a WARNING in a git checkout and an ERROR in a sealed tree.
A checkout heals itself on the next `hermes update`; a sealed tree
cannot provision anything, so a mismatch there means the artifact was
built against a different pin table than the code it ships, and only
its steward can fix it.
"""
try:
from hermes_cli.runtime_registry import load_facts, load_pins
from hermes_constants import get_runtime_dir
from hermes_cli.runtime_tree import Sealed, runtime_tree
from hermes_constants import get_install_root, get_runtime_dir
runtime_dir = get_runtime_dir()
pins = load_pins()
facts = load_facts(runtime_dir)
tree = runtime_tree(get_install_root())
except Exception as exc:
check_warn("Managed runtimes unreadable", f"({exc})")
return
sealed = isinstance(tree, Sealed)
# What a user can actually do about it differs per install kind:
# a checkout runs an update, a sealed tree rebuilds with its steward.
remedy = (
f"(rebuild this {tree.steward} artifact — a sealed install cannot provision)"
if sealed
else "(reprovisioned on the next 'hermes update')"
)
report_drift = check_fail if sealed else check_warn
for tool, pin in pins.items():
fact = facts.get(tool)
if fact is None:
@ -232,23 +250,20 @@ def _check_managed_runtimes() -> None:
if system:
check_ok(f"{tool} (system)", f"at {system}")
else:
check_warn(
f"{tool} not provisioned",
"(installed on the next 'hermes update')",
)
report_drift(f"{tool} not provisioned", remedy)
continue
if not (runtime_dir / fact.path).is_file():
check_warn(
report_drift(
f"{tool} recorded but missing",
"(runtime dir was modified; 'hermes update' reinstalls it)",
"(runtime dir was modified) " + remedy if not sealed else remedy,
)
continue
# Pins are exact, so this is equality: anything else means the
# pin moved and this install has not caught up yet.
if fact.version != pin["version"]:
check_warn(
report_drift(
f"{tool} {fact.version} does not match the pin {pin['version']}",
"(reprovisioned on the next 'hermes update')",
remedy,
)
continue
check_ok(f"{tool} {fact.version}", "(managed)")

View File

@ -0,0 +1 @@
../runtime-pins.json

View File

@ -41,7 +41,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from hermes_constants import get_runtime_dir
from hermes_constants import get_install_root, get_runtime_dir
from hermes_cli.runtime_tree import Sealed, runtime_tree
from hermes_cli.runtime_registry import (
PinnedFile,
RuntimeFact,
@ -530,6 +531,80 @@ def provision_runtimes(
return results
def stale_tools(
runtime_dir: Path | None = None,
install_root: Path | None = None,
target: str | None = None,
) -> dict[str, tuple[str, Optional[str]]]:
"""Pinned tools whose installed state does not match the pin table.
Maps tool (pinned version, installed version or None). Empty means
every pin is satisfied. This is the same equality check
``_provision_one`` makes before deciding to re-download exact pins
make it an equality check, not a range check.
"""
rt = runtime_dir if runtime_dir is not None else get_runtime_dir()
resolved_target = target or current_target()
facts = load_facts(rt)
drift: dict[str, tuple[str, Optional[str]]] = {}
for tool, entry in load_pins(install_root).items():
fact = facts.get(tool)
installed = fact.version if fact is not None else None
if fact is not None and not (rt / _binary_rel(tool, resolved_target)).is_file():
# Recorded but vanished reads as unprovisioned everywhere
# else; say so here too rather than reporting it as current.
installed = None
if installed != entry["version"]:
drift[tool] = (entry["version"], installed)
return drift
class StaleManagedRuntimes(RuntimeError):
"""A sealed install's runtime tools disagree with its pin table."""
def require_current_runtimes(
project_root: Path | None = None,
runtime_dir: Path | None = None,
install_root: Path | None = None,
) -> None:
"""Fail fast when a SEALED install ships out-of-date runtime tools.
A git checkout provisions on demand: drift there is a normal state
that the next `hermes update` (or the self-heal path) resolves, and
raising would break the very run that fixes it.
A sealed tree cannot self-heal. Its steward Nix, Docker, the
desktop bundle builds the runtime tools as part of the artifact, so
drift means the artifact was assembled against a different pin table
than the code it ships. Every consequence of that is worse and more
confusing than stopping here: tools silently missing from PATH,
or a version the code does not expect. The steward has to rebuild.
"""
root = project_root if project_root is not None else get_install_root()
tree = runtime_tree(root)
if not isinstance(tree, Sealed):
return
drift = stale_tools(runtime_dir=runtime_dir, install_root=install_root)
if not drift:
return
lines = [
f" {tool}: pinned {pinned}, installed {installed or 'nothing'}"
for tool, (pinned, installed) in sorted(drift.items())
]
raise StaleManagedRuntimes(
f"This Hermes is a sealed install managed by {tree.steward!r}, and its "
"managed runtime tools do not match runtime-pins.json:\n"
+ "\n".join(lines)
+ "\n\nThe artifact was built against a different pin table than the code "
"it ships. Rebuild it with its steward — a sealed tree cannot provision "
"these itself."
)
def step_provision_runtimes() -> dict:
"""post_update MACHINE_STEPS entry."""
results = provision_runtimes()

View File

@ -124,10 +124,20 @@ def pins_path(install_root: Path | None = None) -> Path:
repo root for a checkout, the payload's repo/ dir for the desktop
bundle) rather than ``get_install_root()`` the install root is where
tools get INSTALLED, and callers may point it elsewhere.
A wheel has no repo root: `pip install .` lays out site-packages with
no parent-level data files, so the table is also packaged INSIDE
hermes_cli (see the package-data entry in pyproject.toml, fed by a
symlink to the root file so there is still one table). Sealed venv
installs uv2nix, Docker, the desktop payload's site-packages — read
that copy; nothing else changes.
"""
if install_root is not None:
return install_root / PINS_FILENAME
return Path(__file__).resolve().parent.parent / PINS_FILENAME
repo_copy = Path(__file__).resolve().parent.parent / PINS_FILENAME
if repo_copy.is_file():
return repo_copy
return Path(__file__).resolve().parent / PINS_FILENAME
# Loopback http is allowed so tests can serve real archives from a local

View File

@ -36,6 +36,128 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
packages.configKeys = configKeys;
checks = {
# The Nix runtime dir IS the pin table, not a copy of it. Before
# this, nix/npm-12-0-2.nix and runtime-pins.json pinned the same
# npm independently: two files to bump, and a devShell that could
# silently ship a different npm than every user's install.
#
# Asserting the relationship rather than the values means a pin
# bump needs no edit here — and the artifact this builds is the
# same runtime dir Hermes provisions, read by the same registry
# code, so a drift would fail `hermes doctor` on a nix install.
runtime-pins-are-the-source =
let
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
runtimeDir = pkgs.callPackage ./runtime-pins.nix { };
mismatched = lib.filterAttrs (
name: entry: runtimeDir.${name}.pinnedVersion != entry.version
) pins;
in
pkgs.runCommand "hermes-runtime-pins-source" { } (
if mismatched != { } then
throw "Nix runtime tools disagree with runtime-pins.json: ${
toString (builtins.attrNames mismatched)
}"
else
''
echo "PASS: ${toString (builtins.length (builtins.attrNames pins))} tools built from runtime-pins.json"
mkdir -p $out && echo ok > $out/result
''
);
# The nix-built runtime dir satisfies the Python readers with no
# nix-specific code: `hermes_cli.runtime_env` assembles PATH from
# its facts, every pinned tool runs (patchelf'd), and each reports
# the version the table pinned. That is the whole contract — if
# this passes, a nix install needs no special handling anywhere.
runtime-dir-serves-python =
let
runtimeDir = pkgs.callPackage ./runtime-pins.nix { };
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
# "How do I ask your version" is genuinely per-tool; the TOOLS
# come from the table, so a new pin surfaces here as a missing
# probe rather than going silently unchecked.
#
# npm is resolved from PATH but launched through node on
# purpose: its shim is `#!/usr/bin/env node`, and the Nix
# build sandbox has no /usr/bin/env (exit 126). That is a
# sandbox artifact, not a property of the artifact under test
# — `command -v npm` still proves the assembled PATH resolves
# to the pinned npm rather than node's bundled copy.
probes = {
node = "node --version";
npm = "node \"$(command -v npm)\" --version";
uv = "uv --version";
git = "git --version";
gh = "gh --version";
ripgrep = "rg --version";
};
probeFor =
name:
probes.${name}
or (throw "runtime-dir-serves-python: no version probe for pinned tool '${name}'");
in
pkgs.runCommand "hermes-runtime-dir-serves-python"
{
nativeBuildInputs = [ hermesVenv ];
}
''
set -euo pipefail
export HOME=$TMPDIR
# PATH comes from the facts file, via the same assembler
# every Hermes subprocess uses. No hand-built PATH here:
# that would test a PATH this check invented. It is seeded
# with the build PATH because the assembler PREPENDS (that
# is the behaviour under test) and the script still needs
# coreutils afterwards.
eval "$(${hermesVenv}/bin/python3 - <<'PY'
import os
from pathlib import Path
from hermes_cli.runtime_env import with_managed_runtimes
env = with_managed_runtimes(
{"PATH": os.environ["PATH"]}, runtime_dir=Path("${runtimeDir}")
)
for key in ("PATH", "GIT_EXEC_PATH", "GIT_SSL_CAINFO", "npm_config_cache"):
if env.get(key):
print(f"export {key}={env[key]!r}")
PY
)"
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: entry: ''
echo "== ${name} (pinned ${entry.version})"
got=$(${probeFor name} 2>&1 | head -1)
echo " $got"
case "$got" in
*${entry.version}*) ;;
*) echo "expected ${entry.version} from ${name}"; exit 1 ;;
esac
'') pins
)}
# And the sealed-install gate agrees this dir is current.
${hermesVenv}/bin/python3 - <<'PY'
import json, tempfile
from pathlib import Path
from hermes_cli.runtime_provisioner import require_current_runtimes, stale_tools
runtime_dir = Path("${runtimeDir}")
drift = stale_tools(runtime_dir=runtime_dir)
assert not drift, f"nix runtime dir is stale against its own pins: {drift}"
with tempfile.TemporaryDirectory() as td:
sealed = Path(td)
(sealed / "install-stamp.json").write_text(json.dumps({"distribution": "nix"}))
require_current_runtimes(project_root=sealed, runtime_dir=runtime_dir)
print("sealed-install gate: current")
PY
mkdir -p $out && echo ok > $out/result
'';
# Cross-platform evaluation — catches "not supported for interpreter"
# errors (e.g. sphinx dropping python311) without needing a darwin builder.
# Evaluation is pure and instant; it doesn't build anything.

View File

@ -32,10 +32,21 @@
let
repoRoot = ./..;
npm12 = callPackage ./npm-12-0-2.nix { };
# The managed runtime tools come from the repo's pin table, not from a
# version and digest restated here. runtime-pins.json is what a source
# install, `hermes update` and the desktop payload all provision from,
# so the devShell and every nix build get the same tools those users
# get. npm used to be pinned twice (nix/npm-12-0-2.nix vs the table),
# which is two places to bump and one silent skew when someone bumps
# only one.
runtimeDir = callPackage ./runtime-pins.nix { };
npm12 = runtimeDir.npm;
node_gyp_11_4_0 = callPackage ./node-gyp-11-4-0.nix { };
nodejs_26_npm_12 = symlinkJoin {
name = "nodejs-26-npm-12";
# npm FIRST: it supersedes the npm bundled inside node, and
# symlinkJoin resolves a collision in favour of the earlier path.
# The pin table states that relationship as npm's `extends: [node]`.
paths = [
npm12
nodejs_26

View File

@ -1,29 +0,0 @@
{
stdenv,
makeWrapper,
fetchurl,
nodejs_26,
}:
stdenv.mkDerivation rec {
pname = "npm";
version = "12.0.2";
src = fetchurl {
url = "https://registry.npmjs.org/npm/-/npm-${version}.tgz";
hash = "sha256-XbuGxx0HoZV/LpBzQJLdali9zZ68LY1ByhxuaiHTZOE=";
};
nativeBuildInputs = [ makeWrapper ];
dontBuild = true;
installPhase = ''
mkdir -p $out/lib/npm12
cp -r . $out/lib/npm12/
mkdir -p $out/bin
makeWrapper ${nodejs_26}/bin/node $out/bin/npm \
--add-flags "$out/lib/npm12/bin/npm-cli.js"
makeWrapper ${nodejs_26}/bin/node $out/bin/npx \
--add-flags "$out/lib/npm12/bin/npx-cli.js"
'';
}

243
nix/runtime-pins.nix Normal file
View File

@ -0,0 +1,243 @@
# nix/runtime-pins.nix — managed runtime tools, built from runtime-pins.json
#
# runtime-pins.json is the ONE table of managed tool versions and digests.
# It already feeds the Python provisioner (source installs, `hermes
# update`, desktop payload staging). This file makes Nix a fourth consumer
# of that table rather than a second table: every version, URL and digest
# here is read from the JSON, so a pin bump stays one edit.
#
# Shape:
#
# * one derivation per pinned tool, each holding that tool's own tree
# exactly as upstream ships it;
# * `extends` in the table becomes a real Nix dependency — npm's
# derivation takes node's, so Nix orders the builds and neither this
# file nor a reader restates "npm needs node";
# * `bundle` symlinks those derivations into the directory layout
# `hermes_cli/runtime_registry.py` describes, and writes `runtimes.json`
# with the registry's own code.
#
# Nothing here wraps a program or exports an environment variable. The
# bundle is a runtime dir, and `hermes_cli/runtime_env.py` already knows
# how to turn one of those into PATH, GIT_EXEC_PATH, npm_config_cache and
# the rest — on every install kind. A Nix-specific version of any of that
# would be a second implementation of tested behaviour.
{
lib,
stdenv,
fetchurl,
autoPatchelfHook,
unzip,
python3,
runCommand,
curl,
expat,
fontconfig,
zlib,
}:
let
repoRoot = ../.;
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
# Pin-table target keys use Node/Python spellings so one string works on
# both sides of the JS/Python boundary; Nix systems spell it the other
# way round. This is the only place the two vocabularies meet.
targetBySystem = {
"x86_64-linux" = "linux-x64";
"aarch64-linux" = "linux-arm64";
"x86_64-darwin" = "darwin-x64";
"aarch64-darwin" = "darwin-arm64";
};
target =
targetBySystem.${stdenv.hostPlatform.system}
or (throw "runtime-pins: no pin target for ${stdenv.hostPlatform.system}");
# A tool either pins one target-independent artifact ('any', a registry
# tarball whose bytes do not vary) or one per target. Same resolution
# the Python registry does — see `pinned_file`.
artifactOf =
name: entry:
entry.files.any
or entry.files.${target}
or (throw "runtime-pins: ${name} has no pinned download for ${target}");
# fetchurl's `sha256` takes the bare lowercase hex the table already
# stores — the same string the Python provisioner verifies, so there is
# no second encoding to keep in sync. Nix enforces it as a fixed-output
# derivation: a tampered pin fails the build, as it fails provisioning.
fetchPinned = name: entry: fetchurl { inherit (artifactOf name entry) url sha256; };
extendsOf = entry: entry.extends or [ ];
# Prebuilt upstream binaries link against a normal FHS glibc, which does
# not exist here. autoPatchelfHook rewrites the interpreter and RPATH
# onto the nixpkgs runtime; macOS binaries are already relocatable.
#
# One library set covers every tool: these are the shared objects the
# pinned artifacts actually ask for (zlib broadly; curl/expat for
# dugite's http helpers; fontconfig for the Skia lib dugite ships).
patchelfInputs = lib.optionals stdenv.hostPlatform.isLinux [
stdenv.cc.cc.lib
zlib
curl
expat
fontconfig
];
mkToolBase =
name: entry: extra:
stdenv.mkDerivation (
{
pname = "hermes-runtime-${name}";
version = entry.version;
src = fetchPinned name entry;
nativeBuildInputs =
[ unzip ] ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ];
buildInputs = patchelfInputs;
dontUnpack = true;
dontBuild = true;
dontConfigure = true;
passthru = {
pinnedVersion = entry.version;
pinnedUrl = (artifactOf name entry).url;
extends = map (dep: tools.${dep}) (extendsOf entry);
};
meta = {
description = "Hermes managed runtime ${name} ${entry.version} (pinned in runtime-pins.json)";
platforms = lib.platforms.unix;
};
}
// extra
);
# The common case: unpack the artifact and keep upstream's own layout.
#
# Un-nesting a lone versioned wrapper directory is decided by what the
# archive CONTAINS, not by a per-tool list — the same rule the Python
# provisioner uses, and for the same reason (uv nests on POSIX and not
# on Windows, so a hardcoded list gets it wrong).
mkUnpackedTool =
name: entry:
mkToolBase name entry {
installPhase = ''
runHook preInstall
mkdir -p unpacked
tar -xf "$src" -C unpacked 2>/dev/null || unzip -q "$src" -d unpacked
inner=unpacked
entries=("$inner"/*)
if [ ''${#entries[@]} -eq 1 ] && [ -d "''${entries[0]}" ]; then
inner="''${entries[0]}"
fi
mkdir -p "$out"
cp -R "$inner"/. "$out/"
runHook postInstall
'';
};
# npm is the one tool that cannot simply be unpacked. Its own bin/npm
# resolves npm-cli.js from dirname(process.execPath), so unpacked onto a
# PATH it finds the npm BUNDLED inside node — the copy this pin exists
# to supersede — and dies with MODULE_NOT_FOUND when that copy is gone.
# Letting npm install itself produces the launchers the platform
# actually needs instead of hand-written shims, and is the same install
# the Python provisioner performs (`_stage_npm`) from the same
# digest-verified tarball, offline.
#
# `extends` is what makes this work without a special case: node is a
# real build input, so Nix has already built and patched it.
mkNpmTool =
name: entry:
let
node = tools.node;
in
mkToolBase name entry {
installPhase = ''
runHook preInstall
mkdir -p "$out"
# npm insists on a writable HOME and cache; the sandbox's HOME is
# deliberately unwritable. Neither belongs in $out — a real
# install keeps its cache in the runtime dir, which
# managed_tool_env points npm_config_cache at.
HOME="$TMPDIR" npm_config_cache="$TMPDIR/npm-cache" \
${node}/bin/node ${node}/lib/node_modules/npm/bin/npm-cli.js \
install --global --prefix "$out" --offline --no-audit --no-fund \
"$src"
runHook postInstall
'';
};
# An `extends` edge means "staged by what it extends", which today is
# npm-shaped: run the extended tool's installer. A second extender with
# different mechanics would add a branch here; one edge, one meaning
# until then.
mkTool =
name: entry: if extendsOf entry == [ ] then mkUnpackedTool name entry else mkNpmTool name entry;
tools = lib.mapAttrs mkTool pins;
# The registry code that writes the facts, as a store path. Only the
# leaf modules the fact-writing imports — all pure-stdlib, so a bare
# python3 loads them with no venv, and the bundle does not depend on
# the whole repo (which would rebuild it on any source change).
registrySrc = runCommand "hermes-runtime-registry-src" { } ''
mkdir -p "$out/hermes_cli"
touch "$out/hermes_cli/__init__.py"
cp ${../hermes_cli/runtime_registry.py} "$out/hermes_cli/runtime_registry.py"
cp ${../hermes_cli/runtime_provisioner.py} "$out/hermes_cli/runtime_provisioner.py"
cp ${../hermes_cli/runtime_tree.py} "$out/hermes_cli/runtime_tree.py"
cp ${../hermes_constants.py} "$out/hermes_constants.py"
cp ${../runtime-pins.json} "$out/runtime-pins.json"
'';
# The bundle IS a runtime dir: `<dir>/<tool>/...` per the registry's
# layout, plus the `runtimes.json` facts manifest. Symlinks, so the
# tools stay separately built and separately cached.
#
# Facts are written by runtime_registry.py itself — schema version,
# `extends`-derived PATH order and JSON shape have one implementation,
# and the ordering the Python readers apply is the ordering the table
# declares.
bundle = runCommand "hermes-runtime-dir" { passthru = tools // { inherit target; }; } ''
mkdir -p "$out"
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: drv: ''ln -s ${drv} "$out/${name}"'') tools
)}
export PYTHONPATH=${registrySrc}
${python3}/bin/python3 - "$out" ${registrySrc} "${target}" <<'PY'
import sys
from pathlib import Path
from hermes_cli.runtime_registry import (
RuntimeFact, install_order, load_pins, path_order, save_facts,
)
from hermes_cli.runtime_provisioner import _binary_rel, _path_dirs
runtime_dir, registry_src, target = (
Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3],
)
pins = load_pins(registry_src)
facts = {}
for tool in install_order(pins):
rel = _binary_rel(tool, target)
if not (runtime_dir / rel).exists():
raise SystemExit(f"runtime-pins: {tool} is missing {rel}")
facts[tool] = RuntimeFact(
version=pins[tool]["version"],
path=rel,
path_dirs=_path_dirs(tool, target),
)
save_facts(facts, runtime_dir, path_order=path_order(pins))
PY
'';
in
bundle

View File

@ -401,7 +401,7 @@ py-modules = [
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.setuptools.package-data]
hermes_cli = ["observability/schemas/*.json"]
hermes_cli = ["observability/schemas/*.json", "runtime-pins.json"]
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
# status phrases fall back to the tiny hardcoded set and the Telegram

View File

@ -313,6 +313,143 @@ class TestSelectiveProvisioning:
assert [r.tool for r in results] == ["gh"]
class TestSealedInstallStalenessGate:
"""A sealed tree cannot provision, so drift there is fatal.
A git checkout heals itself on the next update raising would break
the very run that fixes it. A Nix/Docker/desktop artifact has its
tools built in by its steward, so a mismatch means the artifact was
assembled against a different pin table than the code it ships.
"""
def _sealed(self, root: Path, steward: str = "nix") -> Path:
root.mkdir(parents=True, exist_ok=True)
(root / "install-stamp.json").write_text(
json.dumps({"distribution": steward}), encoding="utf-8"
)
return root
def _current_runtime(self, rt: Path, pins_root: Path, target: str) -> None:
"""A runtime dir that satisfies every pin in *pins_root*."""
facts = {}
for tool, entry in rr.load_pins(pins_root).items():
rel = rp._binary_rel(tool, target)
binary = rt / rel
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_text("#!/bin/sh\n")
facts[tool] = rr.RuntimeFact(version=entry["version"], path=rel)
rr.save_facts(facts, rt)
def test_a_current_sealed_install_passes(self, tmp_path, target):
pins = _pins_file(tmp_path / "repo", {
"gh": {"version": "2.97.0", "files": {
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
})
rt = tmp_path / "rt"
self._current_runtime(rt, pins, target)
rp.require_current_runtimes(
project_root=self._sealed(tmp_path / "sealed"),
runtime_dir=rt,
install_root=pins,
)
def test_a_stale_sealed_install_refuses_with_the_drift_named(
self, tmp_path, target
):
pins = _pins_file(tmp_path / "repo", {
"gh": {"version": "2.98.0", "files": {
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
})
rt = tmp_path / "rt"
rel = rp._binary_rel("gh", target)
binary = rt / rel
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_text("#!/bin/sh\n")
rr.save_facts({"gh": rr.RuntimeFact(version="2.97.0", path=rel)}, rt)
with pytest.raises(rp.StaleManagedRuntimes) as excinfo:
rp.require_current_runtimes(
project_root=self._sealed(tmp_path / "sealed"),
runtime_dir=rt,
install_root=pins,
)
message = str(excinfo.value)
assert "nix" in message
# Naming the versions is the point: "rebuild it" is unactionable
# without knowing what drifted.
assert "2.98.0" in message and "2.97.0" in message
def test_a_git_checkout_with_the_same_drift_does_not_raise(
self, tmp_path, target
):
"""It provisions on demand; the next update fixes it."""
pins = _pins_file(tmp_path / "repo", {
"gh": {"version": "2.98.0", "files": {
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
})
checkout = tmp_path / "checkout"
(checkout / ".git").mkdir(parents=True)
rp.require_current_runtimes(
project_root=checkout, runtime_dir=tmp_path / "empty", install_root=pins
)
def test_an_unprovisioned_sealed_install_refuses(self, tmp_path, target):
"""Nothing installed at all is drift too — a sealed artifact is
supposed to ship its tools already built."""
pins = _pins_file(tmp_path / "repo", {
"gh": {"version": "2.97.0", "files": {
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
})
with pytest.raises(rp.StaleManagedRuntimes, match="nothing"):
rp.require_current_runtimes(
project_root=self._sealed(tmp_path / "sealed"),
runtime_dir=tmp_path / "empty",
install_root=pins,
)
def test_a_recorded_but_vanished_binary_counts_as_stale(self, tmp_path, target):
"""Every other reader treats recorded-but-missing as
unprovisioned; the gate must not call it current."""
pins = _pins_file(tmp_path / "repo", {
"gh": {"version": "2.97.0", "files": {
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
})
rt = tmp_path / "rt"
# A fact, but no file behind it.
rr.save_facts(
{"gh": rr.RuntimeFact(version="2.97.0", path=rp._binary_rel("gh", target))},
rt,
)
assert rp.stale_tools(runtime_dir=rt, install_root=pins) == {
"gh": ("2.97.0", None)
}
class TestPinsShipWithTheCode:
def test_a_sealed_venv_can_read_its_own_pin_table(self):
"""`pip install .` lays out site-packages with no repo root, so
the table is packaged inside hermes_cli too. Without it a nix /
docker / desktop venv cannot read the pins it was built from
which is how this was found.
"""
import hermes_cli
packaged = Path(hermes_cli.__file__).resolve().parent / rr.PINS_FILENAME
assert packaged.is_file(), (
"hermes_cli/runtime-pins.json is missing — a sealed venv install "
"would have no pin table"
)
assert json.loads(packaged.read_text(encoding="utf-8")) == json.loads(
(Path(hermes_cli.__file__).resolve().parent.parent / rr.PINS_FILENAME)
.read_text(encoding="utf-8")
), "the packaged pin table drifted from the repo's"
class TestLayout:
def test_windows_and_posix_binaries_land_where_readers_expect(self):
assert rp._binary_rel("node", "win32-x64") == "node/node.exe"