MIL-010: code metrics and coverage badges workflow #164
@@ -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
|
||||
@@ -280,3 +280,10 @@ class TestMain:
|
||||
code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")])
|
||||
assert code == 1
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
@@ -205,6 +205,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
f"::error::[configuration] metrics file '{args.metrics}' cannot be read: {error}"
|
||||
)
|
||||
return 1
|
||||
if file_count == 0:
|
||||
print("::notice::No code files were found; the badges show 0.")
|
||||
if coverage is None:
|
||||
print("::notice::No coverage-report given; no coverage badge is written.")
|
||||
metrics_json = json.dumps(metrics, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user