Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions plugins/filter/host_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.")
Expand All @@ -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:
Expand Down Expand Up @@ -133,14 +156,19 @@ 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/<path>"`` with the path escaped for double quotes.

Inside double quotes the shell expands ``$`` and the backtick and gives the backslash and the double quote a meaning, and nothing else, in sh, bash and zsh alike, so a backslash before each of those four makes the path reach the variable exactly as given. (``!`` is special only to an interactive shell reading a typed line, never to one reading a startup file.)

: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]]:
Expand Down
16 changes: 14 additions & 2 deletions roles/github_runner_host_tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion roles/github_runner_host_tools/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion roles/github_runner_host_tools/meta/argument_specs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading