From 87d3f8d168c89700ffe46c9d0cf7b384a582f067 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 13:09:56 +0000 Subject: [PATCH] fix(host-tools): put Homebrew and mise on PATH for a non-interactive bash A command run over SSH starts a non-interactive bash, which returns from ~/.bashrc at the interactive guard of Debian and Ubuntu's default file. Homebrew's shellenv and mise's activation are written below it, and mise's activation is a prompt hook, so on those hosts brew, mise and mise's tools were not found over SSH. A bash host now gets a block named path at the top of ~/.bashrc, written like the environment variables, that puts mise's shim directory ahead of Homebrew's bin and sbin on PATH. That is the form mise documents for programs that never see an activated shell, and it starts no process and prints nothing, which a startup file must not do for scp and sftp. Only the tools that are wanted are named. A PATH assignment that already names the prefix's bin in a startup file counts as the equivalent, and a block an earlier run wrote follows a changed prefix or a tool switched off in place. The interactive lines stay where they are. The tests drive the role against Ubuntu's default bashrc and look the commands up with a real non-interactive bash, before and after. --- plugins/filter/host_tools.py | 34 +++- roles/github_runner_host_tools/README.md | 16 +- .../defaults/main.yml | 2 +- .../meta/argument_specs.yml | 2 +- .../tasks/shell_integration.yml | 2 +- tests/unit/test_host_tools.py | 155 +++++++++++++++++- tests/unit/test_host_tools_filters.py | 58 ++++++- 7 files changed, 256 insertions(+), 13 deletions(-) diff --git a/plugins/filter/host_tools.py b/plugins/filter/host_tools.py index 227a4eb3..e67908f5 100644 --- a/plugins/filter/host_tools.py +++ b/plugins/filter/host_tools.py @@ -52,12 +52,33 @@ def host_tools_homebrew_prefix(system: Any, machine: Any) -> str: raise AnsibleFilterError(f"Homebrew supports Darwin and Linux, not {system!r}.") +# The characters special in an extended regular expression, which a literal path in a grep pattern has to escape. +ERE_SPECIALS = re.compile(r"([.\[\]\\*^$+?(){}|])") + +# The directory of mise's shims, relative to the user's home directory, where mise puts them by default ([shims_dir](https://mise.jdx.dev/configuration/settings.html#shims_dir); https://mise.jdx.dev/dev-tools/shims.html). +MISE_SHIMS_DIR = ".local/share/mise/shims" + + +def _bash_path(prefix: str, homebrew: Any, mise: Any) -> dict[str, Any]: + """Return the line that puts Homebrew's and mise's directories on ``PATH`` for a non-interactive bash. + + A non-interactive bash cannot use the lines that set Homebrew and mise up for an interactive one: it returns from ``~/.bashrc`` at the interactive guard before them, and mise's activation is a prompt hook that never runs without a prompt. What it needs is the directories themselves on ``PATH``, which mise documents for programs that never see an activated shell (https://mise.jdx.dev/dev-tools/shims.html, "Add shims to PATH"): the shim directory, ahead of Homebrew's so that a tool mise installs wins over one Homebrew ships. Homebrew's ``bin`` holds ``brew`` and, when it installed mise, ``mise``. + + ``pattern`` matches a line outside a comment that assigns ``PATH`` with the prefix's ``bin`` in it, which a startup file that puts Homebrew on the path itself has. + """ + directories = ([f"$HOME/{MISE_SHIMS_DIR}"] if mise else []) + ([_double_quoted(f"{prefix}/bin"), _double_quoted(f"{prefix}/sbin")] if homebrew else []) + escaped = ERE_SPECIALS.sub(r"\\\1", f"{prefix}/bin") + return {"name": "path", "pattern": f"^[^#]*PATH=[^#]*{escaped}", "line": 'export PATH="' + ":".join(directories) + ':$PATH"', "non_interactive": True} + + def host_tools_shell_integrations(prefix: Any, shell: Any, homebrew: Any, mise: Any) -> list[dict[str, Any]]: """Return the startup lines that bring Homebrew and mise into a shell. - Each line is guarded by a test that the binary is executable, so a startup file that outlives the tool does no harm. ``pattern`` is an extended regular expression that matches a line of a startup file that already does the same, outside a comment, ``name`` tells the integrations apart, and ``non_interactive`` is false because Homebrew and mise are wanted in an interactive shell only (the line goes where the file's own interactive setup is, never ahead of an early return for non-interactive shells). + Each tool's line is guarded by a test that the binary is executable, so a startup file that outlives the tool does no harm. ``pattern`` is an extended regular expression that matches a line of a startup file that already does the same, outside a comment, ``name`` tells the integrations apart, and ``non_interactive`` is false for the tools' own lines because Homebrew's environment and mise's activation are wanted in an interactive shell only (the line goes where the file's own interactive setup is, never ahead of an early return for non-interactive shells). + + A bash host also gets a ``path`` line with ``non_interactive`` true, which puts the directories of the tools on ``PATH`` for a non-interactive bash, as ``sshd`` starts for a command (see ``_bash_path``). zsh needs none, because ``~/.zshenv`` is read in full by every zsh and is where its lines already go. - :param prefix: the Homebrew prefix, whose ``bin`` holds ``brew`` and ``mise``. :param shell: the shell's name, ``zsh`` or ``bash``, which mise's activation is generated for. :param homebrew: whether Homebrew is wanted in the shell. :param mise: whether mise is wanted in the shell. :returns: ``[{"name", "pattern", "line", "non_interactive"}, ...]``, Homebrew first, with a dictionary for each tool that is wanted. :raises AnsibleFilterError: the prefix is not an absolute path or the shell is not one the role supports. + :param prefix: the Homebrew prefix, whose ``bin`` holds ``brew`` and ``mise``. :param shell: the shell's name, ``zsh`` or ``bash``, which mise's activation is generated for. :param homebrew: whether Homebrew is wanted in the shell. :param mise: whether mise is wanted in the shell. :returns: ``[{"name", "pattern", "line", "non_interactive"}, ...]``, the ``path`` line of a bash host first, then Homebrew's, with a dictionary for each tool that is wanted. :raises AnsibleFilterError: the prefix is not an absolute path or the shell is not one the role supports. """ if not isinstance(prefix, str) or not prefix.startswith("/"): raise AnsibleFilterError(f"The Homebrew prefix must be an absolute path, not {prefix!r}.") @@ -66,6 +87,8 @@ def host_tools_shell_integrations(prefix: Any, shell: Any, homebrew: Any, mise: brew = shlex.quote(f"{prefix}/bin/brew") mise_binary = shlex.quote(f"{prefix}/bin/mise") integrations: list[dict[str, Any]] = [] + if shell == "bash" and (homebrew or mise): + integrations.append(_bash_path(prefix, homebrew, mise)) if homebrew: integrations.append({"name": "homebrew", "pattern": "^[^#]*brew shellenv", "line": f'[ -x {brew} ] && eval "$({brew} shellenv)"', "non_interactive": False}) if mise: @@ -133,6 +156,11 @@ def _reject_names_in_both(env: dict[Any, Any], home_relative: dict[Any, Any]) -> raise AnsibleFilterError(f"{', '.join(map(str, both))} is set both as a plain value and as a home-relative path; give each variable once.") +def _double_quoted(text: str) -> str: + """Return ``text`` escaped for the inside of a double-quoted shell word, where only ``$``, the backtick, the double quote and the backslash are special (in sh, bash and zsh alike), so it reaches the word exactly as given.""" + return re.sub(r'([\\$`"])', r"\\\1", text) + + def _home_relative_value(name: str, value: Any) -> str: """Return the shell word that expands to the user's home directory followed by a relative path: ``"$HOME/"`` with the path escaped for double quotes. @@ -140,7 +168,7 @@ def _home_relative_value(name: str, value: Any) -> str: :raises AnsibleFilterError: as ``_home_relative_path``. """ - return '"$HOME/' + re.sub(r'([\\$`"])', r"\\\1", _home_relative_path(name, value)) + '"' + return '"$HOME/' + _double_quoted(_home_relative_path(name, value)) + '"' def host_tools_shell_env(env: Any, home_relative: Any) -> list[dict[str, Any]]: diff --git a/roles/github_runner_host_tools/README.md b/roles/github_runner_host_tools/README.md index c712583f..cf817bf2 100644 --- a/roles/github_runner_host_tools/README.md +++ b/roles/github_runner_host_tools/README.md @@ -33,13 +33,25 @@ For each tool the role looks for an equivalent line (`brew shellenv` for Homebre | zsh | `.zshenv`, `.zshrc`, `.zprofile` | `.zshenv` | | bash | `.bashrc`, `.bash_profile`, `.profile` | `.bashrc` | -`.zshenv` is read by every zsh, including the non-interactive one an SSH command starts ([zsh, Startup Files](https://zsh.sourceforge.io/Doc/Release/Files.html); [Wayback Machine snapshot](https://web.archive.org/web/20261024165218/https://zsh.sourceforge.io/Doc/Release/Files.html)), so a command run over SSH finds both tools on a zsh host. A non-interactive bash reads `.bashrc` only as far as the file's interactive guard, which Debian's and Ubuntu's default `.bashrc` puts near its top, so on a bash host the two lines, which are appended at the end of the file where the interactive setup is, are for interactive shells; the environment variables below are written at the top instead. Each block is a `blockinfile` block between `ANSIBLE MANAGED BLOCK` marker lines, one per tool, holding one line guarded by `[ -x ... ]` that uses the absolute path of the binary: +`.zshenv` is read by every zsh, including the non-interactive one an SSH command starts ([zsh, Startup Files](https://zsh.sourceforge.io/Doc/Release/Files.html); [Wayback Machine snapshot](https://web.archive.org/web/20261024165218/https://zsh.sourceforge.io/Doc/Release/Files.html)), so a command run over SSH finds both tools on a zsh host. A non-interactive bash reads `.bashrc` only as far as the file's interactive guard, which Debian's and Ubuntu's default `.bashrc` puts near its top, so on a bash host the two lines, which are appended at the end of the file where the interactive setup is, are for interactive shells only. Each block is a `blockinfile` block between `ANSIBLE MANAGED BLOCK` marker lines, one per tool, holding one line guarded by `[ -x ... ]` that uses the absolute path of the binary: ```sh [ -x /opt/homebrew/bin/brew ] && eval "$(/opt/homebrew/bin/brew shellenv)" [ -x /opt/homebrew/bin/mise ] && eval "$(/opt/homebrew/bin/mise activate zsh)" ``` +### Homebrew and mise for a non-interactive bash + +A command run over SSH starts a non-interactive bash. It returns at the interactive guard before the two lines above, and `mise activate` is a prompt hook, which never runs without a prompt, so neither tool would be found. A bash host therefore gets a third block, named `path`, at the top of `~/.bashrc` (above the guard, in the same way as the environment variables below), which puts the directories themselves on `PATH`: mise's shim directory ahead of Homebrew's `bin` and `sbin`, so that a tool mise installs wins over one Homebrew ships. The shim directory is the one mise documents for programs that never see an activated shell ([mise, Shims](https://mise.jdx.dev/dev-tools/shims.html#how-to-add-mise-shims-to-path); [Wayback Machine snapshot](https://web.archive.org/web/20261103032400/https://mise.jdx.dev/dev-tools/shims.html)): + +```sh +export PATH="$HOME/.local/share/mise/shims:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH" +``` + +Homebrew's `bin` holds `brew` and, when Homebrew installed it, `mise`, so `command -v brew mise` finds both over SSH, and a shim finds the version a directory selects. The block lists only the tools that are wanted (no shim directory without mise). There is no process to start and nothing printed, which matters because a startup file that prints breaks `scp` and `sftp`. mise's own documentation also offers `eval "$(mise activate bash --shims)"`, which additionally covers the system shim directory and mise's command wrappers; the role does not use them and keeps to the one directory, which is where mise puts shims by default (`shims_dir`). A host that sets `MISE_DATA_DIR`, `XDG_DATA_HOME` or `shims_dir` elsewhere has its shims there, and the block would not name them. The block is not needed on zsh, whose `~/.zshenv` is read in full by every zsh and already holds the two lines. + +A `PATH` assignment in any of the shell's startup files that names the prefix's `bin` (`export PATH="/opt/homebrew/bin:$PATH"`, outside a comment) counts as an equivalent, and the block is then not added. A Homebrew or mise line the host has below the guard does not, since a non-interactive bash never reaches it. A block an earlier version of the role wrote is kept current in place (a different prefix, or mise switched off, rewrites it), and a host set up by an earlier version, which had the two interactive blocks and no `path` block, gets the new block at the top on the next run with the rest of the file unmoved. + A host whose startup files already do this, set up by hand or by a dotfiles manager, is not touched. A block the role wrote earlier counts as the equivalent line and is kept current, so a different prefix updates it in place. The role never passes a mode, so an existing startup file keeps the mode it has, and a startup file that is a symlink stays one and its target is edited. ## Global mise defaults @@ -235,4 +247,4 @@ github_runner_host_tools_mise_env_home_relative: ## Testing -`tests/unit/test_host_tools.py` covers the filters in `plugins/filter/host_tools.py` (the prefix of each system and architecture, the shell startup lines, and the TOML rendering against golden strings), the defaults against the argument specs and this file, the shape of the pinned commit and checksum, that the only formula the role ever names to Homebrew is mise, the wiring in `playbooks/site.yml`, and the role's tasks (`tests/host_tools/run.yml`) against stand-ins for `brew`, `sudo`, `apt-get`, `dpkg-query`, `getent`, `dscl` and Homebrew's installer on `PATH`, with the installer served from a local directory and every shell startup file in a temporary home. They check that the installer runs non-interactively as the user and only when its checksum matches, that a second run and `--check` change nothing, that root is refused, that a startup file keeps its mode, its content and its symlink, and that `config.toml` is never written. The mise `env` table is read back with a real `mise env`, which has to be on `PATH` (the CI job installs it), for values with `$`, template braces, quotes and a home directory whose name has a space, a quote and a dollar sign. They also check the package-install settings and the shell variables: that nothing is read or written when the variables are empty, that the `.npmrc` block holds only the role's lines, keeps the user's lines and credentials (made-up values) out of the output, is idempotent and shrinks as entries are dropped, that a key the user sets outside the block is kept and reported, that a new `.npmrc` is `0600` and no existing file or symlink changes mode, that the pnpm merge keeps unrelated keys and comments, and that a shell variable's value, quotes and dollar signs included, round-trips through `sh` and `bash` reading the written file. The bash cases run against `tests/host_tools/ubuntu-26.04-skel.bashrc`, a byte-for-byte copy of Ubuntu's default `/etc/skel/.bashrc` (its checksum is asserted), and read the result with a real non-interactive `bash`, which sees a variable only when the block is above the interactive guard; they cover a block an earlier version appended below the guard being moved to the top without a duplicate, a second run and `--check` changing nothing, and a variable the file assigns already being kept. The home-relative variables are read back through real `sh`, `bash` and `zsh` with a home directory whose name has a space, a quote and a dollar sign, and a path with every character special in double quotes, to show that the home directory is expanded when the file is read and nothing else is. Nothing is downloaded and no real package manager, privilege or startup file is touched. +`tests/unit/test_host_tools.py` covers the filters in `plugins/filter/host_tools.py` (the prefix of each system and architecture, the shell startup lines, and the TOML rendering against golden strings), the defaults against the argument specs and this file, the shape of the pinned commit and checksum, that the only formula the role ever names to Homebrew is mise, the wiring in `playbooks/site.yml`, and the role's tasks (`tests/host_tools/run.yml`) against stand-ins for `brew`, `sudo`, `apt-get`, `dpkg-query`, `getent`, `dscl` and Homebrew's installer on `PATH`, with the installer served from a local directory and every shell startup file in a temporary home. They check that the installer runs non-interactively as the user and only when its checksum matches, that a second run and `--check` change nothing, that root is refused, that a startup file keeps its mode, its content and its symlink, and that `config.toml` is never written. The mise `env` table is read back with a real `mise env`, which has to be on `PATH` (the CI job installs it), for values with `$`, template braces, quotes and a home directory whose name has a space, a quote and a dollar sign. They also check the package-install settings and the shell variables: that nothing is read or written when the variables are empty, that the `.npmrc` block holds only the role's lines, keeps the user's lines and credentials (made-up values) out of the output, is idempotent and shrinks as entries are dropped, that a key the user sets outside the block is kept and reported, that a new `.npmrc` is `0600` and no existing file or symlink changes mode, that the pnpm merge keeps unrelated keys and comments, and that a shell variable's value, quotes and dollar signs included, round-trips through `sh` and `bash` reading the written file. A non-interactive `bash` sourcing the Ubuntu `.bashrc` fixture finds `brew`, `mise` and a shim after a run and finds none before it, and the `PATH` line is read back by `sh` and `bash` under prefixes and home directories holding a space, quotes and dollar signs. The bash cases run against `tests/host_tools/ubuntu-26.04-skel.bashrc`, a byte-for-byte copy of Ubuntu's default `/etc/skel/.bashrc` (its checksum is asserted), and read the result with a real non-interactive `bash`, which sees a variable only when the block is above the interactive guard; they cover a block an earlier version appended below the guard being moved to the top without a duplicate, a second run and `--check` changing nothing, and a variable the file assigns already being kept. The home-relative variables are read back through real `sh`, `bash` and `zsh` with a home directory whose name has a space, a quote and a dollar sign, and a path with every character special in double quotes, to show that the home directory is expanded when the file is read and nothing else is. Nothing is downloaded and no real package manager, privilege or startup file is touched. diff --git a/roles/github_runner_host_tools/defaults/main.yml b/roles/github_runner_host_tools/defaults/main.yml index f91955c6..36474b5b 100644 --- a/roles/github_runner_host_tools/defaults/main.yml +++ b/roles/github_runner_host_tools/defaults/main.yml @@ -5,7 +5,7 @@ github_runner_host_tools_homebrew: true # Whether the role installs mise through Homebrew and keeps it at its latest release. Needs github_runner_host_tools_homebrew, since Homebrew is how mise is installed. github_runner_host_tools_mise: true -# Whether the role adds Homebrew's shellenv and the mise activation to the connecting user's shell startup file. It adds a line only when none of that shell's startup files has it already, so a host that is set up by hand is left as it is. +# Whether the role adds Homebrew's shellenv and the mise activation to the connecting user's shell startup file, and on a bash host a block at the top of ~/.bashrc that puts Homebrew's directories and mise's shim directory on PATH, which a non-interactive bash (an SSH command) reads and the two interactive lines are never reached by. It adds a line only when none of that shell's startup files has it already, so a host that is set up by hand is left as it is. github_runner_host_tools_shell_integration: true # The commit of Homebrew/install whose install.sh the role downloads, and the SHA-256 checksum of that file. The download is refused unless the file matches, so the installer is the one that was read, never whatever the branch holds later. Raise them together: take the commit, download its install.sh and take its checksum. Used only on a host that has no Homebrew. diff --git a/roles/github_runner_host_tools/meta/argument_specs.yml b/roles/github_runner_host_tools/meta/argument_specs.yml index 61fe6551..7d069837 100644 --- a/roles/github_runner_host_tools/meta/argument_specs.yml +++ b/roles/github_runner_host_tools/meta/argument_specs.yml @@ -17,7 +17,7 @@ argument_specs: github_runner_host_tools_shell_integration: type: bool default: true - description: Whether the role adds Homebrew's shellenv and the mise activation to the connecting user's shell startup file, when none of that shell's startup files has them. + description: Whether the role adds Homebrew's shellenv and the mise activation to the connecting user's shell startup file, when none of that shell's startup files has them, and on a bash host a block at the top of ~/.bashrc that puts Homebrew's directories and mise's shim directory on PATH for a non-interactive shell. github_runner_host_tools_homebrew_install_commit: type: str default: 8c82c081753d4263da0cfe615ef8d9433425f22b diff --git a/roles/github_runner_host_tools/tasks/shell_integration.yml b/roles/github_runner_host_tools/tasks/shell_integration.yml index c3bb658d..bbcf550d 100644 --- a/roles/github_runner_host_tools/tasks/shell_integration.yml +++ b/roles/github_runner_host_tools/tasks/shell_integration.yml @@ -28,7 +28,7 @@ when: github_runner_host_tools_shell not in github_runner_host_tools_shell_files - name: Add what each shell startup file is missing - # The list is empty for a shell the role does not set up, because a loop is evaluated before its when and the filter refuses a shell it does not know. Homebrew's and mise's lines come first, only when the integration is on and Homebrew is managed (the prefix is worked out only then), and the environment variables follow. + # The list is empty for a shell the role does not set up, because a loop is evaluated before its when and the filter refuses a shell it does not know. Homebrew's and mise's lines come first (on bash, with the PATH block for a non-interactive shell, which is written at the top of the file), only when the integration is on and Homebrew is managed (the prefix is worked out only then), and the environment variables follow. ansible.builtin.include_tasks: shell_integration_line.yml loop: "{{ (((github_runner_host_tools_effective_prefix | exadev.github_runner.host_tools_shell_integrations(github_runner_host_tools_shell, github_runner_host_tools_homebrew | bool, github_runner_host_tools_mise | bool)) if github_runner_host_tools_shell_integration | bool and github_runner_host_tools_homebrew | bool else []) + (github_runner_host_tools_shell_env | exadev.github_runner.host_tools_shell_env(github_runner_host_tools_shell_env_home_relative))) if github_runner_host_tools_shell in github_runner_host_tools_shell_files else [] }}" loop_control: diff --git a/tests/unit/test_host_tools.py b/tests/unit/test_host_tools.py index 4e9f950f..972bc544 100644 --- a/tests/unit/test_host_tools.py +++ b/tests/unit/test_host_tools.py @@ -175,6 +175,12 @@ def homebrew_block(prefix: Path) -> str: return f'# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools homebrew\n[ -x {prefix}/bin/brew ] && eval "$({prefix}/bin/brew shellenv)"\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools homebrew\n' +def path_block(prefix: Path, homebrew: bool = True, mise: bool = True) -> str: + """The block a bash host gets at the top of ~/.bashrc, so that a non-interactive bash finds Homebrew and the mise shims.""" + directories = (["$HOME/.local/share/mise/shims"] if mise else []) + ([f"{prefix}/bin", f"{prefix}/sbin"] if homebrew else []) + return "# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools path\nexport PATH=\"" + ":".join(directories) + ":$PATH\"\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools path\n" + + def mise_block(prefix: Path, shell: str) -> str: return f'# BEGIN ANSIBLE MANAGED BLOCK github_runner_host_tools mise\n[ -x {prefix}/bin/mise ] && eval "$({prefix}/bin/mise activate {shell})"\n# END ANSIBLE MANAGED BLOCK github_runner_host_tools mise\n' @@ -605,14 +611,14 @@ def test_zsh_gets_both_blocks_in_zshenv_and_a_second_run_changes_nothing(self) - def test_bash_gets_both_blocks_in_bashrc_with_the_shell_it_is_activated_for(self) -> None: self.have_homebrew() self.ok(self.run_role(system="Linux", os_family="Debian", machine="x86_64")) - self.assertEqual((self.home / ".bashrc").read_text(), homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) + self.assertEqual((self.home / ".bashrc").read_text(), path_block(self.prefix) + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) self.assertFalse((self.home / ".zshenv").exists()) def test_the_login_shell_comes_from_the_account_entry_not_from_the_os(self) -> None: self.have_homebrew() (self.stub / "shell").write_text("/bin/bash") self.ok(self.run_role()) - self.assertEqual((self.home / ".bashrc").read_text(), homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) + self.assertEqual((self.home / ".bashrc").read_text(), path_block(self.prefix) + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) self.assertIn("dscl . -read /Users/example UserShell", self.logged()) def test_a_line_a_startup_file_has_already_is_not_added_again_in_any_file_of_that_shell(self) -> None: @@ -1177,7 +1183,7 @@ def test_the_homebrew_and_mise_lines_stay_at_the_end_below_the_guard_and_the_var (self.stub / "mise.state").write_text("current") skeleton = self.ubuntu_home() self.ok(self.run_role(system="Linux", os_family="Debian", machine="x86_64", github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) - self.assertEqual((self.home / ".bashrc").read_text(), ENV_BLOCK_UV + skeleton + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) + self.assertEqual((self.home / ".bashrc").read_text(), ENV_BLOCK_UV + path_block(self.prefix) + skeleton + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) def test_a_second_run_on_the_ubuntu_bashrc_changes_nothing(self) -> None: self.ubuntu_home() @@ -1398,6 +1404,149 @@ def test_a_home_relative_mapping_of_the_wrong_type_is_refused_by_the_argument_sp self.assertNotEqual(result.returncode, 0) self.assertEqual(list(self.home.iterdir()), []) + # Homebrew's and mise's directories on PATH for a non-interactive bash. + + def tools_on_disk(self) -> None: + """What Homebrew and mise leave on a host: brew and mise in the prefix, and a shim in mise's shim directory.""" + self.have_homebrew() + shutil.copy(self.tools / "brew", self.prefix / "bin" / "mise") + shims = self.home / ".local" / "share" / "mise" / "shims" + shims.mkdir(parents=True) + shutil.copy(self.tools / "brew", shims / "node") + (self.stub / "mise.state").write_text("current") + + def bash_finds(self, *names: str, interactive: bool = False) -> str: + """Where a real bash that sources ~/.bashrc finds each command, with only the system PATH to start from, as sshd's non-interactive bash does.""" + script = f'. "$1"\nfor name in {" ".join(names)}; do command -v "$name" || echo "$name: not found"; done' + result = subprocess.run(["bash", *(["-i"] if interactive else []), "-c", script, "bash", str(self.home / ".bashrc")], capture_output=True, text=True, check=True, env={"PATH": "/usr/bin:/bin", "HOME": str(self.home)}) + return result.stdout + + def found(self, *names: str) -> str: + return "".join(f"{directory}/{name}\n" for name, directory in zip(names, (self.home / ".local" / "share" / "mise" / "shims", self.prefix / "bin", self.prefix / "bin"))) + + def changed_tasks(self, result: subprocess.CompletedProcess[str]) -> list[str]: + """The names of the tasks that reported a change, which under --check include Homebrew's and apt's own, so a test of the startup file looks at the shell tasks only.""" + return [name for name, status in re.findall(r"TASK \[[^:\]]*: ([^\]]*)\] \**\n(\w+):", result.stdout) if status == "changed"] + + def run_linux(self, *extra: str, **variables: Any) -> subprocess.CompletedProcess[str]: + return self.run_role(*extra, system="Linux", os_family="Debian", machine="x86_64", **variables) + + def test_a_non_interactive_bash_on_the_ubuntu_bashrc_finds_brew_mise_and_the_shims_and_did_not_before(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + self.assertEqual(self.bash_finds("node", "brew", "mise"), "node: not found\nbrew: not found\nmise: not found\n") + self.ok(self.run_linux()) + self.assertEqual(self.bash_finds("node", "brew", "mise"), self.found("node", "brew", "mise")) + self.assertEqual((self.home / ".bashrc").read_text(), path_block(self.prefix) + skeleton + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) + + def test_an_interactive_bash_finds_them_too_and_the_shims_stay_ahead_of_homebrews_directory(self) -> None: + self.tools_on_disk() + self.ubuntu_home() + self.ok(self.run_linux()) + self.assertEqual(self.bash_finds("brew", "mise", interactive=True), f"{self.prefix}/bin/brew\n{self.prefix}/bin/mise\n") + path = subprocess.run(["bash", "-c", '. "$1"\nprintf %s "$PATH"', "bash", str(self.home / ".bashrc")], capture_output=True, text=True, check=True, env={"PATH": "/usr/bin:/bin", "HOME": str(self.home)}).stdout + self.assertEqual(path, f"{self.home}/.local/share/mise/shims:{self.prefix}/bin:{self.prefix}/sbin:/usr/bin:/bin") + + def test_a_second_run_and_check_mode_change_nothing_once_the_path_block_is_at_the_top(self) -> None: + self.tools_on_disk() + self.ubuntu_home() + self.ok(self.run_linux()) + after_first = (self.home / ".bashrc").read_text() + result = self.run_linux() + self.ok(result) + self.assertEqual(self.recap(result)["changed"], 0, result.stdout) + self.assertEqual((self.home / ".bashrc").read_text(), after_first) + result = self.run_linux("--check") + self.ok(result) + self.assertEqual([name for name in self.changed_tasks(result) if "startup file" in name or "block" in name], [], result.stdout) + self.assertEqual((self.home / ".bashrc").read_text(), after_first) + + def test_check_mode_reports_the_path_block_and_writes_nothing(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + result = self.run_linux("--check") + self.ok(result) + self.assertGreaterEqual(self.recap(result)["changed"], 1, result.stdout) + self.assertEqual((self.home / ".bashrc").read_text(), skeleton) + + def test_a_host_set_up_by_an_earlier_version_gets_the_path_block_at_the_top_and_nothing_else_moves(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + earlier = skeleton + homebrew_block(self.prefix) + mise_block(self.prefix, "bash") + ENV_BLOCK_UV + (self.home / ".bashrc").write_text(earlier) + self.ok(self.run_linux(github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"})) + self.assertEqual((self.home / ".bashrc").read_text(), ENV_BLOCK_UV + path_block(self.prefix) + skeleton + homebrew_block(self.prefix) + mise_block(self.prefix, "bash")) + self.assertEqual(self.bash_finds("node", "brew", "mise"), self.found("node", "brew", "mise")) + + def test_a_homebrew_line_the_host_has_below_the_guard_does_not_stop_the_path_block_being_written(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + own = f'eval "$({self.prefix}/bin/brew shellenv)"\n' + (self.home / ".bashrc").write_text(skeleton + own) + self.ok(self.run_linux()) + self.assertEqual((self.home / ".bashrc").read_text(), path_block(self.prefix) + skeleton + own + mise_block(self.prefix, "bash")) + + def test_a_path_assignment_that_puts_homebrew_on_the_path_already_is_kept_and_no_block_is_added(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + for name, assignment in (("export", f'export PATH="{self.prefix}/bin:$PATH"\n'), ("bare", f"PATH=$PATH:{self.prefix}/bin\n")): + with self.subTest(form=name): + (self.home / ".bashrc").write_text(assignment + skeleton) + self.ok(self.run_linux(github_runner_host_tools_shell_integration=True)) + self.assertNotIn("github_runner_host_tools path", (self.home / ".bashrc").read_text()) + self.assertTrue((self.home / ".bashrc").read_text().startswith(assignment)) + + def test_a_commented_out_path_assignment_does_not_count(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + own = f'# export PATH="{self.prefix}/bin:$PATH"\n' + (self.home / ".bashrc").write_text(own + skeleton) + self.ok(self.run_linux()) + self.assertTrue((self.home / ".bashrc").read_text().startswith(path_block(self.prefix) + own)) + + def test_the_block_follows_the_prefix_and_the_tools_wanted_and_is_never_duplicated(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + self.ok(self.run_linux()) + other = self.work / "another-prefix" + (other / "bin").mkdir(parents=True) + shutil.copy(self.tools / "brew", other / "bin" / "brew") + shutil.copy(self.tools / "brew", other / "bin" / "mise") + self.ok(self.run_linux(github_runner_host_tools_homebrew_prefix=str(other))) + text = (self.home / ".bashrc").read_text() + self.assertEqual(text.count("github_runner_host_tools path"), 2) + self.assertTrue(text.startswith(path_block(other))) + self.assertTrue(text.endswith(homebrew_block(other) + mise_block(other, "bash"))) + self.assertIn(skeleton, text) + + def test_mise_left_out_puts_only_homebrews_directories_on_the_path(self) -> None: + self.tools_on_disk() + skeleton = self.ubuntu_home() + self.ok(self.run_linux(github_runner_host_tools_mise=False)) + self.assertEqual((self.home / ".bashrc").read_text(), path_block(self.prefix, mise=False) + skeleton + homebrew_block(self.prefix)) + + def test_a_mise_switched_off_later_takes_the_shims_out_of_the_block_in_place(self) -> None: + self.tools_on_disk() + self.ubuntu_home() + self.ok(self.run_linux()) + self.ok(self.run_linux(github_runner_host_tools_mise=False)) + self.assertTrue((self.home / ".bashrc").read_text().startswith(path_block(self.prefix, mise=False) + "#")) + self.assertEqual((self.home / ".bashrc").read_text().count("github_runner_host_tools path"), 2) + + def test_without_homebrew_the_role_writes_no_path_block_and_touches_nothing(self) -> None: + self.ok(self.run_settings(system="Linux", os_family="Debian", machine="x86_64", github_runner_host_tools_shell_integration=True)) + self.assertEqual(list(self.home.iterdir()), []) + + def test_the_integration_switched_off_writes_no_path_block(self) -> None: + self.tools_on_disk() + self.ok(self.run_linux(github_runner_host_tools_shell_integration=False)) + self.assertFalse((self.home / ".bashrc").exists()) + + def test_zsh_gets_no_path_block_because_zshenv_is_read_by_every_zsh(self) -> None: + self.tools_on_disk() + self.ok(self.run_role()) + self.assertNotIn("github_runner_host_tools path", (self.home / ".zshenv").read_text()) + def test_check_mode_reports_a_variable_and_writes_nothing(self) -> None: result = self.run_settings("--check", github_runner_host_tools_shell_env={"UV_PYTHON_DOWNLOADS": "never"}) self.ok(result) diff --git a/tests/unit/test_host_tools_filters.py b/tests/unit/test_host_tools_filters.py index ba19c3d3..5432ee35 100644 --- a/tests/unit/test_host_tools_filters.py +++ b/tests/unit/test_host_tools_filters.py @@ -52,11 +52,12 @@ def test_each_tool_gets_a_guarded_line_and_a_pattern_for_an_equivalent(self) -> ) def test_mise_is_activated_for_the_shell_it_is_in(self) -> None: - [mise] = host_tools.host_tools_shell_integrations("/usr/local", "bash", False, True) + [mise] = [item for item in host_tools.host_tools_shell_integrations("/usr/local", "bash", False, True) if item["name"] == "mise"] self.assertTrue(mise["line"].endswith('activate bash)"')) def test_a_tool_that_is_not_wanted_has_no_line(self) -> None: - self.assertEqual([item["name"] for item in host_tools.host_tools_shell_integrations("/usr/local", "bash", True, False)], ["homebrew"]) + self.assertEqual([item["name"] for item in host_tools.host_tools_shell_integrations("/usr/local", "bash", True, False)], ["path", "homebrew"]) + self.assertEqual([item["name"] for item in host_tools.host_tools_shell_integrations("/usr/local", "zsh", True, False)], ["homebrew"]) self.assertEqual(host_tools.host_tools_shell_integrations("/usr/local", "bash", False, False), []) def test_a_prefix_with_a_space_is_quoted(self) -> None: @@ -77,6 +78,59 @@ def test_a_relative_prefix_or_an_unknown_shell_is_refused(self) -> None: host_tools.host_tools_shell_integrations("/opt/homebrew", "fish", True, True) +class BashPathFilterTest(unittest.TestCase): + def path_item(self, prefix: str = "/home/linuxbrew/.linuxbrew", homebrew: bool = True, mise: bool = True) -> dict[str, object]: + [item] = [item for item in host_tools.host_tools_shell_integrations(prefix, "bash", homebrew, mise) if item["name"] == "path"] + return item + + def test_bash_gets_one_line_with_the_shims_ahead_of_homebrews_directories_for_a_non_interactive_shell(self) -> None: + self.assertEqual( + self.path_item(), + { + "name": "path", + "pattern": "^[^#]*PATH=[^#]*/home/linuxbrew/\\.linuxbrew/bin", + "line": 'export PATH="$HOME/.local/share/mise/shims:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH"', + "non_interactive": True, + }, + ) + + def test_only_the_tools_wanted_are_on_the_path(self) -> None: + self.assertEqual(self.path_item(mise=False)["line"], 'export PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH"') + self.assertEqual(self.path_item(homebrew=False)["line"], 'export PATH="$HOME/.local/share/mise/shims:$PATH"') + + def test_the_tools_own_lines_stay_for_interactive_shells_only(self) -> None: + items = {item["name"]: item["non_interactive"] for item in host_tools.host_tools_shell_integrations("/usr/local", "bash", True, True)} + self.assertEqual(items, {"path": True, "homebrew": False, "mise": False}) + + def test_zsh_gets_no_path_line_because_zshenv_is_read_by_every_zsh(self) -> None: + self.assertNotIn("path", [item["name"] for item in host_tools.host_tools_shell_integrations("/usr/local", "zsh", True, True)]) + + def test_the_line_gives_a_shell_the_directories_exactly_whatever_the_prefix_and_home_hold(self) -> None: + for prefix in ("/home/linuxbrew/.linuxbrew", "/opt/my brew", "/opt/$brew", "/opt/`id`", '/opt/"q"', "/opt/it's", "/opt/back\\slash", "/opt/a:b"): + item = self.path_item(prefix) + for home in ("/home/example", "/home/a b", "/home/it's", '/home/"q"', "/home/$d"): + for shell in ("sh", "bash"): + with self.subTest(prefix=prefix, home=home, shell=shell): + result = subprocess.run([shell, "-c", str(item["line"]) + '\nprintf %s "$PATH"'], capture_output=True, text=True, check=True, env={"PATH": "/usr/bin:/bin", "HOME": home}) + self.assertEqual(result.stdout, f"{home}/.local/share/mise/shims:{prefix}/bin:{prefix}/sbin:/usr/bin:/bin") + self.assertEqual(result.stderr, "") + + def test_the_pattern_finds_a_path_assignment_naming_the_prefix_and_nothing_else(self) -> None: + item = self.path_item() + pattern = str(item["pattern"]) + for text in ('export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"', "PATH=$PATH:/home/linuxbrew/.linuxbrew/bin", ' export PATH=/home/linuxbrew/.linuxbrew/bin:/usr/bin'): + with self.subTest(text=text): + self.assertTrue(grep_finds(pattern, text)) + for text in ('# export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"', 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"', 'export PATH="$HOME/.local/bin:$PATH"', 'export PATH="/home/linuxbrewX.linuxbrew/bin:$PATH"', 'export PATH="/opt/homebrew/bin:$PATH"'): + with self.subTest(text=text): + self.assertFalse(grep_finds(pattern, text)) + + def test_a_prefix_with_characters_special_in_a_regular_expression_is_matched_literally(self) -> None: + pattern = str(self.path_item("/opt/a+b(c)[d]{e}|f.g")["pattern"]) + self.assertTrue(grep_finds(pattern, "export PATH=/opt/a+b(c)[d]{e}|f.g/bin:$PATH")) + self.assertFalse(grep_finds(pattern, "export PATH=/opt/aab(c)[d]{e}|fxg/bin:$PATH")) + + class MiseTomlFilterTest(unittest.TestCase): def test_tools_and_settings_render_as_two_tables(self) -> None: config = {"tools": {"node": "22", "python": ["3.13", "3.12"]}, "settings": {"experimental": True, "jobs": 4, "ratio": 0.5, "http_timeout": "30s"}}