diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29b2ea0..a7249c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,30 +147,59 @@ jobs: coverage-report: runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build Lua test container + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: - python-version: "3.12" + context: . + file: docker/Dockerfile.test + tags: fairvisor-test-ci + load: true + cache-from: type=gha + cache-to: type=gha,mode=max - - name: Install E2E coverage deps - run: | - pip install -r tests/e2e/requirements.txt - pip install pytest-cov + - name: Generate Lua coverage report + run: docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work fairvisor-test-ci bin/ci/run_lua_coverage.sh - - name: Generate Python coverage report (non-blocking) - run: | - mkdir -p artifacts/coverage - pytest tests/e2e/test_health.py --cov=tests/e2e --cov-report=xml:artifacts/coverage/python-coverage.xml || true + - name: Build coverage summary, regression gate, and badge payload + run: bin/ci/build_lua_coverage_badge.sh - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-artifacts - path: artifacts/coverage + path: | + artifacts/coverage + artifacts/pages if-no-files-found: ignore + + - name: Upload coverage Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: artifacts/pages + + deploy-coverage-pages: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: coverage-report + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Configure Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Deploy coverage Pages payload + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..2d98c22 --- /dev/null +++ b/.luacov @@ -0,0 +1,14 @@ +return { + include = { + "src/fairvisor/", + }, + exclude = { + "spec/", + "tests/", + "cli/", + "bin/", + "src/nginx/", + }, + statsfile = "artifacts/coverage/luacov.stats.out", + reportfile = "artifacts/coverage/luacov.report.out", +} diff --git a/README.md b/README.md index a83065e..07bc768 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ License: MPL-2.0 Latest release CI + Lua coverage GHCR image Platforms: linux/amd64 · linux/arm64 Docs diff --git a/bin/ci/build_lua_coverage_badge.sh b/bin/ci/build_lua_coverage_badge.sh new file mode 100755 index 0000000..ebab38f --- /dev/null +++ b/bin/ci/build_lua_coverage_badge.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPORT_FILE="${1:-artifacts/coverage/luacov.report.out}" +COVERAGE_DIR="${COVERAGE_DIR:-artifacts/coverage}" +PAGES_DIR="${PAGES_DIR:-artifacts/pages}" +SUMMARY_FILE="${SUMMARY_FILE:-${COVERAGE_DIR}/coverage-summary.json}" +PAGES_SUMMARY_FILE="${PAGES_SUMMARY_FILE:-${PAGES_DIR}/coverage-summary.json}" +BADGE_FILE="${BADGE_FILE:-${PAGES_DIR}/coverage-badge.json}" +TOTAL_BADGE_FILE="${TOTAL_BADGE_FILE:-${PAGES_DIR}/coverage-total-badge.json}" +INDEX_FILE="${INDEX_FILE:-${PAGES_DIR}/index.html}" +BASELINE_URL="${BASELINE_URL:-https://fairvisor.github.io/edge/coverage-summary.json}" + +if [[ ! -f "${REPORT_FILE}" ]]; then + echo "coverage report not found: ${REPORT_FILE}" >&2 + exit 1 +fi + +mkdir -p "${COVERAGE_DIR}" "${PAGES_DIR}" + +read -r total_hits total_missed total_coverage < <( + awk ' + /^Total[[:space:]]+[0-9]+[[:space:]]+[0-9]+[[:space:]]+[0-9.]+%$/ { + gsub("%", "", $4) + print $2, $3, $4 + } + ' "${REPORT_FILE}" | tail -n 1 +) || true + +read -r non_generated_hits non_generated_missed non_generated_coverage < <( + awk ' + BEGIN { + hits = 0 + missed = 0 + } + /^src\/fairvisor\// && $1 !~ /^src\/fairvisor\/generated\// { + hits += $2 + missed += $3 + } + END { + total = hits + missed + if (total == 0) { + print "0 0 0.00" + } else { + printf "%d %d %.2f\n", hits, missed, (hits * 100) / total + } + } + ' "${REPORT_FILE}" +) || true + +if [[ -z "${total_coverage:-}" ]]; then + echo "failed to parse total coverage from ${REPORT_FILE}" >&2 + exit 1 +fi + +coverage_color() { + local coverage="$1" + + awk -v coverage="${coverage}" ' + BEGIN { + if (coverage >= 95) { + print "brightgreen" + } else if (coverage >= 90) { + print "green" + } else if (coverage >= 80) { + print "yellowgreen" + } else if (coverage >= 70) { + print "yellow" + } else if (coverage >= 60) { + print "orange" + } else { + print "red" + } + } + ' +} + +resolve_baseline_coverage() { + if [[ -n "${LUA_COVERAGE_MIN_NON_GENERATED:-}" ]]; then + printf '%s\n' "${LUA_COVERAGE_MIN_NON_GENERATED}" + return 0 + fi + + if [[ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]]; then + return 0 + fi + + local payload + if ! payload="$(curl --fail --silent --show-error --location "${BASELINE_URL}" 2>/dev/null)"; then + echo "baseline not found, skipping regression gate for bootstrap run: ${BASELINE_URL}" >&2 + return 0 + fi + + local baseline + if ! baseline="$( + printf '%s' "${payload}" | python3 -c ' +import json +import sys + +try: + data = json.load(sys.stdin) + value = data["non_generated"]["coverage"] +except Exception: + raise SystemExit(1) + +print(value) +' + )"; then + echo "baseline could not be parsed, skipping regression gate: ${BASELINE_URL}" >&2 + return 0 + fi + + printf '%s\n' "${baseline}" +} + +assert_minimum_coverage() { + local actual="$1" + local minimum="$2" + local label="$3" + + if [[ -z "${minimum}" ]]; then + return 0 + fi + + if awk -v actual="${actual}" -v minimum="${minimum}" 'BEGIN { exit !(actual + 1e-9 < minimum) }'; then + echo "${label} coverage regression: ${actual}% < ${minimum}%" >&2 + exit 1 + fi +} + +badge_json() { + local label="$1" + local message="$2" + local color="$3" + + cat < "${BADGE_FILE}" +badge_json "lua coverage total" "${total_coverage}%" "$(coverage_color "${total_coverage}")" > "${TOTAL_BADGE_FILE}" + +cat > "${SUMMARY_FILE}" < "${INDEX_FILE}" < + + + + + Fairvisor Lua Coverage + + + +
+

Fairvisor Lua Coverage

+

Coverage is generated by luacov in CI. The badge in README uses the non-generated metric so autogenerated tables do not mask regressions in handwritten runtime code.

+
+
+
Total coverage
+
${total_coverage}%
+
${total_hits} hits / ${total_missed} missed
+
+
+
Non-generated coverage
+
${non_generated_coverage}%
+
${non_generated_hits} hits / ${non_generated_missed} missed
+
+
+

Generated at ${updated_at} for commit ${commit_sha}.

+

coverage-badge.json | coverage-total-badge.json | coverage-summary.json

+
+ + +EOF + +echo "total coverage: ${total_coverage}%" +echo "non-generated coverage: ${non_generated_coverage}%" +if [[ -n "${baseline_non_generated_coverage:-}" ]]; then + echo "baseline non-generated coverage: ${baseline_non_generated_coverage}%" +fi diff --git a/bin/ci/run_lua_coverage.sh b/bin/ci/run_lua_coverage.sh new file mode 100755 index 0000000..7dcfbb5 --- /dev/null +++ b/bin/ci/run_lua_coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts/coverage}" + +mkdir -p "${ARTIFACTS_DIR}" +rm -f "${ARTIFACTS_DIR}/luacov.stats.out" \ + "${ARTIFACTS_DIR}/luacov.report.out" + +echo "[coverage] busted unit + integration with luacov" +busted --coverage spec/unit/ spec/integration/ + +echo "[coverage] luacov text report" +luacov