name: Code Coverage # Coverage ratchet for both suites — backend pytest (`Coverage`) and the web # vitest frontend (`Coverage (ui)`). One job handles both: it triggers on either # producing workflow and branches on github.event.workflow_run.name to pick the # artifact, status context, and wording. Runs on workflow_run (privileged, # statuses:write) but does NOT check out PR code — it only consumes the artifact # and the GitHub API, so it isn't a "dangerous workflow". # # Baseline storage: the latest coverage on main is kept as the matching commit # status on main's HEAD (no committed file, so no bot push to a protected main and # no CI re-trigger). On push to main the job records that status; on a PR it reads # main's status as the baseline and flags a drop below it (beyond # COVERAGE_TOLERANCE). # # Soft rollout: while COVERAGE_ENFORCE is "false" a regression is reported as a # success status annotated "would fail once enforced" — never a red ✗. To turn on # real red statuses, set COVERAGE_ENFORCE: "true"; to make them actually block a # merge, also mark the status a required check in branch protection. on: workflow_run: workflows: [CI, web Tests] types: [completed] # Read-only at the top level; write scopes live on the job below. permissions: contents: read concurrency: # Keyed by producing workflow + head SHA so backend and frontend runs on the # same commit don't cancel each other. group: code-coverage-${{ github.event.workflow_run.name }}-${{ github.event.workflow_run.head_sha }} cancel-in-progress: true env: # Absorbs coverage nondeterminism (parallel shards, sysmon line-only backend) # so a tiny jitter doesn't fail a PR. A real regression clears this easily. COVERAGE_TOLERANCE: "0.5" # "false" = observe only: a regression posts a success status annotated # "would fail once enforced" instead of a red ✗. "true" = a regression posts a # real failure (red ✗). This stays non-blocking until the status is also marked # a required check in branch protection — so red ✗ surfaces the drop without # blocking the merge. COVERAGE_ENFORCE: "true" # How many recent main commits to scan for the last recorded baseline status. # Must exceed the longest expected run of consecutive merges that don't touch # a given suite. Capped at 100 (the GraphQL history page size); raising it # past 100 would require cursor pagination. BASELINE_LOOKBACK: "100" jobs: post: name: Post coverage status permissions: actions: read # download the coverage artifact from the producing run contents: read # read main's baseline statuses via the GraphQL API statuses: write # post the coverage status on the head SHA # PR runs (gate) and pushes to main (record baseline). Other completions have # no PR head SHA / aren't the baseline branch. if: >- ${{ github.event.workflow_run.event == 'pull_request' || (github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') }} runs-on: ubuntu-latest timeout-minutes: 5 env: # Per-suite parameters, selected by which workflow triggered this run. ART_NAME: ${{ github.event.workflow_run.name == 'CI' && 'coverage-summary' || 'ui-coverage-summary' }} CONTEXT: ${{ github.event.workflow_run.name == 'CI' && 'Coverage' || 'Coverage (ui)' }} NOUN: ${{ github.event.workflow_run.name == 'CI' && 'Coverage' || 'UI coverage' }} METRIC: ${{ github.event.workflow_run.name == 'CI' && 'Total coverage' || 'Total UI line coverage' }} steps: # Data only — never the PR's code. Tolerate a missing artifact (fork PRs, # or a run that produced no coverage) via the no-data guard below. - name: Download coverage summary continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} name: ${{ env.ART_NAME }}-${{ github.event.workflow_run.id }} path: coverage-summary - name: Evaluate coverage and post status shell: bash env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ github.event.workflow_run.head_sha }} EVENT: ${{ github.event.workflow_run.event }} # Makes the status' "Details" link land on the producing run, whose # summary has the full coverage table. RUN_URL: ${{ github.event.workflow_run.html_url }} run: | set -euo pipefail if [[ ! -f coverage-summary/total.txt ]]; then echo "::notice::No ${ART_NAME} artifact; nothing to post." exit 0 fi TOTAL=$(tr -d '[:space:]' < coverage-summary/total.txt) # On main: record the new baseline as the status on this commit. if [[ "$EVENT" == "push" ]]; then gh api "repos/$REPO/statuses/$SHA" \ -f state=success \ -f context="$CONTEXT" \ -f target_url="$RUN_URL" \ -f description="${METRIC}: ${TOTAL}%" >/dev/null echo "Recorded baseline ${CONTEXT}=${TOTAL}% on main $SHA" exit 0 fi # On a PR: baseline = the most recent $CONTEXT status recorded on main. # We can't just read main's HEAD: the two producers are path-filtered # against each other (backend CI ignores web/**, web Tests only # runs on web/**), so a one-sided merge leaves HEAD carrying only one # suite's status. Reading HEAD alone would then report "no baseline yet" # and silently disable the other gate. Instead scan recent main commits # and take the most recent that actually carries $CONTEXT. A single # GraphQL query fetches the whole window's statuses at once (the legacy # commit statuses we post appear under Commit.status.contexts), so this # is one API call regardless of how far back the baseline sits. BASELINE_JSON=$(gh api graphql \ -f query='query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){ref(qualifiedName:"refs/heads/main"){target{... on Commit{history(first:$n){nodes{oid status{contexts{context description}}}}}}}}}' \ -F owner="${REPO%/*}" -F name="${REPO#*/}" -F n="$BASELINE_LOOKBACK" 2>/dev/null || true) # Newest-first; keep only commits carrying $CONTEXT, take the first. BASELINE_LINE=$(printf '%s' "$BASELINE_JSON" | jq -r --arg ctx "$CONTEXT" ' [ .data.repository.ref.target.history.nodes[] | { oid: .oid, desc: (.status.contexts[]? | select(.context == $ctx) | .description) } ] | .[0] // empty | "\(.oid)\t\(.desc)"' 2>/dev/null || true) BASELINE_SHA=$(printf '%s' "$BASELINE_LINE" | cut -f1) BASELINE=$(printf '%s' "$BASELINE_LINE" | cut -f2- | grep -oE '[0-9]+(\.[0-9]+)?' | head -n1 || true) if [[ -n "$BASELINE" ]]; then echo "Baseline ${CONTEXT}=${BASELINE}% from main ${BASELINE_SHA}" fi if [[ -z "$BASELINE" ]]; then # No $CONTEXT status in the last $BASELINE_LOOKBACK main commits # (first rollout, or this suite hasn't run on main yet) — report, # don't gate. gh api "repos/$REPO/statuses/$SHA" \ -f state=success \ -f context="$CONTEXT" \ -f target_url="$RUN_URL" \ -f description="${METRIC}: ${TOTAL}% (no baseline yet)" >/dev/null echo "::notice::No ${CONTEXT} baseline on main yet; reported ${TOTAL}% without gating." exit 0 fi PASS=$(awk -v c="$TOTAL" -v b="$BASELINE" -v t="$COVERAGE_TOLERANCE" \ 'BEGIN { print (c + t >= b) ? 1 : 0 }') if [[ "$PASS" == "1" ]]; then gh api "repos/$REPO/statuses/$SHA" \ -f state=success \ -f context="$CONTEXT" \ -f target_url="$RUN_URL" \ -f description="${NOUN} ${TOTAL}% (baseline ${BASELINE}%)" >/dev/null echo "PASS: ${NOUN} ${TOTAL}% >= baseline ${BASELINE}% (tol ${COVERAGE_TOLERANCE})" elif [[ "$COVERAGE_ENFORCE" == "true" ]]; then gh api "repos/$REPO/statuses/$SHA" \ -f state=failure \ -f context="$CONTEXT" \ -f target_url="$RUN_URL" \ -f description="${NOUN} dropped: ${TOTAL}% < baseline ${BASELINE}%" >/dev/null echo "FAIL: ${NOUN} ${TOTAL}% < baseline ${BASELINE}% (tol ${COVERAGE_TOLERANCE})" else # Observe-only: surface the would-be regression without a red ✗. gh api "repos/$REPO/statuses/$SHA" \ -f state=success \ -f context="$CONTEXT" \ -f target_url="$RUN_URL" \ -f description="${NOUN} ${TOTAL}% < baseline ${BASELINE}% (would fail once enforced)" >/dev/null echo "::warning::${NOUN} regression (not gating): ${TOTAL}% < baseline ${BASELINE}% (tol ${COVERAGE_TOLERANCE})" fi