176 lines
9.0 KiB
YAML
176 lines
9.0 KiB
YAML
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
|