diff --git a/.github/actions/install-nixie/CHANGELOG.md b/.github/actions/install-nixie/CHANGELOG.md new file mode 100644 index 00000000..be285b94 --- /dev/null +++ b/.github/actions/install-nixie/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## v1.0.0 (Unreleased) + +- Add pinned Nixie and Merman CLI installation. +- Prefer `cargo binstall` for Merman and fall back to a locked source build. +- Support version overrides for Nixie, Merman CLI, and Python. diff --git a/.github/actions/install-nixie/README.md b/.github/actions/install-nixie/README.md new file mode 100644 index 00000000..f4abd41d --- /dev/null +++ b/.github/actions/install-nixie/README.md @@ -0,0 +1,62 @@ +# Install Nixie + +Install pinned Nixie and Merman CLI releases for Mermaid validation. + +The action installs Nixie through `uv` and its Merman rendering backend through +Cargo. It uses `cargo binstall` when available and falls back to a locked +`cargo install` build from crates.io. + +## Inputs + +| Name | Description | Required | Default | +| ---------------- | ---------------------------------------- | -------- | ------- | +| `nixie-version` | Nixie CLI version to install | no | `1.1.0` | +| `merman-version` | Merman CLI version to install | no | `0.7.0` | +| `python-version` | Python version used to install Nixie | no | `3.14` | + +## Outputs + +| Name | Description | +| ------ | ------------------------------------------------------- | +| _None_ | The action emits no outputs. | + +## Usage + +```yaml +- name: Set up Rust + uses: leynos/shared-actions/.github/actions/setup-rust@v1 + +- name: Install Nixie + uses: leynos/shared-actions/.github/actions/install-nixie@v1 + +- name: Validate Mermaid diagrams + run: nixie --renderer merman +``` + +To override the pinned versions: + +```yaml +- uses: leynos/shared-actions/.github/actions/install-nixie@v1 + with: + nixie-version: "1.1.0" + merman-version: "0.7.0" + python-version: "3.14" +``` + +## Behaviour + +- **Prerequisites**: `cargo` and `uv` must already be available on `PATH`. + The repository's `setup-rust` action provisions both tools and + `cargo-binstall`. +- **Merman installation**: When `cargo binstall` is available, the action + installs the selected Merman release from a binary package with locked + metadata. Otherwise it builds the exact selected release from crates.io with + `cargo install --locked`. +- **Nixie installation**: The action uses `uv tool install` with the selected + Python and exact Nixie release. +- **Failure behaviour**: Missing prerequisites and failed installations stop + the action immediately with a non-zero exit status. + +## Release history + +See [CHANGELOG](CHANGELOG.md). diff --git a/.github/actions/install-nixie/action.yml b/.github/actions/install-nixie/action.yml new file mode 100644 index 00000000..4744b738 --- /dev/null +++ b/.github/actions/install-nixie/action.yml @@ -0,0 +1,44 @@ +name: Install Nixie +description: Install pinned Nixie and Merman CLI releases for Mermaid validation +inputs: + nixie-version: + description: Nixie CLI version to install + required: false + default: "1.1.0" + merman-version: + description: Merman CLI version to install + required: false + default: "0.7.0" + python-version: + description: Python version used by uv to install Nixie + required: false + default: "3.14" +runs: + using: composite + steps: + - name: Install Nixie and Merman CLI + shell: bash + env: + NIXIE_VERSION: ${{ inputs.nixie-version }} + MERMAN_VERSION: ${{ inputs.merman-version }} + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + + if ! command -v cargo >/dev/null 2>&1; then + echo "::error::cargo is required to install merman-cli" >&2 + exit 1 + fi + if ! command -v uv >/dev/null 2>&1; then + echo "::error::uv is required to install nixie-cli" >&2 + exit 1 + fi + + if cargo binstall --version >/dev/null 2>&1; then + cargo binstall --no-confirm --locked "merman-cli@${MERMAN_VERSION}" + else + echo "cargo-binstall unavailable; building merman-cli from crates.io" + cargo install --locked merman-cli --version "=${MERMAN_VERSION}" + fi + + uv tool install --python "${PYTHON_VERSION}" "nixie-cli==${NIXIE_VERSION}" diff --git a/.github/actions/install-nixie/tests/test_action.py b/.github/actions/install-nixie/tests/test_action.py new file mode 100644 index 00000000..7d332ebd --- /dev/null +++ b/.github/actions/install-nixie/tests/test_action.py @@ -0,0 +1,152 @@ +"""Behavioural tests for the install-nixie composite action.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import typing as typ +from pathlib import Path + +import pytest +import yaml + +ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" + + +def _load_action() -> dict[str, typ.Any]: + """Load the install-nixie action manifest.""" + return yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + + +def _install_script() -> str: + """Return the action's installation shell fragment.""" + steps = _load_action()["runs"]["steps"] + assert len(steps) == 1, "install-nixie should have one atomic install step" + run_script = steps[0].get("run") + assert isinstance(run_script, str), "install step must define a shell script" + return run_script + + +def _write_executable(path: Path, content: str) -> None: + """Write an executable command stub.""" + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _run_install_script( + tmp_path: Path, + *, + binstall_available: bool, + include_cargo: bool = True, + include_uv: bool = True, +) -> subprocess.CompletedProcess[str]: + """Execute the install fragment against deterministic command stubs.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not found on PATH") + + stubs_dir = tmp_path / "stubs" + stubs_dir.mkdir() + calls_path = tmp_path / "calls" + if include_cargo: + binstall_status = 0 if binstall_available else 1 + _write_executable( + stubs_dir / "cargo", + f"""#!/bin/bash +set -euo pipefail +if [ "${{1:-}}" = "binstall" ] && [ "${{2:-}}" = "--version" ]; then + exit {binstall_status} +fi +printf 'cargo' >> "$CALLS_PATH" +printf ' <%s>' "$@" >> "$CALLS_PATH" +printf '\n' >> "$CALLS_PATH" +""", + ) + if include_uv: + _write_executable( + stubs_dir / "uv", + """#!/bin/bash +set -euo pipefail +printf 'uv' >> "$CALLS_PATH" +printf ' <%s>' "$@" >> "$CALLS_PATH" +printf '\n' >> "$CALLS_PATH" +""", + ) + + env = { + **os.environ, + "CALLS_PATH": calls_path.as_posix(), + "MERMAN_VERSION": "0.7.0", + "NIXIE_VERSION": "1.1.0", + "PATH": stubs_dir.as_posix(), + "PYTHON_VERSION": "3.14", + } + return subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. + [bash, "-c", _install_script()], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + +def test_manifest_exposes_pinned_version_inputs() -> None: + """The action should expose the reviewed Nixie toolchain pins.""" + manifest = _load_action() + + assert manifest["runs"]["using"] == "composite" + assert manifest["inputs"]["nixie-version"]["default"] == "1.1.0" + assert manifest["inputs"]["merman-version"]["default"] == "0.7.0" + assert manifest["inputs"]["python-version"]["default"] == "3.14" + + +def test_install_script_prefers_cargo_binstall(tmp_path: Path) -> None: + """Merman should use a locked binary install when cargo-binstall exists.""" + result = _run_install_script(tmp_path, binstall_available=True) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "calls").read_text(encoding="utf-8").splitlines() == [ + "cargo <--no-confirm> <--locked> ", + "uv <--python> <3.14> ", + ] + + +def test_install_script_falls_back_to_cargo_install(tmp_path: Path) -> None: + """Merman should use a locked source build without cargo-binstall.""" + result = _run_install_script(tmp_path, binstall_available=False) + + assert result.returncode == 0, result.stderr + assert "cargo-binstall unavailable" in result.stdout + assert (tmp_path / "calls").read_text(encoding="utf-8").splitlines() == [ + "cargo <--locked> <--version> <=0.7.0>", + "uv <--python> <3.14> ", + ] + + +@pytest.mark.parametrize( + ("include_cargo", "include_uv", "expected_error"), + [ + (False, True, "cargo is required to install merman-cli"), + (True, False, "uv is required to install nixie-cli"), + ], + ids=["missing-cargo", "missing-uv"], +) +def test_install_script_reports_missing_prerequisite( + tmp_path: Path, + *, + include_cargo: bool, + include_uv: bool, + expected_error: str, +) -> None: + """Missing runner prerequisites should produce actionable errors.""" + result = _run_install_script( + tmp_path, + binstall_available=False, + include_cargo=include_cargo, + include_uv=include_uv, + ) + + assert result.returncode == 1 + assert expected_error in result.stderr diff --git a/.gitignore b/.gitignore index 4bb8ceaa..ae8f7f23 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +.coverage *.lcov # nyc test coverage diff --git a/README.md b/README.md index 6f7f3772..d0b62306 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ GitHub Actions | Export Cargo metadata | `.github/actions/export-cargo-metadata` | v1 | | Export Postgres URL | `.github/actions/export-postgres-url` | v1 | | Generate coverage | `.github/actions/generate-coverage` | v1 | +| Install Nixie | `.github/actions/install-nixie` | unreleased | | Linux packages | `.github/actions/linux-packages` | v1 | | macOS package | `.github/actions/macos-package` | v1 | | Ratchet coverage | `.github/actions/ratchet-coverage` | v1 |