From c9a992bf7848bd8faead785ccbfa212af041beff Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 18:17:01 +0100 Subject: [PATCH 1/4] Install devlaunch's tools into a pixi home of its own `pixi global install` is not only an install: it is an edit to `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a container already has an owner -- an image's, or a dotfiles repo's. Writing there made devlaunch a second author of a file with one owner, which cost something in both directions: 1. `pixi global sync` removes every env the manifest does not list, so a dotfiles apply that rewrites the manifest and syncs *uninstalls* the zellij the setup pass just installed -- on a schedule nothing here can see, and the next launch reinstalls it, forever. 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout (its `.devcontainer/on_create.sh`), so the append landed in the user's work tree and every `git status` in the workspace came up dirty. Nothing about that is one repo's quirk: dl launches arbitrary repos, and an install that dirties the tree it was pointed at is the launch damaging the work it exists to serve. So PIXI_HOME_TARGET (`$HOME/.devlaunch/pixi`), exported by `_pixi_home_export()` at the top of both scripts that run pixi, which makes both unrepresentable rather than handled: nothing syncs that manifest, and every path under it is one devlaunch created, so no repo state can be beneath it. Three details are load-bearing, and each is pinned by a test: - The export comes *before* the bootstrap, not after. pixi's own installer honours PIXI_HOME (`${PIXI_HOME:-$HOME/.pixi}`), so the other order leaves the binary in one home and every env it installs in another -- the one shape of this that fails silently, since both halves work alone. - Not `$HOME/.local/share/devlaunch/pixi`, the conventional answer. Containers bind-mount `~/.cache`, `~/.config` and `~/.local/share` straight from the host, so a prefix tree under one would be shared by every container on the machine and written into the host's own home. Prefixes are baked with absolute paths and two syncs sharing one tree is prefix-dev/pixi#5476 -- exactly the hazard PIXI_CACHE_TARGET refuses to let PIXI_HOME near. - Never exported into the login profile. A user's own `pixi global install` in a workspace still goes to their `~/.pixi`, which is the separation this exists to keep; only the bin directories go on PATH. What it costs is a duplicate extracted prefix in the case where a tool is installed but unreachable from a login shell -- disk only, since PIXI_HOME does not move the download cache and `PIXI_CACHE_TARGET` still shares it -- and that is the case where the old behaviour reinstalled on every launch anyway. The devcontainer feature's installer still names `~/.pixi`, deliberately: it is baked into an image at build time, where that path is the image's own and there is no checkout to dirty. Its row in the profile-guard table says so. Verified against real pixi rather than only the fakes: the rendered `zellij_script()`, run against a home whose manifest is symlinked onto a tracked file, installs zellij 0.45.0 into `~/.devlaunch/pixi`, leaves the file untouched, and writes the profile line that keeps the next launch's `command -v` from reinstalling. The same script before this change appends through the link. --- README.md | 34 +++++++++++++++ devlaunch/tools.py | 83 +++++++++++++++++++++++++++++++---- test/unit/test_tools.py | 95 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 197 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 694fd663..bf9c691e 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,40 @@ hit the failure, the fixes available to you are to run that image as your own uid, or to take the cache out of play for it (`rm -rf ~/.cache/devlaunch/pixi` recovers a directory an earlier container left owned by someone else). +### Where the tools themselves land + +The cache above is shared; the *environments* devlaunch installs into are not, +and they are not the container's `~/.pixi` either. `gh`, `claude` and `zellij` go +into **`~/.devlaunch/pixi`**, a pixi home of devlaunch's own, because +`pixi global install` is not only an install — it is an edit to +`$PIXI_HOME/manifests/pixi-global.toml`, a declarative file that in a container +already has an owner. Writing there made devlaunch a second author, and cost +something in both directions: + +- `pixi global sync` removes every environment the manifest does not list, so a + dotfiles apply that rewrites the manifest and syncs **uninstalls** the zellij + devlaunch just installed — and the next launch reinstalls it, forever. +- The manifest is not always a file. A devcontainer is free to symlink + `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout, + and one does; the append then landed in the work tree and every `git status` + in the workspace came up dirty. + +Neither is expressible against a home devlaunch created: nothing syncs that +manifest, and no repo state can sit under that path. It costs a duplicate +extracted prefix in the one case where a tool is installed but unreachable from +a login shell — disk only, since `PIXI_HOME` does not move the download cache — +and that is the case where the old behaviour reinstalled on every launch anyway. + +Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the wrong +one here: containers bind-mount `~/.cache`, `~/.config` and `~/.local/share` +straight from the host, so a prefix tree under one of them would be shared by +every container on the machine and written into your own home — pixi#5476 again, +the hazard the cache mount is careful to keep `PIXI_HOME` away from. + +`PIXI_HOME` is set only for devlaunch's own install scripts, never exported into +the login profile, so **your own `pixi global install` in a workspace still goes +to your own `~/.pixi`**. Only the bin directory goes on `PATH`. + ### Existing containers, and what a recreate is for **A mount lands only when a container is created.** devpod re-applies diff --git a/devlaunch/tools.py b/devlaunch/tools.py index 098667b8..017f95fd 100644 --- a/devlaunch/tools.py +++ b/devlaunch/tools.py @@ -22,7 +22,9 @@ holds, which turns the slowest part of a cold launch (minutes of in-container downloads) into a local copy. Only when the host has nothing to lend, or the lent binaries do not run there (a different arch or libc), does the old -network path run: bootstrap pixi, `pixi global install` each tool. +network path run: bootstrap pixi, `pixi global install` each tool -- into a +pixi home of devlaunch's own rather than the container's `~/.pixi`, for the +reasons PIXI_HOME_TARGET gives. What "lacks them" means is the probe's job, and it has three answers rather than two, because a `claude` on PATH is not necessarily a `claude` worth @@ -114,6 +116,49 @@ # that reads someone else's artifact is only correct until they change it. PROFILE_MARK = "# devlaunch:" +# The pixi home devlaunch installs its own tools into, and never `~/.pixi`. +# +# `pixi global install` is not just an install: it is an edit to +# `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file whose owner then +# syncs it. `~/.pixi` is somebody else's -- an image's, or a dotfiles repo's -- +# and writing there made devlaunch a second author of a file with one owner, +# which cost something in both directions: +# +# 1. `pixi global sync` removes every env the manifest does not list, so a +# dotfiles apply that rewrites the manifest and syncs *uninstalls* the +# zellij this module just installed -- on a schedule nothing here can see, +# and the next launch reinstalls it, forever. +# 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks +# `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the +# checkout (its `.devcontainer/on_create.sh`), so the append landed in the +# user's work tree and every `git status` in the workspace came up dirty. +# Nothing about that is one repo's quirk: dl launches arbitrary repos, and +# an install that dirties the tree it was pointed at is the launch damaging +# the work it exists to serve. +# +# A home of devlaunch's own makes both unrepresentable rather than handled. +# Nothing syncs this manifest, so what is installed here stays installed; and +# every path under it is one devlaunch created, so no repo state can be beneath +# it. What it costs is a duplicate prefix in the case where a tool is installed +# but unreachable from a login shell -- disk only, since PIXI_HOME does not move +# the package cache (`PIXI_CACHE_TARGET` still shares the downloads) -- and that +# case is the one where the old behaviour reinstalled on every launch anyway. +# +# `$HOME/.devlaunch/pixi` and *not* `$HOME/.local/share/devlaunch/pixi`, which is +# the conventional answer and the wrong one here. Containers bind-mount the XDG +# trees -- `~/.cache`, `~/.config`, `~/.local/share` -- straight from the host, +# so a prefix tree under `.local/share` would be shared by every container on the +# machine and written into the host's own home. Prefixes are baked with absolute +# paths and two syncs sharing one prefix tree is prefix-dev/pixi#5476: exactly +# the hazard PIXI_CACHE_TARGET refuses to let PIXI_HOME near. A dotted directory +# in `$HOME` is nobody's convention to mount, and `transfer_script` already +# stages under `$HOME/.devlaunch-lend`. +# +# Deliberately *not* exported into the login profile. Only these scripts set it, +# so a user's own `pixi global install` still goes to their `~/.pixi` -- which is +# the separation this exists to keep. Only the bin directories go on PATH. +PIXI_HOME_TARGET = "$HOME/.devlaunch/pixi" + @dataclass(frozen=True) class Tool: @@ -293,7 +338,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: Idempotent and cheap on the common path: every tool already on PATH is skipped, so a workspace that has been provisioned before does nothing but answer. It runs under a login shell (see provision_tools), which is what puts - an earlier run's ~/.pixi/bin on PATH -- checked from a non-login shell every + an earlier run's PIXI_HOME_TARGET/bin on PATH -- checked from a non-login shell every tool would look missing and be reinstalled on every launch. Exits 0 unless an install actually failed, so "nothing to do" and "all @@ -308,7 +353,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: """ all_present = _all_present(tools) installs = "\n".join(_install_line(tool) for tool in tools) - # The trampoline pixi writes into ~/.pixi/bin does not work for packages + # The trampoline pixi writes into its bin directory does not work for packages # that ship a shell script, which is why the env's own bin directory is # added too -- the same workaround .devcontainer/claude-code/install.sh # carries, for the same package. @@ -321,10 +366,10 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: # (since the check above is `command -v`) reinstalled from scratch # on every single launch. _profile_resolution(), - _profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"), + _profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"), _profile_prepend( - '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && ' - 'export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"', + f'[ -d "{PIXI_HOME_TARGET}/envs/claude-shim/bin" ] && ' + f'export PATH="{PIXI_HOME_TARGET}/envs/claude-shim/bin:$PATH"', on_failure="failed=1", ), ] @@ -342,6 +387,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: # Everything already there: leave without touching pixi, the # profile, or the network. Every launch after the first takes this. f"if {all_present}; then exit 0; fi", + _pixi_home_export(), _pixi_bootstrap(), installs, profile_lines, @@ -364,7 +410,7 @@ def zellij_script() -> str: exits before pixi, the profile or the network are touched, and that is the answer on every launch after the first. The check works because this runs under the pass's login shell, which is what puts an earlier run's - ~/.pixi/bin on PATH -- from a non-login shell an installed zellij looks + PIXI_HOME_TARGET/bin on PATH -- from a non-login shell an installed zellij looks missing and would be reinstalled on every single launch. The PATH prepend is written here rather than relied on from elsewhere, @@ -395,10 +441,11 @@ def zellij_script() -> str: # Already there: nothing else in this script may run. The commonest # answer by far, and the reason a stage on every pass is affordable. f"if command -v {shlex.quote(ZELLIJ_TOOL.command)} >/dev/null 2>&1; then exit 0; fi", + _pixi_home_export(), _pixi_bootstrap(), _install_line(ZELLIJ_TOOL), _profile_resolution(), - _profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"), + _profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"), 'exit "$failed"', ] ) @@ -721,6 +768,24 @@ def _read_outcome(name: str, status: Optional[str]) -> StageOutcome: return StageNotReached(name=name) +def _pixi_home_export() -> str: + """Point pixi at devlaunch's own home (PIXI_HOME_TARGET) for what follows. + + One line at the top of every script here that runs pixi, and it has to cover + more than the installs: pixi's own installer honours the variable too + (`PIXI_HOME="${PIXI_HOME:-$HOME/.pixi}"` in its install.sh), so exporting it + once puts the binary and the environments in the same place instead of + bootstrapping pixi into a home its installs do not use. + + Exported rather than passed per command, so nothing pixi does on its own + behalf can miss it -- and set flatly rather than defaulted to an existing + value, because a PIXI_HOME already in the container's environment is the + image's answer to where *its* globals live, which is the answer this is here + not to use. + """ + return f'export PIXI_HOME="{PIXI_HOME_TARGET}"' + + def _pixi_bootstrap() -> str: """Install pixi if the image has none, since every tool here comes from it. @@ -734,7 +799,7 @@ def _pixi_bootstrap() -> str: "if ! command -v pixi >/dev/null 2>&1; then", ' echo "devlaunch: installing pixi"', " curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true", - ' export PATH="$HOME/.pixi/bin:$PATH"', + f' export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', "fi", ] ) diff --git a/test/unit/test_tools.py b/test/unit/test_tools.py index 5e73dd48..dc6fb243 100644 --- a/test/unit/test_tools.py +++ b/test/unit/test_tools.py @@ -119,7 +119,7 @@ def test_a_failed_install_is_reported_through_the_exit_status(self): def test_it_puts_the_pixi_bin_directory_on_the_login_path(self): """Without this the next launch reinstalls everything, forever.""" script = provision_script() - assert ".pixi/bin" in script + assert f"{tools.PIXI_HOME_TARGET}/bin" in script assert ".profile" in script def test_it_writes_to_the_profile_bash_actually_reads(self): @@ -190,10 +190,13 @@ class TestProfileGuards: # script, because a test that derives what to expect from the thing under # test agrees with it however wrong it is. PREPENDED = { - "provision": ".pixi/bin", + "provision": ".devlaunch/pixi/bin", "transfer": ".local/bin", + # The feature installer is baked into an image at build time, where + # ~/.pixi is the image's own and there is no checkout to dirty, so it + # is the one writer that still names it. "feature": ".pixi/bin", - "zellij": ".pixi/bin", + "zellij": ".devlaunch/pixi/bin", } # Every shape a container's home can have when a writer arrives. bash @@ -1606,9 +1609,16 @@ def _sandbox(tmp_path, *, has_zellij=False, pixi_exit=None, has_pixi_install=Fal log = tmp_path / "calls.log" quoted_log = shlex.quote(str(log)) - def fake(name, exit_code, noise=""): + env_log = tmp_path / "env.log" + + def fake(name, exit_code, noise="", record_env=False): path = sysbin / name body = f'echo "{name} $*" >> {quoted_log}\n' + if record_env: + # A log of its own rather than the call log, so the existing + # "pixi was never run" assertions keep asking about calls and + # not about a variable whose value contains the word. + body += f'echo "PIXI_HOME=${{PIXI_HOME-unset}}" >> {shlex.quote(str(env_log))}\n' if noise: body += f"echo {shlex.quote(noise)}\n" path.write_text(f"#!/bin/sh\n{body}exit {exit_code}\n", encoding="utf-8") @@ -1619,7 +1629,7 @@ def fake(name, exit_code, noise=""): if pixi_exit is not None: # Noise on *stdout*, because that is the stream the pass's protocol # shares and the one a stage may never speak on. - fake("pixi", pixi_exit, noise="PIXI-NOISE") + fake("pixi", pixi_exit, noise="PIXI-NOISE", record_env=True) # A curl that cannot reach anything, which is what an offline image is. fake("curl", 1) if has_pixi_install: @@ -1697,7 +1707,7 @@ def test_a_cold_container_gets_it_and_the_next_shell_can_find_it(self, tmp_path) home, result, calls = self._run(tmp_path, pixi_exit=0) assert self._zellij_outcome(result.stdout) == tools.StageOk(name=tools.ZELLIJ_STAGE) assert "pixi global install zellij" in calls - assert 'export PATH="$HOME/.pixi/bin:$PATH"' in (home / ".profile").read_text( + assert f'export PATH="{tools.PIXI_HOME_TARGET}/bin:$PATH"' in (home / ".profile").read_text( encoding="utf-8" ) @@ -1752,3 +1762,76 @@ def test_the_tools_opt_out_asks_for_no_zellij(self, monkeypatch, tmp_path): assert tools.HOSTNAME_STAGE in names _, _, calls = self._run(tmp_path, pixi_exit=0) assert "pixi" not in calls + + def test_the_install_never_touches_the_containers_own_pixi_home(self, tmp_path): + """The property, run rather than read off the script text. + + `pixi global install` edits `$PIXI_HOME/manifests/pixi-global.toml`, a + declarative file, and in a container `~/.pixi` is somebody else's: an + image's, or a dotfiles repo's. Two owners cost this both ways -- a + `pixi global sync` against a manifest that does not declare zellij + *uninstalls* it, and kinisi_ros's devcontainer symlinks that very + manifest onto a tracked file in the checkout, so the append dirtied the + user's work tree. A home of devlaunch's own is what makes neither + expressible. + """ + home, _, _ = self._run(tmp_path, pixi_exit=0) + recorded = (tmp_path / "env.log").read_text(encoding="utf-8") + assert f"PIXI_HOME={home}/.devlaunch/pixi" in recorded + assert not (home / ".pixi").exists() + + def test_the_home_is_set_before_anything_pixi_runs(self): + """Ordering, because the bootstrap is a consumer and not just a prelude. + + pixi's own installer honours PIXI_HOME (`${PIXI_HOME:-$HOME/.pixi}`), so + exporting it after the bootstrap would leave the binary in one home and + every environment it installs in another -- which is the one shape of + this change that fails silently, since both halves work alone. + """ + script = tools.zellij_script() + assert script.index("PIXI_HOME=") < script.index("command -v pixi") + assert script.index("PIXI_HOME=") < script.index("pixi global install") + + +class TestPixiHome: + """Where devlaunch's own tools live, asked of every script that installs one.""" + + def test_no_script_devlaunch_runs_in_a_container_names_the_shared_home(self): + """`~/.pixi` may appear in this module's reasoning, never in its output. + + Asked of the rendered scripts rather than the source, so the docstrings + that explain *why* the path is avoided cannot fail the test that it is. + """ + for name, script in ( + ("provision", provision_script()), + ("zellij", tools.zellij_script()), + ): + assert "$HOME/.pixi" not in script, name + + def test_the_home_is_not_under_a_bind_mounted_xdg_tree(self): + """The reason it is not `~/.local/share/devlaunch/pixi`. + + Containers mount `~/.cache`, `~/.config` and `~/.local/share` straight + from the host, so a prefix tree under one of them is shared by every + container on the machine and written into the host's own home -- and + prefixes are baked with absolute paths, which is prefix-dev/pixi#5476, + the hazard dl.PIXI_CACHE_TARGET already refuses to let PIXI_HOME near. + """ + target = tools.PIXI_HOME_TARGET + assert target.startswith("$HOME/") + for mounted in (".local/share", ".config", ".cache"): + assert mounted not in target + + def test_the_package_cache_is_still_shared(self): + """PIXI_HOME moves the manifest and the prefixes, not the downloads. + + Verified against pixi 0.77: `PIXI_HOME=x pixi info` reports the global + bin and manifest under `x` and leaves the cache dir alone. If that ever + stopped being true, every container would go back to downloading its own + packages -- the whole point of the shared mount -- so the two variables + being independent is pinned here rather than assumed. + """ + from devlaunch import dl + + assert dl.PIXI_CACHE_TARGET not in tools.PIXI_HOME_TARGET + assert tools.PIXI_HOME_TARGET not in dl.PIXI_CACHE_TARGET From 39e066bb91ea581eb6fb95a3b96682d5ddefaac8 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 18:31:11 +0100 Subject: [PATCH 2/4] Port the pixi home to the Rust build, which is the one that ships The previous commit fixed devlaunch/tools.py, and `dl` on a user's machine is not that: 0.1.0 onwards is the Rust rewrite, the released binary is an ELF, and the Python tree is the reference implementation beside it. So the fix reached nobody until this. `rust/devlaunch-core/src/flows/provision.rs` gets PIXI_HOME_EXPORT ahead of the bootstrap in both scripts, and PIXI_BOOTSTRAP / PIXI_BIN_LINE / CLAUDE_SHIM_BIN_LINE move to `$HOME/.devlaunch/pixi`. The goldens in its test module -- "what devlaunch/tools.py renders, byte for byte" -- were regenerated from the Python module itself rather than hand-edited, which is the only way the claim in their header stays true; the marks in them are content hashes, so retyping one is a duplicate PATH entry in every workspace forever. The interesting half is what did *not* move. `.devcontainer/claude-code/install.sh` still names `~/.pixi`: it runs at image build time, where that path is the image's own, there is no checkout to dirty and no other owner to race -- and editing it would invalidate the devcontainer prebuild for no gain. So the build-time writer and the two runtime writers now append different lines under different marks, and both land. That is the dedupe working rather than failing: two pixi homes are two directories a login shell genuinely needs, and `_profile_prepend`'s mark being a hash of the line is exactly what keeps one writer from reading the other's work as its own. Three tests had encoded the old agreement and now state the new one: - `Writer::prepended` had a `_ => ".pixi/bin"` catch-all, which is how the build-time and runtime writers came to share an answer they no longer share. Every arm is spelled out; a `_` here would let a fourth writer inherit whichever answer happened to be the default. - `a_marks_digest_is_twelve_hex_characters_of_the_lines_sha256` pins the two new digests, and now also the feature installer's own two -- those are pasted into a shell script that cannot derive them, so nothing but a test holds them. - `the_feature_installer_writes_the_very_fragments_devlaunch_renders` compares against the feature's own lines instead of the runtime consts, which is what the Python test beside it already did. That difference is why the Python suite stayed green on this while the Rust one went red -- worth knowing, because the Rust form is the stronger of the two and it is the one that caught this. cargo test -p devlaunch-core: 1140 passed. clippy --all-targets -D warnings and cargo fmt --check clean. --- rust/devlaunch-core/src/flows/provision.rs | 129 ++++++++++++++++----- 1 file changed, 103 insertions(+), 26 deletions(-) diff --git a/rust/devlaunch-core/src/flows/provision.rs b/rust/devlaunch-core/src/flows/provision.rs index 002d295b..d213b788 100644 --- a/rust/devlaunch-core/src/flows/provision.rs +++ b/rust/devlaunch-core/src/flows/provision.rs @@ -412,17 +412,63 @@ fn install_line(tool: &Tool) -> String { const PIXI_BOOTSTRAP: &str = r#"if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi"#; -/// The `~/.pixi/bin` prepend, which two scripts and the feature installer write. -const PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.pixi/bin:$PATH""#; - -/// The trampoline pixi writes into `~/.pixi/bin` does not work for packages that -/// ship a shell script, which is why the env's own bin directory is added too — -/// the same workaround `.devcontainer/claude-code/install.sh` carries, for the +/// Point pixi at a home of devlaunch's own (`$HOME/.devlaunch/pixi`), never `~/.pixi`. +/// +/// `pixi global install` is not only an install: it is an edit to +/// `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a +/// container already has an owner — an image's, or a dotfiles repo's. Writing +/// there made devlaunch a second author of a file with one owner, and it cost +/// something in both directions: +/// +/// 1. `pixi global sync` removes every env the manifest does not list, so a +/// dotfiles apply that rewrites the manifest and syncs *uninstalls* the zellij +/// the setup pass just installed — on a schedule nothing here can see, and the +/// next launch reinstalls it, forever. +/// 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks +/// `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout, +/// so the append landed in the user's work tree and every `git status` in the +/// workspace came up dirty. That is not one repo's quirk: dl launches arbitrary +/// repos, and an install that dirties the tree it was pointed at is the launch +/// damaging the work it exists to serve. +/// +/// A home of devlaunch's own makes both unrepresentable rather than handled: +/// nothing syncs this manifest, and every path under it is one devlaunch created. +/// +/// `$HOME/.devlaunch/pixi` and *not* `$HOME/.local/share/devlaunch/pixi`, which is +/// the conventional answer and the wrong one here — containers bind-mount +/// `~/.cache`, `~/.config` and `~/.local/share` straight from the host, so a prefix +/// tree under one would be shared by every container on the machine and written +/// into the host's own home. Prefixes are baked with absolute paths and two syncs +/// sharing one tree is prefix-dev/pixi#5476: the hazard the shared package cache +/// refuses to let PIXI_HOME near. +/// +/// Set on every script here that runs pixi, ahead of everything else it does. +/// +/// It has to come before the bootstrap and not merely before the installs: pixi's +/// own installer honours the variable too (`${PIXI_HOME:-$HOME/.pixi}` in its +/// install.sh), so the other order would leave the binary in one home and every +/// env it installs in another — the one shape of this that fails silently, since +/// both halves work alone. +/// +/// Set flatly rather than defaulted to an existing value: a PIXI_HOME already in +/// the container's environment is the image's answer to where *its* globals live, +/// which is the answer this is here not to use. Never written to the login +/// profile, so a user's own `pixi global install` still goes to their `~/.pixi`. +const PIXI_HOME_EXPORT: &str = r#"export PIXI_HOME="$HOME/.devlaunch/pixi""#; + +/// The bin prepend, which both scripts write. The feature installer still names +/// `~/.pixi/bin`: it is baked into an image at build time, where that path is the +/// image's own and there is no checkout to dirty. +const PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.devlaunch/pixi/bin:$PATH""#; + +/// The trampoline pixi writes into its bin directory does not work for packages +/// that ship a shell script, which is why the env's own bin directory is added too +/// — the same workaround `.devcontainer/claude-code/install.sh` carries, for the /// same package. -const CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH""#; +const CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH""#; /// The `~/.local/bin` prepend a lend writes, and it goes in *last* on purpose: /// the container this lend exists for already has a shim earlier on PATH, so the @@ -475,6 +521,7 @@ pub(crate) fn provision_script(tools: &[Tool]) -> String { // Everything already there: leave without touching pixi, the profile, or // the network. Every launch after the first takes this. format!("if {}; then exit 0; fi", all_present(tools)), + PIXI_HOME_EXPORT.to_owned(), PIXI_BOOTSTRAP.to_owned(), installs, profile_lines, @@ -515,6 +562,7 @@ pub(crate) fn zellij_script() -> String { "if command -v {} >/dev/null 2>&1; then exit 0; fi", quote(ZELLIJ_TOOL.command) ), + PIXI_HOME_EXPORT.to_owned(), PIXI_BOOTSTRAP.to_owned(), install_line(&ZELLIJ_TOOL), profile_resolution("$HOME"), @@ -1761,10 +1809,11 @@ mod tests { exec >&2 failed=0 if command -v gh >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v gh >/dev/null 2>&1; then echo "devlaunch: installing gh" @@ -1778,18 +1827,19 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 -grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_PROVISION_SCRIPT_JQ: &str = r#"set -u exec >&2 failed=0 if command -v jq >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v jq >/dev/null 2>&1; then echo "devlaunch: installing jq" @@ -1799,17 +1849,18 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 -grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_ZELLIJ_SCRIPT: &str = r#"set -u failed=0 if command -v zellij >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v zellij >/dev/null 2>&1; then echo "devlaunch: installing zellij" @@ -1819,7 +1870,7 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_PROBE_SCRIPT: &str = r#"set -u @@ -1863,10 +1914,11 @@ fi"#; const PYTHON_ZELLIJ_STAGE: &str = r#"if bash -c 'set -u failed=0 if command -v zellij >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v zellij >/dev/null 2>&1; then echo "devlaunch: installing zellij" @@ -1876,7 +1928,7 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '"'"'# devlaunch: 6b593c3a6327'"'"' "$PROFILE" 2>/dev/null || printf '"'"'%s\n'"'"' '"'"'# devlaunch: 6b593c3a6327'"'"' '"'"'export PATH="$HOME/.pixi/bin:$PATH"'"'"' >> "$PROFILE" || failed=1 +grep -qxF '"'"'# devlaunch: 87ccd356540b'"'"' "$PROFILE" 2>/dev/null || printf '"'"'%s\n'"'"' '"'"'# devlaunch: 87ccd356540b'"'"' '"'"'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"'"'"' >> "$PROFILE" || failed=1 exit "$failed"' >&2; then echo "devlaunch-probe stage zellij ok" else @@ -1893,16 +1945,16 @@ elif [ -f "$TARGET_HOME/.bash_login" ]; then PROFILE="$TARGET_HOME/.bash_login" else PROFILE="$TARGET_HOME/.profile" fi"#; - const PYTHON_PREPEND_PIXI_BIN: &str = r#"grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE""#; + const PYTHON_PREPEND_PIXI_BIN: &str = r#"grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE""#; - const PYTHON_PREPEND_SHIM_BIN: &str = r#"grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE""#; + const PYTHON_PREPEND_SHIM_BIN: &str = r#"grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE""#; const PYTHON_PREPEND_LOCAL_BIN: &str = r#"grep -qxF '# devlaunch: 63c662ba0560' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 63c662ba0560' 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE""#; const PYTHON_PIXI_BOOTSTRAP: &str = r#"if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi"#; const PYTHON_INSTALL_CLAUDE: &str = r#"if ! command -v claude >/dev/null 2>&1; then @@ -2462,9 +2514,15 @@ fi /// script, because a test that derives what to expect from the thing under /// test agrees with it however wrong it is. fn prepended(self) -> &'static str { + // No catch-all: the two runtime writers and the build-time feature + // installer no longer agree, and a `_` arm is what would let a third + // writer inherit whichever answer happened to be the default. match self { + Writer::Provision | Writer::Zellij => ".devlaunch/pixi/bin", Writer::Transfer => ".local/bin", - _ => ".pixi/bin", + // The feature is baked into an image at build time, where ~/.pixi + // is the image's own and there is no checkout to dirty. + Writer::Feature => ".pixi/bin", } } } @@ -2670,9 +2728,16 @@ fi // The marks are content hashes a shell script cannot derive for itself, so // the devcontainer feature pastes them; a different derivation here would // silently cost every workspace one duplicate PATH entry per line. - assert_eq!(mark_digest(PIXI_BIN_LINE), "6b593c3a6327"); - assert_eq!(mark_digest(CLAUDE_SHIM_BIN_LINE), "12897b113bea"); + assert_eq!(mark_digest(PIXI_BIN_LINE), "87ccd356540b"); + assert_eq!(mark_digest(CLAUDE_SHIM_BIN_LINE), "190e825e206b"); assert_eq!(mark_digest(LOCAL_BIN_LINE), "63c662ba0560"); + // The feature installer's own two, which are the marks actually pasted + // into `.devcontainer/claude-code/install.sh`. Different lines from the + // runtime writers' since those moved to `~/.devlaunch/pixi`, and so + // different marks -- which is the dedupe working rather than failing: two + // pixi homes are two directories a login shell genuinely needs. + assert_eq!(mark_digest(FEATURE_PIXI_BIN_LINE), "6b593c3a6327"); + assert_eq!(mark_digest(FEATURE_CLAUDE_SHIM_BIN_LINE), "12897b113bea"); } // ======================================================================= @@ -2753,7 +2818,7 @@ fi fn it_puts_the_pixi_bin_directory_on_the_login_path() { // Without this the next launch reinstalls everything, forever. let script = provision_script(&REQUIRED_TOOLS); - assert!(script.contains(".pixi/bin")); + assert!(script.contains(".devlaunch/pixi/bin")); assert!(script.contains(".profile")); } @@ -2947,6 +3012,18 @@ fi } } + /// The two PATH lines `.devcontainer/claude-code/install.sh` writes, which are + /// deliberately *not* [`PIXI_BIN_LINE`] and [`CLAUDE_SHIM_BIN_LINE`] any more. + /// + /// The feature runs at image build time and installs into the image's own + /// `~/.pixi`, where there is no checkout to dirty and no other owner to race -- + /// so it has no reason to move, and moving it would invalidate the devcontainer + /// prebuild for nothing. The runtime writers moved; this did not, and the two + /// therefore append two different lines under two different marks. Both land, + /// which is correct: they name two real directories. + const FEATURE_PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.pixi/bin:$PATH""#; + const FEATURE_CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH""#; + #[test] fn the_feature_installer_writes_the_very_fragments_devlaunch_renders() { // The installer's two profile edits are `profile_prepend`'s own output, @@ -2956,7 +3033,7 @@ fi // line changes, and a drifted mark quietly costs a workspace one duplicate // PATH entry per line. let installer = feature_installer(); - for line in [PIXI_BIN_LINE, CLAUDE_SHIM_BIN_LINE] { + for line in [FEATURE_PIXI_BIN_LINE, FEATURE_CLAUDE_SHIM_BIN_LINE] { let fragment = profile_prepend(line, None); assert!( installer.contains(&fragment), From c44a2e502871c9981520e949fc55e091e71993a4 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 18:48:59 +0100 Subject: [PATCH 3/4] Hold the Rust goldens to what Python actually renders Found reviewing the two commits before it, and it is the hole they walked through. The goldens in the Rust test module are headed "what devlaunch/tools.py renders, byte for byte" -- and that was a claim in a comment. They are hardcoded strings compared against the *Rust* renderer, so nothing ever compared them to the Python one they are named for. Moving the tools into their own pixi home had to be carried into both trees by hand. The Rust side's internal tests caught a divergence the Python side's did not (the feature installer's PATH line, which deliberately did not move). Had the edit gone the other way -- Python only -- nothing at all would have failed: the goldens would have kept agreeing with the Rust renderer while both drifted from the module they are named after, and the drift would have surfaced as a container whose PATH line no later writer recognises. `rust/tools/parity_cases.txt` does not cover it; it compares dl's observable behaviour, and these scripts are payload strings inside a `devpod ssh --command`. So the comparison is made in the tree that owns the definition: extract every `PYTHON_*` const out of provision.rs and assert it equals the call in devlaunch.tools that is supposed to produce it. No cargo and no Rust toolchain -- it is a pytest, which is what puts the failure in front of whoever edits either side. Two tests rather than one, because the first alone would only check the goldens it happens to have a mapping for: the second fails on any `PYTHON_*` const with no rendering named for it, so a new golden cannot be added outside the check. The mapping is written out rather than discovered, for the reason the module already gives about tests that derive their expectations from the thing under test. Both failure modes were verified by breaking them on purpose: one character of a content-hash mark in a golden fails with the name of the golden, and an unmapped new golden fails with its name. --- devlaunch/tools.py | 11 +++-- test/unit/test_tools.py | 104 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/devlaunch/tools.py b/devlaunch/tools.py index 017f95fd..bcc49ad8 100644 --- a/devlaunch/tools.py +++ b/devlaunch/tools.py @@ -338,8 +338,9 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: Idempotent and cheap on the common path: every tool already on PATH is skipped, so a workspace that has been provisioned before does nothing but answer. It runs under a login shell (see provision_tools), which is what puts - an earlier run's PIXI_HOME_TARGET/bin on PATH -- checked from a non-login shell every - tool would look missing and be reinstalled on every launch. + an earlier run's bin directory (PIXI_HOME_TARGET) on PATH -- checked from a + non-login shell every tool would look missing and be reinstalled on every + launch. Exits 0 unless an install actually failed, so "nothing to do" and "all installs worked" are the same answer to the caller. @@ -409,9 +410,9 @@ def zellij_script() -> str: Idempotent and nearly free on the common path: a `zellij` already on PATH exits before pixi, the profile or the network are touched, and that is the answer on every launch after the first. The check works because this runs - under the pass's login shell, which is what puts an earlier run's - PIXI_HOME_TARGET/bin on PATH -- from a non-login shell an installed zellij looks - missing and would be reinstalled on every single launch. + under the pass's login shell, which is what puts an earlier run's bin + directory (PIXI_HOME_TARGET) on PATH -- from a non-login shell an installed + zellij looks missing and would be reinstalled on every single launch. The PATH prepend is written here rather than relied on from elsewhere, because the container this most often lands in has not had it written. A diff --git a/test/unit/test_tools.py b/test/unit/test_tools.py index dc6fb243..37c42dcf 100644 --- a/test/unit/test_tools.py +++ b/test/unit/test_tools.py @@ -4,6 +4,7 @@ import logging import os import pathlib +import re import shlex import shutil import subprocess @@ -1835,3 +1836,106 @@ def test_the_package_cache_is_still_shared(self): assert dl.PIXI_CACHE_TARGET not in tools.PIXI_HOME_TARGET assert tools.PIXI_HOME_TARGET not in dl.PIXI_CACHE_TARGET + + +# The Rust port's golden strings, whose own header calls them "what +# devlaunch/tools.py renders, byte for byte". Until this class existed, that was +# a claim in a comment: the goldens are hardcoded, so they were compared against +# the Rust renderer and never against the Python one they are named for. +RUST_PROVISION = ( + pathlib.Path(__file__).resolve().parents[2] / "rust/devlaunch-core/src/flows/provision.rs" +) + + +class TestRustGoldensAreWhatPythonRenders: + """The two implementations of these scripts, held to one text. + + `dl` ships as the Rust build; this tree is the reference implementation + beside it. Both render the same shell into someone else's container, and + until this class the only thing keeping them in step was that a person + editing one remembered the other. That is the arrangement this module + refuses everywhere else -- `PROFILE_MARK` exists because "a guard that reads + someone else's artifact is only correct until they change it", and + `_profile_prepend` derives its marks so a collision is unrepresentable + rather than asserted. + + The hole was not hypothetical. Moving the tools into their own pixi home had + to be carried into both trees by hand, and the Rust side's *internal* tests + caught a divergence the Python side's did not (the feature installer's PATH + line, which deliberately did not move). Had the edit gone the other way -- + Python only -- nothing at all would have failed: these goldens would have + kept agreeing with the Rust renderer while both drifted from the module they + are named after. `rust/tools/parity_cases.txt` does not cover this; it + compares `dl`'s observable behaviour, and these scripts are payload strings + inside a `devpod ssh --command`. + + So the comparison is made here, in the tree that owns the definition, and + the second test is what stops a new golden from being added outside it. + """ + + # Each golden, and the call in this module that must produce it. Written out + # rather than discovered, because a mapping derived from the thing under test + # agrees with it however wrong it is. + @staticmethod + def _renderings(tmp_path): + # pylint: disable=protected-access + claude = next(tool for tool in REQUIRED_TOOLS if tool.command == "claude") + stages = {stage.name: stage for stage in tools.setup_stages("myws")} + pixi_bin = f'export PATH="{tools.PIXI_HOME_TARGET}/bin:$PATH"' + shim_bin = ( + f'[ -d "{tools.PIXI_HOME_TARGET}/envs/claude-shim/bin" ] && ' + f'export PATH="{tools.PIXI_HOME_TARGET}/envs/claude-shim/bin:$PATH"' + ) + return { + "PYTHON_PROVISION_SCRIPT": provision_script(), + "PYTHON_PROVISION_SCRIPT_JQ": provision_script([Tool(command="jq", package="jq")]), + "PYTHON_ZELLIJ_SCRIPT": tools.zellij_script(), + "PYTHON_PROBE_SCRIPT": tools.probe_script(), + "PYTHON_TRANSFER_SCRIPT": tools.transfer_script(fake_payload(tmp_path)), + "PYTHON_HOSTNAME_STAGE": tools._stage_snippet(stages[tools.HOSTNAME_STAGE]), + "PYTHON_ZELLIJ_STAGE": tools._stage_snippet(stages[tools.ZELLIJ_STAGE]), + "PYTHON_PROFILE_RESOLUTION_HOME": tools._profile_resolution("$HOME"), + "PYTHON_PROFILE_RESOLUTION_TARGET": tools._profile_resolution("$TARGET_HOME"), + "PYTHON_PREPEND_PIXI_BIN": tools._profile_prepend(pixi_bin), + "PYTHON_PREPEND_SHIM_BIN": tools._profile_prepend(shim_bin), + "PYTHON_PREPEND_LOCAL_BIN": tools._profile_prepend( + 'export PATH="$HOME/.local/bin:$PATH"' + ), + "PYTHON_PIXI_BOOTSTRAP": tools._pixi_bootstrap(), + "PYTHON_INSTALL_CLAUDE": tools._install_line(claude), + } + + @staticmethod + def _goldens(): + """Every `PYTHON_*` golden in the Rust test module, by name. + + Cut out of the source text rather than run, so this needs no cargo and + no Rust toolchain -- it is a `pytest` that fails in the tree where the + definition lives, which is where the edit that would break it is made. + """ + text = RUST_PROVISION.read_text(encoding="utf-8") + found = dict(re.findall(r'const (PYTHON_[A-Z_]+): &str = r#"(.*?)"#;', text, re.S)) + assert found, f"no goldens found in {RUST_PROVISION} -- did the raw-string form change?" + return found + + def test_every_golden_is_byte_for_byte_what_this_module_renders(self, tmp_path): + """The claim in the goldens' own header, checked instead of trusted.""" + renderings = self._renderings(tmp_path) + for name, golden in sorted(self._goldens().items()): + assert name in renderings, f"{name} has no rendering to compare against" + assert golden == renderings[name], ( + f"{name} in {RUST_PROVISION.name} is no longer what this module renders; " + "regenerate it from the Python call rather than editing it by hand -- " + "the marks in it are content hashes, and a retyped digit is a duplicate " + "PATH entry in every workspace forever" + ) + + def test_no_golden_escapes_the_comparison(self, tmp_path): + """A golden added without a rendering here would be checked by nothing. + + The test above only compares the goldens it has a mapping for, so this is + the half that makes the coverage total: a new `PYTHON_*` const fails here + until someone says which call in this module is supposed to produce it. + """ + unmapped = sorted(set(self._goldens()) - set(self._renderings(tmp_path))) + assert not unmapped, f"goldens with no Python rendering to check them: {unmapped}" From 40db02db3cb9ee63cb48f286be7f3138a4cf3f89 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 19:01:45 +0100 Subject: [PATCH 4/4] Release 0.2.1 `Auto-publish` fires on every push to `main` and releases only when `[workspace.package] version` in rust/Cargo.toml differs from its parent commit's, so the bump is what turns this merge into a release; it then reads the release body out of the `## [0.2.1]` section and fails the run if that section is missing. `rust/Cargo.lock` moves with it -- the workspace members inherit the version, and every build in CI passes `--locked`, so a lock left at 0.2.0 is a red build rather than a stale file. `devlaunch-test-support`'s `versioned_with_the_workspace` pins the version as a string and had to be edited too. Worth knowing that no CI step runs it: the rust-parity job tests `devlaunch-runner`, `devlaunch-core`, `aid` and `dl` by name and never that crate, so the one test standing guard over this was found by grep rather than by the gate it exists to be part of. --- CHANGELOG.md | 37 ++++++++++++++++++++++++++ rust/Cargo.lock | 10 +++---- rust/Cargo.toml | 2 +- rust/devlaunch-test-support/src/lib.rs | 2 +- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf04b44..5164b15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-08-20 + +### Fixed + +- **The tools devlaunch installs no longer edit the container's own pixi + manifest.** `gh`, `claude` and `zellij` go into `~/.devlaunch/pixi` rather than + `~/.pixi`, because `pixi global install` is not only an install: it is an edit + to `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a + container already has an owner. Writing there cost something in both + directions. `pixi global sync` removes every environment the manifest does not + list, so a dotfiles apply that rewrote the manifest and synced **uninstalled** + the zellij devlaunch had just installed — invisibly, and the next launch + reinstalled it, forever. And the manifest is not always a file: a devcontainer + is free to symlink it onto a tracked file inside the checkout, and + `kinisi_ros`'s does, so the append landed in the work tree and every + `git status` in the workspace came up dirty. An install that dirties the tree + it was pointed at is the launch damaging the work it exists to serve, and + `dl` launches arbitrary repos. A home devlaunch created makes both + unrepresentable rather than handled: nothing syncs that manifest, and no repo + state can sit beneath that path. + + Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the + wrong one here — containers bind-mount `~/.cache`, `~/.config` and + `~/.local/share` straight from the host, so a prefix tree under one would be + shared by every container on the machine and written into your own home; + prefixes are baked with absolute paths, which is prefix-dev/pixi#5476, the + hazard the shared package cache already keeps `PIXI_HOME` away from. + `PIXI_HOME` is set only for devlaunch's own install scripts and never exported + into the login profile, so **your own `pixi global install` in a workspace + still goes to your own `~/.pixi`**; only the bin directories go on `PATH`. + + Existing containers are untouched: their tools are already on the login `PATH`, + so the presence check passes and nothing is reinstalled. The devcontainer + feature's installer still writes `~/.pixi` deliberately — it runs at image + build time, where that path is the image's own and there is no checkout to + dirty. + ## [0.2.0] - 2026-08-20 ### Added diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f4c893e3..7ff99f43 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "aid" -version = "0.2.0" +version = "0.2.1" dependencies = [ "dl", "serde_json", @@ -438,7 +438,7 @@ dependencies = [ [[package]] name = "devlaunch-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "devlaunch-runner", "devlaunch-test-support", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "devlaunch-runner" -version = "0.2.0" +version = "0.2.1" dependencies = [ "libc", "tempfile", @@ -464,7 +464,7 @@ dependencies = [ [[package]] name = "devlaunch-test-support" -version = "0.2.0" +version = "0.2.1" dependencies = [ "devlaunch-runner", "serde_json", @@ -510,7 +510,7 @@ dependencies = [ [[package]] name = "dl" -version = "0.2.0" +version = "0.2.1" dependencies = [ "assert_cmd", "clap", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d5024097..39c61fb9 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,7 +11,7 @@ members = [ # The single source of the version (docs/rust-rewrite-plan.md: cutover ships # 0.1.0, version read from Cargo.toml). [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "MIT" repository = "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/blooop/devlaunch" diff --git a/rust/devlaunch-test-support/src/lib.rs b/rust/devlaunch-test-support/src/lib.rs index afc5ce60..34c3796f 100644 --- a/rust/devlaunch-test-support/src/lib.rs +++ b/rust/devlaunch-test-support/src/lib.rs @@ -32,6 +32,6 @@ pub use response::Response; mod tests { #[test] fn versioned_with_the_workspace() { - assert_eq!(env!("CARGO_PKG_VERSION"), "0.2.0"); + assert_eq!(env!("CARGO_PKG_VERSION"), "0.2.1"); } }