diff --git a/Makefile b/Makefile index 7af5d8f..d94e4c9 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,8 @@ setup-release: $(VENV)/bin/activate .env # Setup for pre-built image jobs (deps already in image, just link venv + install project) setup-image: @if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \ + export PATH="$$HOME/.local/bin:$$PATH"; \ + python3 -m devx.tools.install_tools --tool hadolint 2>/dev/null || true; \ else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi .env: @@ -101,12 +103,12 @@ lint-all: lint workflow-lint lint-dockerfiles lint-dockerfiles: @echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..." - @if command -v hadolint >/dev/null 2>&1; then \ - find docker -name 'Dockerfile*' -exec hadolint {} +; \ - echo "[lint-dockerfiles] All Dockerfiles passed."; \ - else \ - echo "[lint-dockerfiles] hadolint not found — skipping (install with: pip install hadolint or download from GitHub)"; \ + @if ! command -v hadolint >/dev/null 2>&1; then \ + echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \ + exit 1; \ fi + @find docker -name 'Dockerfile*' -exec hadolint {} + + @echo "[lint-dockerfiles] All Dockerfiles passed." test-unit: devx-test-unit diff --git a/src/devx/tools/install_tools.py b/src/devx/tools/install_tools.py index 66d0816..7a5bf9c 100644 --- a/src/devx/tools/install_tools.py +++ b/src/devx/tools/install_tools.py @@ -6,6 +6,7 @@ Handles installation of: - git-cliff (changelog generator) - act_runner (Gitea Actions local runner, optional) - tea (Gitea CLI — official command-line tool for Gitea API operations) +- hadolint (Dockerfile linter) Each tool is installed to ``~/.local/bin`` if not already on PATH. Idempotent: skips tools that are already available. @@ -39,6 +40,8 @@ ACT_RUNNER_VERSION = "0.2.11" TEA_VERSION = "0.14.1" +HADOLINT_VERSION = "2.12.0" + def _arch() -> str: """Return the architecture string used by release assets.""" @@ -161,7 +164,20 @@ def install_tea() -> bool: return True -TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"] +def install_hadolint() -> bool: + """Install hadolint if not already present. Returns True if installed/skipped.""" + if _is_installed("hadolint"): + click.echo("hadolint: already installed") + return True + machine = platform.machine().lower() + arch = "x86_64" if machine in {"x86_64", "amd64"} else "arm64" + url = f"https://github.com/hadolint/hadolint/releases/download/v{HADOLINT_VERSION}/hadolint-Linux-{arch}" + dest = _download_binary(url, "hadolint") + click.echo(f"hadolint: installed to {dest}") + return True + + +TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"] def _install_tool(name: str) -> bool: @@ -174,6 +190,8 @@ def _install_tool(name: str) -> bool: return install_act_runner() if name == "tea": return install_tea() + if name == "hadolint": + return install_hadolint() raise click.ClickException(f"Unknown tool: {name}") diff --git a/tests/unit/test_install_tools.py b/tests/unit/test_install_tools.py index 24cd69a..f7dcf1c 100644 --- a/tests/unit/test_install_tools.py +++ b/tests/unit/test_install_tools.py @@ -222,6 +222,24 @@ class TestInstallTea: assert (tmp_path / "tea").exists() +class TestInstallHadolint: + def test_already_installed(self) -> None: + with patch.object(install_tools, "_is_installed", return_value=True): + assert install_tools.install_hadolint() is True + + def test_install(self, tmp_path: Path) -> None: + def _write_file(url: str, path: Path) -> tuple[str, None]: + Path(path).write_bytes(b"binary") + return str(path), None + + with patch.object(install_tools, "_is_installed", return_value=False): + with patch.object(install_tools, "TARGET_DIR", tmp_path): + with patch.object(platform, "machine", return_value="x86_64"): + with patch.object(install_tools, "_download", side_effect=_write_file): + assert install_tools.install_hadolint() is True + assert (tmp_path / "hadolint").exists() + + class TestListTools: def test_list(self, tmp_path: Path) -> None: with patch.object(install_tools, "TARGET_DIR", tmp_path): @@ -251,6 +269,11 @@ class TestInstallTool: assert install_tools._install_tool("tea") is True mock.assert_called_once() + def test_hadolint(self) -> None: + with patch.object(install_tools, "install_hadolint", return_value=True) as mock: + assert install_tools._install_tool("hadolint") is True + mock.assert_called_once() + def test_unknown_tool(self) -> None: with pytest.raises(ClickException, match="Unknown tool"): install_tools._install_tool("unknown") @@ -269,7 +292,7 @@ class TestMain: with patch.object(install_tools, "_install_tool", return_value=True) as mock_install: result = runner.invoke(install_tools.main, []) assert result.exit_code == 0 - assert mock_install.call_count == 4 + assert mock_install.call_count == 5 def test_install_specific_tool(self) -> None: runner = CliRunner()