Skip to content

fix(ci) #5701: coverage-report waits for the HA suite, and refuses to publish a partial merge - #5705

Merged
lvca merged 7 commits into
mainfrom
fix/5701-coverage-report-waits-for-ha
Aug 1, 2026
Merged

lvca merged 7 commits into
mainfrom
fix/5701-coverage-report-waits-for-ha

Conversation

@lvca

@lvca lvca commented Aug 1, 2026 •

Copy link
Copy Markdown
Member

Fixes #5701.

The bug

coverage-report downloads ha-integration-coverage-reports but its needs listed only
[unit-tests, integration-tests, slow-unit-tests]. actions/download-artifact reads the artifact
store of the current run and has no happens-before relationship with the uploader - that ordering
exists only through needs. ha-integration-tests takes ~55 minutes, by far the longest job in
the workflow, so coverage-report usually started well before it finished.

The download then failed, continue-on-error: true swallowed the failure, and the merged report
went to Codecov with the whole ha-raft module absent. Codecov does not read an absent module as
"unknown" - it reads it as uncovered, which is how a 29-line change to
engine/src/main/java/com/arcadedb/index/hash/ produced Hits 101236 -> 100126 (-1110) on #5685.

Confirmed on the real runs

run coverage-report started ha-integration-tests finished HA artifact
30691056907 08:53:51 09:08:18 Artifact not found for name: ha-integration-coverage-reports
30691165476 08:59:57 09:12:02 missing
30693081526 10:08:14 10:08:03 present (finished 11s earlier)

So it was a race, not a constant. That is the part that made it hard to see: identical config
produced a complete report on a slow run and an incomplete one on a fast run, and the incomplete
runs also became the base every later pull request was compared against - one bad run yields a
phantom drop and then a phantom recovery on whoever comes next.

What changed

1. The ordering. ha-integration-tests added to coverage-report.needs. The job condition
becomes !cancelled() rather than success() || failure(), so a suite that reported failing tests
still publishes what JaCoCo wrote (the suites run with --fail-never), while a cancelled run - the
one case where an artifact is genuinely absent rather than merely late - still skips.

2. No more silent partial merges. The issue notes that continue-on-error: true hides exactly
this class of bug, and suggests failing or at least logging. This goes with failing.
.github/scripts/collect-coverage-reports.sh replaces the bare
find . -path **/jacoco*.xml -printf '%p,': it takes the suites it expects as <suite>=<dir>
pairs, verifies each contributed at least one report, writes a per-suite table to the step summary,
and exits non-zero naming the missing suites. The Codacy and Codecov steps lose their
if: success() || failure() so they are skipped when it fails. A partial upload is worse than no
upload, and a red coverage-report naming the missing suite is a far better signal than a green
one reporting a fabricated regression.

3. The generalisation - suggested in place of just adding the needs entry. Adding one entry
fixes today's instance and leaves the class open: nothing about a download-artifact step tells
you which job must precede it, so the next one is equally invisible.
.github/scripts/check-workflow-artifact-deps.py makes it an enforced invariant - for every
download step it resolves the producing job and asserts it is in the consumer's transitive needs
closure, across every workflow. It handles transitive needs, matrix-interpolated artifact names
matched as globs against pattern: consumers, selector-less downloads (which depend on every
producer), and cross-run downloads (which this run's ordering cannot constrain). Run against the
unfixed workflow it reports this bug and nothing else; across the other 17 workflows it is clean.
Wired into the setup job.

Verification

.github/scripts/tests/test-ci-scripts.sh (also wired into setup) - 15 checks, both directions,
against fixtures rather than the live workflows:

check-workflow-artifact-deps.py
  ok   - rejects a download whose producer is not in needs
  ok   - accepts the same workflow once needs lists the producer
  ok   - accepts a producer reached transitively through needs
  ok   - resolves a matrix-named upload against a pattern download
  ok   - rejects a pattern download that does not wait for the matrix
  ok   - rejects a matrix-named download that does not wait for the matrix
  ok   - does not guess at a fully interpolated selector
  ok   - rejects an unselective download that misses a producer
  ok   - ignores a download from another workflow run
  ok   - the repository's own workflows are ordered

collect-coverage-reports.sh
  ok   - collects a report from every suite
  ok   - refuses to publish when a suite's artifact is missing
  ok   - refuses to publish when a suite produced no report
  ok   - the file list is comma-separated with no empty entries
  ok   - rejects an argument that is not <suite>=<dir>

15 checks passed

Mutation-checked: reverting only the needs line and re-running turns
the repository's own workflows are ordered red with the exact #5701 diagnostic, and nothing else
changes. The two collect-coverage-reports.sh refusal cases are the #5701 shape directly - the HA
artifact never downloaded, and the HA job ran but wrote no report.

Trade-off worth stating

coverage-report now waits for the slowest job, so the Codecov and Codacy checks land roughly 10
minutes later than they do today. That is the cost of the signal being true; there is no ordering
that produces a complete merge sooner.

On #5702

Independent of it. ha-integration-tests runs with --fail-never and uploads with
if: success() || failure(), so its coverage artifact exists whether or not tests pass. The issue
notes this fix will surface partial HA coverage from partially-failed runs - it will, and that is
the correct behaviour: coverage genuinely lost because tests did not run is a real signal, unlike a
module vanishing entirely. Only a cancelled HA job produces no artifact, and that now fails loudly
by name instead of silently.

Behavior change worth knowing about

coverage-report can now go red where it previously went green. Two paths:

  1. A suite that ran but never uploaded - a runner death or a job timeout, not a test failure,
    which --fail-never already covers - leaves its artifact missing and the collector fails the
    job by name. Given ha-integration-tests fails on main ~75% of the time, with a different test each run #5702, the HA suite is the likely one.
  2. Any suite reporting skipped skips coverage-report entirely rather than publishing three of
    four.

Both are deliberate. The alternative to (1) is publishing a merge without ha-raft, which is
#5701 itself; a red check naming the suite is the honest version of the same event. If it proves
noisy, the remedy is fixing #5702 rather than re-enabling silent partial merges.

Neither can block a merge today: main has no branch protection (GET /branches/main/protection
returns 404), no rulesets, and Mergify's only rule targets Dependabot. No status check is required.

… publish a partial merge

`coverage-report` downloaded `ha-integration-coverage-reports` but listed only
[unit-tests, integration-tests, slow-unit-tests] in `needs`. A download only sees artifacts
already uploaded, so the job started ~14 minutes before `ha-integration-tests` finished - it is
the longest job in the workflow at ~55 minutes - the download failed, `continue-on-error: true`
swallowed it, and the merge went to Codecov without the ha-raft module. Codecov read the module
as uncovered and reported 1,110 covered lines disappearing on a PR that had touched 29 lines in
`engine/.../index/hash/`.

It was a race, not a constant: on runs where the other three jobs happened to finish late the
artifact was there. Run 30693081526 downloaded it (HA finished 11s before), runs 30691165476 and
30691056907 did not. So the check flipped between correct and wrong on identical config, and the
incorrect runs also became the base later pull requests were compared against.

- add `ha-integration-tests` to `coverage-report.needs`, and relax the job condition to
  `!cancelled()` so a suite with failing tests still publishes what JaCoCo wrote
- replace the bare `find` with `collect-coverage-reports.sh`, which names the suites it expects,
  fails when one contributed no report, and writes a per-suite table to the step summary. The
  Codacy and Codecov steps lose their `if: success() || failure()` so they are skipped on that
  failure: a partial upload is worse than no upload, because it poisons the base too
- add `check-workflow-artifact-deps.py`, which asserts that every `download-artifact` step's
  producer is in the consumer's transitive `needs` closure, over every workflow. It reports this
  bug on the unfixed file and nothing else across the other 17 workflows. Wired into `setup`

Related: #5702 (the HA suite's own flakiness), which this fix is independent of - the suite runs
with --fail-never, so its coverage artifact uploads whether or not tests pass.
@codacy-production

codacy-production Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 100 complexity

Metric Results
Complexity 100

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@mergify

mergify Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

…tifact-deps

D213 on two multi-line docstrings and D403 on one summary. D213 is also the convention the
repository's own Python already follows by a wide margin (342 docstrings to 105).
@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(ci) #5701 - coverage-report waits for the HA suite

Thorough, well-diagnosed PR. The root cause (a download-artifact step with no needs edge to its producer) is correctly identified, the fix is minimal where it needs to be, and the generalisation into an enforced invariant plus a two-directional test suite is exactly the right level of rigor for a bug that "survives code review." I verified the changed coverage-report job, confirmed the collector's <suite>=<dir> args line up with the four download-artifact path: values, and manually traced check-workflow-artifact-deps.py against the repo's real matrix downloads (native-image.yml docker/build, test-python-bindings.yml download-jars/test) - it resolves all of them correctly. Nice work.

A few observations, all minor - nothing blocking.

Correctness / robustness

  1. Bidirectional glob matching can over-match (latent false positive). matching_producers accepts a match when either fnmatch(artifact, selector) or fnmatch(selector, artifact) holds. The reverse direction is genuinely needed (a literal/specific download of a matrix-glob upload), but it also means an unrelated literal artifact that happens to fall under a matrix family's glob creates a spurious ordering requirement. Example: uploads build-\${{ matrix.os }} (-> build-*) and build-logs, plus a download name: build-logs. The download would be reported as also needing the matrix build-* job. The repo is clean today, but this is the kind of edge that could red-light CI later when someone adds a prefix-colliding artifact name. Worth a one-line note in the script's docstring so the next person understands the tradeoff before debugging a "false" violation.

  2. run-id skip is name-agnostic. A download with run-id: \${{ github.run_id }} (i.e. the current run) is treated as cross-run and skipped, even though this run's ordering does apply. Rare, but it silently disables the check for that step. Just flagging.

  3. New hard gate on the critical setup job, for all workflows. check-workflow-artifact-deps.py now fails the entire build on any violation across every workflow. That's the intent, and it's verified clean now, but it does couple all-CI liveness to this script's future false-positive rate (see build support #1). The redundant re-run inside test-ci-scripts.sh ("the repository's own workflows are ordered") is a good belt-and-suspenders guard, though.

Behavior worth confirming is intended

  1. if: \${{ !cancelled() }} when an upstream fails. If build-and-package fails, the four test jobs are skipped, coverage-report still runs (!cancelled() is true), the downloads find nothing, and collect-coverage-reports.sh now exits non-zero - adding a second red check on an already-red build. This is arguably correct (loud beats silent), but it's a behavior change from the old success() || failure() + swallowing find, so worth a conscious ack.

Nits

  1. python3 -m pip install --quiet pyyaml in setup pulls an unpinned dependency into the gate on every run. Consider pinning (pyyaml==x.y.z) for reproducibility, consistent with the SHA-pinned actions philosophy already enforced in this same job.
  2. collect-coverage-reports.sh is solid under set -euo pipefail - the empty-files array is unreachable because the missing check exits first, so the "\${files[*]}" expansion is safe. Good.

Tests

Coverage is excellent: both directions (must-fail / must-not-fail), transitive needs, matrix-vs-pattern, opaque selectors, unselective downloads, cross-run, the two collector refusal cases, and the output-shape contract. The mutation-check note in the description (revert the needs line -> exactly one test flips red with the #5701 diagnostic) is the right way to prove the test actually pins the bug. The one thing not exercised is the over-match edge from observation #1 - a fixture with a prefix-colliding literal + matrix family would document the intended boundary.

Overall: correct fix, strong verification, good docs. The items above are hardening suggestions, not objections.

Codacy has both D212 and D213 enabled, and they are mutually exclusive - satisfying D213 made it
report D212 on the same two docstrings. Neither rule applies to a one-line summary, so the
explanation moves to a comment above the function and nothing is lost.
@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(ci) #5701 - coverage-report waits for the HA suite

Thorough, well-reasoned fix. The root-cause analysis (download-artifact has no happens-before with the uploader outside of needs, and continue-on-error + a bare find turned "suite missing" and "suite present" into the same green step) is correct, and the three-part response - fix the ordering, fail loudly on a partial merge, and enforce the invariant generically - is the right shape. The self-test fixtures pinning both directions (must-fail and must-not-fail) are excellent, especially the mutation check. I read the full diff and cross-checked the <suite>=<dir> arguments against the four download-artifact paths in mvn-test.yml; they line up (unit-coverage, slow-unit-coverage, integration-coverage, ha-integration-coverage).

Correctness / behavior notes

  • !cancelled() + hard-failing upstream job: with ha-integration-tests now in needs and if: !cancelled(), a genuinely failing (not --fail-never) upstream job (e.g. unit-tests dying on a compile error before JaCoCo writes anything) will still start coverage-report, and collect-coverage-reports.sh will then exit non-zero naming that suite. So a single compile failure now surfaces as two red jobs. That is arguably correct/informative (the collector even hints "cancelled or failed before writing its JaCoCo XML"), but it is a behavior change worth noting: coverage-report red no longer implies "coverage problem", it can also mean "an upstream suite hard-failed". Fine to keep as-is; just flagging.

  • matching_producers bidirectional glob can over-approximate: matching the selector against producers in both directions is right for the matrix-vs-pattern case, but it can also manufacture a dependency that does not exist. A static download of report-final will match a matrix producer that uploads report-${{ matrix.version }} (glob report-*), because fnmatch("report-final", "report-*") is true. The checker would then demand the consumer wait for that matrix job even though report-* never produces report-final. This errs on the safe side (asks for more ordering, never less) and does not affect this repo today, so it is a low-priority note, not a blocker - but it could produce a false positive that forces a spurious needs entry in a future workflow.

Minor

  • collect-coverage-reports.sh relies on bash-4.4+ empty-array semantics under set -u; GitHub ubuntu-latest is bash 5.x so this is fine in practice, no change needed.
  • The new checker only scans .github/workflows/*.{yml,yaml} and skips jobs that call reusable workflows via uses: (no steps). That is the right scope for this fix; noting it so the invariant boundaries are explicit.

Trade-off

The ~10-minute-later Codecov/Codacy signal is called out honestly in the description and is the unavoidable cost of a complete merge. Agreed it is the right call - a true-but-late signal beats a fast-but-fabricated one.

Nice work. The generalization into an enforced invariant plus regression fixtures is the part that keeps the whole class of #5701 from recurring, and it is done carefully. LGTM.

Note: I reviewed by reading the full diff; I could not execute the scripts in this environment, so the self-test pass/fail counts are taken from the PR description rather than independently re-run.

…porting a suite that never ran

Code review follow-ups on #5705.

Bidirectional glob matching over-matched: `build-${{ matrix.os }}` collapsed to `build-*`, which
covers an unrelated `build-logs` uploaded by another job, so consuming that literal was reported as
needing the matrix job too. A false violation is expensive here - this check gates every other job
- so rather than note the tradeoff, the matrix is now resolved outright: `matrix.os` expands to
exactly {ubuntu, macos} from the base matrix and from `include` entries, which is the shape most of
this repository's matrices use. The glob remains only as the fallback for a matrix built at run
time (`fromJSON`), where over-matching is the safe direction.

- `run-id: ${{ github.run_id }}` names the current run, so that step is an ordinary consumer. It
  was being skipped as a cross-run read, which silently disabled the check for it
- `coverage-report` now skips when a suite was *skipped* rather than failing on its absent
  artifact. A skipped suite means build-and-package failed and nothing ran, so the second red check
  added nothing; the collector's failure now means only what it says, a suite that should have
  uploaded and did not
- pin pyyaml==6.0.3, for the reason the actions in that job are pinned to a SHA

Five new fixtures: the prefix-collision false positive and its true-positive twin, an include-only
matrix, a run-time matrix falling back to the glob, and a current-run `run-id`. 20 checks.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks - all six addressed in d02d6b4. Taking them in order:

1. Bidirectional glob over-match. Valid, and fixed properly rather than documented. The
suggestion was a note in the docstring, but a false positive is expensive precisely because of your
observation #3 - this check gates every other job - so the right move was to remove the
approximation. matching_producers no longer reduces a matrix name to a glob when it can resolve
it: build-${{ matrix.os }} now expands to exactly {build-ubuntu, build-macos} from the base
matrix and from include: entries (the shape most of this repo's matrices use, e.g.
test-python-bindings.yml). Your exact example is now a fixture, in both directions:

ok - does not demand a matrix job whose glob merely covers another artifact   # name: build-logs
ok - still demands the matrix job when the name is one it produces            # name: build-macos
ok - resolves a value contributed only by a matrix include

The glob survives only as the fallback for a matrix that cannot be read statically -
native-image.yml's matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} - where over-matching is
the safe direction. That case is a fixture too. Independent matrix keys still expand as a cartesian
product, which over-approximates a correlated include-only matrix; that errs the same safe way
and is noted in the code.

2. run-id skip is name-agnostic. Valid, fixed. A run-id containing github.run_id names
the current run, so the step is an ordinary consumer and is checked. Fixture added.

3. Hard gate on setup. Acknowledged, and it is the intent. The mitigation is #1 - the
false-positive rate is the thing that makes this coupling risky, so that is where the work went.
The redundant re-run inside test-ci-scripts.sh stays.

4. !cancelled() when an upstream fails. Good catch, and on reflection the behaviour you
described is not what I want. A skipped suite is categorically different from a suite that ran
and uploaded nothing: it produced no report because build-and-package failed and it never
started, so a second red check adds no information. The job condition is now

if: >-
  ${{ !cancelled()
  && needs.unit-tests.result != 'skipped'
  && ... }}

which keeps the collector's failure meaning only what it says - a suite that should have uploaded
and did not.

5. Unpinned pyyaml. Valid, pinned to pyyaml==6.0.3, with the rationale in a comment next to
the SHA-pinned actions it sits beside.

6. Agreed, no change.

Suite is at 20 checks, all green, and setup has passed on CI for every push so far - so the
checker has now run against all 18 real workflows three times.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: coverage-report ordering fix

Reviewed the diff, the two new scripts, the self-test, and the reworked coverage-report job against the rest of mvn-test.yml. This is a high-quality fix - the root-cause analysis (race between coverage-report and the 55-min ha-integration-tests, masked by continue-on-error) is correct, and rather than just patching the one needs entry it turns the ordering into an enforced invariant with tests in both directions. Nicely done.

Verified

  • Paths line up. The four download-artifact path: values (unit-coverage, slow-unit-coverage, integration-coverage, ha-integration-coverage) exactly match the <suite>=<dir> args passed to collect-coverage-reports.sh, and those are the only four coverage-producing suites in the workflow. No suite silently dropped.
  • The gate actually gates. check-workflow-artifact-deps.py + test-ci-scripts.sh run in setup, which is transitively upstream of every test job via build-and-package, so a violation blocks the whole pipeline.
  • Skip vs. fail semantics. Removing if: success() || failure() from the Codacy/Codecov steps is correct: continue-on-error: true on the downloads keeps success() true, so Get coverage files still runs; when it exits non-zero the two publish steps are skipped by their default success(). Partial merges no longer reach Codecov.
  • The folded >- ${{ !cancelled() && ... != 'skipped' }} job condition is valid, and --fail-never means a suite with failing tests still uploads its JaCoCo XML, so publishing-on-failure is the right call.

Minor observations (non-blocking)

  1. Reusable-workflow jobs are invisible to the analyzer. steps_of() returns [] for a job that uses jobs.<id>.uses: ./.github/workflows/foo.yml, so any upload/download inside a called workflow is neither resolved as a producer nor checked as a consumer. The repo has none today so it errs safe, but if a future uses: job uploads an artifact that another job downloads, you'd get a spurious "which no job in this workflow uploads" failure on a green build. Worth a one-line comment noting the limitation so it isn't a surprise later.

  2. Step-ordering coupling in setup. test-ci-scripts.sh invokes the Python checker, which sys.exits if PyYAML is missing. It works because the preceding step runs pip install pyyaml, but that's an implicit dependency - if the two steps are ever reordered the self-test breaks confusingly. Installing PyYAML at the top of the test harness (or a shared earlier step) would make it self-contained.

  3. All-or-nothing skip condition. coverage-report is skipped entirely if any of the four suites reports skipped. Correct today (a skip means build-and-package failed). Just note that if a suite ever gains a path filter that legitimately skips it while the others run, the whole coverage report vanishes rather than publishing the three that did run - which is consistent with the collector's all-or-nothing contract, but a coupling to keep in mind.

  4. Nit: the wrapped comment it never ran - build\n# and-package failed reads as "build- / and-package" across the line break. Cosmetic.

Overall: solid root-cause fix, well-generalized, well-tested. The only substantive item is the reusable-workflow blind spot (1), and even that is currently benign. LGTM.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: coverage-report waits for the HA suite

Overall this is a high-quality fix. The root-cause analysis (race between download-artifact and the slow ha-integration-tests uploader, continue-on-error: true swallowing the miss, and the poisoned Codecov base) is convincing and backed by real run timings. The three-layer response - fix the needs, fail loudly on a partial merge, and turn the ordering into an enforced invariant - is the right shape, and the test file exercises both directions against fixtures rather than the live workflows. The mutation check (revert the needs line, watch exactly one fixture go red) is exactly the evidence that makes a CI change reviewable.

A few notes, mostly minor. Nothing here is blocking.

Design / correctness

  1. check-workflow-artifact-deps.py hard-gates the entire build. It runs in setup, so a false positive from a workflow shape you did not anticipate blocks every downstream job, not just coverage. The over-approximation in names_of is documented as "safe" for missing orderings, but a spurious violation (e.g. the correlated include-only cartesian expansion naming an artifact no job actually produces, then reporting it as "races the matrix") still fails the whole run. You deliberately resolved the matrix to shrink this surface, which is the right call - just flagging that the failure mode of this guard is maximally disruptive, so if it ever misfires on a legitimate workflow the fix needs to be fast. Worth considering whether a rollout period as a warning (non-zero exit only in a dedicated job that does not gate others) would de-risk it.

  2. Reusable/called workflows are not inspected. steps_of only reads a job's steps; a job that is uses: ./.github/workflows/x.yml has none, so any download-artifact inside a called workflow is invisible to the check. Not a problem for today's workflows, but the invariant quietly does not cover that shape - a one-line note in the script header would set expectations for the next person.

  3. HA infra failure now reds coverage-report. --fail-never covers test failures, but a job/runner death before the upload step leaves ha-integration-tests with result failure (not cancelled, not skipped), so !cancelled() is true, the HA download misses, and the collector fails the job. Given HA is both the longest and (per ha-integration-tests fails on main ~75% of the time, with a different test each run #5702) the flakiest suite, this could turn the coverage check red fairly often on non-coverage infra flakiness. That is the documented "fail loudly" tradeoff and I think it is defensible - the failure names HA, which is at least honest - but it is a real behavior change worth confirming the team is happy with.

Nits

  1. The commit-4 if: folded scalar with four needs.<suite>.result != 'skipped' clauses is correct but has to be kept in sync by hand with needs if suites are ever added. Not worth changing now.

  2. collect-coverage-reports.sh relies on bash 4.4+ semantics for empty-array expansion under set -u. Fine on ubuntu-latest (bash 5.x) and the code guards the expansions anyway - just noting it is not portable to macOS system bash 3.2 if these are ever run locally.

  3. Dropping the trailing-comma find ... -printf '%p,' for a proper comma join is a nice incidental cleanup - the old form could emit an empty trailing list entry.

Style / conventions

Matches the repo: no em dashes, SHA-pinned actions preserved, pyyaml pinned for the same reason, docstrings reworked to satisfy pydocstyle (D212/D213) rather than suppressed. Good.

Verification note

I reviewed the full final diff (all four commits, including the matrix-resolution rework). I could not execute test-ci-scripts.sh or the checker in this sandboxed review environment, so I am trusting the reported "20 checks passed" and the "own workflows are ordered" assertion; the workflow count (18) and the arcadedb-jars producer/consumer ordering in test-python-bindings.yml check out on inspection.

Nice work - a genuinely hard class of bug (green on incomplete input, race-dependent) turned into a standing invariant with a regression test.

…upload

Both reviews flagged the same blind spot: a job that delegates to a reusable workflow
(`jobs.<id>.uses:`) has no steps to read, so an upload inside it is invisible and a consumer of
that artifact would be reported as downloading something "no job in this workflow uploads" - a
spurious hard failure, in a check that gates every other job.

Documented in the header as asked, and made safe rather than only documented: a workflow
containing such a job stops reporting the missing-producer violation, since the producer may be one
of the steps the check cannot see. Ordering violations it can still see are still reported, which
the second new fixture pins.

- give PyYAML its own step in `setup`. The self-test invokes the Python checker, so it depended on
  a `pip install` buried in the preceding step's script - reordering them would have broken it
- reflow a comment that wrapped as "build- / and-package"
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Both rounds addressed in d7572ef. The one point both reviews raised independently was the
reusable-workflow blind spot, so that got the real work.

Reusable workflows (R3.1, R4.2). Both asked for a one-line note. Documented, and also made
safe: a job with jobs.<id>.uses: has no steps, so an upload inside it is invisible, and the
concrete harm you both named is the spurious which no job in this workflow uploads hard failure.
A workflow containing such a job now suppresses that violation only - the producer may be a step
the check cannot see - while ordering violations it can still see are still reported. Two fixtures
pin both halves:

ok - does not report an artifact a reusable workflow may upload
ok - still reports a visible violation alongside a reusable workflow

PyYAML step coupling (R3.2). Valid - the self-test invoked the checker, which needed a
pip install buried in the preceding step's script. PyYAML now has its own named step, so the
dependency is visible rather than positional.

Comment reflow (R3.4). Fixed.

Warning-only rollout (R4.1). Declining, with reasoning. The original issue's complaint is that
a check which is red for unrelated reasons trains reviewers to ignore it; a check that only warns
is that same failure mode by construction. The concrete misfire you each named is the reusable
workflow case, and that is now suppressed rather than warned about, which is the targeted version
of the same de-risking. The matrix resolution in d02d6b4 shrank the other surface.

All-or-nothing skip / hand-synced if: (R3.3, R4.4). Agreed, and agreed not worth changing
now - both are consistent with the collector's all-or-nothing contract.

HA infra failure reds coverage-report (R4.3). Correctly identified as the one real behavior
question, and it needs @lvca's call rather than mine. A job that dies before its upload step -
runner death or a timeout, not a test failure, which --fail-never already covers - leaves the
artifact missing and the collector fails. Given #5702, that may be more than rare. I have kept it,
because the only alternative is publishing the merge without ha-raft, which is #5701 itself. If it
proves noisy the remedy is #5702, not re-enabling silent partial merges - but flagging it plainly
as a deliberate tradeoff rather than an oversight.

Suite is at 22 checks, all green.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(ci) #5701 - coverage-report waits for the HA suite

Thorough diagnosis and a fix that treats the class of bug rather than just the instance. The write-up (race table across three real runs, mutation check) makes the root cause easy to trust. Overall this is high-quality work; comments below are mostly confirmations and a couple of things worth stating explicitly.

What's right

  • Root cause is correct. download-artifact has no happens-before edge with the uploader outside needs; adding ha-integration-tests to coverage-report.needs is the actual fix. Verified the four coverage producers in mvn-test.yml (lines 190/244/300/354) are now exactly the four suites listed in both needs and the collector args, and the download path: values (unit-coverage, slow-unit-coverage, integration-coverage, ha-integration-coverage) match the collector's <suite>=<dir> pairs. No producer is left out.
  • Uploads survive failing suites. Each Upload *-coverage-reports step is if: success() || failure() with --fail-never, so a red suite still uploads its JaCoCo XML and the merge stays complete. The !cancelled() && ... != 'skipped' condition lines up with that: fail -> still publish, skip/cancel -> don't. Consistent.
  • Keeping continue-on-error: true on the downloads is the right call - it hands the "which suite is missing and why" message to collect-coverage-reports.sh instead of dying on a bare "Artifact not found". Good that the collector steps then deliberately carry no if: so they skip on its failure.
  • The enforcement script is the valuable part. Turning "the next needs omission is equally invisible" into an enforced invariant is the right generalisation, and the boundary handling (transitive closure, matrix glob vs pattern:, same-run run-id, cross-run skips, selector-less = every producer) is carefully reasoned. Test coverage exercises both directions for each case.

Things worth stating / questions

  1. Publish is now all-or-nothing. A single suite that ends up skipped (or whose artifact is genuinely absent) suppresses the entire coverage publish, where before three-of-four still went up. The PR argues a partial merge is worse than none, which I agree with - but if coverage-report / Codecov / Codacy is a required status check, please confirm a skipped coverage-report doesn't leave PRs blocked (skipped required checks behave differently across branch-protection configs). This is the one behavior change most likely to surprise.

  2. opaque suppression is workflow-wide, not step-scoped. In check_workflow, a single job with a top-level uses: sets opaque for the whole file, which suppresses every "no job uploads this" finding in that workflow (ordering violations are still reported). It is documented as a known boundary and errs safe, so fine - just flagging that a genuinely-typo'd artifact name in a file that also happens to call a reusable workflow would go unreported. Following uses: into local workflows later would close it.

  3. Minor (bash): in collect-coverage-reports.sh, find ... | sort runs inside a process substitution, so a find failure won't trip set -o pipefail. Not a real concern for these paths, just noting it isn't caught.

  4. New CI dependency: the check adds a Python + pyyaml==6.0.3 install to the setup job that gates everything. It is pinned and quiet, so acceptable; worth being aware it is now on the critical path of every run.

Trade-off

The stated ~10-minute latency cost (coverage now waits on the 55-min HA job) is real and unavoidable given the ordering requirement - correctly called out. No objection.

Nice work - the fix, the regression fixtures, and the general invariant together are exactly the right shape for this bug.

…uppresses

The suppression added for the reusable-workflow case was silent, and a typo'd artifact name in a
file that also calls a reusable workflow looks exactly like an artifact that workflow uploads. The
benefit of the doubt still goes to the workflow - this check gates every other job - but the
finding is now printed with the reason it was not raised. Silently discarding it is how #5701 stayed
invisible in the first place.

`find` in collect-coverage-reports.sh now writes to a file instead of straight into the read loop.
A process substitution is not part of the pipeline, so `pipefail` never saw its exit status and an
unreadable directory read as an empty one - the exact distinction the script exists to draw.

Two fixtures: the suppressed finding is reported, and an unreadable suite directory fails. 24 checks.
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in 7ef8284.

1. Is a skipped coverage-report going to block PRs? Checked rather than assumed - it cannot.
main has no branch protection at all (GET /branches/main/protection -> 404 "Branch not
protected"), there are no repository rulesets, and the only Mergify rule is
author=dependabot[bot] on approval. No status check is required, so neither a skipped nor a red
coverage-report gates anything. Good question to have asked though - it would have been the one
genuinely disruptive outcome.

2. opaque suppression is workflow-wide. Right, and your specific worry is the real one: a
typo'd artifact name in a file that also calls a reusable workflow looks identical to an artifact
that workflow uploads, so it would have gone unreported. The benefit of the doubt still goes to the
workflow - this check gates every other job - but the finding is no longer silent:

check-workflow-artifact-deps: x.yml: 1 missing-producer finding(s) not reported, because a job in
this workflow delegates to a reusable workflow whose steps cannot be read:
      x.yml: job 'consume', step '...' downloads 'name: typo-or-from-called', which no job in
      this workflow uploads

Informational, exit 0. Silently discarding a finding is how #5701 stayed invisible in the first
place, so suppressing one without saying so would have repeated the mistake this PR is about.
I stopped short of following uses: into local workflows: it is the proper closure, but the repo
has no reusable workflows today, so it would be unexercised machinery on the critical path of every
build - and a false positive there is the expensive direction. The header documents the closure
path for whoever needs it.

3. find in a process substitution escapes pipefail. Valid, and worth fixing even though the
paths are safe: an unreadable directory read as an empty one, which is precisely the distinction
this script exists to draw. find now writes to a temp file and its status is checked. Fixture
added (skipped when running as root, which can read it anyway).

4. Python + pyyaml on the critical path. Acknowledged. Pinned, quiet, ~1s.

24 checks, all green.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(ci) #5701 - coverage-report waits for the HA suite

Thorough, well-reasoned fix. The root cause (a download-artifact step with no happens-before relationship to its producer except through needs) is correctly diagnosed and backed by the three real runs in the table. The three-layer response - fix the ordering, refuse partial merges, and enforce the invariant across all workflows - is exactly right, and the test coverage (24 checks, both directions, mutation-verified) is well above what a CI change usually gets. I verified the artifact names/paths line up between the upload steps, the download steps, and the <suite>=<dir> args passed to collect-coverage-reports.sh, and confirmed the upload steps carry if: success() || failure(), which is what makes the !cancelled() condition sound.

A few observations, none blocking:

1. New hard-failure path can newly block PRs (worth stating in the PR body)

Previously a cancelled/absent HA artifact was swallowed and coverage-report went green. Now collect-coverage-reports.sh exits non-zero and, because the Codacy/Codecov steps lost their if: success() || failure(), the job goes red. That is the correct signal, but it also means HA-suite flakiness (#5702: a cancelled/timed-out HA job) will now turn coverage-report red rather than merely partial. If this check is branch-protection-required, that is a behavior change in how often it can block a merge. The PR argues independence from #5702, which is true at the artifact level, but the practical coupling (HA cancellation -> red coverage-report) is worth calling out explicitly.

2. All-or-nothing skip couples to an assumption about why a suite is skipped

The if skips coverage-report entirely when any suite reports result == 'skipped'. The rationale ("skipped means build-and-package failed, so all four skip together") holds today because all four share that dependency. But it is a latent coupling: if a future change lets one suite be skipped independently (a path filter, a matrix condition, a manual gate), the coverage from the three suites that did run is silently dropped rather than published. A short comment noting the "all suites skip together" assumption would make that dependency explicit for the next editor.

3. opaque suppression is workflow-wide, not job-scoped

opaque = any("uses" in job for job in jobs.values()) downgrades every missing-producer finding in the file to a non-failing note as soon as one job delegates to a reusable workflow. The ordering check still fires (confirmed by the reusable-unordered fixture), so the core invariant is preserved - but a genuine typo in an artifact name inside an ordinary, fully-visible job in that same file becomes a note instead of a failure. For missing-producer this is unavoidable without following uses:, so the scope is defensible; resolving local uses: ./.github/workflows/*.yml targets would let you narrow it later. Not needed now (mvn-test.yml has no reusable-workflow jobs, so nothing is suppressed there today), just flagging the blast radius.

4. This linter gates the entire build

check-workflow-artifact-deps.py runs in setup, so a false positive blocks all CI. The code is deliberately conservative (globs over-match toward demanding an ordering, matrices resolve to exact names to avoid build-* collisions, dynamic matrices fall back to the glob), which is the right bias, and it is clean across the repo's workflows. Worth being conscious that any future gap in the resolver has total blast radius; the conservative design is what keeps that acceptable.

Minor

  • In main(), the two comprehensions reuse the name found for both the violations and notes halves of each tuple (for found, _ then for _, found in ...). Correct, but mildly confusing to read - distinct names would help.
  • collect-coverage-reports.sh: nice catch routing find through a temp file so pipefail actually observes its exit status rather than a process substitution swallowing it - that is the exact unreadable-vs-empty distinction the script exists to draw, and the chmod 000 fixture pins it.

Overall: solid fix with genuinely strong verification. The points above are refinements, not corrections.

The skip condition reads any one skipped suite as "none of them ran", which is true only because
all four hang off build-and-package. Give one its own path filter or condition and the other three
would still have coverage worth publishing, and the condition would drop it. Noted where the next
editor will be standing.

Also name the two halves of the (violations, notes) tuples distinctly in main().
@lvca

lvca commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in 3e5e38f plus a PR-body edit.

1. New hard-failure path, worth stating explicitly. Agreed - added a "Behavior change worth
knowing about" section to the PR body covering both paths (a suite that ran but died before
uploading, and the all-or-nothing skip), why each is deliberate, and the fact that the alternative
to the first is publishing without ha-raft, which is #5701 itself. On the branch-protection
question: checked, and nothing is required - GET /branches/main/protection returns 404 "Branch not
protected", there are no rulesets, and Mergify's only rule is author=dependabot[bot]. So neither
path can block a merge today.

2. All-or-nothing skip couples to why a suite is skipped. Good catch, and the assumption was
load-bearing but unwritten. Now stated where the next editor will be standing:

# This reads any one skipped suite as "none of them ran", which holds because all four hang off
# build-and-package and so skip together. Give one of them a path filter or a condition of its
# own and that stops being true: the other three would still have coverage worth publishing,
# and this condition would drop it. Revisit here if a suite ever gains its own gate.

3. opaque blast radius. Agreed on all counts, including that it is unavoidable for
missing-producer without following uses:, and that nothing is suppressed in mvn-test.yml today.
Leaving it; the header documents the closure path.

4. The linter gates the whole build. Conscious of it - it is why the matrix resolution went in
rather than a docstring note about the build-* collision, and why the reusable-workflow case
downgrades to a note instead of failing. Conservative bias is the design, not an accident.

Minor: renamed the two comprehension variables in main(). And thanks for confirming the
find temp-file change - that one came from your last round.

24 checks, all green.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 100 complexity

Metric Results
Complexity 100

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review: fix(ci) #5701

Reviewed the full diff, cross-checked the workflow wiring against the two scripts, and read the checker logic case by case. This is a careful, well-reasoned fix and the root-cause analysis (a needs/download-artifact happens-before race hidden by continue-on-error) is correct and precisely diagnosed. The extras (a generalized invariant checker plus a self-test with both-direction fixtures) turn a one-line fix into something that keeps the class of bug from recurring. Nice work.

Correctness checks that pass

  • Download paths match collector args. The four download-artifact path: values (unit-coverage, slow-unit-coverage, integration-coverage, ha-integration-coverage) line up exactly with the <suite>=<dir> pairs passed to collect-coverage-reports.sh.
  • All suites skip together holds. All four test jobs (unit-tests, slow-unit-tests, integration-tests, ha-integration-tests) declare needs: build-and-package with no independent if/path filter, so the != skipped guard in the new if is sound today. The inline comment already flags the exact condition under which that stops being true, which is the right thing to leave for the next maintainer.
  • if: >- ${{ !cancelled() && ... }} is valid and best-practice here: wrapping in ${{ }} avoids the YAML !-as-tag pitfall that bites bare !cancelled() conditions.
  • Skipping Codacy/Codecov on collector failure works via default step semantics (a failed prior step short-circuits later steps), so dropping their if: success() || failure() correctly means do not publish a partial merge. The continue-on-error: true on the downloads is still needed so the collector, not a bare Artifact not found, reports the missing suite.
  • collect-coverage-reports.sh correctly distinguishes absent dir / empty dir / unreadable dir (the find into a scratch file so pipefail sees the status is a nice touch and is exercised by a test), and guards every array expansion behind a ${#arr[@]} check, so it is safe under set -u.

Minor observations (non-blocking)

  1. opaque suppression is workflow-wide, not per-job (check-workflow-artifact-deps.py:215). A single reusable-workflow job (jobs.<id>.uses:) downgrades every "no job uploads this" finding in that file to a suppressed note, including a genuine artifact-name typo in an unrelated job in the same file. It is documented and errs on the safe side (a note is still emitted, and ordering violations are still hard failures), so this is fine for now, but it could be tightened to per-job scope later if a mixed file ever appears.
  2. The checker now hard-gates the whole build. It is wired into setup, which every job needs, and a YAML parse error returns a cannot-parse violation that fails the run. That is the correct call for self-authored workflow files; just worth being aware that a future workflow using a construct the parser mishandles would block all of CI, not just the coverage check.
  3. COVERAGE_FILES now carries relative paths (unit-coverage/.../jacoco.xml) rather than the old ./... from find .. Fine for Codecov (disable_search: true plus explicit files:) and Codacy since the working dir is the repo root; just noting the shape changed.

Test coverage
Excellent, and the standout part of this PR. 15 checks pinning both directions (must-fail and must-not-fail) against fixtures rather than the live workflows, plus the mutation check (reverting only the needs line reproduces the issue with the exact diagnostic). This matches the discipline CLAUDE.md asks for on new code. I could not execute the suite in this review sandbox, but the fixtures cover the tricky cases (transitive needs, matrix-glob vs pattern, prefix collisions, include-only matrices, run-time matrices, current-vs-cross-run run-id, selector-less downloads, reusable-workflow blind spot).

Trade-offs, well stated
The PR is upfront that coverage now lands ~10 min later (waits for the slowest job) and that coverage-report can go red where it previously went green (runner death / timeout, or any skipped suite). Both are the honest version of the signal and are documented at the point of change.

No blocking concerns. LGTM.

Note: this review covers CI config/scripts only; there is no Java to compile or unit-test here.

@lvca lvca self-assigned this Aug 1, 2026
@lvca
lvca requested a review from robfrank August 1, 2026 16:40
@lvca lvca added this to the 26.8.1 milestone Aug 1, 2026
@lvca
lvca merged commit 0a13d82 into main Aug 1, 2026
24 of 26 checks passed
@lvca
lvca deleted the fix/5701-coverage-report-waits-for-ha branch August 1, 2026 16:42
robfrank pushed a commit that referenced this pull request Aug 14, 2026
… publish a partial merge (#5705)

* fix(ci) #5701: coverage-report waits for the HA suite, and refuses to publish a partial merge

`coverage-report` downloaded `ha-integration-coverage-reports` but listed only
[unit-tests, integration-tests, slow-unit-tests] in `needs`. A download only sees artifacts
already uploaded, so the job started ~14 minutes before `ha-integration-tests` finished - it is
the longest job in the workflow at ~55 minutes - the download failed, `continue-on-error: true`
swallowed it, and the merge went to Codecov without the ha-raft module. Codecov read the module
as uncovered and reported 1,110 covered lines disappearing on a PR that had touched 29 lines in
`engine/.../index/hash/`.

It was a race, not a constant: on runs where the other three jobs happened to finish late the
artifact was there. Run 30693081526 downloaded it (HA finished 11s before), runs 30691165476 and
30691056907 did not. So the check flipped between correct and wrong on identical config, and the
incorrect runs also became the base later pull requests were compared against.

- add `ha-integration-tests` to `coverage-report.needs`, and relax the job condition to
  `!cancelled()` so a suite with failing tests still publishes what JaCoCo wrote
- replace the bare `find` with `collect-coverage-reports.sh`, which names the suites it expects,
  fails when one contributed no report, and writes a per-suite table to the step summary. The
  Codacy and Codecov steps lose their `if: success() || failure()` so they are skipped on that
  failure: a partial upload is worse than no upload, because it poisons the base too
- add `check-workflow-artifact-deps.py`, which asserts that every `download-artifact` step's
  producer is in the consumer's transitive `needs` closure, over every workflow. It reports this
  bug on the unfixed file and nothing else across the other 17 workflows. Wired into `setup`

Related: #5702 (the HA suite's own flakiness), which this fix is independent of - the suite runs
with --fail-never, so its coverage artifact uploads whether or not tests pass.

* fix(ci) #5701: satisfy Codacy's pydocstyle rules in check-workflow-artifact-deps

D213 on two multi-line docstrings and D403 on one summary. D213 is also the convention the
repository's own Python already follows by a wide margin (342 docstrings to 105).

* fix(ci) #5701: keep the docstrings single-line, prose moves to comments

Codacy has both D212 and D213 enabled, and they are mutually exclusive - satisfying D213 made it
report D212 on the same two docstrings. Neither rule applies to a one-line summary, so the
explanation moves to a comment above the function and nothing is lost.

* fix(ci) #5701: resolve the matrix instead of globbing it, and stop reporting a suite that never ran

Code review follow-ups on #5705.

Bidirectional glob matching over-matched: `build-${{ matrix.os }}` collapsed to `build-*`, which
covers an unrelated `build-logs` uploaded by another job, so consuming that literal was reported as
needing the matrix job too. A false violation is expensive here - this check gates every other job
- so rather than note the tradeoff, the matrix is now resolved outright: `matrix.os` expands to
exactly {ubuntu, macos} from the base matrix and from `include` entries, which is the shape most of
this repository's matrices use. The glob remains only as the fallback for a matrix built at run
time (`fromJSON`), where over-matching is the safe direction.

- `run-id: ${{ github.run_id }}` names the current run, so that step is an ordinary consumer. It
  was being skipped as a cross-run read, which silently disabled the check for it
- `coverage-report` now skips when a suite was *skipped* rather than failing on its absent
  artifact. A skipped suite means build-and-package failed and nothing ran, so the second red check
  added nothing; the collector's failure now means only what it says, a suite that should have
  uploaded and did not
- pin pyyaml==6.0.3, for the reason the actions in that job are pinned to a SHA

Five new fixtures: the prefix-collision false positive and its true-positive twin, an include-only
matrix, a run-time matrix falling back to the glob, and a current-run `run-id`. 20 checks.

* fix(ci) #5701: do not hard-fail on artifacts a reusable workflow may upload

Both reviews flagged the same blind spot: a job that delegates to a reusable workflow
(`jobs.<id>.uses:`) has no steps to read, so an upload inside it is invisible and a consumer of
that artifact would be reported as downloading something "no job in this workflow uploads" - a
spurious hard failure, in a check that gates every other job.

Documented in the header as asked, and made safe rather than only documented: a workflow
containing such a job stops reporting the missing-producer violation, since the producer may be one
of the steps the check cannot see. Ordering violations it can still see are still reported, which
the second new fixture pins.

- give PyYAML its own step in `setup`. The self-test invokes the Python checker, so it depended on
  a `pip install` buried in the preceding step's script - reordering them would have broken it
- reflow a comment that wrapped as "build- / and-package"

* fix(ci) #5701: report the findings the reusable-workflow blind spot suppresses

The suppression added for the reusable-workflow case was silent, and a typo'd artifact name in a
file that also calls a reusable workflow looks exactly like an artifact that workflow uploads. The
benefit of the doubt still goes to the workflow - this check gates every other job - but the
finding is now printed with the reason it was not raised. Silently discarding it is how #5701 stayed
invisible in the first place.

`find` in collect-coverage-reports.sh now writes to a file instead of straight into the read loop.
A process substitution is not part of the pipeline, so `pipefail` never saw its exit status and an
unreadable directory read as an empty one - the exact distinction the script exists to draw.

Two fixtures: the suppressed finding is reported, and an unreadable suite directory fails. 24 checks.

* fix(ci) #5701: write down the "all four suites skip together" assumption

The skip condition reads any one skipped suite as "none of them ran", which is true only because
all four hang off build-and-package. Give one its own path filter or condition and the other three
would still have coverage worth publishing, and the condition would drop it. Noted where the next
editor will be standing.

Also name the two halves of the (violations, notes) tuples distinctly in main().

(cherry picked from commit 0a13d82)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codecov/project fails on unrelated PRs: coverage-report does not wait for ha-integration-tests

1 participant