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
33 changes: 27 additions & 6 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ This directory contains scripts used by GitHub Actions workflows.

## format-coverage.sh

Formats Go coverage output as a markdown table with color-coded indicators.
Formats Go statement coverage as a markdown table with color-coded indicators. Uses `normalize-coverage.sh` to filter and merge the profile before calculating package totals. Codecov measures line coverage separately, so its percentage can differ.

### Usage

```bash
./format-coverage.sh <coverage-file> [current-coverage] [main-coverage]
./format-coverage.sh <coverage-file> [current-coverage] [main-coverage] [main-status]
```

**Arguments:**
- `coverage-file`: Path to the Go coverage file (typically `coverage.out`)
- `current-coverage`: (optional) Overall coverage percentage for display (e.g., "74.3%")
- `main-coverage`: (optional) Main branch coverage percentage for comparison (e.g., "74.0%")
- `current-coverage`: (optional) Overall statement coverage percentage for display (e.g., "89.2%"), calculated from the normalised profile
- `main-coverage`: (optional) Main branch statement coverage percentage for comparison (e.g., "85.3%"), calculated from the same normalised scope
- `main-status`: (optional) Reason main coverage is unavailable (e.g., "tests timed out after 900 seconds"). Pass an empty `main-coverage` argument to display this reason and omit the comparison, rather than treating a failed measurement as 0%.

### Examples

Expand All @@ -42,11 +43,16 @@ COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
./.github/scripts/format-coverage.sh coverage.out "75.2%" "74.3%"
```

**When main coverage is unavailable:**
```bash
./.github/scripts/format-coverage.sh coverage.out "89.2%" "" "tests timed out after 900 seconds"
```

### Output Format

The script generates a markdown report with:
- Overall coverage statistics
- Coverage comparison (if main branch coverage provided)
- Coverage comparison (if main branch coverage provided), or an unavailable-baseline reason when `main-status` is provided
- Table of coverage by package with color-coded indicators:
- 🟢 Green: ≥90% coverage
- 🟡 Yellow: ≥75% coverage
Expand All @@ -69,4 +75,19 @@ mise test-coverage
Or save to a file for inspection:

```bash
./.github/scripts/format-coverage.sh coverage.out "74.3%" > coverage-report.md
./.github/scripts/format-coverage.sh coverage.out "74.3%" > coverage-report.md
```

## normalize-coverage.sh

Filters `/cmd/` and `/tests/` files from a Go coverage profile and merges repeated blocks from cross-package instrumentation by summing their hit counts. The coverage task, formatter, and main-baseline workflow share this helper so they measure the same library scope.

```bash
bash ./.github/scripts/normalize-coverage.sh coverage.out > coverage.normalized.out
```

Use a different output file to avoid truncating the input profile. Calculate statement coverage from the normalised profile:

```bash
go tool cover -func=coverage.normalized.out | awk '/^total:/ {print $NF}'
```
61 changes: 24 additions & 37 deletions .github/scripts/format-coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,33 @@
set -euo pipefail

# Script to format Go coverage output as a markdown table
# Usage: ./format-coverage.sh coverage.out [current-coverage] [main-coverage]
# Usage: ./format-coverage.sh coverage.out [current-coverage] [main-coverage] [main-status]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

COVERAGE_FILE="${1:-coverage.out}"
CURRENT_COVERAGE="${2:-}"
MAIN_COVERAGE="${3:-}"
MAIN_STATUS="${4:-}"

if [ ! -f "$COVERAGE_FILE" ]; then
echo "Error: Coverage file '$COVERAGE_FILE' not found"
exit 1
fi

NORMALIZED_FILE=$(mktemp)
trap 'rm -f "$NORMALIZED_FILE"' EXIT
bash "$(dirname "$0")/normalize-coverage.sh" "$COVERAGE_FILE" > "$NORMALIZED_FILE"
COVERAGE_FILE="$NORMALIZED_FILE"

# Start markdown output
echo "## 📊 Test Coverage Report"
echo ""

# Show current and main coverage if provided
if [ -n "$CURRENT_COVERAGE" ]; then
echo "**Current Coverage:** \`$CURRENT_COVERAGE\`"
echo "**Current Statement Coverage:** \`$CURRENT_COVERAGE\`"

if [ -n "$MAIN_COVERAGE" ]; then
echo "**Main Branch Coverage:** \`$MAIN_COVERAGE\`"
echo "**Main Branch Statement Coverage:** \`$MAIN_COVERAGE\`"
echo ""

# Calculate difference
Expand All @@ -41,51 +47,32 @@ if [ -n "$CURRENT_COVERAGE" ]; then
echo "**Coverage Change:** ✅ No change"
fi
fi
elif [ -n "$MAIN_STATUS" ]; then
echo "**Main Branch Statement Coverage:** unavailable ($MAIN_STATUS). Comparison omitted."
fi
fi

echo ""
echo "### Coverage by Package"
echo "### Statement Coverage by Package"
echo ""
echo "Go measures covered statements. Codecov measures fully covered lines, so its percentage can differ."
echo ""

# Create table header
echo "| Package | Coverage |"
echo "|---------|----------|"

# Parse coverage and group by package
go tool cover -func="$COVERAGE_FILE" | grep -E '\.go:[0-9]+:' | \
awk -F: '{
# Extract package path from filename
split($1, parts, "/");
pkg = "";
for(i=1; i<length(parts); i++) {
if(pkg != "") pkg = pkg "/";
pkg = pkg parts[i];
}

# Extract coverage percentage from the last field
split($0, line, /[[:space:]]+/);
coverage = line[length(line)];

# Store coverage by package
if(pkg in packages) {
packages[pkg] = packages[pkg] "," coverage;
} else {
packages[pkg] = coverage;
}
# Count statements, not averages of function percentages, from normalized blocks.
awk '
NR > 1 {
pkg = $1;
sub(/\/[^\/]+$/, "", pkg);
totals[pkg] += $2;
if($3 > 0) covered[pkg] += $2;
}
END {
for(pkg in packages) {
# Calculate average coverage for package
split(packages[pkg], covs, ",");
sum = 0;
count = 0;
for(i in covs) {
gsub(/%/, "", covs[i]);
sum += covs[i];
count++;
}
avg = (count > 0) ? sum/count : 0;
for(pkg in totals) {
avg = (totals[pkg] > 0) ? 100 * covered[pkg] / totals[pkg] : 0;

# Format package name (remove common prefix)
display_pkg = pkg;
Expand All @@ -100,7 +87,7 @@ END {
# Output with coverage value for sorting
printf "%.1f|`%s`|%s\n", avg, display_pkg, emoji;
}
}' | sort -n | awk -F'|' '{
}' "$COVERAGE_FILE" | sort -n | awk -F'|' '{
# Re-format after sorting by coverage
printf "| %s | %s %.1f%% |\n", $2, $3, $1;
}'
Expand Down
15 changes: 15 additions & 0 deletions .github/scripts/normalize-coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail

# Keep the library scope and merge blocks repeated by cross-package coverage.
# Usage: bash normalize-coverage.sh profile.out > normalized.out
awk '
NR == 1 { mode = $0; next }
$1 !~ /\/cmd\/|\/tests\// {
statements[$1] = $2;
hits[$1] += $3;
}
END {
print mode;
for (block in statements) print block, statements[block], hits[block];
}' "${1:?Coverage profile required}"
84 changes: 51 additions & 33 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,45 +151,53 @@ jobs:
if: github.event_name == 'pull_request'
id: main-coverage
run: |
# Store current working directory
CURRENT_DIR=$(pwd)

# Fetch main branch with shallow clone for speed
MAIN_DIR="$RUNNER_TEMP/main-branch"
git fetch --depth=1 origin main:main
git worktree add "$MAIN_DIR" main
trap 'cd "$CURRENT_DIR"; git worktree remove --force "$MAIN_DIR"' EXIT
cd "$MAIN_DIR"

if ! git submodule update --init --recursive; then
echo "::warning::Main coverage unavailable: submodule initialization failed"
echo "main-status=submodule initialization failed" >> "$GITHUB_OUTPUT"
exit 0
fi

# Checkout main branch in a temporary directory
git worktree add /tmp/main-branch main

# Initialize submodules in the main branch worktree
cd /tmp/main-branch
git submodule update --init --recursive || echo "Submodule initialization failed, continuing without submodules"
cd "$CURRENT_DIR"

# Run tests on main branch to get coverage (with timeout)
cd /tmp/main-branch
timeout 300 go test -coverprofile=main-coverage.out -covermode=atomic ./... > /dev/null 2>&1 || echo "Main branch tests failed or timed out"

if [ -f main-coverage.out ]; then
# Filter out cmd and tests folders from main branch coverage (same as current branch)
grep -v -E '/cmd/|/tests/' main-coverage.out > main-coverage.filtered.out || true
mv main-coverage.filtered.out main-coverage.out

MAIN_COVERAGE=$(go tool cover -func=main-coverage.out | grep total | awk '{print $3}' || echo "0.0%")
echo "main-coverage=$MAIN_COVERAGE" >> $GITHUB_OUTPUT
echo "Main branch coverage: $MAIN_COVERAGE"

# Copy main coverage file back to current directory
cp main-coverage.out "$CURRENT_DIR/main-coverage.out"
if timeout 900 go test -race -coverpkg=./... -coverprofile=main-coverage.out -covermode=atomic ./... ./jsonschema/oas3/tests/... ./openapi/linter/customrules/... ./openapi/linter/converter/tests/... > "$CURRENT_DIR/main-coverage.log" 2>&1; then
TEST_STATUS=0
else
echo "main-coverage=0.0%" >> $GITHUB_OUTPUT
echo "Could not get main branch coverage"
TEST_STATUS=$?
fi

# Return to original directory
cd "$CURRENT_DIR"
if [ "$TEST_STATUS" -ne 0 ]; then
if [ "$TEST_STATUS" -eq 124 ]; then
REASON="tests timed out after 900 seconds"
else
REASON="tests failed with exit code $TEST_STATUS"
fi
echo "::warning::Main coverage unavailable: $REASON"
echo "main-status=$REASON" >> "$GITHUB_OUTPUT"
tail -n 100 "$CURRENT_DIR/main-coverage.log"
exit 0
fi

# Clean up worktree (force removal to handle modified files)
git worktree remove --force /tmp/main-branch || rm -rf /tmp/main-branch
if [ ! -s main-coverage.out ]; then
echo "::warning::Main coverage unavailable: no coverage profile generated"
echo "main-status=no coverage profile generated" >> "$GITHUB_OUTPUT"
exit 0
fi

bash "$CURRENT_DIR/.github/scripts/normalize-coverage.sh" main-coverage.out > main-coverage.filtered.out
mv main-coverage.filtered.out main-coverage.out
if MAIN_COVERAGE=$(go tool cover -func=main-coverage.out | awk '/^total:/ {print $NF}') && [ -n "$MAIN_COVERAGE" ]; then
echo "main-coverage=$MAIN_COVERAGE" >> "$GITHUB_OUTPUT"
echo "Main branch coverage: $MAIN_COVERAGE"
cp main-coverage.out "$CURRENT_DIR/main-coverage.out"
else
echo "::warning::Main coverage unavailable: coverage profile could not be read"
echo "main-status=coverage profile could not be read" >> "$GITHUB_OUTPUT"
fi

- name: Generate coverage summary
id: coverage-summary
Expand All @@ -198,7 +206,8 @@ jobs:
CURRENT_COV="${{ steps.coverage.outputs.coverage }}"
MAIN_COV="${{ steps.main-coverage.outputs.main-coverage }}"

./.github/scripts/format-coverage.sh coverage.out "$CURRENT_COV" "$MAIN_COV" > coverage-summary.md
MAIN_STATUS="${{ steps.main-coverage.outputs.main-status }}"
./.github/scripts/format-coverage.sh coverage.out "$CURRENT_COV" "$MAIN_COV" "$MAIN_STATUS" > coverage-summary.md

- name: Upload coverage artifact
uses: actions/upload-artifact@v7
Expand All @@ -208,12 +217,21 @@ jobs:
coverage.out
coverage.html
coverage-summary.md
main-coverage.log

- name: Setup Python for Codecov
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.out
disable_search: true
fail_ci_if_error: true
use_pypi: true
Comment thread
TristanSpeakEasy marked this conversation as resolved.

# Build jobs (run in parallel)
build-ubuntu:
Expand Down
Loading
Loading