From 7b4df52d48908d4d6dad3a7cd7bf893aedae1350 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 20 Jul 2026 00:51:13 +0200 Subject: [PATCH 1/3] Add install-whitaker shared action Cache and run `whitaker-installer`, preferring cargo-binstall when it is available and falling back to a locked Cargo build otherwise. Document the action and cover its cache, preferred installation path, and fallback behaviour with deterministic contract tests. --- .github/actions/install-whitaker/CHANGELOG.md | 9 + .github/actions/install-whitaker/README.md | 40 ++++ .github/actions/install-whitaker/action.yml | 36 ++++ .../install-whitaker/tests/test_action.py | 174 ++++++++++++++++++ README.md | 1 + 5 files changed, 260 insertions(+) create mode 100644 .github/actions/install-whitaker/CHANGELOG.md create mode 100644 .github/actions/install-whitaker/README.md create mode 100644 .github/actions/install-whitaker/action.yml create mode 100644 .github/actions/install-whitaker/tests/test_action.py diff --git a/.github/actions/install-whitaker/CHANGELOG.md b/.github/actions/install-whitaker/CHANGELOG.md new file mode 100644 index 00000000..6028e9ae --- /dev/null +++ b/.github/actions/install-whitaker/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to the `install-whitaker` action will be documented in +this file. + +## v1.0.0 (Unreleased) + +- Add cached installation of the Whitaker Dylint suite +- Prefer cargo-binstall with a `cargo install` fallback diff --git a/.github/actions/install-whitaker/README.md b/.github/actions/install-whitaker/README.md new file mode 100644 index 00000000..dca45d84 --- /dev/null +++ b/.github/actions/install-whitaker/README.md @@ -0,0 +1,40 @@ +# Install Whitaker + +Install the Whitaker Dylint suite with a cached `whitaker-installer`. + +The action restores the installer and cargo-binstall cache before installation. +When the installer is not cached, it prefers `cargo binstall` and falls back to +`cargo install` when cargo-binstall is unavailable. It then runs +`whitaker-installer` to install the suite. + +## Inputs + +| Name | Description | Required | Default | +| ------------------- | ----------------------------------------- | -------- | ------- | +| `installer-version` | Version of `whitaker-installer` to install | no | `0.2.6` | + +## Outputs + +This action has no outputs. + +## Usage + +```yaml +- name: Set up Rust + uses: leynos/shared-actions/.github/actions/setup-rust@v1 + +- name: Install Whitaker + uses: leynos/shared-actions/.github/actions/install-whitaker@v1 + +- name: Lint + run: make lint +``` + +The runner must have Cargo available. If `cargo binstall --version` succeeds, +the action installs the requested version with `cargo binstall --locked`. +Otherwise, it builds the same version from crates.io with +`cargo install --locked`. + +## Release history + +See the [changelog](CHANGELOG.md). diff --git a/.github/actions/install-whitaker/action.yml b/.github/actions/install-whitaker/action.yml new file mode 100644 index 00000000..245eed7c --- /dev/null +++ b/.github/actions/install-whitaker/action.yml @@ -0,0 +1,36 @@ +name: Install Whitaker +description: Install the Whitaker Dylint suite with a cached installer + +inputs: + installer-version: + description: Version of whitaker-installer to install + required: false + default: "0.2.6" + +runs: + using: composite + steps: + - name: Cache Whitaker installer + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/bin/whitaker-installer + ~/.cache/cargo-binstall + key: >- + whitaker-installer-${{ runner.os }}-${{ runner.arch }}-${{ inputs.installer-version }} + + - name: Install Whitaker Dylint suite + shell: bash + env: + WHITAKER_INSTALLER_VERSION: ${{ inputs.installer-version }} + run: | + set -euo pipefail + if ! command -v whitaker-installer >/dev/null 2>&1; then + if cargo binstall --version >/dev/null 2>&1; then + cargo binstall --no-confirm --locked "whitaker-installer@${WHITAKER_INSTALLER_VERSION}" + else + echo "cargo-binstall unavailable; building whitaker-installer from crates.io" + cargo install --locked whitaker-installer --version "${WHITAKER_INSTALLER_VERSION}" + fi + fi + whitaker-installer diff --git a/.github/actions/install-whitaker/tests/test_action.py b/.github/actions/install-whitaker/tests/test_action.py new file mode 100644 index 00000000..f465f793 --- /dev/null +++ b/.github/actions/install-whitaker/tests/test_action.py @@ -0,0 +1,174 @@ +"""Contract tests for the install-whitaker 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_manifest() -> dict[str, object]: + """Load the action manifest.""" + return typ.cast( + "dict[str, object]", + yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")), + ) + + +def _install_script() -> str: + """Return the suite installation shell fragment.""" + manifest = _load_manifest() + runs = manifest["runs"] + assert isinstance(runs, dict) + steps = typ.cast("list[dict[str, object]]", runs["steps"]) + step = next( + (item for item in steps if item.get("name") == "Install Whitaker Dylint suite"), + None, + ) + assert step is not None + script = step.get("run") + assert isinstance(script, str) + return script + + +def _write_executable(path: Path, content: str) -> None: + """Write an executable test stub.""" + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _write_cargo_stub(bin_dir: Path) -> None: + """Write a Cargo stub that records and simulates installer commands.""" + _write_executable( + bin_dir / "cargo", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$CARGO_LOG" +if [ "${1:-}" = "binstall" ] && [ "${2:-}" = "--version" ]; then + [ "$BINSTALL_AVAILABLE" = "true" ] + exit +fi +cat > "$FAKE_BIN_DIR/whitaker-installer" <<'INSTALLER' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "suite installed" >> "$INSTALLER_LOG" +INSTALLER +chmod +x "$FAKE_BIN_DIR/whitaker-installer" +""", + ) + + +def _run_install_script( + tmp_path: Path, + *, + binstall_available: bool, + installer_present: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run the installation fragment with deterministic command stubs.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not found on PATH") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + cargo_log = tmp_path / "cargo.log" + installer_log = tmp_path / "installer.log" + _write_cargo_stub(bin_dir) + if installer_present: + _write_executable( + bin_dir / "whitaker-installer", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "suite installed" >> "$INSTALLER_LOG" +""", + ) + + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}/usr/bin{os.pathsep}/bin", + "BINSTALL_AVAILABLE": str(binstall_available).lower(), + "CARGO_LOG": cargo_log.as_posix(), + "FAKE_BIN_DIR": bin_dir.as_posix(), + "INSTALLER_LOG": installer_log.as_posix(), + "WHITAKER_INSTALLER_VERSION": "0.2.6", + } + 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_version_and_cache_contract() -> None: + """The manifest should expose the pin and cache the installer artefacts.""" + manifest = _load_manifest() + + assert manifest["inputs"] == { + "installer-version": { + "description": "Version of whitaker-installer to install", + "required": False, + "default": "0.2.6", + } + } + runs = manifest["runs"] + assert isinstance(runs, dict) + steps = typ.cast("list[dict[str, object]]", runs["steps"]) + cache_step = steps[0] + assert cache_step["uses"] == ( + "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" + ) + cache_config = typ.cast("dict[str, str]", cache_step["with"]) + assert "~/.cargo/bin/whitaker-installer" in cache_config["path"] + assert "~/.cache/cargo-binstall" in cache_config["path"] + assert "${{ inputs.installer-version }}" in cache_config["key"] + + +def test_installs_with_cargo_binstall_when_available(tmp_path: Path) -> None: + """cargo-binstall should be preferred when its subcommand is available.""" + result = _run_install_script(tmp_path, binstall_available=True) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "cargo.log").read_text(encoding="utf-8").splitlines() == [ + "binstall --version", + "binstall --no-confirm --locked whitaker-installer@0.2.6", + ] + assert (tmp_path / "installer.log").read_text(encoding="utf-8") == ( + "suite installed\n" + ) + + +def test_falls_back_to_cargo_install(tmp_path: Path) -> None: + """Cargo should build whitaker-installer when cargo-binstall is unavailable.""" + result = _run_install_script(tmp_path, binstall_available=False) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "cargo.log").read_text(encoding="utf-8").splitlines() == [ + "binstall --version", + "install --locked whitaker-installer --version 0.2.6", + ] + assert "cargo-binstall unavailable" in result.stdout + + +def test_reuses_cached_installer(tmp_path: Path) -> None: + """A restored installer should avoid both Cargo installation paths.""" + result = _run_install_script( + tmp_path, + binstall_available=False, + installer_present=True, + ) + + assert result.returncode == 0, result.stderr + assert not (tmp_path / "cargo.log").exists() + assert (tmp_path / "installer.log").read_text(encoding="utf-8") == ( + "suite installed\n" + ) diff --git a/README.md b/README.md index 6f7f3772..25987c69 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 Whitaker | `.github/actions/install-whitaker` | unreleased | | Linux packages | `.github/actions/linux-packages` | v1 | | macOS package | `.github/actions/macos-package` | v1 | | Ratchet coverage | `.github/actions/ratchet-coverage` | v1 | From 1eb98de9c2332e267ae11d804db971f72121b2c3 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 20 Jul 2026 00:53:55 +0200 Subject: [PATCH 2/3] Align install-whitaker input table Match the repository's aligned Markdown table style so the action guide passes the Markdown lint gate. --- .github/actions/install-whitaker/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-whitaker/README.md b/.github/actions/install-whitaker/README.md index dca45d84..81405b0a 100644 --- a/.github/actions/install-whitaker/README.md +++ b/.github/actions/install-whitaker/README.md @@ -9,8 +9,8 @@ When the installer is not cached, it prefers `cargo binstall` and falls back to ## Inputs -| Name | Description | Required | Default | -| ------------------- | ----------------------------------------- | -------- | ------- | +| Name | Description | Required | Default | +| ------------------- | ------------------------------------------ | -------- | ------- | | `installer-version` | Version of `whitaker-installer` to install | no | `0.2.6` | ## Outputs From 10e3bd5ac705c645e5eb659a4507846c977bc057 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 20 Jul 2026 00:56:26 +0200 Subject: [PATCH 3/3] Ignore Python coverage data Keep coverage-enabled validation from leaving an untracked `.coverage` artefact after the Markdown and spelling gates run. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4bb8ceaa..6e7146d6 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ __pycache__/ venv/ .env.* .pytest_cache/ +.coverage .mypy_cache/ *.egg-info/