From 45219a19f6583de8be75f3af3f7ec9ae6c5c051d Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 10 Oct 2026 16:13:57 +0800 Subject: [PATCH] Add the reusable quality-check workflow quality-checks.yml implements the contract of ADR-0005: inputs for the languages, the working directory and one pinned version per tool; ruff, mypy --strict, shellcheck and shfmt installed from PyPI into a venv; every tool runs and any failure fails the job; a language without files is skipped with a notice. Offline tests run the shell steps with stub tools. Verified live from another organisation with clean files, a seeded failure and an empty language. Task: MIL-003#3 --- .gitea/workflows/quality-checks.yml | 153 ++++++++++++++++++ tests/test_quality_workflow.py | 241 ++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 .gitea/workflows/quality-checks.yml create mode 100644 tests/test_quality_workflow.py diff --git a/.gitea/workflows/quality-checks.yml b/.gitea/workflows/quality-checks.yml new file mode 100644 index 0000000..4f69cad --- /dev/null +++ b/.gitea/workflows/quality-checks.yml @@ -0,0 +1,153 @@ +name: Quality checks + +# Reusable workflow (ADR-0005). A repository calls it with +# +# jobs: +# quality: +# uses: TirSystem/github-action/.gitea/workflows/quality-checks.yml@main +# +# It checks the Python and shell files of the repository with pinned tools, +# changes no file and needs no secret. +on: + workflow_call: + inputs: + languages: + description: "Comma list of the languages to check: python, shell" + type: string + default: "python,shell" + working-directory: + description: "Directory, relative to the repository root, whose files are checked" + type: string + default: "." + ruff-version: + description: "Version of ruff (Python lint and format)" + type: string + default: "0.6.9" + mypy-version: + description: "Version of mypy (Python types, --strict)" + type: string + default: "1.11.2" + shellcheck-py-version: + description: "Version of the shellcheck-py package (ShellCheck 0.10.0)" + type: string + default: "0.10.0.1" + shfmt-py-version: + description: "Version of the shfmt-py package (shfmt 3.11.0)" + type: string + default: "3.11.0.2" + +jobs: + quality-checks: + permissions: + contents: read + runs-on: ubuntu-latest + env: + LANGUAGES: ${{ inputs.languages }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + RUFF_VERSION: ${{ inputs.ruff-version }} + MYPY_VERSION: ${{ inputs.mypy-version }} + SHELLCHECK_PY_VERSION: ${{ inputs.shellcheck-py-version }} + SHFMT_PY_VERSION: ${{ inputs.shfmt-py-version }} + QC_VENV: /tmp/quality-tools + steps: + - name: Validate the inputs + run: | + set -u + IFS=',' read -ra langs <<< "${LANGUAGES// /}" + if [ "${#langs[@]}" -eq 0 ]; then + echo "::error::[configuration] languages is empty; use python, shell or both, comma separated." + exit 1 + fi + for lang in "${langs[@]}"; do + case "$lang" in + python | shell) ;; + *) + echo "::error::[configuration] Unknown language '$lang'; use python and shell, comma separated." + exit 1 + ;; + esac + done + case "$WORKING_DIRECTORY" in + "" | /* | .. | ../* | */.. | */../*) + echo "::error::[configuration] working-directory must be a relative path inside the repository." + exit 1 + ;; + esac + echo "Checking ${LANGUAGES} in ${WORKING_DIRECTORY}" + + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Install the tools + run: | + set -eu + IFS=',' read -ra langs <<< "${LANGUAGES// /}" + packages=() + for lang in "${langs[@]}"; do + case "$lang" in + python) packages+=("ruff==${RUFF_VERSION}" "mypy==${MYPY_VERSION}") ;; + shell) packages+=("shellcheck-py==${SHELLCHECK_PY_VERSION}" "shfmt-py==${SHFMT_PY_VERSION}") ;; + esac + done + python3 -m venv "$QC_VENV" + "$QC_VENV/bin/pip" install --quiet "${packages[@]}" + + - name: Run the checks + run: | + set -u + QC_BIN="${QC_BIN:-$QC_VENV/bin}" + if [ ! -d "$WORKING_DIRECTORY" ]; then + echo "::error::[configuration] working-directory '$WORKING_DIRECTORY' does not exist." + exit 1 + fi + cd "$WORKING_DIRECTORY" + + failed=() + run_tool() { + local name="$1" + shift + echo "::group::$name" + if "$@"; then + echo "::endgroup::" + echo "PASS $name" + else + echo "::endgroup::" + echo "FAIL $name" + failed+=("$name") + fi + } + wanted() { + local lang + IFS=',' read -ra langs <<< "${LANGUAGES// /}" + for lang in "${langs[@]}"; do + [ "$lang" = "$1" ] && return 0 + done + return 1 + } + + if wanted python; then + mapfile -t files < <(git ls-files -- '*.py') + if [ "${#files[@]}" -eq 0 ]; then + echo "::notice::No python files in '$WORKING_DIRECTORY'; nothing was checked." + else + run_tool "ruff check" "$QC_BIN/ruff" check "${files[@]}" + run_tool "ruff format --check" "$QC_BIN/ruff" format --check "${files[@]}" + run_tool "mypy --strict" "$QC_BIN/mypy" --strict "${files[@]}" + fi + fi + + if wanted shell; then + mapfile -t files < <(git ls-files -- '*.sh') + if [ "${#files[@]}" -eq 0 ]; then + echo "::notice::No shell files in '$WORKING_DIRECTORY'; nothing was checked." + else + run_tool "shellcheck" "$QC_BIN/shellcheck" "${files[@]}" + run_tool "shfmt -d" "$QC_BIN/shfmt" -d "${files[@]}" + fi + fi + + if [ "${#failed[@]}" -gt 0 ]; then + echo "::error::Quality checks failed: ${failed[*]}" + exit 1 + fi + echo "All selected checks passed." diff --git a/tests/test_quality_workflow.py b/tests/test_quality_workflow.py new file mode 100644 index 0000000..fcc6933 --- /dev/null +++ b/tests/test_quality_workflow.py @@ -0,0 +1,241 @@ +"""Offline tests for the reusable quality-check workflow (ADR-0005). + +The shell steps of the workflow are run with stub tools in a throwaway git +repository, so no network and no real linter is needed. +""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from workflow_source import REPO_ROOT + +WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "quality-checks.yml" +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif(BASH is None, reason="bash is needed to run the workflow steps") + + +@pytest.fixture(scope="module") +def document() -> dict: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def step_script(document: dict, name: str) -> str: + steps = document["jobs"]["quality-checks"]["steps"] + return next(step["run"] for step in steps if step.get("name") == name) + + +class TestDefinition: + def test_is_a_reusable_workflow_with_the_contract_inputs(self, document): + inputs = document[True]["workflow_call"]["inputs"] # PyYAML reads `on` as True + assert set(inputs) == { + "languages", + "working-directory", + "ruff-version", + "mypy-version", + "shellcheck-py-version", + "shfmt-py-version", + } + assert inputs["languages"]["default"] == "python,shell" + assert inputs["working-directory"]["default"] == "." + + def test_tool_versions_are_pinned_to_the_adr(self, document): + inputs = document[True]["workflow_call"]["inputs"] + assert inputs["ruff-version"]["default"] == "0.6.9" + assert inputs["mypy-version"]["default"] == "1.11.2" + assert inputs["shellcheck-py-version"]["default"] == "0.10.0.1" + assert inputs["shfmt-py-version"]["default"] == "3.11.0.2" + + def test_has_least_privilege_and_needs_no_secret(self, document): + job = document["jobs"]["quality-checks"] + assert job["permissions"] == {"contents": "read"} + assert "secrets" not in WORKFLOW.read_text(encoding="utf-8").replace("needs no secret", "") + + def test_inputs_reach_the_scripts_only_through_the_environment(self, document): + for step in document["jobs"]["quality-checks"]["steps"]: + assert "${{" not in step.get("run", ""), f"{step.get('name')} interpolates an expression" + + @pytest.mark.parametrize( + "name", ["Validate the inputs", "Install the tools", "Run the checks"] + ) + def test_every_script_parses(self, document, name): + script = step_script(document, name) + result = subprocess.run([BASH, "-n"], input=script, text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +STUB = """#!/usr/bin/env bash +name="$(basename "$0")" +[ "$name" = ruff ] && name="ruff $1" +echo "$name :: $*" >> "$STUB_LOG" +case ",${STUB_FAIL}," in + *",$name,"*) + echo "$2:1:1: stub finding from $name" + exit 1 + ;; +esac +exit 0 +""" + + +@pytest.fixture() +def bin_dir(tmp_path) -> Path: + directory = tmp_path / "bin" + directory.mkdir() + for tool in ("ruff", "mypy", "shellcheck", "shfmt"): + path = directory / tool + path.write_text(STUB, encoding="utf-8", newline="\n") + path.chmod(0o755) + return directory + + +@pytest.fixture() +def repo(tmp_path) -> Path: + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + return root + + +def add_files(root: Path, *relative: str) -> None: + for name in relative: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x\n", encoding="utf-8", newline="\n") + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + + +def run_step(document, name, repo, bin_dir, tmp_path, **env): + script = step_script(document, name) + log = tmp_path / "tools.log" + environment = dict(os.environ) + environment.update( + { + "LANGUAGES": "python,shell", + "WORKING_DIRECTORY": ".", + "QC_VENV": (tmp_path / "venv").as_posix(), + "QC_BIN": bin_dir.as_posix(), + "STUB_LOG": log.as_posix(), + "STUB_FAIL": "", + } + ) + environment.update(env) + result = subprocess.run( + [BASH, "-c", script], cwd=repo, env=environment, text=True, capture_output=True + ) + calls = log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + return result, calls + + +class TestValidateInputs: + @pytest.mark.parametrize("languages", ["python", "shell", "python,shell", "python, shell"]) + def test_accepts_the_supported_languages(self, document, repo, bin_dir, tmp_path, languages): + result, _ = run_step(document, "Validate the inputs", repo, bin_dir, tmp_path, LANGUAGES=languages) + assert result.returncode == 0, result.stdout + result.stderr + + @pytest.mark.parametrize("languages", ["go", "python,ruby", ""]) + def test_rejects_an_unknown_or_empty_language(self, document, repo, bin_dir, tmp_path, languages): + result, _ = run_step(document, "Validate the inputs", repo, bin_dir, tmp_path, LANGUAGES=languages) + assert result.returncode == 1 + assert "[configuration]" in result.stdout + + @pytest.mark.parametrize("directory", ["/etc", "..", "../other", "a/../..", ""]) + def test_rejects_a_directory_outside_the_repository( + self, document, repo, bin_dir, tmp_path, directory + ): + result, _ = run_step( + document, "Validate the inputs", repo, bin_dir, tmp_path, WORKING_DIRECTORY=directory + ) + assert result.returncode == 1 + assert "working-directory" in result.stdout + + +class TestRunChecks: + def test_runs_every_python_and_shell_tool_on_the_tracked_files( + self, document, repo, bin_dir, tmp_path + ): + add_files(repo, "app.py", "pkg/util.py", "run.sh", "README.md") + result, calls = run_step(document, "Run the checks", repo, bin_dir, tmp_path) + + assert result.returncode == 0, result.stdout + result.stderr + assert [c.split(" :: ")[0] for c in calls] == [ + "ruff check", "ruff format", "mypy", "shellcheck", "shfmt", + ] + assert "app.py pkg/util.py" in calls[0] + assert "--strict" in calls[2] + assert "run.sh" in calls[3] + assert "All selected checks passed." in result.stdout + + def test_a_failing_tool_fails_the_job_and_the_others_still_run( + self, document, repo, bin_dir, tmp_path + ): + add_files(repo, "app.py", "run.sh") + result, calls = run_step( + document, "Run the checks", repo, bin_dir, tmp_path, STUB_FAIL="ruff check,shellcheck" + ) + + assert result.returncode == 1 + assert len(calls) == 5, "every tool runs even after a failure" + assert "::group::ruff check" in result.stdout + assert "app.py:1:1: stub finding from ruff check" in result.stdout + assert "FAIL ruff check" in result.stdout and "FAIL shellcheck" in result.stdout + assert "PASS mypy --strict" in result.stdout + assert "Quality checks failed: ruff check shellcheck" in result.stdout + + def test_a_language_without_files_is_skipped_and_passes( + self, document, repo, bin_dir, tmp_path + ): + add_files(repo, "README.md") + result, calls = run_step(document, "Run the checks", repo, bin_dir, tmp_path) + + assert result.returncode == 0 + assert calls == [] + assert "No python files" in result.stdout and "No shell files" in result.stdout + + def test_only_the_selected_languages_are_checked(self, document, repo, bin_dir, tmp_path): + add_files(repo, "app.py", "run.sh") + _, calls = run_step(document, "Run the checks", repo, bin_dir, tmp_path, LANGUAGES="shell") + assert [c.split(" :: ")[0] for c in calls] == ["shellcheck", "shfmt"] + + def test_only_files_under_the_working_directory_are_checked( + self, document, repo, bin_dir, tmp_path + ): + add_files(repo, "top.py", "service/api.py", "service/deploy.sh") + _, calls = run_step( + document, "Run the checks", repo, bin_dir, tmp_path, WORKING_DIRECTORY="service" + ) + text = "\n".join(calls) + assert "api.py" in text and "deploy.sh" in text + assert "top.py" not in text + + def test_untracked_files_are_not_checked(self, document, repo, bin_dir, tmp_path): + add_files(repo, "tracked.py") + (repo / "untracked.py").write_text("x\n", encoding="utf-8") + _, calls = run_step(document, "Run the checks", repo, bin_dir, tmp_path, LANGUAGES="python") + assert "untracked.py" not in "\n".join(calls) + + def test_a_missing_working_directory_is_a_configuration_error( + self, document, repo, bin_dir, tmp_path + ): + add_files(repo, "app.py") + result, calls = run_step( + document, "Run the checks", repo, bin_dir, tmp_path, WORKING_DIRECTORY="nope" + ) + assert result.returncode == 1 + assert "[configuration]" in result.stdout + assert calls == [] + + def test_no_file_is_changed(self, document, repo, bin_dir, tmp_path): + add_files(repo, "app.py", "run.sh") + def snapshot(): + return {p.name: p.read_bytes() for p in repo.iterdir() if p.is_file()} + + before = snapshot() + run_step(document, "Run the checks", repo, bin_dir, tmp_path, STUB_FAIL="ruff check") + after = snapshot() + assert before == after