diff --git a/.editorconfig b/.editorconfig index 58de178..d3c791b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,3 +5,8 @@ root = true end_of_line = lf charset = utf-8 insert_final_newline = true + +[*.sh] +indent_style = space +indent_size = 2 +switch_case_indent = true diff --git a/.gitea/workflows/validate-workflows.yml b/.gitea/workflows/validate-workflows.yml index e31f372..68defb6 100644 --- a/.gitea/workflows/validate-workflows.yml +++ b/.gitea/workflows/validate-workflows.yml @@ -26,3 +26,9 @@ jobs: - name: Check the coverage of the tools run: .venv/bin/python -m coverage report + + - name: Lint the shell scripts of the actions + env: + SHELLCHECK: .venv/bin/shellcheck + SHFMT: .venv/bin/shfmt + run: bash tools/lint-action-scripts.sh actions diff --git a/tests/requirements.txt b/tests/requirements.txt index e18e2b4..cfb4328 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,5 @@ pytest>=8,<10 PyYAML>=6,<7 coverage>=7,<8 +shellcheck-py==0.10.0.1 +shfmt-py==3.11.0.2 diff --git a/tests/test_lint_action_scripts.py b/tests/test_lint_action_scripts.py new file mode 100644 index 0000000..a13f63c --- /dev/null +++ b/tests/test_lint_action_scripts.py @@ -0,0 +1,86 @@ +"""Offline tests for tools/lint-action-scripts.sh (MIL-012 task 6). + +The script runs shellcheck and shfmt on a folder; the tests give it a clean +script, a script with a shellcheck problem and a badly formatted one, so a +seeded error is shown to fail the run. +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +from workflow_source import REPO_ROOT + +SCRIPT = REPO_ROOT / "tools" / "lint-action-scripts.sh" +WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "validate-workflows.yml" +BASH = shutil.which("bash") +# the tools are in the virtual environment of the interpreter that runs the tests +SEARCH_PATH = os.pathsep.join([str(Path(sys.executable).parent), os.environ.get("PATH", "")]) +SHELLCHECK = shutil.which("shellcheck", path=SEARCH_PATH) +SHFMT = shutil.which("shfmt", path=SEARCH_PATH) +HAVE_TOOLS = BASH and SHELLCHECK and SHFMT + +CLEAN = '#!/usr/bin/env bash\nmain() {\n echo "$1"\n}\n\nmain "$@"\n' +SHELLCHECK_PROBLEM = '#!/usr/bin/env bash\nmain() {\n echo $1\n}\n\nmain "$@"\n' +BADLY_FORMATTED = '#!/usr/bin/env bash\nmain() {\n echo "$1"\n}\n\nmain "$@"\n' + + +def lint(folder: Path) -> subprocess.CompletedProcess: + # shfmt reads .editorconfig from the folder upwards; a temporary folder is + # outside this repository, so it gets a copy of the repository's file + if folder != REPO_ROOT / "actions": + shutil.copy(REPO_ROOT / ".editorconfig", folder / ".editorconfig") + env = {**os.environ, "SHELLCHECK": str(SHELLCHECK), "SHFMT": str(SHFMT)} + return subprocess.run( + [BASH, str(SCRIPT), str(folder)], + capture_output=True, + text=True, + check=False, + cwd=REPO_ROOT, + env=env, + ) + + +@pytest.mark.skipif(not HAVE_TOOLS, reason="bash, shellcheck and shfmt are needed") +class TestLint: + def test_a_clean_script_passes(self, tmp_path): + (tmp_path / "ok.sh").write_bytes(CLEAN.encode()) # LF on every platform + result = lint(tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "clean" in result.stdout + + def test_a_shellcheck_problem_fails(self, tmp_path): + (tmp_path / "bad.sh").write_bytes(SHELLCHECK_PROBLEM.encode()) # LF on every platform + result = lint(tmp_path) + assert result.returncode == 1 + assert "::error::[lint] shellcheck" in result.stdout + + def test_a_badly_formatted_script_fails(self, tmp_path): + (tmp_path / "ugly.sh").write_bytes(BADLY_FORMATTED.encode()) # LF on every platform + result = lint(tmp_path) + assert result.returncode == 1 + assert "::error::[lint] shfmt" in result.stdout + + def test_no_scripts_is_not_a_failure(self, tmp_path): + result = lint(tmp_path) + assert result.returncode == 0 + assert "nothing was checked" in result.stdout + + def test_the_scripts_of_the_actions_are_clean(self): + result = lint(REPO_ROOT / "actions") + assert result.returncode == 0, result.stdout + result.stderr + + +class TestWorkflow: + def test_validate_workflows_runs_the_lint_with_pinned_tools(self): + document = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + commands = [step.get("run", "") for step in document["jobs"]["validate"]["steps"]] + assert any("tools/lint-action-scripts.sh" in command for command in commands) + requirements = (REPO_ROOT / "tests" / "requirements.txt").read_text(encoding="utf-8") + assert "shellcheck-py==" in requirements + assert "shfmt-py==" in requirements diff --git a/tools/lint-action-scripts.sh b/tools/lint-action-scripts.sh new file mode 100644 index 0000000..1e680d5 --- /dev/null +++ b/tools/lint-action-scripts.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Lint the shell scripts of the composite actions (MIL-012 task 6): shellcheck, +# and shfmt in the format .editorconfig sets (two spaces, indented case labels). +# validate-workflows.yml runs it on every pull request and push to main. +# +# Usage: lint-action-scripts.sh [] (default: actions) +# Env: SHELLCHECK, SHFMT the commands to run (default: from the PATH) +# Exit: 0 every script is clean (or there is none); 1 at least one finding or +# a missing tool +# Needs: bash, find, shellcheck, shfmt + +SHELLCHECK="${SHELLCHECK:-shellcheck}" +SHFMT="${SHFMT:-shfmt}" + +main() { + set -u + local dir="${1:-actions}" failed=0 tool + for tool in "$SHELLCHECK" "$SHFMT"; do + command -v "$tool" >/dev/null 2>&1 || { + echo "::error::[environment] $tool is not installed." + exit 1 + } + done + local files=() + while IFS= read -r file; do + files+=("$file") + done < <(find "$dir" -name '*.sh' -type f | sort) + if [ "${#files[@]}" -eq 0 ]; then + echo "::notice::No shell scripts under '$dir'; nothing was checked." + exit 0 + fi + "$SHELLCHECK" "${files[@]}" || { + echo "::error::[lint] shellcheck found problems." + failed=1 + } + "$SHFMT" -d "${files[@]}" || { + echo "::error::[lint] shfmt would change a script; run shfmt -w." + failed=1 + } + [ "$failed" -eq 0 ] && echo "Checked ${#files[@]} script(s): clean." + exit "$failed" +} + +main "$@"