The failure class
ci.yml and release.yml both run the full pytest suite against the same Postgres service, but their trigger sets do not overlap:
# ci.yml:4-8
on:
push:
branches: [main]
pull_request:
# release.yml:48-51
on:
push:
tags: [...]
A tag push never reaches ci.yml. So every hardening pass CI receives applies to one copy only, and the two jobs drift apart with nothing to report it. Drift is invisible until a release fails — which is exactly how it surfaced.
What it cost, measured
Run 30741657854 (tag v4.17.0): the test job hung on FlashRank's untimeouted requests.get(..., stream=True) until pytest-timeout killed the suite, blocking all five downstream publish jobs. v4.17.0 is tagged and published nothing — no GitHub release, no PyPI upload, no .mcpb bundle — on a tree that had just passed 20 green checks on #334.
Every network-hardening step CI absorbed since 2026-07-27 had skipped release.yml. #335 realigned the two by hand and shipped the result as v4.17.1.
Why the hand-alignment is not the fix
#335 restores parity at one point in time. It does nothing to prevent the next divergence: the two jobs are still two files, and the next person to harden CI still has no signal that a second copy exists. The comment block added at release.yml:120-129 is documentation, not a mechanism — it can be read past.
Proposed fix
Extract the shared job into a reusable workflow (.github/workflows/test-suite.yml, on: workflow_call) that both callers invoke, so hardening lands in one place by construction.
The one asymmetry that must survive the extraction: requirements/release.txt deliberately omits tree-sitter, tree-sitter-language-pack, igraph, leidenalg and texttable, so the release gate skips the AST tests and needs none of ci.yml's three tree-sitter steps — porting them blindly fails on ImportError. The reusable workflow therefore needs an input (requirements file / extras set) rather than a single hardcoded install, and the narrower release surface should stay a stated, visible property rather than an accident of which file you happened to edit.
Acceptance
- one definition of the test job, called by both
ci.yml and release.yml;
- the release gate's narrower dependency surface expressed as an explicit input, not a duplicated step list;
- a green tag-triggered release run proving the extraction did not break the publish path.
Filed as the follow-up recorded in #335.
The failure class
ci.ymlandrelease.ymlboth run the full pytest suite against the same Postgres service, but their trigger sets do not overlap:A tag push never reaches
ci.yml. So every hardening pass CI receives applies to one copy only, and the two jobs drift apart with nothing to report it. Drift is invisible until a release fails — which is exactly how it surfaced.What it cost, measured
Run 30741657854 (tag
v4.17.0): thetestjob hung on FlashRank's untimeoutedrequests.get(..., stream=True)until pytest-timeout killed the suite, blocking all five downstream publish jobs. v4.17.0 is tagged and published nothing — no GitHub release, no PyPI upload, no.mcpbbundle — on a tree that had just passed 20 green checks on #334.Every network-hardening step CI absorbed since 2026-07-27 had skipped
release.yml. #335 realigned the two by hand and shipped the result as v4.17.1.Why the hand-alignment is not the fix
#335 restores parity at one point in time. It does nothing to prevent the next divergence: the two jobs are still two files, and the next person to harden CI still has no signal that a second copy exists. The comment block added at
release.yml:120-129is documentation, not a mechanism — it can be read past.Proposed fix
Extract the shared job into a reusable workflow (
.github/workflows/test-suite.yml,on: workflow_call) that both callers invoke, so hardening lands in one place by construction.The one asymmetry that must survive the extraction:
requirements/release.txtdeliberately omits tree-sitter, tree-sitter-language-pack, igraph, leidenalg and texttable, so the release gate skips the AST tests and needs none of ci.yml's three tree-sitter steps — porting them blindly fails onImportError. The reusable workflow therefore needs an input (requirements file / extras set) rather than a single hardcoded install, and the narrower release surface should stay a stated, visible property rather than an accident of which file you happened to edit.Acceptance
ci.ymlandrelease.yml;Filed as the follow-up recorded in #335.