Skip to content

Commit 3b950f2

Browse files
JuliaEdomclaude
andcommitted
ci(coverage): stop one red gate from hiding the eight behind it
Two coverage regressions turned up this week, and neither was found by the gate that exists to find it. The auth-manager module sat at 82% against a 90% gate for two weeks; key rotation sat at 89.6% and passed only because coverage rounds before it compares. Both were found by running the gate commands by hand. The reason they were invisible is structural, not specific to those two modules. Steps in a job stop at the first failure, and test-unit runs nine per-module coverage gates in sequence after the full suite. While an earlier step in that job was failing, every gate after it simply never ran -- so a module could lose twelve points of coverage and the gate meant to catch it never executed to notice. The gates share nothing but the venv. Each runs its own pytest over its own files and reports on its own module; none consumes anything an earlier one produces. There was never a reason for one to gate the next. So each now carries `if: !cancelled() && steps.install.outcome == 'success'`: run whenever the environment installed, skip only on a cancelled run or a broken install (where all nine would be noise). Every gate still fails the job on its own -- nothing is downgraded to a warning. The difference is that a single run now reports all nine verdicts instead of stopping at the first, which is what turns "the gate is red" into "the gate is red *and* these two others slipped". test_coverage_policy.py::test_every_coverage_gate_runs_even_after_an_earlier_one_fails holds the shape: a gate added without the condition fails it, and so does dropping the `id: install` the condition refers to. Proven red against the pre-change ci.yml (1 failed, 9 passed), green after (10 passed). Verified: ci.yml parses and all nine gates carry the condition, ruff lint/format clean, test_coverage_policy.py passes (10 passed), docs and audit-gate ratchets pass (25 passed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ybRVCaL1Lyd9d5kFTndh3
1 parent 9879196 commit 3b950f2

3 files changed

Lines changed: 62 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ jobs:
247247
with:
248248
python-version: "3.11"
249249
- name: Install dependencies
250+
id: install
250251
run: |
251252
bash scripts/ci_sync.sh test-integrations
252253
- name: Verify SDK/integrations joint installability
@@ -264,22 +265,27 @@ jobs:
264265
- name: Enforce changed-code coverage
265266
run: diff-cover .artifacts/coverage/coverage.xml --compare-branch=origin/main --fail-under=80
266267
- name: Run quality validators coverage gate
268+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
267269
run: |
268270
python -m pytest tests/unit/test_validators.py -v --tb=short --cov=agentflow_runtime.quality.validators --cov-report=term-missing --cov-fail-under=90
269271
- name: Run freshness monitor coverage gate
272+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
270273
run: |
271274
python -m pytest tests/unit/test_freshness_monitor.py -v --tb=short --cov=agentflow_runtime.quality.monitors.freshness_monitor --cov-report=term-missing --cov-fail-under=90
272275
- name: Run event producer coverage gate
276+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
273277
run: |
274278
python -m pytest tests/unit/test_event_producer.py -v --tb=short --cov=agentflow_runtime.ingestion.producers.event_producer --cov-report=term-missing --cov-fail-under=90
275279
- name: Run SQL guard coverage gate
280+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
276281
run: |
277282
# Security-critical NL->SQL guard: validate_nl_sql (SELECT-only, no DML,
278283
# tenant table allow-list, recursive-CTE shadow reject). test_sql_guard
279284
# plus test_sql_guard_mutation give full module coverage (100% local);
280285
# the 90% gate keeps a 10pp regression cushion.
281286
python -m pytest tests/unit/test_sql_guard.py tests/unit/test_sql_guard_mutation.py -v --tb=short --cov=agentflow_runtime.serving.semantic_layer.sql_guard --cov-report=term-missing --cov-fail-under=90
282287
- name: Run rate limiter coverage gate
288+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
283289
run: |
284290
# Security-critical sliding-window rate limiter (Redis + in-memory
285291
# fail-closed fallback); local module coverage is 98% (only the optional
@@ -297,6 +303,7 @@ jobs:
297303
-k "not auth_middleware" -v --tb=short -p no:schemathesis -p no:cov
298304
python -m coverage report --include="*/serving/api/rate_limiter.py" --show-missing --fail-under=90
299305
- name: Run auth manager coverage gate
306+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
300307
run: |
301308
# Security-critical auth manager (key match/verify, tenant isolation,
302309
# rate-limit/failed-auth windows, rotation grace) and a mutmut target;
@@ -331,6 +338,7 @@ jobs:
331338
-p no:schemathesis
332339
python -m coverage report --include="*/serving/api/auth/manager.py" --show-missing --fail-under=90
333340
- name: Run key rotation coverage gate
341+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
334342
run: |
335343
# Security-critical key-rotation lifecycle (create/rotate/revoke,
336344
# grace-period scheduling, rotation status) and a mutmut target. Like
@@ -353,6 +361,7 @@ jobs:
353361
-p no:schemathesis
354362
python -m coverage report --include="*/serving/api/auth/key_rotation.py" --show-missing --fail-under=90
355363
- name: Run outbox coverage gate
364+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
356365
run: |
357366
# Security/reliability-critical at-least-once outbox dispatch loop
358367
# (delivery, retry/backoff, poison-to-failed, mark-sent transactions)
@@ -362,6 +371,7 @@ jobs:
362371
python -m coverage run -m pytest tests/unit/test_outbox_processor.py tests/unit/test_outbox_connection_guard.py -p no:schemathesis
363372
python -m coverage report --include="*/processing/outbox.py" --show-missing --fail-under=90
364373
- name: Run query package coverage gate
374+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
365375
run: |
366376
# The NL->SQL orchestration surface (engine, entity/metric/NL query
367377
# mixins, SQL builder) and a mutmut target set; the old single-file

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to AgentFlow are documented in this file.
44

55
## [Unreleased]
66

7+
### CI — a red coverage gate no longer hides the gates behind it
8+
9+
* **Steps in a job stop at the first failure**, and `test-unit` runs nine
10+
per-module coverage gates in a row. While an earlier step was failing, the
11+
auth-manager gate never ran at all — which is how that module drifted from
12+
94% to 82% for two weeks with a gate supposedly holding it at 90%, and how
13+
the key-rotation gate reached 89.6% (passing only because coverage rounds
14+
before it compares). Both were found by running the gates by hand.
15+
* **Each gate now runs whenever the environment installed**
16+
(`if: !cancelled() && steps.install.outcome == 'success'`). They measure
17+
different modules and share nothing but the venv, so there was never a
18+
reason for one to gate the next. Every gate still fails the job on its own;
19+
the difference is that one run now reports all nine verdicts instead of
20+
stopping at the first.
21+
* **A ratchet keeps it that way.**
22+
`test_coverage_policy.py::test_every_coverage_gate_runs_even_after_an_earlier_one_fails`
23+
fails if a gate is added without the condition, or if the install step
24+
loses the `id` the condition refers to.
25+
726
### Supply chain — releases were being built by a yanked builder
827

928
* **`build` 1.5.1 is yanked upstream** ("considers breaking changes, will

‎tests/unit/test_coverage_policy.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,36 @@ def test_ci_has_scoped_query_package_coverage_gate() -> None:
177177
assert "coverage run" in gate_step["run"]
178178
assert "*/serving/semantic_layer/query/*" in gate_step["run"]
179179
assert "--fail-under=90" in gate_step["run"]
180+
181+
182+
def test_every_coverage_gate_runs_even_after_an_earlier_one_fails() -> None:
183+
"""A red gate must not hide the gates behind it.
184+
185+
Steps in a job stop at the first failure, so for two weeks the
186+
auth-manager gate never ran: an earlier step in test-unit was failing, and
187+
the module drifted from 94% to 82% with nothing to report it. Each gate
188+
measures a different module and depends on nothing the others produce, so
189+
each one runs whenever the environment installed -- and still fails the
190+
job on its own.
191+
"""
192+
workflow = yaml.safe_load(
193+
(PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
194+
)
195+
steps = workflow["jobs"]["test-unit"]["steps"]
196+
197+
install = next(step for step in steps if step.get("name") == "Install dependencies")
198+
assert install.get("id") == "install", "the gate condition references steps.install"
199+
200+
gates = [
201+
step
202+
for step in steps
203+
if str(step.get("name", "")).startswith("Run ")
204+
and str(step.get("name", "")).endswith(" coverage gate")
205+
]
206+
assert len(gates) >= 9, f"expected the per-module gates, found {len(gates)}"
207+
208+
for gate in gates:
209+
condition = gate.get("if")
210+
assert condition is not None, f"{gate['name']} has no if: condition"
211+
assert "!cancelled()" in condition, gate["name"]
212+
assert "steps.install.outcome == 'success'" in condition, gate["name"]

0 commit comments

Comments
 (0)