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
52 changes: 52 additions & 0 deletions .github/workflows/coverage-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Coverage Comment

# This workflow runs AFTER "Code Coverage" completes and posts the coverage
# report as a PR comment. It is intentionally separated because:
# - The "Code Coverage" workflow runs in the context of the PR head commit
# and therefore has NO write access to pull-requests (GitHub security model
# for fork PRs).
# - This workflow runs in the context of the base branch and therefore CAN
# write PR comments, even for PRs opened from forks.
on:
workflow_run:
workflows: ["Code Coverage"]
types: [completed]

permissions:
contents: read
pull-requests: write # needed to create / update the comment

jobs:
comment:
runs-on: ubuntu-latest
# Only run when the triggering workflow succeeded or failed (not skipped).
if: >
github.event.workflow_run.event == 'pull_request' &&
(github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure')
steps:
- name: Download coverage artifacts
uses: actions/download-artifact@v4
with:
name: coverage-report
github-token: ${{ secrets.GITHUB_TOKEN }}
# Download from the triggering workflow run, not the current one.
run-id: ${{ github.event.workflow_run.id }}

- name: Read PR number
id: pr
run: |
if [ -f pr-number.txt ]; then
echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
else
echo "number=" >> "$GITHUB_OUTPUT"
fi

- name: Post coverage comment on PR
if: steps.pr.outputs.number != ''
uses: marocchino/sticky-pull-request-comment@v2
with:
header: code-coverage
number: ${{ steps.pr.outputs.number }}
path: coverage-report.md
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
148 changes: 130 additions & 18 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@ on:
branches: [main, release-*]
paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**']

# Only read-only permissions needed here.
# PR comments are posted by coverage-comment.yml via workflow_run
# so that fork PRs can also receive comments with write access.
permissions:
contents: read

env:
THRESHOLD_TOTAL: 70 # overall coverage threshold (%)
THRESHOLD_DIFF: 90 # changed-line coverage threshold (%)

jobs:
coverage:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history needed for diff coverage

- name: Set up Go
uses: actions/setup-go@v5
Expand All @@ -34,29 +43,116 @@ jobs:
restore-keys: |
${{ runner.os }}-go-

- name: Run tests with coverage
# ── Run tests ────────────────────────────────────────────────────────────
- name: Run tests with coverage (excluding pkg/server)
run: make test-coverage-ci

# ── Convert coverage format for diff-cover ────────────────────────────
- name: Install gocover-cobertura
run: go install github.com/t-yuki/gocover-cobertura@latest

- name: Convert to Cobertura XML
run: gocover-cobertura < coverage.out > coverage.xml

- name: Install diff-cover
run: pip install diff-cover

# ── Check total coverage threshold ───────────────────────────────────
- name: Check total coverage (>= ${{ env.THRESHOLD_TOTAL }}%)
id: total_coverage
run: |
sudo make test-coverage
TOTAL=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | sed 's/%//')
echo "total=${TOTAL}" >> "$GITHUB_OUTPUT"
echo "Total coverage: ${TOTAL}% (threshold: ${{ env.THRESHOLD_TOTAL }}%)"
if awk "BEGIN { exit !( ${TOTAL} + 0 < ${{ env.THRESHOLD_TOTAL }}) }"; then
echo "status=fail" >> "$GITHUB_OUTPUT"
echo "❌ Total coverage ${TOTAL}% is below the required ${{ env.THRESHOLD_TOTAL }}%"
else
echo "status=pass" >> "$GITHUB_OUTPUT"
echo "✅ Total coverage ${TOTAL}% meets the threshold of ${{ env.THRESHOLD_TOTAL }}%"
fi

- name: Generate coverage report
# ── Check diff (changed-line) coverage threshold ─────────────────────
- name: Fetch base branch for diff
if: github.event_name == 'pull_request'
run: git fetch origin ${{ github.base_ref }} --depth=1

- name: Check diff coverage (>= ${{ env.THRESHOLD_DIFF }}%)
id: diff_coverage
if: github.event_name == 'pull_request'
run: |
go tool cover -func=coverage.out > coverage.txt
BASE=origin/${{ github.base_ref }}
DIFF_OUTPUT=$(diff-cover coverage.xml \
--compare-branch="${BASE}" \
--fail-under=${{ env.THRESHOLD_DIFF }} \
2>&1) && DIFF_EXIT=0 || DIFF_EXIT=$?

DIFF_PCT=$(echo "$DIFF_OUTPUT" | grep -oP 'Diff coverage is \K[0-9.]+' || echo "N/A")
echo "diff_pct=${DIFF_PCT}" >> "$GITHUB_OUTPUT"
echo "$DIFF_OUTPUT"

if [ "$DIFF_EXIT" -ne 0 ]; then
echo "diff_status=fail" >> "$GITHUB_OUTPUT"
else
echo "diff_status=pass" >> "$GITHUB_OUTPUT"
fi

- name: Display coverage summary
# ── Generate per-package coverage table ──────────────────────────────
- name: Generate coverage report markdown
run: |
echo "## Code Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
tail -1 coverage.txt >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Detailed Coverage by Package</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
cat coverage.txt >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
{
echo "## 📊 Code Coverage Report"
echo ""

TOTAL="${{ steps.total_coverage.outputs.total }}"
STATUS="${{ steps.total_coverage.outputs.status }}"
[ "$STATUS" = "pass" ] && ICON="✅" || ICON="❌"

echo "| Metric | Coverage | Threshold | Status |"
echo "|--------|----------|-----------|--------|"
echo "| Overall | **${TOTAL}%** | ${{ env.THRESHOLD_TOTAL }}% | ${ICON} |"

if [ "${{ github.event_name }}" = "pull_request" ]; then
DIFF_PCT="${{ steps.diff_coverage.outputs.diff_pct }}"
DIFF_STATUS="${{ steps.diff_coverage.outputs.diff_status }}"
[ "$DIFF_STATUS" = "pass" ] && DIFF_ICON="✅" || DIFF_ICON="❌"
echo "| Changed lines | **${DIFF_PCT}%** | ${{ env.THRESHOLD_DIFF }}% | ${DIFF_ICON} |"
fi

echo ""
echo "<details>"
echo "<summary>📦 Per-package breakdown</summary>"
echo ""
echo '```'
go tool cover -func=coverage.out | grep -v "^total:" | \
awk '{printf "%-80s %s\n", $1, $3}' | sort
echo ""
go tool cover -func=coverage.out | grep "^total:"
echo '```'
echo ""
echo "</details>"
} > coverage-report.md

# ── Write step summary ────────────────────────────────────────────────
- name: Write job summary
run: cat coverage-report.md >> "$GITHUB_STEP_SUMMARY"

# ── Save PR number so the comment workflow can find the right PR ──────
- name: Save PR number
if: github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > pr-number.txt

# ── Upload artifacts (report + pr-number for comment workflow) ────────
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage.out
coverage.xml
coverage-report.md
pr-number.txt
retention-days: 14

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand All @@ -67,3 +163,19 @@ jobs:
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

# ── Enforce thresholds (after artifacts uploaded so report is always saved) ──
- name: Enforce total coverage threshold
if: steps.total_coverage.outputs.status == 'fail'
run: |
echo "❌ Total coverage ${{ steps.total_coverage.outputs.total }}% is below the required ${{ env.THRESHOLD_TOTAL }}%"
exit 1

- name: Enforce diff coverage threshold
if: >
github.event_name == 'pull_request' &&
steps.diff_coverage.outputs.diff_status == 'fail'
run: |
echo "❌ Changed-line coverage ${{ steps.diff_coverage.outputs.diff_pct }}% is below the required ${{ env.THRESHOLD_DIFF }}%"
exit 1

16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ REVISION=$(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet

RELEASE_INFO = -X main.revision=${REVISION} -X main.gitVersion=${VERSION} -X main.buildTime=${BUILD_TIMESTAMP}

.PHONY: release test test-coverage
.PHONY: release test test-coverage test-coverage-ci

release:
@CGO_ENABLED=0 ${PROXY} GOOS=linux GOARCH=${GOARCH} go vet -tags disable_libgit2 $(PACKAGES)
Expand Down Expand Up @@ -48,3 +48,17 @@ test-coverage:
fi
@rm -f ./server.test
@echo "Coverage report generated: coverage.out"

# CI-friendly coverage: no sudo required, excludes pkg/server, uses -coverpkg for
# cross-package coverage so every package's contribution is reflected in the report.
test-coverage-ci:
@echo "Running CI coverage (excluding pkg/server)..."
$(eval PKGS := $(shell go list -tags disable_libgit2 ./pkg/... | grep -v pkg/server))
$(eval COVERPKG := $(shell go list -tags disable_libgit2 ./pkg/... | grep -v pkg/server | tr '\n' ',' | sed 's/,$$//'))
@go test -tags disable_libgit2 -race \
-coverprofile=coverage.out \
-coverpkg=$(COVERPKG) \
-covermode=atomic \
-timeout 10m \
$(PKGS)
@echo "Coverage report generated: coverage.out"
Loading
Loading