diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9182e13c..6eaac6eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,11 +149,17 @@ jobs: - name: Verify cargo-llvm-cov version run: cargo llvm-cov --version | grep -F "$CARGO_LLVM_COV_VERSION" - name: Generate line coverage + id: line_coverage_generation run: cargo llvm-cov --json --summary-only --output-path coverage.json - name: Enforce complete line coverage + id: line_coverage_gate + if: ${{ !cancelled() && steps.line_coverage_generation.outcome == 'success' }} run: python3 scripts/check_coverage.py coverage.json --kind lines + - name: Diagnose line coverage generation failure + if: ${{ !cancelled() && steps.line_coverage_generation.outcome == 'failure' }} + run: echo "::error::line coverage generation failed before the coverage gate" - name: Diagnose missing line coverage - if: failure() + if: failure() && steps.line_coverage_gate.outcome == 'failure' run: | python3 - <<'PY' import json @@ -194,6 +200,8 @@ jobs: branch-coverage: name: Production branch coverage + needs: line-coverage + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 15 services: @@ -229,11 +237,17 @@ jobs: - name: Verify cargo-llvm-cov version run: cargo +nightly-2026-08-18 llvm-cov --version | grep -F "$CARGO_LLVM_COV_VERSION" - name: Generate branch coverage + id: branch_coverage_generation run: cargo +nightly-2026-08-18 llvm-cov --branch --json --summary-only --output-path coverage-branches.json - name: Enforce complete branch coverage + id: branch_coverage_gate + if: ${{ !cancelled() && steps.branch_coverage_generation.outcome == 'success' }} run: python3 scripts/check_coverage.py coverage-branches.json --kind branches + - name: Diagnose branch coverage generation failure + if: ${{ !cancelled() && steps.branch_coverage_generation.outcome == 'failure' }} + run: echo "::error::branch coverage generation failed before the coverage gate" - name: Diagnose missing branch coverage - if: failure() + if: failure() && steps.branch_coverage_gate.outcome == 'failure' run: | python3 - <<'PY' import json diff --git a/tests/ci_contract.rs b/tests/ci_contract.rs index 6e8de03d..6e66480c 100644 --- a/tests/ci_contract.rs +++ b/tests/ci_contract.rs @@ -4,6 +4,16 @@ const CI_WORKFLOW: &str = include_str!("../.github/workflows/ci.yml"); const RUST_TOOLCHAIN: &str = include_str!("../rust-toolchain.toml"); const DEPENDABOT: &str = include_str!("../.github/dependabot.yml"); +#[test] +fn runtime_ci_preserves_check_identities_and_limits_peak_fanout() { + assert_eq!(CI_WORKFLOW.matches("runs-on: ubuntu-latest").count(), 3); + assert!(CI_WORKFLOW.contains("\n line-coverage:\n name: Production line coverage\n")); + assert!(CI_WORKFLOW.contains( + "\n branch-coverage:\n name: Production branch coverage\n needs: line-coverage\n if: ${{ always() && !cancelled() }}\n" + )); + assert!(!CI_WORKFLOW.contains("\n coverage:\n")); +} + #[test] fn every_checkout_is_bound_to_the_pull_request_head() { let exact_head_ref = "ref: ${{ github.event.pull_request.head.sha || github.sha }}"; @@ -78,6 +88,22 @@ fn lockfile_failure_evidence_is_scoped_to_the_lock_gate() { ); } +#[test] +fn coverage_jobs_preserve_both_exact_gates() { + assert!(CI_WORKFLOW.contains("id: line_coverage_gate")); + assert!(CI_WORKFLOW.contains("id: branch_coverage_gate")); + assert!(CI_WORKFLOW.contains("if: failure() && steps.line_coverage_gate.outcome == 'failure'")); + assert!( + CI_WORKFLOW.contains("if: failure() && steps.branch_coverage_gate.outcome == 'failure'") + ); + assert_eq!( + CI_WORKFLOW + .matches("cargo install cargo-llvm-cov --locked --version \"$CARGO_LLVM_COV_VERSION\"") + .count(), + 1 + ); +} + #[test] fn coverage_failures_identify_the_incomplete_source_files() { assert!(CI_WORKFLOW.contains("INCOMPLETE_FILE")); diff --git a/tests/ci_serial_coverage_contract.rs b/tests/ci_serial_coverage_contract.rs new file mode 100644 index 00000000..49b5e0f5 --- /dev/null +++ b/tests/ci_serial_coverage_contract.rs @@ -0,0 +1,48 @@ +//! Regression contract for capacity-aware coverage scheduling. +//! +//! Runtime CI must reduce peak hosted-runner pressure without renaming the two +//! long-lived coverage check identities. Branch coverage therefore waits for +//! the line-coverage job, but still runs after a line-coverage failure unless +//! the workflow was cancelled. Generation failures remain explicit operator +//! diagnostics rather than silently skipped evidence. + +const CI_WORKFLOW: &str = include_str!("../.github/workflows/ci.yml"); + +#[test] +fn coverage_jobs_preserve_their_check_identities() { + assert!(CI_WORKFLOW.contains("\n line-coverage:\n name: Production line coverage\n")); + assert!(CI_WORKFLOW.contains("\n branch-coverage:\n name: Production branch coverage\n")); + assert!(!CI_WORKFLOW.contains("name: Production line and branch coverage")); +} + +#[test] +fn branch_coverage_is_serialized_without_becoming_fail_open() { + assert!(CI_WORKFLOW.contains( + "\n branch-coverage:\n name: Production branch coverage\n needs: line-coverage\n if: ${{ always() && !cancelled() }}\n" + )); + assert_eq!(CI_WORKFLOW.matches("runs-on: ubuntu-latest").count(), 3); +} + +#[test] +fn line_generation_failure_has_operator_diagnostics() { + assert!(CI_WORKFLOW + .contains("- name: Generate line coverage\n id: line_coverage_generation")); + assert!(CI_WORKFLOW.contains( + "- name: Diagnose line coverage generation failure\n if: ${{ !cancelled() && steps.line_coverage_generation.outcome == 'failure' }}" + )); + assert!( + CI_WORKFLOW.contains("::error::line coverage generation failed before the coverage gate") + ); +} + +#[test] +fn branch_generation_failure_has_operator_diagnostics() { + assert!(CI_WORKFLOW + .contains("- name: Generate branch coverage\n id: branch_coverage_generation")); + assert!(CI_WORKFLOW.contains( + "- name: Diagnose branch coverage generation failure\n if: ${{ !cancelled() && steps.branch_coverage_generation.outcome == 'failure' }}" + )); + assert!( + CI_WORKFLOW.contains("::error::branch coverage generation failed before the coverage gate") + ); +}