Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ jobs:
bash test/push-nuget_test.sh
bash test/normalize-ci-matrix_test.sh
bash test/install-dotnet-coverage_test.sh
bash test/write-badges_test.sh

- name: Run python tests
run: python3 -m unittest discover -s test -p '*_test.py'
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Harden `scripts/write-badges.sh` (the `update-badges.yaml` helper) against silent failures. `git fetch origin "$badge_branch" || true` previously swallowed every failure — auth, network, or a genuinely absent branch alike — then fell through to the orphan-rebuild path; a real fetch failure is now aborted loud, and only the benign "branch does not exist yet" case (detected up front with `git ls-remote --exit-code`) rebuilds the orphan branch. Malformed input now fails the badge job instead of writing a bogus badge file: a non-array `coverage-data`/`matrix-data`, a coverage entry missing `slug`/`label`, or a matrix entry missing `lang`/`os`/`arch`/`passed` (which would have written `ci-null-null-null.json`) is rejected with a `::error::` annotation. Added `test/write-badges_test.sh` pinning the `coverage-<slug>.json` / `ci-<lang>-<os>-<arch>.json` filename contract, the null-percent skip, and the new fail-loud paths, wired into the `build-and-test` self-test job. Healthy consumers on the built-in `matrix-status` → `update-badges.yaml` flow see no change. (#24)
- Fix `csharp-ci.yaml` and `scala-ci.yaml` silently emitting an empty `coverage` output when the coverage-percent artifact download failed. The `coverage-output` job downloaded the artifact with `continue-on-error: true` and defaulted to empty when the file was absent, conflating "no shard set `coverage: true`" (empty is correct) with "the coverage shard uploaded the artifact but the download failed" (throttling, retention, infra) — the latter fed a blank or stale coverage badge from an otherwise green run. The job now derives whether a coverage shard was configured from the normalized matrix: when one was, the artifact download must succeed and yield a non-empty value or the job fails loud; when none was, the download step is skipped and the empty output stays intentional. Consumers whose coverage shard uploads normally see no change. (#25)
- Fix `go-ci.yaml`'s `Augment coverage report with cyclomatic complexity` step staging per-function coverage output at the fixed path `/tmp/cov-func.txt`. On shared self-hosted runners (e.g. the Hetzner pool) `/tmp` outlives the job and is shared across runner instances, so a leftover file owned by a different runner user fails the step with `permission denied`, and two Go jobs landing on the same host can interleave writes and stamp the wrong per-function coverage into each other's `Attended` column. The staging file now lives under `$RUNNER_TEMP`, which is private to the runner instance and wiped between jobs; GitHub-hosted consumers see no change. Closes out the self-hosted-runner hardening of this step begun in 2.3.2's sudo removal. (#12)

Expand Down
37 changes: 34 additions & 3 deletions scripts/write-badges.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,35 @@ badge_branch="${BADGE_BRANCH:?BADGE_BRANCH is required}"

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

require_json_array() {
local source_name="$1" value="$2"
if ! jq -e 'type == "array"' <<<"$value" >/dev/null 2>&1; then
echo "::error::$source_name is not a JSON array: $value" >&2
exit 1
fi
}

require_field() {
local source_name="$1" entry="$2" field_name="$3" field_value="$4"
if [ -z "$field_value" ] || [ "$field_value" = "null" ]; then
echo "::error::$source_name entry is missing '$field_name': $entry" >&2
exit 1
fi
}

require_json_array COVERAGE_DATA "$coverage_data"
require_json_array MATRIX_DATA "$matrix_data"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git fetch origin "$badge_branch" || true
badge_ref="refs/heads/$badge_branch"
ls_remote_rc=0
git ls-remote --exit-code origin "$badge_ref" >/dev/null || ls_remote_rc=$?
if [ "$ls_remote_rc" -ne 0 ] && [ "$ls_remote_rc" -ne 2 ]; then
echo "::error::git ls-remote for $badge_ref failed with exit code $ls_remote_rc" >&2
exit "$ls_remote_rc"
fi

worktree_dir="$(mktemp -d)"
cleanup() {
Expand All @@ -21,19 +46,21 @@ cleanup() {
}
trap cleanup EXIT

if git rev-parse --verify "origin/$badge_branch" >/dev/null 2>&1; then
if [ "$ls_remote_rc" -eq 0 ]; then
git fetch origin "+$badge_ref:refs/remotes/origin/$badge_branch"
git worktree add "$worktree_dir" "origin/$badge_branch"
git -C "$worktree_dir" checkout -B "$badge_branch"
else
git worktree add --orphan -b "$badge_branch" "$worktree_dir"
git -C "$worktree_dir" rm -rf . 2>/dev/null || true
fi

while IFS= read -r entry; do
[ -z "$entry" ] && continue
slug="$(jq -r '.slug' <<<"$entry")"
label="$(jq -r '.label' <<<"$entry")"
percent="$(jq -r '.percent' <<<"$entry")"
require_field coverage-data "$entry" slug "$slug"
require_field coverage-data "$entry" label "$label"
if [ -z "$percent" ] || [ "$percent" = "null" ]; then
continue
fi
Expand All @@ -46,6 +73,10 @@ while IFS= read -r entry; do
os_name="$(jq -r '.os' <<<"$entry")"
arch="$(jq -r '.arch' <<<"$entry")"
passed="$(jq -r '.passed' <<<"$entry")"
require_field matrix-data "$entry" lang "$lang"
require_field matrix-data "$entry" os "$os_name"
require_field matrix-data "$entry" arch "$arch"
require_field matrix-data "$entry" passed "$passed"
python3 "$script_dir/matrix_badge_json.py" "$os_name" "$arch" "$passed" > "$worktree_dir/ci-$lang-$os_name-$arch.json"
done < <(jq -c '.[]' <<<"$matrix_data")

Expand Down
193 changes: 193 additions & 0 deletions test/write-badges_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail

here="$(cd "$(dirname "$0")" && pwd)"
script="$here/../scripts/write-badges.sh"

export GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_SYSTEM=/dev/null

fail=0
check() {
local name="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo "ok - $name"
else
echo "FAIL - $name: expected [$expected] got [$actual]"
fail=1
fi
}

check_rc() {
local name="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo "ok - $name"
else
echo "FAIL - $name: expected rc [$expected] got [$actual]"
fail=1
fi
}

check_contains() {
local name="$1" needle="$2" hay="$3"
case "$hay" in
*"$needle"*) echo "ok - $name" ;;
*) echo "FAIL - $name: [$hay] does not contain [$needle]"; fail=1 ;;
esac
}

sandbox=""
origin=""
work=""

setup_repos() {
sandbox="$(mktemp -d)"
origin="$sandbox/origin.git"
work="$sandbox/work"
git init -q --bare "$origin"
git init -q -b main "$work"
git -C "$work" config user.name test
git -C "$work" config user.email test@example.com
git -C "$work" remote add origin "$origin"
git -C "$work" commit -q --allow-empty -m init
git -C "$work" push -q origin main
}

seed_remote_badge_branch() {
local branch="$1" filename="$2"
local seed="$sandbox/seed"
git clone -q "$origin" "$seed"
git -C "$seed" config user.name test
git -C "$seed" config user.email test@example.com
git -C "$seed" checkout -q --orphan "$branch"
git -C "$seed" rm -r -f -q --ignore-unmatch .
echo '{"seed":true}' > "$seed/$filename"
git -C "$seed" add "$filename"
git -C "$seed" commit -qm seed
git -C "$seed" push -q origin "$branch"
rm -rf "$seed"
}

badge_branch_files() {
local branch="$1"
git -C "$origin" ls-tree --name-only "refs/heads/$branch" 2>/dev/null | sort | tr '\n' ' ' | sed 's/ $//'
}

run() {
local coverage="$1" matrix="$2" branch="$3"
local errf; errf="$(mktemp)"
set +e
out="$(cd "$work" && COVERAGE_DATA="$coverage" MATRIX_DATA="$matrix" BADGE_BRANCH="$branch" bash "$script" 2>"$errf")"
rc=$?
set -e
err="$(cat "$errf")"; rm -f "$errf"
}

setup_repos
run '[{"slug":"csharp","label":"C#","percent":91}]' \
'[{"lang":"csharp","os":"ubuntu","arch":"x64","passed":true}]' badges
check_rc "fresh repo happy path exit 0" 0 "$rc"
check "fresh repo writes contract filenames" \
"ci-csharp-ubuntu-x64.json coverage-csharp.json" "$(badge_branch_files badges)"
check_contains "coverage badge content on branch" '"message": "91%"' \
"$(git -C "$origin" show badges:coverage-csharp.json)"
check_contains "matrix badge content on branch" '"message": "passing"' \
"$(git -C "$origin" show badges:ci-csharp-ubuntu-x64.json)"
rm -rf "$sandbox"

setup_repos
run '[{"slug":"csharp","label":"C#","percent":null},{"slug":"go","label":"Go","percent":""}]' '[]' badges
check_rc "all coverage percents null exit 0" 0 "$rc"
check_contains "all coverage percents null skips commit" "badges unchanged; skipping commit" "$out"
check "all coverage percents null pushes nothing" "" "$(badge_branch_files badges)"
rm -rf "$sandbox"

setup_repos
run '[{"slug":"csharp","label":"C#","percent":null},{"slug":"go","label":"Go","percent":87}]' '[]' badges
check_rc "mixed null percent exit 0" 0 "$rc"
check "mixed null percent writes only the non-null badge" \
"coverage-go.json" "$(badge_branch_files badges)"
rm -rf "$sandbox"

setup_repos
run '[{"label":"C#","percent":91}]' '[]' badges
check_rc "coverage entry missing slug exit 1" 1 "$rc"
check_contains "coverage entry missing slug annotated" \
"::error::coverage-data entry is missing 'slug'" "$err"
rm -rf "$sandbox"

setup_repos
run '[]' '[{"os":"ubuntu","arch":"x64","passed":true}]' badges
check_rc "matrix entry missing lang exit 1" 1 "$rc"
check_contains "matrix entry missing lang annotated" \
"::error::matrix-data entry is missing 'lang'" "$err"
check "matrix entry missing lang pushes nothing" "" "$(badge_branch_files badges)"
rm -rf "$sandbox"

setup_repos
run '[]' '[{"lang":"csharp","os":null,"arch":"x64","passed":true}]' badges
check_rc "matrix entry null os exit 1" 1 "$rc"
check_contains "matrix entry null os annotated" \
"::error::matrix-data entry is missing 'os'" "$err"
rm -rf "$sandbox"

setup_repos
run '[]' '[{"lang":"csharp","os":"ubuntu","arch":"x64"}]' badges
check_rc "matrix entry missing passed exit 1" 1 "$rc"
check_contains "matrix entry missing passed annotated" \
"::error::matrix-data entry is missing 'passed'" "$err"
rm -rf "$sandbox"

setup_repos
run '[]' '[{"lang":"csharp","os":"ubuntu","arch":"x64","passed":"cancelled"}]' badges
check_rc "matrix entry unknown status exit 2" 2 "$rc"
check_contains "matrix entry unknown status surfaced" "unknown matrix status" "$err"
rm -rf "$sandbox"

setup_repos
run '[bad' '[]' badges
check_rc "malformed coverage-data json exit 1" 1 "$rc"
check_contains "malformed coverage-data annotated" \
"::error::COVERAGE_DATA is not a JSON array" "$err"
rm -rf "$sandbox"

setup_repos
run '[]' '{"lang":"csharp"}' badges
check_rc "non-array matrix-data exit 1" 1 "$rc"
check_contains "non-array matrix-data annotated" \
"::error::MATRIX_DATA is not a JSON array" "$err"
rm -rf "$sandbox"

setup_repos
seed_remote_badge_branch badges coverage-csharp.json
run '[{"slug":"csharp","label":"C#","percent":42}]' '[]' badges
check_rc "existing badge branch exit 0" 0 "$rc"
check "existing badge branch keeps single file" \
"coverage-csharp.json" "$(badge_branch_files badges)"
check_contains "existing badge branch content updated" '"message": "42%"' \
"$(git -C "$origin" show badges:coverage-csharp.json)"
check "existing badge branch fast-forwards to two commits" 2 \
"$(git -C "$origin" rev-list --count refs/heads/badges)"
rm -rf "$sandbox"

setup_repos
seed_remote_badge_branch badges coverage-csharp.json
git -C "$work" remote set-url origin "$sandbox/does-not-exist.git"
run '[{"slug":"csharp","label":"C#","percent":42}]' '[]' badges
check_rc "unreachable origin exit non-zero" 128 "$rc"
check_contains "unreachable origin annotated" \
"::error::git ls-remote for refs/heads/badges failed with exit code 128" "$err"
rm -rf "$sandbox"

setup_repos
run '[{"slug":"csharp","label":"C#","percent":91}]' '[]' badges
run '[{"slug":"csharp","label":"C#","percent":91}]' '[]' badges
check_rc "unchanged rerun exit 0" 0 "$rc"
check_contains "unchanged rerun skips commit" "badges unchanged; skipping commit" "$out"
check "unchanged rerun keeps single commit" 1 \
"$(git -C "$origin" rev-list --count refs/heads/badges)"
rm -rf "$sandbox"

exit $fail