Lint the shell scripts of the actions in the validation run
tools/lint-action-scripts.sh runs shellcheck and shfmt on actions/**/*.sh with pinned tools; validate-workflows.yml runs it, and .editorconfig sets the shell format. The tests show a shellcheck problem and a badly formatted script fail. Refs #187 Task: MIL-012#6
This commit is contained in:
@@ -5,3 +5,8 @@ root = true
|
|||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.sh]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
switch_case_indent = true
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Check the coverage of the tools
|
- name: Check the coverage of the tools
|
||||||
run: .venv/bin/python -m coverage report
|
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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
pytest>=8,<10
|
pytest>=8,<10
|
||||||
PyYAML>=6,<7
|
PyYAML>=6,<7
|
||||||
coverage>=7,<8
|
coverage>=7,<8
|
||||||
|
shellcheck-py==0.10.0.1
|
||||||
|
shfmt-py==3.11.0.2
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 [<directory>] (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 "$@"
|
||||||
Reference in New Issue
Block a user