MIL-010: code metrics and coverage badges workflow #164

Merged
Tirsvad merged 10 commits from mil-010-implementation into main 2026-10-14 08:59:20 +02:00
5 changed files with 526 additions and 0 deletions
Showing only changes of commit 4fc0a8cab3 - Show all commits
+81
View File
@@ -0,0 +1,81 @@
name: Code metrics
# Reusable workflow (ADR-0010). A repository calls it with
#
# on:
# push:
# branches: [main]
# jobs:
# metrics:
# uses: TirSystem/github-action/.gitea/workflows/code-metrics.yml@v1
# permissions:
# contents: write
#
# It counts the code files and the lines of code of the repository (comment
# and blank lines are not counted), reads an optional Cobertura XML coverage
# report and commits three SVG badges to a branch of the calling repository.
# It runs no test, needs no secret and writes only the badges branch.
on:
workflow_call:
inputs:
working-directory:
description: "Directory, relative to the repository root, whose code is counted"
type: string
default: "."
exclude:
description: "Comma list of patterns of files and directories that are not counted, in addition to the defaults of ADR-0010"
type: string
default: ""
coverage-report:
description: "Path, relative to the repository root, of a Cobertura XML coverage report; empty writes no coverage badge"
type: string
default: ""
coverage-warn:
description: "Coverage in percent below which the coverage badge is red"
type: string
default: "60"
coverage-good:
description: "Coverage in percent from which the coverage badge is green"
type: string
default: "80"
badges-branch:
description: "Branch that holds the badge files; it is created when it does not exist"
type: string
default: "badges"
tools-ref:
description: "Tag or branch of TirSystem/github-action whose tools/ files are run; pin it to the exact tag together with the workflow"
type: string
default: "v1"
jobs:
code-metrics:
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
env:
WORKING_DIRECTORY: ${{ inputs.working-directory }}
EXCLUDE: ${{ inputs.exclude }}
COVERAGE_REPORT: ${{ inputs.coverage-report }}
COVERAGE_WARN: ${{ inputs.coverage-warn }}
COVERAGE_GOOD: ${{ inputs.coverage-good }}
BADGES_BRANCH: ${{ inputs.badges-branch }}
steps:
- name: Check out the repository
uses: actions/checkout@v4.2.2
- name: Fetch the shared scripts
uses: actions/checkout@v4.2.2
with:
repository: TirSystem/github-action
ref: ${{ inputs.tools-ref }}
path: shared-scripts
- name: Validate the inputs
run: bash shared-scripts/tools/code-metrics.sh validate
- name: Count the code and write the badges
run: bash shared-scripts/tools/code-metrics.sh measure
- name: Publish the badges
run: bash shared-scripts/tools/code-metrics.sh publish
+7
View File
@@ -280,3 +280,10 @@ class TestMain:
code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")]) code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")])
assert code == 1 assert code == 1
assert "cannot be read" in capsys.readouterr().out assert "cannot be read" in capsys.readouterr().out
def test_no_code_files_is_a_notice_and_badges_that_show_zero(self, tmp_path, capsys):
out = tmp_path / "out"
metrics = metrics_file(tmp_path, files=0, lines=0)
assert badges.main(["--metrics", str(metrics), "--output-dir", str(out)]) == 0
assert "::notice::No code files were found" in capsys.readouterr().out
assert 'aria-label="code files: 0"' in (out / "code-files.svg").read_text()
+306
View File
@@ -0,0 +1,306 @@
"""Offline tests for the reusable code-metrics workflow and tools/code-metrics.sh (ADR-0010).
The script steps run in a throwaway git repository whose `origin` is a bare
repository on disk, so no network and no instance is needed.
"""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from workflow_source import REPO_ROOT
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "code-metrics.yml"
SCRIPT = REPO_ROOT / "tools" / "code-metrics.sh"
BASH = shutil.which("bash")
GIT = shutil.which("git")
pytestmark = pytest.mark.skipif(
BASH is None or GIT is None, reason="bash and git are needed to run the workflow steps"
)
COVERAGE = '<?xml version="1.0" ?>\n<coverage lines-covered="87" lines-valid="100"><packages/></coverage>\n'
@pytest.fixture(scope="module")
def document() -> dict:
return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
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 {name: item["default"] for name, item in inputs.items()} == {
"working-directory": ".",
"exclude": "",
"coverage-report": "",
"coverage-warn": "60",
"coverage-good": "80",
"badges-branch": "badges",
"tools-ref": "v1",
}
def test_asks_for_contents_write_and_nothing_else_and_needs_no_secret(self, document):
job = document["jobs"]["code-metrics"]
assert job["permissions"] == {"contents": "write"}
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):
job = document["jobs"]["code-metrics"]
for step in job["steps"]:
assert "${{" not in step.get("run", ""), f"{step.get('name')} interpolates an expression"
assert set(job["env"]) == {
"WORKING_DIRECTORY",
"EXCLUDE",
"COVERAGE_REPORT",
"COVERAGE_WARN",
"COVERAGE_GOOD",
"BADGES_BRANCH",
}
def test_the_workflow_embeds_no_script_and_runs_the_file(self, document):
steps = document["jobs"]["code-metrics"]["steps"]
assert [step["run"] for step in steps if "run" in step] == [
"bash shared-scripts/tools/code-metrics.sh validate",
"bash shared-scripts/tools/code-metrics.sh measure",
"bash shared-scripts/tools/code-metrics.sh publish",
]
def test_the_scripts_are_fetched_at_the_tools_ref_input(self, document):
steps = document["jobs"]["code-metrics"]["steps"]
fetch = next(step for step in steps if step.get("name") == "Fetch the shared scripts")
assert fetch["with"] == {
"repository": "TirSystem/github-action",
"ref": "${{ inputs.tools-ref }}",
"path": "shared-scripts",
}
def test_the_script_parses(self):
result = subprocess.run([BASH, "-n", str(SCRIPT)], text=True, capture_output=True)
assert result.returncode == 0, result.stderr
def test_an_unknown_command_is_refused(self):
result = subprocess.run([BASH, str(SCRIPT), "bogus"], text=True, capture_output=True)
assert result.returncode == 2
def git(*args: str, cwd: Path) -> str:
result = subprocess.run(
[GIT, "-c", "user.name=t", "-c", "user.email=t@t", *args],
cwd=cwd,
text=True,
capture_output=True,
check=True,
)
return result.stdout.strip()
@pytest.fixture()
def python3_bin(tmp_path) -> Path:
"""A directory with a `python3` that runs the interpreter of the tests."""
directory = tmp_path / "pybin"
directory.mkdir()
shim = directory / "python3"
shim.write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n', newline="\n")
shim.chmod(0o755)
return directory
@pytest.fixture()
def remote(tmp_path) -> Path:
path = tmp_path / "remote.git"
subprocess.run([GIT, "init", "-q", "--bare", str(path)], check=True)
return path
@pytest.fixture()
def repo(tmp_path, remote) -> Path:
root = tmp_path / "repo"
root.mkdir()
git("init", "-q", "-b", "main", cwd=root)
git("remote", "add", "origin", str(remote), cwd=root)
(root / "a.py").write_text("x = 1\n# c\n\n", newline="\n")
(root / "run.sh").write_text("echo hi\n", newline="\n")
git("add", "-A", cwd=root)
git("commit", "-q", "-m", "init", cwd=root)
return root
def run(command: str, repo: Path, tmp_path: Path, python3_bin: Path, **env: str):
environment = dict(os.environ)
environment.update(
{
"WORKING_DIRECTORY": ".",
"EXCLUDE": "",
"COVERAGE_REPORT": "",
"COVERAGE_WARN": "60",
"COVERAGE_GOOD": "80",
"BADGES_BRANCH": "badges",
"BADGES_OUT": str(tmp_path / "out"),
"PATH": f"{python3_bin.as_posix()}{os.pathsep}{environment['PATH']}",
}
)
environment.update(env)
return subprocess.run(
[BASH, str(SCRIPT), command], cwd=repo, env=environment, text=True, capture_output=True
)
def branch_files(remote: Path, branch: str = "badges") -> list[str]:
out = subprocess.run(
[GIT, "--git-dir", str(remote), "ls-tree", "-r", "--name-only", branch],
text=True,
capture_output=True,
)
return out.stdout.split()
def commit_count(remote: Path, branch: str = "badges") -> int:
out = subprocess.run(
[GIT, "--git-dir", str(remote), "rev-list", "--count", branch], text=True, capture_output=True
)
return int(out.stdout.strip() or 0)
def measure_and_publish(repo, tmp_path, python3_bin, **env):
measured = run("measure", repo, tmp_path, python3_bin, **env)
assert measured.returncode == 0, measured.stdout + measured.stderr
return run("publish", repo, tmp_path, python3_bin, **env)
class TestValidate:
def test_accepts_the_defaults(self, repo, tmp_path, python3_bin):
result = run("validate", repo, tmp_path, python3_bin)
assert result.returncode == 0, result.stdout
assert "badges go to the branch badges" in result.stdout
@pytest.mark.parametrize(
("name", "value", "message"),
[
("WORKING_DIRECTORY", "/etc", "working-directory must be a relative path"),
("WORKING_DIRECTORY", "../x", "working-directory must be a relative path"),
("WORKING_DIRECTORY", "nope", "working-directory 'nope' does not exist"),
("COVERAGE_REPORT", "/tmp/c.xml", "coverage-report must be a relative path"),
("BADGES_BRANCH", "main", "must not be a default branch"),
("BADGES_BRANCH", "master", "must not be a default branch"),
("BADGES_BRANCH", "", "must not be a default branch"),
("BADGES_BRANCH", "a b", "is not a valid branch name"),
("BADGES_BRANCH", "x..y", "is not a valid branch name"),
],
)
def test_refuses_an_invalid_input_and_names_it(
self, repo, tmp_path, python3_bin, name, value, message
):
result = run("validate", repo, tmp_path, python3_bin, **{name: value})
assert result.returncode == 1
assert f"::error::[configuration] " in result.stdout
assert message in result.stdout
class TestPublish:
def test_creates_the_branch_with_the_badges_and_the_numbers(self, repo, remote, tmp_path, python3_bin):
result = measure_and_publish(repo, tmp_path, python3_bin)
assert result.returncode == 0, result.stdout + result.stderr
assert sorted(branch_files(remote)) == ["code-files.svg", "code-lines.svg", "code-metrics.json"]
shown = subprocess.run(
[GIT, "--git-dir", str(remote), "show", "badges:code-lines.svg"], text=True, capture_output=True
).stdout
assert 'aria-label="lines of code: 2"' in shown # a.py has one code line, run.sh one
message = subprocess.run(
[GIT, "--git-dir", str(remote), "log", "-1", "--format=%s", "badges"],
text=True,
capture_output=True,
).stdout.strip()
assert message == "Update code metrics badges [skip ci]"
def test_the_branch_of_the_caller_is_not_changed(self, repo, remote, tmp_path, python3_bin):
before = git("status", "--porcelain", cwd=repo)
measure_and_publish(repo, tmp_path, python3_bin)
assert git("status", "--porcelain", cwd=repo) == before
assert git("branch", "--show-current", cwd=repo) == "main"
def test_an_unchanged_run_commits_nothing(self, repo, remote, tmp_path, python3_bin):
measure_and_publish(repo, tmp_path, python3_bin)
second = measure_and_publish(repo, tmp_path, python3_bin)
assert second.returncode == 0
assert "Badges are current; nothing to commit." in second.stdout
assert commit_count(remote) == 1
def test_a_changed_number_is_one_commit_that_changes_only_badge_files(
self, repo, remote, tmp_path, python3_bin
):
measure_and_publish(repo, tmp_path, python3_bin)
(repo / "b.py").write_text("y = 2\nz = 3\n", newline="\n")
git("add", "-A", cwd=repo)
git("commit", "-q", "-m", "more", cwd=repo)
result = measure_and_publish(repo, tmp_path, python3_bin)
assert result.returncode == 0, result.stdout
assert commit_count(remote) == 2
changed = subprocess.run(
[GIT, "--git-dir", str(remote), "show", "--name-only", "--format=", "badges"],
text=True,
capture_output=True,
).stdout.split()
assert sorted(changed) == ["code-files.svg", "code-lines.svg", "code-metrics.json"]
def test_a_coverage_report_adds_the_coverage_badge(self, repo, remote, tmp_path, python3_bin):
(repo / "cov.xml").write_text(COVERAGE, newline="\n")
result = measure_and_publish(repo, tmp_path, python3_bin, COVERAGE_REPORT="cov.xml")
assert result.returncode == 0, result.stdout
assert "coverage.svg" in branch_files(remote)
def test_a_missing_report_fails_names_the_file_and_changes_nothing(
self, repo, remote, tmp_path, python3_bin
):
result = run("measure", repo, tmp_path, python3_bin, COVERAGE_REPORT="nope.xml")
assert result.returncode == 1
assert "::error::[configuration] coverage-report 'nope.xml' does not exist." in result.stdout
assert branch_files(remote) == []
def test_the_shared_scripts_directory_is_not_counted(self, repo, remote, tmp_path, python3_bin):
shared = repo / "shared-scripts" / "tools"
shared.mkdir(parents=True)
(shared / "big.py").write_text("a = 1\n" * 50, newline="\n")
measure_and_publish(repo, tmp_path, python3_bin)
shown = subprocess.run(
[GIT, "--git-dir", str(remote), "show", "badges:code-files.svg"], text=True, capture_output=True
).stdout
assert 'aria-label="code files: 2"' in shown
def test_a_branch_that_holds_other_files_is_refused(self, repo, remote, tmp_path, python3_bin):
git("push", "-q", "origin", "main:docs", cwd=repo)
result = measure_and_publish(repo, tmp_path, python3_bin, BADGES_BRANCH="docs")
assert result.returncode == 1
assert "it is not a badges branch" in result.stdout
def test_publish_without_measure_is_refused(self, repo, tmp_path, python3_bin):
result = run("publish", repo, tmp_path, python3_bin)
assert result.returncode == 1
assert "run measure first" in result.stdout
def test_a_rejected_push_is_retried_once_after_a_fetch(self, repo, remote, tmp_path, python3_bin):
marker = tmp_path / "rejected-once"
hook = remote / "hooks" / "pre-receive"
hook.write_text(
f'#!/bin/sh\nif [ ! -e "{marker.as_posix()}" ]; then touch "{marker.as_posix()}"; '
"echo rejected once >&2; exit 1; fi\nexit 0\n",
newline="\n",
)
hook.chmod(0o755)
result = measure_and_publish(repo, tmp_path, python3_bin)
assert result.returncode == 0, result.stdout + result.stderr
assert marker.exists()
def test_two_rejected_pushes_fail(self, repo, remote, tmp_path, python3_bin):
hook = remote / "hooks" / "pre-receive"
hook.write_text("#!/bin/sh\necho rejected >&2\nexit 1\n", newline="\n")
hook.chmod(0o755)
result = measure_and_publish(repo, tmp_path, python3_bin)
assert result.returncode == 1
assert re.search(r"push to 'badges' failed twice", result.stdout)
+2
View File
@@ -205,6 +205,8 @@ def main(argv: list[str] | None = None) -> int:
f"::error::[configuration] metrics file '{args.metrics}' cannot be read: {error}" f"::error::[configuration] metrics file '{args.metrics}' cannot be read: {error}"
) )
return 1 return 1
if file_count == 0:
print("::notice::No code files were found; the badges show 0.")
if coverage is None: if coverage is None:
print("::notice::No coverage-report given; no coverage badge is written.") print("::notice::No coverage-report given; no coverage badge is written.")
metrics_json = json.dumps(metrics, indent=2, sort_keys=True) + "\n" metrics_json = json.dumps(metrics, indent=2, sort_keys=True) + "\n"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Steps of the reusable workflow .gitea/workflows/code-metrics.yml (ADR-0010).
# The workflow checks out the calling repository, fetches this file into
# shared-scripts/ and runs one command per step:
#
# code-metrics.sh validate check the inputs, before anything is counted
# code-metrics.sh measure count the code, read the coverage report and
# write the badge files (count_loc.py, badges.py)
# code-metrics.sh publish commit the badge files to the badges branch,
# and nothing when none of them changed
#
# Settings come from the environment, never from interpolated expressions:
# WORKING_DIRECTORY, EXCLUDE, COVERAGE_REPORT, COVERAGE_WARN, COVERAGE_GOOD,
# BADGES_BRANCH, BADGES_OUT (the directory of the badge files; a temporary
# directory under RUNNER_TEMP by default).
# Every failure is printed as ::error::[category] message.
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="${BADGES_OUT:-${RUNNER_TEMP:-/tmp}/code-metrics}"
ALLOWED_FILES='^(code-files\.svg|code-lines\.svg|coverage\.svg|code-metrics\.json)$'
COMMIT_MESSAGE="Update code metrics badges [skip ci]"
fail() {
echo "::error::[configuration] $1"
exit 1
}
relative_path() {
case "$1" in
"" | /* | .. | ../* | */.. | */../*) return 1 ;;
esac
}
validate_inputs() {
set -u
relative_path "$WORKING_DIRECTORY" ||
fail "working-directory must be a relative path inside the repository."
[ -d "$WORKING_DIRECTORY" ] ||
fail "working-directory '$WORKING_DIRECTORY' does not exist."
if [ -n "$COVERAGE_REPORT" ]; then
relative_path "$COVERAGE_REPORT" ||
fail "coverage-report must be a relative path inside the repository."
fi
case "$BADGES_BRANCH" in
main | master | HEAD | "") fail "badges-branch '$BADGES_BRANCH' must not be a default branch." ;;
esac
git check-ref-format --branch "$BADGES_BRANCH" > /dev/null 2>&1 ||
fail "badges-branch '$BADGES_BRANCH' is not a valid branch name."
echo "Counting ${WORKING_DIRECTORY}; badges go to the branch ${BADGES_BRANCH}"
}
measure() {
set -eu
metrics="$(mktemp)"
python3 "$TOOLS_DIR/count_loc.py" \
--directory "$WORKING_DIRECTORY" --exclude "$EXCLUDE" --output "$metrics"
rm -rf "$OUT_DIR"
python3 "$TOOLS_DIR/badges.py" \
--metrics "$metrics" --output-dir "$OUT_DIR" \
--coverage-report "$COVERAGE_REPORT" \
--coverage-warn "$COVERAGE_WARN" --coverage-good "$COVERAGE_GOOD"
rm -f "$metrics"
}
# Copy the badge files into the worktree and stage them; return 0 when
# something differs from the branch.
stage_files() {
local worktree="$1" file
for file in "$OUT_DIR"/*; do
cp "$file" "$worktree/$(basename "$file")"
done
git -C "$worktree" add -- .
! git -C "$worktree" diff --cached --quiet
}
commit_files() {
git -C "$1" -c user.name="code-metrics" -c user.email="code-metrics@invalid" \
commit --quiet -m "$COMMIT_MESSAGE"
}
publish() {
set -eu
[ -d "$OUT_DIR" ] || fail "no badge files in '$OUT_DIR'; run measure first."
local worktree exists=0 committed=0 attempt
worktree="$(mktemp -d)"
if git fetch --quiet origin "+refs/heads/$BADGES_BRANCH:refs/remotes/origin/$BADGES_BRANCH" 2> /dev/null; then
exists=1
if git ls-tree -r --name-only "origin/$BADGES_BRANCH" | grep -qvE "$ALLOWED_FILES"; then
fail "branch '$BADGES_BRANCH' holds files that are not badge files; it is not a badges branch."
fi
git worktree add --quiet --detach "$worktree" "origin/$BADGES_BRANCH"
else
git worktree add --quiet --detach "$worktree" HEAD
git -C "$worktree" checkout --quiet --orphan "$BADGES_BRANCH"
git -C "$worktree" rm -rfq --ignore-unmatch .
fi
for attempt in 1 2; do
if [ "$committed" -eq 0 ]; then
if stage_files "$worktree"; then
commit_files "$worktree"
committed=1
elif [ "$exists" -eq 1 ]; then
echo "Badges are current; nothing to commit."
break
fi
fi
if git -C "$worktree" push --quiet origin "HEAD:refs/heads/$BADGES_BRANCH"; then
echo "Published the badges to the branch ${BADGES_BRANCH}."
break
fi
[ "$attempt" -lt 2 ] || fail "the push to '$BADGES_BRANCH' failed twice."
if git fetch --quiet origin "+refs/heads/$BADGES_BRANCH:refs/remotes/origin/$BADGES_BRANCH" 2> /dev/null; then
git -C "$worktree" reset --quiet --hard "origin/$BADGES_BRANCH"
exists=1
committed=0
fi
done
git worktree remove --force "$worktree"
}
case "${1:-}" in
validate) validate_inputs ;;
measure) measure ;;
publish) publish ;;
*)
echo "::error::[configuration] usage: code-metrics.sh validate|measure|publish" >&2
exit 2
;;
esac