From 3aa06e32fa326eb5932c288e285258047debec58 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 14 Aug 2026 16:24:00 +0800 Subject: [PATCH] ci: shard full-suite coverage and cache merge-base profile The Coverage context was the PR critical path (~17 min end to end): coverage-current re-ran the whole suite serially (-p 1, ~13 min) and coverage-baseline re-ran it again at the merge-base (~13 min) although that profile is a pure function of the base commit. - coverage-current now owns only the scoped (standard-tier) profile; full-suite candidate profiles come from a 5-way shard matrix (app/cli/generators/helpers/remaining) that keeps -p 1 inside each shard on isolated runners. scripts/ci/test-packages.sh list-coverage defines the shards and verify proves the union equals the previous single-run package set exactly once. - the aggregate Coverage job reassembles the disjoint shard profiles into coverage.txt before make coverage-gate, failing closed when a shard file is missing, so gate semantics (100% changed-code + scope-matched overall non-regression) are byte-compatible. - coverage-baseline restores the merge-base full-suite profile from an exact-key cache (merge-base SHA + resolved Go version) written by the last green main push; any miss falls back to recomputing in the merge-base worktree. Exact key only - no prefix fallback, a near-miss profile would compare the candidate against the wrong commit. - new contract tests pin the shard matrix, the assembly step, the exact-key cache pair, and the absence of restore-keys; the package plan test also covers the coverage shard partition. --- .github/workflows/ci.yml | 185 ++++++++++++++---- Makefile | 2 +- docs/ci-pr-gates.md | 14 +- scripts/ci/test-packages.sh | 103 ++++++++++ test/scripts/ci_test_package_plan_test.go | 33 ++++ .../coverage_workflow_contract_test.go | 94 +++++++++ 6 files changed, 390 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c1007d..cdcdb309 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -911,7 +911,7 @@ jobs: coverage-current: name: Coverage (current) needs: lint - if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -926,10 +926,6 @@ jobs: with: go-version-file: go.mod - - name: Install archive tooling - if: needs.lint.outputs.full_suite == 'true' - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Resolve authoritative coverage base env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -951,41 +947,33 @@ jobs: - name: Build run: make build - - name: Run current unit tests with coverage + - name: Run scoped unit tests with coverage shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test - FULL_SUITE: ${{ needs.lint.outputs.full_suite }} run: | set -euo pipefail - if [ "$FULL_SUITE" = true ]; then + changed_output="$( + ./scripts/ci/changed-test-packages.sh \ + changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" + )" + impacted_output="$( + ./scripts/ci/changed-test-packages.sh \ + list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" + )" + if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then + printf 'mode: atomic\n' > coverage.txt + echo "No buildable Go package needs scoped coverage." \ + >> "$GITHUB_STEP_SUMMARY" + else + mapfile -t changed_packages <<< "$changed_output" + mapfile -t impacted_packages <<< "$impacted_output" + coverpkg="$(IFS=,; echo "${changed_packages[*]}")" go test -count=1 -p 1 \ + -coverpkg="$coverpkg" \ -coverprofile=coverage.txt \ -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... - else - changed_output="$( - ./scripts/ci/changed-test-packages.sh \ - changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" - )" - impacted_output="$( - ./scripts/ci/changed-test-packages.sh \ - list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" - )" - if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then - printf 'mode: atomic\n' > coverage.txt - echo "No buildable Go package needs scoped coverage." \ - >> "$GITHUB_STEP_SUMMARY" - else - mapfile -t changed_packages <<< "$changed_output" - mapfile -t impacted_packages <<< "$impacted_output" - coverpkg="$(IFS=,; echo "${changed_packages[*]}")" - go test -count=1 -p 1 \ - -coverpkg="$coverpkg" \ - -coverprofile=coverage.txt \ - -covermode=atomic \ - "${impacted_packages[@]}" - fi + "${impacted_packages[@]}" fi if [ "$(wc -l < coverage.txt)" -gt 1 ]; then go tool cover -func=coverage.txt @@ -998,6 +986,66 @@ jobs: path: coverage.txt retention-days: 1 + coverage-current-full: + name: "Coverage (current: ${{ matrix.shard }})" + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: + - app + - cli + - generators + - helpers + - remaining + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Build + run: make build + + # Each shard keeps -p 1 so the authoritative measurement stays serial + # inside one instrumented process group; shards run on isolated runners, + # and scripts/ci/test-packages.sh verify proves the shard union equals + # the previous single full-suite package set exactly once. + - name: Run current shard tests with coverage + shell: bash + env: + DWS_PACKAGE_VERSION: 0.0.0-test + COVERAGE_SHARD: ${{ matrix.shard }} + run: | + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")" + test -n "$package_output" + mapfile -t packages <<< "$package_output" + test "${#packages[@]}" -gt 0 + go test -count=1 -p 1 \ + -coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \ + -covermode=atomic \ + "${packages[@]}" + go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1 + + - name: Upload current shard coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage-current-shard-${{ matrix.shard }} + path: coverage-shard-${{ matrix.shard }}.txt + retention-days: 1 + coverage-supporting: name: Coverage (supporting) needs: lint @@ -1051,14 +1099,11 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Install archive tooling - if: needs.lint.outputs.full_suite == 'true' - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Resolve authoritative coverage base env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -1076,7 +1121,26 @@ jobs: git rev-parse --verify "${base_ref}^{commit}" >/dev/null echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" + # The merge-base full-suite profile is a pure function of the base + # commit. Reuse the profile published by the last green push run of + # exactly that commit instead of re-running the whole suite; any key + # mismatch falls back to authoritative recomputation. Exact key only, + # never prefix fallback: a near-miss profile would compare the + # candidate against the wrong commit. + - name: Restore cached merge-base coverage profile + id: baseline-cache + if: needs.lint.outputs.full_suite == 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-base.txt + key: dws-coverage-full-v1-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Install archive tooling + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' + run: sudo apt-get update && sudo apt-get install -y zip unzip + - name: Run baseline unit tests with coverage + if: steps.baseline-cache.outputs.cache-hit != 'true' shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test @@ -1125,6 +1189,13 @@ jobs: fi ) + - name: Save merge-base coverage profile cache + if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: coverage-base.txt + key: dws-coverage-full-v1-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} + - name: Upload baseline coverage profile uses: actions/upload-artifact@v4 with: @@ -1137,6 +1208,7 @@ jobs: needs: - lint - coverage-current + - coverage-current-full - coverage-supporting - coverage-baseline - coverage-darwin @@ -1152,6 +1224,7 @@ jobs: FULL_SUITE: ${{ needs.lint.outputs.full_suite }} PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} CURRENT_RESULT: ${{ needs.coverage-current.result }} + CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} DARWIN_RESULT: ${{ needs.coverage-darwin.result }} @@ -1159,6 +1232,7 @@ jobs: run: | failed=0 current_expected=success + current_full_expected=skipped supporting_expected=skipped baseline_expected=success native_expected=skipped @@ -1166,6 +1240,8 @@ jobs: current_expected=skipped baseline_expected=skipped elif [ "$FULL_SUITE" = true ]; then + current_expected=skipped + current_full_expected=success supporting_expected=success fi if [ "$CHANGELOG_ONLY" != true ] && @@ -1176,6 +1252,7 @@ jobs: for profile in \ "current:$CURRENT_RESULT:$current_expected" \ + "current shards:$CURRENT_FULL_RESULT:$current_full_expected" \ "supporting:$SUPPORTING_RESULT:$supporting_expected" \ "baseline:$BASELINE_RESULT:$baseline_expected" do @@ -1211,6 +1288,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + id: setup-go if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/setup-go@v5 with: @@ -1234,11 +1312,12 @@ jobs: git rev-parse --verify "${base_ref}^{commit}" >/dev/null echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" - - name: Download current coverage profile + - name: Download current coverage profiles if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/download-artifact@v4 with: - name: coverage-current-profile + pattern: coverage-current-* + merge-multiple: true path: . - name: Download supporting coverage profiles @@ -1255,6 +1334,26 @@ jobs: name: coverage-baseline-profile path: . + # Shard profiles cover disjoint package sets, so their block-level + # concatenation is the same candidate profile one serial run produced. + # Every expected shard must be present; a missing shard would silently + # shrink the scope-matched overall comparison. + - name: Assemble full-suite coverage profile + if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + shell: bash + run: | + set -euo pipefail + test ! -f coverage.txt + for shard in app cli generators helpers remaining; do + profile="coverage-shard-$shard.txt" + test -f "$profile" + test "$(head -n 1 "$profile")" = "mode: atomic" + done + printf 'mode: atomic\n' > coverage.txt + for shard in app cli generators helpers remaining; do + tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt + done + - name: Enforce coverage gate if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' env: @@ -1275,6 +1374,16 @@ jobs: COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \ make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + # Publish this push's full-suite profile as the merge-base cache for + # future PRs whose merge-base is exactly this commit. Saved only after + # the gate passed so a broken run never becomes a baseline. + - name: Save push coverage profile as merge-base cache + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + uses: actions/cache/save@v4 + with: + path: coverage.txt + key: dws-coverage-full-v1-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' run: | diff --git a/Makefile b/Makefile index 75b4356a..73913b28 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" - @printf " make test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n" @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @printf " make format-check - Check all repository Go source files with gofmt\n" diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 5244b214..2141cecf 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -201,8 +201,18 @@ base_ref=$(git merge-base HEAD origin/main) standard PR, CI derives changed packages and their reverse-dependency test closure, then generates candidate and merge-base profiles with the same test scope and `coverpkg`. High-risk and protected-main runs use the complete -profiles. Supporting and (when platform-selected) native profiles are -generated before the aggregate `Coverage` context evaluates them. The +profiles. The complete candidate profile is produced by disjoint per-shard +helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with +`-p 1` inside each shard; `verify` proves the shard union equals the +full-suite scope exactly once) and concatenated in the aggregate job before +enforcement. The complete merge-base profile is restored from an exact-key +cache written by the last green `main` push of that same commit (key: +merge-base SHA plus resolved Go version); any miss falls back to recomputing +it in a merge-base worktree. Near-miss reuse is forbidden — the caches carry +no prefix restore keys, because a neighbouring commit's profile would compare +the candidate against the wrong baseline. Supporting and (when +platform-selected) native profiles are generated before the aggregate +`Coverage` context evaluates them. The aggregate and native gates require 100% coverage for changed executable Go statements. Overall coverage remains an unrounded, zero-tolerance, scope-matched merge-base non-regression check. Candidate and baseline profiles diff --git a/scripts/ci/test-packages.sh b/scripts/ci/test-packages.sh index 6d57298c..fd36ed46 100755 --- a/scripts/ci/test-packages.sh +++ b/scripts/ci/test-packages.sh @@ -7,6 +7,7 @@ MODULE="$(cd "$ROOT" && go list -m)" usage() { printf '%s\n' \ "usage: $0 list " \ + " $0 list-coverage " \ " $0 verify" >&2 exit 2 } @@ -57,6 +58,52 @@ list_shard() { esac } +# Full-suite coverage measurement shards the same authoritative package set the +# single serial run used: ./ ./cmd/... ./internal/... ./skills/... (pkg/ and +# scripts/ belong to the supporting profile; test/ suites carry no production +# statements for the candidate profile). Shards stay disjoint so their merged +# profile is block-for-block equivalent to one serial invocation. +list_coverage_scope() { + cd "$ROOT" + go list ./ ./cmd/... ./internal/... ./skills/... +} + +list_coverage_shard() { + shard="$1" + cd "$ROOT" + + case "$shard" in + app) + go list ./internal/app/... + ;; + cli) + go list ./internal/cli/... + ;; + generators) + go list ./internal/generator/... + ;; + helpers) + go list ./internal/helpers/... + ;; + remaining) + scope_packages="$(list_coverage_scope)" + printf '%s\n' "$scope_packages" | while IFS= read -r package; do + case "$package" in + "$MODULE/internal/app"|"$MODULE/internal/app/"*) ;; + "$MODULE/internal/cli"|"$MODULE/internal/cli/"*) ;; + "$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;; + "$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;; + *) printf '%s\n' "$package" ;; + esac + done + ;; + *) + printf 'unknown coverage package shard: %s\n' "$shard" >&2 + exit 2 + ;; + esac +} + verify_plan() { workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")" trap 'rm -rf "$workdir"' EXIT HUP INT TERM @@ -114,6 +161,58 @@ verify_plan() { package_count="$(wc -l < "$expected" | tr -d ' ')" printf 'test package plan covers %s default packages exactly once\n' "$package_count" + + coverage_expected="$workdir/coverage-expected" + coverage_assigned="$workdir/coverage-assigned" + coverage_unique="$workdir/coverage-unique" + coverage_duplicates="$workdir/coverage-duplicates" + coverage_missing="$workdir/coverage-missing" + coverage_unexpected="$workdir/coverage-unexpected" + + list_coverage_scope > "$workdir/coverage-scope" + LC_ALL=C sort -u "$workdir/coverage-scope" > "$coverage_expected" + : > "$coverage_assigned" + + for shard in app cli generators helpers remaining; do + shard_packages="$workdir/coverage-$shard" + unsorted_shard_packages="$workdir/coverage-$shard.unsorted" + list_coverage_shard "$shard" > "$unsorted_shard_packages" + LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages" + if [ ! -s "$shard_packages" ]; then + printf 'coverage package shard is empty: %s\n' "$shard" >&2 + exit 1 + fi + cat "$shard_packages" >> "$coverage_assigned" + done + + LC_ALL=C sort "$coverage_assigned" -o "$coverage_assigned" + uniq -d "$coverage_assigned" > "$coverage_duplicates" + LC_ALL=C sort -u "$coverage_assigned" > "$coverage_unique" + comm -23 "$coverage_expected" "$coverage_unique" > "$coverage_missing" + comm -13 "$coverage_expected" "$coverage_unique" > "$coverage_unexpected" + + coverage_failed=0 + if [ -s "$coverage_duplicates" ]; then + printf '%s\n' 'coverage packages assigned to more than one shard:' >&2 + sed 's/^/ /' "$coverage_duplicates" >&2 + coverage_failed=1 + fi + if [ -s "$coverage_missing" ]; then + printf '%s\n' 'full-suite coverage packages missing from the CI coverage plan:' >&2 + sed 's/^/ /' "$coverage_missing" >&2 + coverage_failed=1 + fi + if [ -s "$coverage_unexpected" ]; then + printf '%s\n' 'CI coverage plan contains packages outside its scope:' >&2 + sed 's/^/ /' "$coverage_unexpected" >&2 + coverage_failed=1 + fi + if [ "$coverage_failed" -ne 0 ]; then + exit 1 + fi + + coverage_count="$(wc -l < "$coverage_expected" | tr -d ' ')" + printf 'coverage package plan covers %s full-suite packages exactly once\n' "$coverage_count" } case "${1:-}" in @@ -121,6 +220,10 @@ case "${1:-}" in [ "$#" -eq 2 ] || usage list_shard "$2" ;; + list-coverage) + [ "$#" -eq 2 ] || usage + list_coverage_shard "$2" + ;; verify) [ "$#" -eq 1 ] || usage verify_plan diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go index 136ce8ec..0d7944f2 100644 --- a/test/scripts/ci_test_package_plan_test.go +++ b/test/scripts/ci_test_package_plan_test.go @@ -14,6 +14,39 @@ func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) { if !strings.Contains(output, "default packages exactly once") { t.Fatalf("verify output = %q, want coverage summary", output) } + if !strings.Contains(output, "full-suite packages exactly once") { + t.Fatalf("verify output = %q, want coverage shard plan summary", output) + } +} + +func TestCICoveragePackagePlanRoutesFullSuiteScope(t *testing.T) { + root := testPackagePlanRoot(t) + remaining := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "remaining")) + + for _, suffix := range []string{"/cmd", "/internal/output", "/skills"} { + if !containsPackageSuffix(remaining, suffix) { + t.Errorf("coverage remaining shard does not contain package ending in %q", suffix) + } + } + for _, suffix := range []string{ + "/internal/app", + "/internal/cli", + "/internal/generator", + "/internal/helpers", + "/test/smoke", + "/test/scripts", + "/pkg/cmdutil", + "/scripts/policy/coverage-gate", + } { + if containsPackageSuffix(remaining, suffix) { + t.Errorf("coverage remaining shard unexpectedly contains package ending in %q", suffix) + } + } + + app := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "app")) + if !containsPackageSuffix(app, "/internal/app") { + t.Error("coverage app shard does not contain /internal/app") + } } func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index a591b93c..84dbb831 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -118,3 +118,97 @@ esac assertDiffProfiles(t, runGate(t, &empty), "coverage.txt") }) } + +// TestCoverageWorkflowShardsAndBaselineCache pins the full-suite coverage +// architecture: the candidate profile is produced by disjoint per-shard +// helper jobs and reassembled before enforcement, and the merge-base profile +// is reused only through an exact-key cache written by a green main push of +// that same commit. Near-miss reuse (restore-keys) would compare the +// candidate against the wrong commit and must never appear. +func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + admission := string(data) + + currentStart := strings.Index(admission, "\n coverage-current:\n") + fullStart := strings.Index(admission, "\n coverage-current-full:\n") + supportingStart := strings.Index(admission, "\n coverage-supporting:\n") + baselineStart := strings.Index(admission, "\n coverage-baseline:\n") + gateStart := strings.Index(admission, "\n coverage:\n") + policyStart := strings.Index(admission, "\n policy:\n") + if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart || + baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart { + t.Fatal("CI workflow missing ordered coverage job boundaries") + } + + currentJob := admission[currentStart:fullStart] + if !strings.Contains(currentJob, "needs.lint.outputs.full_suite != 'true'") { + t.Error("coverage-current must be scoped-tier only; the full suite belongs to the shard matrix") + } + if strings.Contains(currentJob, "./ ./cmd/... ./internal/... ./skills/...") { + t.Error("coverage-current must not retain the retired single serial full-suite run") + } + + fullJob := admission[fullStart:supportingStart] + for _, want := range []string{ + "needs.lint.outputs.full_suite == 'true'", + "fail-fast: false", + " - app", + " - cli", + " - generators", + " - helpers", + " - remaining", + `./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD"`, + "go test -count=1 -p 1", + `-coverprofile="coverage-shard-$COVERAGE_SHARD.txt"`, + "-covermode=atomic", + "name: coverage-current-shard-${{ matrix.shard }}", + } { + if !strings.Contains(fullJob, want) { + t.Errorf("coverage-current-full missing shard contract %q", want) + } + } + + baselineJob := admission[baselineStart:gateStart] + baselineKey := "dws-coverage-full-v1-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}" + for _, want := range []string{ + "uses: actions/cache/restore@v4", + "uses: actions/cache/save@v4", + "path: coverage-base.txt", + "key: " + baselineKey, + "if: steps.baseline-cache.outputs.cache-hit != 'true'", + } { + if !strings.Contains(baselineJob, want) { + t.Errorf("coverage-baseline missing cache contract %q", want) + } + } + if strings.Count(baselineJob, "key: "+baselineKey) != 2 { + t.Error("coverage-baseline restore and save must use the identical exact cache key") + } + + gateJob := admission[gateStart:policyStart] + for _, want := range []string{ + "pattern: coverage-current-*", + "merge-multiple: true", + "for shard in app cli generators helpers remaining; do", + "test ! -f coverage.txt", + `test "$(head -n 1 "$profile")" = "mode: atomic"`, + "github.event_name == 'push'", + "key: dws-coverage-full-v1-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}", + `"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`, + } { + if !strings.Contains(gateJob, want) { + t.Errorf("coverage gate missing shard assembly contract %q", want) + } + } + + if strings.Contains(admission, "restore-keys") { + t.Error("coverage caches must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline") + } +}