This document defines the rules and best practices for maintaining CI in this project.
All contributors must read and follow these guidelines before modifying anything under
.github/workflows/.
CI is four workflow files, one per platform/toolchain, each with its own push and
pull_request triggers. There is no aggregate file and no aggregate badge. The layout is
machine-checked by tests/tools/check_ci_layout.py, which reads the table below between the
two HTML comments — if you add a feature entry or a platform, that table is part of the change.
| Workflow file | job |
Features (matrix entries) | Workflow name: |
Check names | Runner |
|---|---|---|---|---|---|
.github/workflows/ci-linux-ubuntu.yml |
linux |
basic-static, basic-shared, web, zlib-off, wsdl, yyjson, brotli, brotli-no-zlib, ssl, h2, full, quic, http3, capi, db |
CI · Linux · Ubuntu |
CI · Linux · Ubuntu / <feature> |
ubuntu-latest |
.github/workflows/ci-linux-ubuntu.yml |
db-servers |
(无矩阵) | CI · Linux · Ubuntu |
CI · Linux · Ubuntu / db-servers |
ubuntu-latest |
.github/workflows/ci-linux-ubuntu.yml |
config-contract |
(无矩阵) | CI · Linux · Ubuntu |
CI · Linux · Ubuntu / config-contract |
ubuntu-latest |
.github/workflows/ci-windows-msvc.yml |
windows |
basic-shared, basic-static, web, wsdl, yyjson, brotli, ssl, h2, quic, http3, capi, db |
CI · Windows · MSVC |
CI · Windows · MSVC / <feature> |
windows-latest |
.github/workflows/ci-windows-msvc.yml |
config-contract |
(无矩阵) | CI · Windows · MSVC |
CI · Windows · MSVC / config-contract |
windows-2022 |
.github/workflows/ci-mingw64.yml |
mingw64 |
(无矩阵) | CI · Windows · MinGW-w64 |
CI · Windows · MinGW-w64 / mingw64 |
windows-latest (MSYS2) |
.github/workflows/ci-macos.yml |
macos |
basic-static, basic-shared, web, yyjson, brotli, ssl, h2, full, quic, http3, capi, db |
CI · macOS |
CI · macOS / <feature> |
macos-latest |
The Features cell is a comma-separated list of the file's feature: values, or (无矩阵)
for a file whose job is not a matrix. check_ci_layout.py compares it both ways against
the file, so deleting a feature entry without touching this table is red — that is what makes
"QUIC was quietly dropped from the MSVC leg" impossible to merge.
This layout is now the measured one, not the intended one. The first run of a commit
carrying all four files produced exactly the 23 checks in the table above — none missing, none
extra, no collisions — and the run that also carries the two corrections described below is
green on all four platforms (2026-09-29). 1.4.1 added the three http3 entries (http3 needs
QUIC + web, so it could not exist before those two did), which takes the table to 26
checks; and then the three capi entries (the C ABI layer — Ubuntu, macOS and MSVC; see §5),
which takes it to 29. The database module added four more — a db entry on Ubuntu, macOS
and MSVC, plus the single db-servers job (MySQL + PostgreSQL + MongoDB + Redis through
service containers, Ubuntu only; see §5) — after which the running total in this paragraph read 33. 1.6.34
added the three yyjson entries (the optional second JSON backend — Ubuntu, macOS and MSVC;
see §5), and while doing so the running total was recounted cell by cell against the table
below: it had been 36, not 33, since the db entries landed (zlib-off, the db-servers
and both config-contract jobs are all checks, and the earlier totals had stopped counting
some of them). With the three yyjson entries the table is 39. 1.6.48 added the four
brotli entries — brotli on Ubuntu, macOS and MSVC plus a fourth Ubuntu-only
brotli-no-zlib (the only cell that compiles the compressor's "no zlib" branches; see §5) —
which takes it to 43. The table below is the
machine-checked source; this paragraph is a narrative and is not — treat any number here as
a claim to re-count rather than to trust.
The windows one had never run before that commit, since there is no MSVC on the
development machine — that leg's first execution is the gate (see §2 for how a new entry is
supposed to be argued). Two things were
only learnable by running it:
src/quic/*.cpp and ngtcp2 compile on MSVC for the first time here, and the macOS leg's
own OpenSSL prelude was wrong in a way that had nothing to do with OpenSSL (see §5).
Why one file per platform. The old single file was 1369 lines with 10 jobs spanning four
platforms and eight feature sets; the two Windows jobs sat 900 lines apart. A feature matrix
that has grown to eight entries stops being readable when it is interleaved with three other
platforms. Splitting is not a coverage change: every gate that existed before still exists,
and the check_ci_layout.py criteria were written to keep it that way.
Inside a file, the skeleton is written once and the entries carry the differences. Each
matrix entry has feature (also the check name), flags (a block sequence of -D
options, expanded with join(matrix.flags, ' ')), and — for the entries that need them —
gate_* strings and openssl_root. Three rules about the entries are load-bearing:
- Never use a YAML boolean in an
if:. GitHub coerces it into a numeric comparison (true == 'true'is1 == NaN, i.e. false), so that leg would never run. Compare strings (matrix.feature == 'quic'), and remember that an entry missing the key evaluates to null, which compares false — exactly what is wanted. flagsis a block sequence, never a flow sequence and never a folded scalar. The comment explaining why-DUVCPP_BUILD_EXPAND=ONis present has to sit on that-Dline; a flow[...]has nowhere to put it, and a folded>silently joins continuation lines into one space and swallows arguments (see the note on that inrelease.yml).- Every matrix job sets
name:explicitly (to<platform> / ${{ matrix.feature }}here). Without it GitHub splices the whole matrix —flagsarray included — into the check name, which is both unreadable and unstable, while branch protection remembers the name.
Two things that are deliberately not in these files.
- No
concurrency:. It would cancel an in-flight run on the next push, and cancelling a run drops a gate. The gates are the only product here. - No aggregate "run everything" workflow. That would put us back where we started.
These are written down, not left implicit. None of them is a regression — the single-file layout did not cover them either.
| Gap | Why |
|---|---|
| MinGW + QUIC | Deferred to the same batch as splitting the MinGW job by feature: that job is a single job whose defaults: shell: msys2 {0} is job-level (it cannot be conditionalised) and whose tail is a hard-coded --platform mingw-x64 package→consumer contract. |
| MinGW + HTTP/3 | The same job, and one more prerequisite: http3 is force-disabled unless QUIC is on, so it cannot be added before the QUIC gap above is closed. |
| MinGW + h2 | Never covered. MinGW has never compiled the HTTP/2 module. |
| MSVC + full | Windows has never had a full leg (full is Ubuntu + macOS only). |
| Windows static + web/webapp | The static entry is WEB=OFF. |
macOS config-contract |
package_release.py's PLATFORMS has no macOS key — see §5. |
| MinGW + WSDL | The same single MinGW job as the three gaps above. release.yml's mingw-x64 and mingw-arm64 legs do enable WSDL, so it is covered at release time but not on push/PR — which is why it was verified locally on the MinGW x64 shape before being turned on there. |
| macOS + WSDL | No entry, and macOS never compiles src/wsdl/. Lower risk than the MinGW gap above: macOS is not a release platform, so there is no path on which it reaches a user unbuilt. |
| MySQL / PostgreSQL / MongoDB / Redis on macOS, MSVC and MinGW | Those runners have no database server, and services: is job-level — it is only available on Ubuntu. The remote backends are measured in the single Ubuntu db-servers job (MySQL 8.0 + PostgreSQL 16 + MongoDB 7 + Redis 7 service containers). The other three db entries set both SQL backends explicitly OFF rather than leaving them on: with no server the tests exit 3 (not judged), which ctest prints as ***Skipped — "not tested" and "tested" are different words but neither is red, so leaving them on buys nothing. redis_real_func uses the same 3-vs-REQUIRE contract; on the non-Ubuntu legs it simply takes the skip path. |
UVCPP_DB_SQLITE_FROM_SOURCE on Linux |
On Linux and macOS the db entries use the default path (find_package(SQLite3) against the system/SDK library); the from-source path is exercised on MSVC and MinGW. What is platform-specific about it is TLS and extraction — file(DOWNLOAD) over Schannel on Windows, MSYS2's libcurl + ca-certificates on MinGW — and both of those are covered. The Linux release legs run it on every tag. The cost of this entry, and it is a real one: MSVC and MinGW now need the network at configure time (hash-pinned zip from sqlite.org). An offline machine can pre-place it, see §5. |
The Linux UVCPP_STATIC_RUNTIME=ON shared library is built but never run |
The only Linux cell that passes the switch is config-contract, and that tree is UVCPP_BUILD_TESTS=OFF; check_doc_snippets.py compiles its snippets with -c and never links or runs them, so that shape is only ever compiled on push/PR. Measured 2026-10-14 (and it is why this row exists): with the switch on, a shared libuvcpp.so and a consumer linked against the system libstdc++.so.6 each instantiate the same COMDAT templates (std::once_flag's __once_proxy, std::__future_base::_State_baseV2) and ELF resolves them "executable first", so one side jumps into the other's frame — nine functional tests crash (five SEGV, four SIGABRT), and relinking a single consumer that way reproduces it, so it is not a test artefact. release.yml's two Linux legs therefore stopped passing the flag in 1.7.0, and the verdict on the shipped Linux .so comes from the release dry run's Test step — not from any push/PR gate. MinGW keeps the flag (PE has no global symbol scope). See doc/build-guide.md §Runtime behaviour. |
full + the database module |
full does not enable UVCPP_ENABLE_DB. Turning it on would put a client library into that entry's dependency row, and its gate strings are about the memory pool, so it could not assert that the db tests were registered anyway — the dedicated db entries do that. |
base build with UVCPP_BUILD_NET=OFF |
tests/functional/CMakeLists.txt filters web/webapp/ssl/h2/wsdl/quic and not net, so NET=OFF would compile net test files against a library with src/net/ filtered out — red by construction. Opening this cell needs that filter first. Today "base" therefore means net at its default (on) with WEB=OFF, i.e. the two basic-* entries. |
Two rows left this table in 1.7.0: MinGW + yyjson and MinGW + brotli. Both were
"MinGW never compiles this" gaps, and the 1.7.0 release-package work closed them by adding
-DUVCPP_ENABLE_YYJSON=ON / -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON (plus
the two remote db backends under UVCPP_DB_RUNTIME_LOAD=ON) to that single job's one
configure — the same -Ds release.yml now passes, so the MinGW push/PR job becomes the
release config's stand-in there rather than a leg that meets src/yyjson/ for the first time
on tag day. The job is still a single job, so MinGW + QUIC/HTTP3/h2 and
MinGW + WSDL above are untouched. check_ci_layout.py's FEATURE_GATES cannot see any of
this: it walks matrix entries only and this file has no matrix — the gate strings for the
three newly added modules are hardcoded in the job's own step (look for yyjson integrated,
brotli enabled, db: SQLite 后端开).
The three workflows that need a second (artifact-only, UVCPP_BUILD_TESTS=OFF) tree —
mingw64, config-contract on Linux and config-contract on MSVC — build
build-mingw-dbg / build-cfg-dbg and pass --debug-tree to package_release.py. That is
what puts the Debug staging, the .pdb, and uvcpp-debug.pc under a per-push gate; the
release chain only runs on tags, so without this the whole Debug path would first execute on
the day of a release.
Rationale for the runners: Windows MSVC compilation is slow, so each Windows entry has
exactly ONE cmake --build + ctest cycle.
Checklist before adding a feature: entry:
- Single test cycle per entry — never run two
cmake --build+ctestcycles in one entry. An extra artifact-only build (no tests, no ctest) is allowed when the entry's deliverable needs a second configuration — see the Debug tree above — but raisetimeout-minutesto cover it. - Add
--timeout 30to everyctestinvocation — prevents a single hung test from blocking the whole leg. (mingw64uses--timeout 60: that tree runs the whole suite under MSYS2 and the same value is whatrelease.ymluses.) - Add
--exclude-regex "test_shutdown_func"to everyctestinvocation. See §4 for why it is the only one still excluded. - Copy runtime DLLs on Windows before running ctest — the MSVC file has one shared, tolerant copy step; extend it if the entry adds a shared dependency. See §3.
- Set
timeout-minutes— 90 minutes for every job here. A matrix cannot set a per-entry timeout, so the worst-case Windows leg sets the job's value. - Use
shell: bashfor all run blocks in the Linux/Windows/macOS files — except the MinGW file, whose job-leveldefaultsalready puts everyrun:in MSYS2's bash. - Matrix
fail-fast: false— a single entry's failure must not cancel the others. - Update the §1 table and run
python tests/tools/check_ci_layout.py.
Adding a platform means adding a fifth file — which check_ci_layout.py criterion 1
rejects outright. That is intentional: a new platform is a spec change (add it to
PLATFORM_FILES in the gate, to the §1 table, and to both READMEs' badges), not something
that should slip in unnoticed.
Files must stay flat under .github/workflows/. check_doc_lines.py's scan set is
.github/workflows/*.yml — no ** — so a workflow in a subdirectory would silently drop its
line-number citations out of that gate's scan set. check_ci_layout.py asserts there is no
nested workflow file for exactly this reason.
When building shared libraries on Windows, test executables need DLLs next to them at runtime.
The CMakeLists.txt POST_BUILD step copies uvcpp.dll, but FetchContent-built dependencies
are NOT auto-copied. The MSVC file has one copy step shared by every entry (every cp
is tolerant: 2>/dev/null || true), so the table below is "what that step must find on
disk for this entry", not "what a given job hardcodes":
| Entry | uvcpp.dll | uv.dll | llhttp.dll | OpenSSL DLLs | nghttp2 DLL | ngtcp2 DLL |
|---|---|---|---|---|---|---|
basic-shared |
✓ | ✓ | — | — | — | — |
basic-static |
— | ✓ | — | — | — | — |
web |
✓ | ✓ | ✓ | — | — | — |
ssl |
✓ | ✓ | ✓ | ✓ | — | — |
h2 |
✓ | ✓ | ✓ | ✓ | — | — |
quic |
✓ | ✓ | — | ✓ | — | — |
Two of those — are the kind that look like an omission and are not:
basic-statichas nouvcpp.dllcell value because a static-only entry never produces one — nothing links against it, the test executables carry the library inside themselves. That is why the oldwindows-staticjob copieduv.dlland nothing else; the shared copy step inci-windows-msvc.ymlstill attemptsuvcpp.dllfor every entry, and this cell is not a claim about that step, it is a claim about what exists on disk.llhttp.dllis—on every entry withUVCPP_BUILD_WEB=OFF(basic-*,quic) — and not merely unneeded, but not built at all: llhttp is fetched from inside theif(UVCPP_BUILD_WEB)block, andCMakeLists.txtsays so in as many words ("llhttp — HTTP 解析器, WEB=ON 时必须下载"). Measured on this machine: thewebtree has_deps/llhttp-build/, thebasic-staticandquictrees have no_deps/llhttp*at all. So acpfor it there is a tolerant no-op covering a file that was never compiled.
nghttp2 and ngtcp2 have no column value on purpose. Both are linked static
(nghttp2_static + NGHTTP2_STATICLIB; ngtcp2_static + ngtcp2_crypto_ossl_static), so
uvcpp.dll gains no new runtime dependency and no copy step is needed. That was the reason
for choosing static: the alternative is one more DLL to chase through every entry, every
packaging script, and every consumer's bin/. The copy step is therefore also the
assertion that the static decision still holds — if either ever becomes shared again, the
test executables fail to start (0xC0000135) and the quic/h2 entries go red.
OpenSSL is a different story and must be copied for ssl / h2 / quic (all three link
it, and on the MSVC runner it is the dynamic choco build): without libssl-3-x64.dll and
libcrypto-3-x64.dll next to the executables, every case in those entries exits with
0xC0000135, which reads like a DLL-copy bug rather than a TLS/QUIC problem.
- name: Copy runtime DLLs (Windows)
shell: bash
run: |
feat="${{ matrix.feature }}"
tree="build-$feat"
for d in tests/unit tests/functional tests/expand; do
mkdir -p "$tree/$d/Release"
cp "$tree/Release/uvcpp.dll" "$tree/$d/Release/" 2>/dev/null || true
cp "$tree/_deps/libuv-build/Release/uv.dll" "$tree/$d/Release/" 2>/dev/null || true
# Add dependency-specific DLLs as needed
doneIf you add a new library via FetchContent that builds as a shared DLL (because
BUILD_SHARED_LIBS=ON by default), you MUST:
- Find where the DLL is output (
_deps/<name>-build/Release/<name>.dll) - Add a
cpline to the shared Windows copy step inci-windows-msvc.yml - Use
2>/dev/null || true— the DLL may not exist in static configs
Do NOT try to force a FetchContent dependency to build static by setting
BUILD_SHARED_LIBS=OFF — some projects (e.g., llhttp) fail to create proper
CMake targets when built that way.
nghttp2 is the counterexample, and it does it the other way round. Instead of
flipping the global BUILD_SHARED_LIBS, it selects its own static target
(nghttp2_static, aliased as nghttp2) inside a function() scope that also
force-sets ENABLE_LIB_ONLY / ENABLE_APP / ENABLE_HPACK_TOOLS / ENABLE_EXAMPLES
as non-cache variables — a plain set(... CACHE ... FORCE) would leak BUILD_TESTING
into the root scope and knock out tests/functional's registration. Two more traps
specific to it:
EXCLUDE_FROM_ALL+ the CMakeFetchContent_Populate+add_subdirectoryshape (copied from zlib, not from llhttp'sMakeAvailable) — the latter registers install rules and would ship nghttp2's ownnghttp2Config.cmake/libnghttp2.pcout ofcmake --install.- nghttp2 does not give consumers
ssize_ton MSVC.src/http2/uvcpp_h2_nghttp2.hdefines it asint(notSSIZE_T) — the library is compiled withint, so anything else is an ABI mismatch._CRT_DECLARE_NONSTDC_NAMESdoes not help, andNGHTTP2_NO_SSIZE_Tdeletes callbacks we need.
ngtcp2 needs the same static-selection trick, and one extra: both it and nghttp2 create an
unconditional add_custom_target(check), so turning them on together used to abort the
configure. The root CMakeLists.txt copies the ngtcp2 sources into the build tree and
removes that one line; §5 has the gate that keeps that workaround honest.
Tests excluded from CI (--exclude-regex):
| Test | Reason | Platforms |
|---|---|---|
test_shutdown_func |
Flaky, not hanging: ~1% (2 failures in 210 runs, both trees). libuv shutdown race, still open. | All |
Only one test is excluded today. Two others used to be listed here and were removed on 2026-09-17 after measuring them instead of trusting the label:
| Test | Was listed as | Measured (80 runs each, both trees) |
|---|---|---|
test_tcp_func |
"Pre-existing hang (dual-loop thread join)" | 0 failures, ≤1 s per run |
test_memory_pool |
"Pre-existing hang (multi-thread pool alloc on Windows)" | 0 failures, ≤1 s per run |
Both had been exclusions for defects fixed long before. test_memory_pool's is
documented: it was a missing-DLL-copy bug (see CMakeLists.txt:3559-3559), fixed and
left in the exclude list anyway. test_tcp_func's dual-loop teardown is most
likely the ~uvcpp_tcp_server fix, which is what removed the two sleep_for
calls that were joining the worker thread — that is an inference from the
record, not something this measurement proves. What the measurement does
show is the part that matters: the exclusion outlived the bug. When you touch
this list, re-measure the entries; a reason written months ago is a hypothesis,
not a finding.
The four platform files were updated to match on 2026-09-17: every one of the 21 matrix
legs that runs ctest passes --exclude-regex "test_shutdown_func" and nothing else
(21 = 7 Linux + 6 Windows MSVC + 7 macOS + 1 MinGW, i.e. the sum of the Features column in
§1, plus the MinGW job), so test_tcp_func and test_memory_pool run on every leg and must
stay green.
Rule: excluded tests must have a tracking issue. Do not add to the exclude list without documenting the reason here and filing a GitHub issue.
Every matrix entry must explicitly set these options — never rely on defaults:
-DCMAKE_BUILD_TYPE=Release
-DUVCPP_BUILD_TESTS=ON
-DUVCPP_BUILD_STATIC=ON|OFF
-DUVCPP_BUILD_SHARED=ON|OFF
-DUVCPP_BUILD_WEB=ON|OFF
For the web/ssl/h2/full entries, also set:
-DUVCPP_ENABLE_ZLIB=ON|OFF
-DUVCPP_ENABLE_OPENSSL=ON|OFF
The h2 entries add:
-DUVCPP_ENABLE_NGHTTP2=ON
This option is OFF by default, and no other entry sets it — so h2 is the only entry
that compiles src/http2/ at all. Everything else builds a library without HTTP/2
and goes green, which is correct for them and meaningless as evidence about HTTP/2.
Two gates in that entry exist purely to keep that from turning into a vacuous green,
and they should not be "simplified" away:
- after configure, assert
UVCPP_ENABLE_NGHTTP2:BOOL=ONinCMakeCache.txtand that configure printednghttp2 integratedandIncluding http2 module in build. The first alone is not enough: the project force-disables nghttp2 when OpenSSL is off, so a cache read can be stale relative to what actually got built. - before ctest, assert
ctest -Nliststest_h2_session_func. Without nghttp2, those test files compile their#elsebranch — amain()that printsSKIPand returns 0 — so "not tested" and "passed" are indistinguishable in a ctest summary. Note also thatctestreports100% tests passedjust as happily when the tests were never registered.
The strings live in the matrix entry (gate_switch / gate_integrated / gate_module /
gate_test) and the steps consume them as matrix.gate_*, because the skeleton is shared
with the entries that have no gates. check_ci_layout.py asserts both halves: the four
strings are inside the entry that needs them, and the file really references matrix.gate_*.
quic mirrors the h2 gates for the same reason (1.4.1), and adds problems of its own.
It runs on three legs — Ubuntu, macOS and Windows MSVC. MinGW is the gap (see §1).
- Only the Ubuntu leg builds OpenSSL from source, and it has to. The module cannot be
configured against a TLS library without the QUIC API, and no package on that runner
provides one: Ubuntu 24.04 ships 3.0.13, which has neither
SSL_provide_quic_datanorSSL_set_quic_tls_cbs. So the leg builds 3.5.0 from source (the expensive step: the job'stimeout-minutes: 90covers it). Two traps in that step are worth naming, because both were measured on a real 3.5 build rather than guessed: the exportedSSL_set_quic_tls_cbscarries a version suffix (SSL_set_quic_tls_cbs@@OPENSSL_3.5.0), so an end-anchorednm | grepmatches nothing and fails the leg for no reason; and the installedbin/opensslhas no RUNPATH, sold.sopicks up the runner's 3.0.13 first and the binary dies withversion `OPENSSL_3.4.0' not found— it needsLD_LIBRARY_PATH. - The macOS leg uses Homebrew's
openssl@3(≥ 3.2, so it has the API) and needs no in-job build — but it must pass-DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)":openssl@3is keg-only andFindOpenSSLhas no Homebrew hints at all. A prelude step looks at that dylib, but it is diagnostic only: it emits::warningand neverexit 1. That is a deliberate correction, not an oversight, and the first-ever macOS run bought it: the prelude rannm -gUand failed the leg for a reason unrelated to OpenSSL.-Umeans defined-only to LLVM'snmand the opposite convention to GNU's (-u), and a Mach-O dylib may be stripped sonmhas no symbol table to read at all — two ways to red that say nothing about the library. The rule this produced: a self-check must never be easier to red than the gate it serves, because a false red costs a whole macOS rotation (seven entries) and points the reader at the wrong cause. What actually decides is gate ②, and it now quotes the configure log's own words into its::error(see the next-but-one bullet), so the reason still survives. The probe is not vacuous even though it no longer decides:nm -gplus a name match does find_SSL_set_quic_tls_cbson the runner (measured — it is what turned this leg green after the-gUform had reddened it), so a genuinely QUIC-incapableopenssl@3would still produce a warning naming itself. - The MSVC leg uses the OpenSSL from
.github/scripts/win-openssl-deps.sh(the same source thessl/h2/http3entries use — see §6 for why it is a script and not a barechoco install openssl). Do not add-DOPENSSL_USE_STATIC_LIBS=ONthere: that package ships no static libraries,find_packagewould fail, andUVCPP_ENABLE_OPENSSLwould be silently downgraded to OFF — red in the wrong place. Because OpenSSL is then dynamic there, the shared DLL-copy step has to placelibssl-3-x64.dll/libcrypto-3-x64.dllnext to the executables (§3). - What a QUIC-incapable OpenSSL looks like, on every leg: not a configure-time fatal.
The root
CMakeLists.txt's QUIC precheck warns and force-set()sUVCPP_ENABLE_QUIC OFFfirst, while the cache still readsON. So the load-bearing gate isngtcp2 integratedbeing absent — gate ① (the cache read) is the weak one. Because the root precheck only warns, gate ②'s failure message carries the configure log's ownCMake Warning/CMake Errorlines**: without them the annotation would name the symptom (ngtcp2 missing) and hide the cause (the OpenSSL), and the log itself is not readable without a login (artifact zips answer 401 anonymously). The Linux leg additionally keeps a hard-failingnm -Dprelude on its self-built OpenSSL — that one is safe to let fail because it was verified green on a real 3.5 build, and it guards the leg that builds OpenSSL in the first place. - Its configure line is the only place in CI that turns SSL on with the web module
off (
-DUVCPP_ENABLE_OPENSSL=ON -DUVCPP_BUILD_WEB=OFF). That combination used to compilesrc/ssl/without ever definingUVCPP_SSL_LIBSand then fail at link time;1.4.1moved the OpenSSL discovery block out ofif(UVCPP_BUILD_WEB)to fix it, and these entries are the evidence that the fix holds. All three keep web off, so this coverage did not get diluted by adding legs. - The test-registration gate matters more here than anywhere else: with QUIC off all three
QUIC test files (
quic_api_func.cpp,quic_handshake_func.cpp,quic_stream_func.cpp) compile their#elsebranch, whosemain()prints an error and returns 2 — so a test that failed to register is a failure, not a silent pass. (This is also why those files are excluded rather than merely skipped; seedoc/testing-guide.md.) The gate string in the matrix entry names one of the three (test_quic_api_func) as a sentinel; the other two ride the samectestinvocation. - On Ubuntu, a second, configure-only step turns on
nghttp2andquicin one tree. That is the only place either entry covers the combination:h2leaves QUIC off and this entry's main configure leaves the web module off. It is a real failure mode, not a hypothetical — the twoadd_custom_target(check)lines collide (§3). It is deliberately configure-only: what breaks is a configure-time line, and building the library afterwards would prove nothing extra about that collision. - A QUIC-incapable OpenSSL on MSVC would need the Ubuntu recipe (build it in-job) as the
fallback. That path has not been taken because
nmakedoes not parallelise on Windows and the package-manager route is tried first. If thequicentry on MSVC goes red withngtcp2 integratedmissing, that is the fallback, not a redesign.
The config-contract packages and the prebuilt release packages are two different
module sets, on purpose — and each is judged by its own artifact, never by a shared
assumption. As of 1.5.0 release.yml enables QUIC/HTTP3 on all six legs (each leg
supplies a QUIC-capable OpenSSL >= 3.2; see the table at the top of that file), as of
1.5.2 it enables WSDL too, and as of 1.7.0 it enables yyjson, brotli (from
source) and the two remote db backends (MySQL/PostgreSQL, via
UVCPP_DB_RUNTIME_LOAD) — see §5's yyjson and brotli entries and the release.yml
header. The two config-contract jobs enable WSDL and yyjson but not QUIC/HTTP3,
brotli, or the remote db backends.
So the containment is now one-directional: everything config-contract turns on, the release
legs also turn on, and the release legs additionally carry QUIC/HTTP3 + brotli + MySQL/PG.
That is the safe direction for the two config-contract jobs, whose job is the
install→consume contract and the generated-header/diagnostic contract, not feature parity —
and it is why the yyjson and brotli cells exist in the per-platform matrices (§5), since the
release-only features would otherwise be compiled for the first time on tag day.
So "does this package have QUIC?" is answered by the per-leg assertion step reading
<tree>/include/uvcpp/uvcpp_config.h, never by grepping CMakeCache.txt: the QUIC/HTTP3
guard chain emits message(WARNING) and plain-set()s the option OFF, so the cache keeps
saying =ON while the compiler sees 0 — a cache-grepping assertion lets a package without
the feature through, which is the one thing it exists to stop.
wsdl is the WSDL 1.1 document model plus the SOAP runtime. Both live in src/wsdl/
(uvcpp_wsdl_*.{h,cpp} and uvcpp_soap_*.{h,cpp} — there is no separate src/soap/) and
hang off the single UVCPP_ENABLE_WSDL switch. It runs on two legs — Ubuntu and Windows
MSVC. MinGW and macOS are the gap (see §1).
- Why the entry exists at all.
UVCPP_ENABLE_WSDLis off by default, so every other entry builds asrc/wsdl/-less library and goes green — correct for them, and meaningless as evidence about WSDL. Until1.5.2the only thing that ever turned it on was the twoconfig-contractjobs, and those are configure-only. Adding-DUVCPP_ENABLE_WSDL=ONtorelease.ymlwithout an entry here would have made the first compile of that configuration happen at release time. This is the same argument ash2's, and the repo has already paid for learning it once:UVCPP_ENABLE_ZLIB=OFF+WEB/WEBAPP=ONhad no entry at all and shipped two classes of defect (#35). - It is
webplus one switch, exactly aszlib-offiswebminus one, and its dep list is byte-identical toweb's. That is deliberate:pugixmlis pulled byFetchContent(CMakeLists.txt:1891,GIT_TAGpinned toPUGIXML_VERSION), not by a package manager, so there is nothing to install — and keeping the package set identical means a red/green difference between the two legs can only be attributed to the switch itself. - All four gates are load-bearing here. Gate ② is
pugixml integrated(the FetchContent actually resolved), gate ③ isIncluding wsdl module in build(src/wsdl/really entered the source list), and gate ④ istest_wsdl_document_func. Gate ④ matters more than usual: the four WSDL/SOAP test files are removed by a glob+FILTER intests/functional/CMakeLists.txtwhen the module is off, andctestreports100% tests passedjust as happily when they were never registered. - It runs the full
ctest, so the gate is not merely "it compiled": the four files that need the module —wsdl_document_func,soap_message_func,web_app_wsdl_func,web_app_soap_func— really execute. The entry's gate string names one of them as a sentinel; the other three ride the samectestinvocation.
yyjson is src/yyjson/ — the optional JSON backend built on this library's own bundled
copy of yyjson: uvcpp_json_doc / uvcpp_json_value
parse, uvcpp_json_builder / uvcpp_json_node construct a mutable tree, and
uvcpp_json_write serialises with yyjson's own writer (the accessors share their names
with the nlohmann ones; the old bridge back into uvcpp_json_writer was removed in 1.7.2,
which is also an API change to uvcpp_json_write). It hangs off the single
UVCPP_ENABLE_YYJSON switch and runs on four legs — the three yyjson matrix entries
(Ubuntu, macOS, Windows MSVC) plus the single ci-mingw64.yml job, whose one configure also
passes -DUVCPP_ENABLE_YYJSON=ON since 1.7.0 (that cell has no matrix entry, so §1's gap
table lost its MinGW + yyjson row rather than gaining a row here).
- It does not replace nlohmann.
using uvcpp_json = nlohmann::jsonanduvcpp_json_parse/dump/get_*are unchanged. As of 1.7.0 it is also in the release package: all sixrelease.ymllegs pass-DUVCPP_ENABLE_YYJSON=ON, so these three entries are the same argumentwsdl's entries make — the release configuration's stand-in on push/PR, not a substitute for it. (Before 1.7.0release.ymldid not pass that-Dand the package shippedinclude/yyjson/uvcpp_json_doc.has an empty shell —package_release.pycopies module directories wholesale, so the file's presence was never the gate; the macro is. That mechanism is unchanged, only the macro's value in the official package is.) - What these entries buy that nothing else does.
tests/functional/yyjson_doc_func.cppcarries the two load-bearing assertions: a 25-row input table where every row is compared first against a hand-written expectation and then against nlohmann (comparing only the two libraries to each other would let a parser that says "OK" to everything pass), and five corpora whereuvcpp_json_writeand nlohmann'sdump()must be byte-identical (one more corpus is deliberately not: it pins the single shape where the two writers really do differ — the scientific-notation form of a double). Without an entry, none of that is ever compiled — "not compiled" and "compiled and green" are different words but the same check colour.tests/functional/yyjson_build_func.cppadds the construction side (nine groups: round trips, key/string copy semantics,add_copyafter the source doc dies, handle stability under 10 000 appends, the allocation accounting, insertion-order keys, the byte-for-byte comparison against nlohmann when keys are inserted in ascending order, the failure paths, and the write side). - Why macOS is included even though the
wsdlentries are not. The gap table in §1 justifies the missing macOSwsdlentry with "macOS is not a release platform, so there is no path on which it reaches a user unbuilt". That argument does not apply here: even before 1.7.0, when the module was not in the release package at all, macOS was the one leg that compiles this header with libc++, whosedoubleformatting is not libstdc++'s. The byte-identical write-back assertion is precisely the kind that differs across those two. flagsiswebplus one switch, and the dep list is byte-identical toweb's on all three legs.yyjsonis pulled byFetchContent(CMakeLists.txt:1951,GIT_TAGpinned toYYJSON_VERSION) and built as an OBJECT library, not found by a package manager, so there is nothing to install — and keeping the package set identical means a red/green difference can only be attributed to the switch.- Gate ② is
yyjson integrated. There is no "dependency missing → force the module off" rule here, so the cache entry is only a cheap corroboration; what actually proves the FetchContent resolved and the object library exists is that string. Gate ③ (Including yyjson module in build) provessrc/yyjson/entered the source list, and gate ④ (test_yyjson_doc_func) proves the test was registered — the file is removed by the glob+FILTER intests/functional/CMakeLists.txtwhen the module is off. It is a sentinel for one of two yyjson test binaries (test_yyjson_build_funcrides the samectest); the one string is enough because the filter that removes them is one rule.
brotli is the second body compressor (UVCPP_ENABLE_BROTLI, default OFF) behind the
same uvcpp_http_compress layer that already carried zlib — the encoder plus the
Accept-Encoding negotiation that picks between gzip/deflate/br. It runs on four
matrix cells: brotli on Ubuntu, macOS and MSVC, plus a fourth Ubuntu-only
brotli-no-zlib (-DUVCPP_ENABLE_ZLIB=OFF -DUVCPP_ENABLE_BROTLI=ON) — and, since 1.7.0, on
the single ci-mingw64.yml job as well (-DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON, a cell with no matrix entry; §1 lost its MinGW + brotli
gap row over it).
- The fourth cell is not redundant with
zlib-off. The two switches are independent, so the build has four states; the table had three of them (web=(1||1),zlib-off=(0||0), and everything else) and no cell ever compiled(0||1). That is the only state in which two whole groups of code exist: the#elsehalf oftest_compress_variant_brotli()— a client that namesgzipwhen this build cannot produce it must get the identity body, not brotli bytes wearing a gzip label — andtest_accept_encoding_no_zlib(), which pins the header's promise that negotiation only ever returns encodings this build can emit (*falls through tobr;*, br;q=0emits nothing). Without the cell those assertions are never compiled anywhere, so deleting them would be green. - The switch is the only difference from
web, on every leg.flagsisweb's list plus-DUVCPP_ENABLE_BROTLI=ON; the three system legs addlibbrotli-dev(Ubuntu) / nothing (macOS — Homebrew'sbrotliis deliberately not installed so theFetchContentleg is the one that runs) / nothing (MSVC, from-source). Keeping the rest of the list identical means a red/green difference can only be attributed to the switch. - The two discovery paths print different strings, so neither may be a gate. System legs
print
Using system brotli via pkg-config (1.1.0); the from-source leg printsbrotli integrated (tag=v1.1.0). The gate string is thereforebrotli enabled, which both print, and it is the analogue ofyyjson integrated— with one difference worth naming: here a missing dependency does force the module back off (find_package/pkg-configfails ⇒ the option is reset with a warning), so this string is load-bearing in the same wayIncluding <module> module in buildis elsewhere. - Gate ③ is the derived macro, and it is what makes the fourth cell a gate rather than a
second copy of
web.HTTP body compressor: UVCPP_HTTP_COMPRESS_ENABLE=is spelled(zlib||brotli)from the two switches, sowebasserts(1||1),zlib-offasserts(0||0)andbrotli-no-zlibasserts(0||1). Those three readings together are what pins the spelling: a macro that ignored one switch would satisfy one cell and contradict another. - Gate ④ has to be a
ctestname, not the sub-test name. The new assertions live insidetest_web_http_client_func(tests/functional/web_http_client_func.cpp), andctest -Nonly ever prints that. Registering the sub-test name asgate_testwould make the gate unpassable — it is the same traph2andcapidocument.
capi is src/capi/ — the C ABI surface (extern "C", C99 headers, for C#/P-Invoke and
any other FFI). It is the one module that is not a feature you can be missing: it compiles
into the existing uvcpp library, so no entry here produces an extra artifact, and the
release package's module set is unchanged by it. It runs on three legs — Ubuntu, macOS and
Windows MSVC. MinGW is the gap (see §1).
Two of the four gate_* strings are the usual ones, and the same two traps apply as for h2
and quic: the cache reads ON even when the guard force-set()s the module off, so
Including capi module in build is the load-bearing string; and a missing test registration
would otherwise be invisible.
What is specific to this entry:
- The counter-example step is the point.
UVCPP_ENABLE_CAPIis force-disabled unless bothUVCPP_BUILD_NETandUVCPP_BUILD_WEBare on (two guards, two warnings — see the rootCMakeLists.txt). Thecapientry's own flags satisfy both, so the guard branches never execute on a normal run — deleting a guard is a green path. The Ubuntu stepGate — C ABI must be force-disabled without web/netconfigures twice on purpose (once withWEB=OFF, once withNET=OFF) and asserts three things each time: configure succeeds (the guard must warn andset(... OFF), not fail), the log contains已强制关闭 capi, and the log does not containIncluding capi module in build. The third one matters: without it, a guard that warns and then builds anyway would pass. It runs on Ubuntu only — the guard logic is platform-independent, and three copies of a configure-only step would cost more than they cover. - The symbol lock is Ubuntu-only too, and that is deliberate rather than an oversight.
tests/tools/check_capi_symbols.pycompares the library's exporteduvcpp_c_*symbols againsttests/tools/capi_symbols.lock(vianm -D, the ELF dialect). Its purpose is to make "someone deleted or renamed a C symbol" red in CI instead of anEntryPointNotFoundExceptionon an already-built C# side. The macOS (nm -gU) and Windows (dumpbin /exports) dialects are not implemented, so the script exits 3 (not judged) there; what the macOS and MSVC entries judge is "it compiles and links", which is their own reason for existing (UVCPP_C_APIresolves todllexport/dllimportonly on_WIN32, andtests/capi/is compiled by a real C front end — Apple clang andclrespectively). - Why it exists separately from
full.full(Ubuntu/macOS only) carries-DUVCPP_ENABLE_CAPI=ONbecause "release andfullboth support the C ABI" is the requirement, not a convenience — butfull's gate strings are about the memory pool, and its ctest count does not tell you whether the pure-C tests were registered. The dedicated entry is where that is asserted. Since1.5.3those are six tests (test_capi_common_func,test_capi_net_func,test_capi_webapp_func, plustest_capi_h2_funcandtest_capi_quic_h3_func, which only exist on the legs that enable NGHTTP2 / HTTP3, andtest_capi_db_func, which only exists whereUVCPP_ENABLE_DBis on); the entry'sgate_teststill names the one from the first batch, because the "is it registered at all" check is a spot check and the last step of the job runs the whole suite. - Which leg judges which slice. The C surface spans modules, so
1.4.4also put-DUVCPP_ENABLE_CAPI=ONon the Ubuntuh2andhttp3entries — one leg only ever enables part of the module set (capihas no SSL/h2/quic,http3has no webapp), and the symbol lock's slices are judged against each slice's own switch.1.5.3added the db slice, which is judged on exactly one leg: the threecapientries all carry-DUVCPP_ENABLE_DB=ON -DUVCPP_ENABLE_DB_SQLITE=ON(with both remote backends explicitlyOFF), and the Ubuntu one is the only leg in the whole matrix that has CAPI and DB open at the same time. Three Ubuntu legs between them judge all eight slices (5 + 5 + 6, with db judged only on the first and webapp only on the first two); seedoc/capi-guide.md§3.5 for the table. - The db slice is the one slice whose judgment can silently evaporate, so the Ubuntu
capientry gained a step for it. IfUVCPP_ENABLE_DBis force-disabled (no backend found) the symbol lock does not go red — it prints "not judged" on the db line and exits 0, and the "every slice is judged by at least one leg" property is gone with nothing to show for it. The stepGate — the C ABI leg really has the db slice opentherefore requiresdb: SQLite 后端开andIncluding db module in buildin the configure log andtest_capi_db_funcinctest -N, the same three criteria thedbentry uses. The macOS and MSVCcapientries carry the same flags for a different reason — not the lock (it is Ubuntu-only) but "the C slice compiles under Apple clang andcland its test runs there". - No OpenSSL, no nghttp2, no zlib for the C layer itself — and, since
1.5.3, exactly one database backend.capi's flags matchweb's dependency row plus, on Ubuntu,libsqlite3-dev(the pure-C db test needs a backend to talk to, and SQLite is the one that needs no server; the two remote backends stay explicitlyOFF, whose tests would exit 3 and read as coverage without being any). MSVC takes SQLite from source (-DUVCPP_DB_SQLITE_FROM_SOURCE=ON), which is the same network-at-configure cost thedbentry already pays and the path the release legs use; macOS finds it in the SDK and installs nothing. That leg stays OpenSSL-free on purpose: it is the "does the C layer stand up by itself" leg, and the C surfaces that do wrap cryptography arrive on their own legs with their own flags (http2on theh2entry,quic+http3on thehttp3entry). The one place this bites is a wrapper whose C++ member only exists under a feature switch — which is whyuvcpp_c_tcp_client_is_tls()answers 0 anduvcpp_c_tcp_client_alpn_selected()answers an empty string in both builds rather than being#if-ed into two behaviours.
Release configurations pass -DUVCPP_ENABLE_CAPI=ON on all six legs (see release.yml),
which is what makes the shipped include/capi/ headers match a library that actually exports
the symbols. UVCPP_CAPI_ENABLE also joined check_config_contract.py's MACROS list, so the
generated-header contract covers it on the Linux and MSVC config-contract jobs.
db is src/db/ — the database module (one uvcpp_db_client per connection, over SQLite /
MySQL / PostgreSQL). UVCPP_ENABLE_DB is OFF by default, and it is the only module that
needs a third-party client library (libsqlite3 / libmysqlclient / libpq), so every other
entry compiles not one line of src/db/. That is the same shape as h2 and wsdl, with one
more layer: db has three backends behind it, and if none of the three is found the module
itself is force-disabled (a plain set(UVCPP_ENABLE_DB OFF), so CMakeCache.txt still reads
UVCPP_ENABLE_DB:BOOL=ON). The second gate string is therefore about a backend:
gate_switch |
UVCPP_ENABLE_DB:BOOL=ON in CMakeCache.txt — the switch was passed and was not force-disabled at the cache level |
gate_integrated |
db: SQLite 后端开 in the configure log — a backend actually succeeded. Without this one the entry can go green having configured a module that has nothing to talk to |
gate_module |
Including db module in build — src/db/ really reached the source list |
gate_test |
test_db_sqlite_func is in ctest -N — without this, "the module was filtered out" and "the tests passed" look identical |
The four entries, and what each one measures:
- Ubuntu
db, macOSdb— SQLite throughfind_package(SQLite3), both remote backends explicitlyOFF. On Ubuntu that islibsqlite3-dev; on macOS no package at all is needed, becauseModules/Platform/Darwin.cmakeinserts${CMAKE_OSX_SYSROOT}/usrat the front ofCMAKE_SYSTEM_PREFIX_PATH, so the SDK'ssqlite3.hand/usr/lib/libsqlite3.tbdare found (Homebrew'ssqliteis keg-only, and its own caveat is "macOS provides SQLite"). - MSVC
db, and the MinGW job's tail — SQLite from source (-DUVCPP_DB_SQLITE_FROM_SOURCE=ON), which is the path the six release legs use. Windows has no system sqlite3, so this is also the only way to cover the module there. It compiles one staticsqlite3.cwithPOSITION_INDEPENDENT_CODE ON(the system.aon Ubuntu 24.04 is not PIC, and the system.sowould add aDT_NEEDEDthat release'slddassertion bans — seedoc/db-guide.md). On MinGW it also avoidslibsqlite3.dll.a: linking that would putsqlite3.dllinto the DLL's imports and trip the self-containment assertion in that file. Configure-time network is required; an offline machine can drop the hash-pinned zip into${build}/_deps/uvcpp-sqlite/. - Ubuntu
db-servers— the remote db backends (MySQL, PostgreSQL and, since 1.7.5, MongoDB) against real servers, plus Redis 7 forredis_real_func(Redis is not a db backend — it needs a real server, which is the same criterion).services:is job-level, so this cannot be a matrix entry (it would drag four containers into all twelve Ubuntu cells, includingbasic-static). It is the mirror image of thedbentries: SQLite explicitlyOFF, MySQL / PostgreSQL / MongoDBON, plus two reverse assertions — the configure log must not containdb: SQLite 后端开, andtest_db_sqlite_funcmust not be registered. Without those, "-DUVCPP_ENABLE_DB_SQLITE=OFFstopped working" is a fully green path on a machine that has sqlite3 anyway.
What is specific to db-servers:
UVCPP_DB_TEST_REQUIRE=1is the judgement, notctest. Without a URL the remote tests exit 3 by design, andtests/functional/CMakeLists.txtgives themSKIP_RETURN_CODE 3, so ctest prints***Skipped. That is honest, but a job whose only interesting tests skipped is not red.UVCPP_DB_TEST_REQUIREturns "no URL" into exit 1 (andUVCPP_REDIS_TEST_REQUIRE=1does the same forredis_real_func). Servers are up; if the tests skip, the configuration is wrong, and green would be the worst possible outcome.- MySQL 8 keeps its default authentication plugin (
caching_sha2_password), with no--default-authentication-plugin=mysql_native_password. That flag is the usual reflex, and the argument for it sounds solid — caching_sha2 over a non-TLS connection makes the client fetch the server's RSA public key (MYSQL_OPT_GET_SERVER_PUBLIC_KEY), and this module's driver calls plainmysql_real_connect— but it is not needed, and that was measured, not assumed: acaching_sha2_passworduser on MySQL 8.0.46 authenticated through this driver and the shared suite passed 160/160, and the reason is visible in the server counters — one suite run movedSsl_acceptsby 6 (the suite opens six connections), because the driver sets no SSL option, so the client's defaultssl-mode=PREFERREDnegotiates TLS against the self-signed certificates MySQL 8 generates during--initialize. The connection is TLS, which is exactly the precondition caching_sha2 needs. The flag is deprecated since 8.0.34 and removed in 8.4, so keeping it would only break the next person who moves that image. - PostgreSQL 16 authenticates with
scram-sha-256, and the URL's password is really used. The official image defaults to scram, while every local database used during development runstrust— so the local runs of the shared suite never exercised the password path at all, and "the URL contains a password" is not evidence that the library reads it. Measured by flipping the local cluster toscram-sha-256for127.0.0.1(ALTER ROLE uvcpp PASSWORD …, one line ofpg_hba.conf,pg_reload_conf()): the branch's own binary,UVCPP_DB_TEST_REQUIRE=1 UVCPP_DB_TEST_PGSQL_URL='postgresql://uvcpp:uvcpp@127.0.0.1:55432/uvcpp_test' test_db_pgsql_func, gives 160/160 and exit 0, while the same binary with a wrong password exits 1 withFATAL: password authentication failed for user "uvcpp". Souvcpp_db_url::parse(src/db/uvcpp_db_factory.cpp) does handuser:passwordto libpq rather than dropping it, and a wrong URL is red rather than a quiet skip. (The cluster was set back totrustafterwards.) - The URLs live in the step's
env:, not in the test sources:mysql://root:uvcpp@127.0.0.1:3306/uvcpp_test,postgresql://uvcpp:uvcpp@127.0.0.1:5432/uvcpp_test,mongodb://127.0.0.1:27017/?serverSelectionTimeoutMS=5000andredis://127.0.0.1:6379/0, matching what the four containers are created with.
Since 1.5.3 the C surface of the module rides these same legs. src/capi/uvcpp_c_db.h
is the eighth slice of the C ABI, and its test test_capi_db_func looks at the same
UVCPP_DB_TEST_*_URL variables as the C++ suite — so once the two env: lines above are
present, the C test goes over MySQL and PostgreSQL with no further change, and the
SKIP_RETURN_CODE 3 / UVCPP_DB_TEST_REQUIRE=1 contract applies to it verbatim. From the
other direction the three capi entries now carry a SQLite backend (see the capi entries
above). Two things follow that are easy to get backwards:
- The
db-serversentry needed-DUVCPP_BUILD_WEB=ONas well, becauseCAPI ⇒ WEBandUVCPP_BUILD_WEBisOFFby default —-DUVCPP_ENABLE_CAPI=ONalone is force-disabled by the guard chain and the entry would look like it has CAPI while exporting not one C symbol. Its gate therefore greps the configure log forIncluding capi module in build, not the cache (CMakeCache.txtwould still readON— the guard chain uses a plainset()). - Because the C test reads the URLs from the environment and the C++ test does too, the
db-serversentry is the only place the C db wrapper is exercised against a remote backend, and the threecapientries are the only place the symbol lock judges the db slice. Neither leg can be dropped without silently losing one of those.
Release configurations pass -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON
together with -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON -DUVCPP_ENABLE_DB_ODBC=ON -DUVCPP_ENABLE_DB_MONGODB=ON -DUVCPP_DB_RUNTIME_LOAD=ON at
all twelve configure points.
The RUNTIME_LOAD is load-bearing rather than tidiness: those legs run on runners that
have libpq-dev installed, find_package(PostgreSQL QUIET) would succeed silently, and
the shipped libuvcpp.so would grow a libpq.so.5 DT_NEEDED that the release ldd
assertion exists to ban. Packages therefore carry all five backends' code, but no
database client library: MySQL / PostgreSQL / ODBC / MongoDB symbols are taken from
libraries loaded at run time. That is also why 1.6.x's "SQLite only" shape is gone — see
db-driver-policy.md for the full policy. MongoDB's libmongoc /
libbson sit in the same "installed by the user, only referenced" class, and their
headers come from libmongoc-dev / the MSYS2 mongo-c-driver package exactly like the
other client headers. Redis is not in this class at all: it is a built-in RESP client
with no external library, so every leg compiles it without any package.
Consumer-visible UVCPP_*_ENABLE macros come from a generated
include/uvcpp/uvcpp_config.h (cmake/uvcpp_config.h.in via configure_file), and
every public header includes it before its first module guard. A consumer passes no
-D at all; a conflicting -D from the consumer is a hard #error naming this build's
value.
Why a separate job rather than a step on web/h2: on Ubuntu those entries install
libuv1-dev, and package_release.py requires libuv to live under the tree's _deps/
— otherwise it deliberately exits 2 and produces no package. This job configures the way
release.yml does (UVCPP_BUILD_LIBUV_FROM_SOURCE=ON) and with OpenSSL ON, because
the member-layout shift being guarded against (ssl_ctx_ is declared before
http_/registry_ in uvcpp_web_app) only bites when UVCPP_OPENSSL_ENABLE=1. The job
asserts that value in the generated header before building: with it off, criterion 1
silently degrades into a no-op. The same is asserted for UVCPP_WSDL_ENABLE, because
check_doc_snippets.py treats "module really on" as the premise for the wsdl/ snippets —
and for UVCPP_DB_ENABLE, for the same reason: doc/db-guide.md carries five snippets that
need db/uvcpp_db.h, and with the module off this gate exits 3, which makes both
config-contract jobs red. That is not hypothetical — it is what the 2026-10-10 run of
c130406 did, and the fix was to give that tree the release configuration's four database
-Ds (including UVCPP_DB_SQLITE_FROM_SOURCE=ON, so the job needs no libsqlite3-dev). The
assertion reads the generated header, not the cache, because "all three backends missing"
force-disables the module with a plain set() — the cache keeps saying ON while the compiler
sees 0, and the resulting exit 3 looks exactly like "the package is missing a module".
tests/tools/check_config_contract.py runs four criteria:
- ABI-shaped positive — compile a consumer with a raw compiler invocation (
-Ionly, zero-D), construct auvcpp_web_app, assertconnection_count() == 0, against the packaged DLL. "It compiles and runs" would be vacuous: the header is self-consistent either way, while a wrong macro set compiles, links, and then lands in one of two places — an inline accessor reading the wrong offset and returning a garbage count (1073741824, the original symptom), or a hard crash (0xC0000409on MSVC, empty stdout). Asserting "the count must be a weird value" would hang forever on the crashing landing; the criterion asserts== 0, so both landings go red. - Negative — the same raw invocation plus one conflicting
-Dmust fail to compile, with the generated header's#errortext in the output. It must stay a raw invocation: through CMaketarget_compile_definitionsis emitted afterCMAKE_CXX_FLAGSand both compilers take the last-D(MSVC only warns C4005), so the#errornever fires and the check becomes vacuous. - Static — the include's position relative to the first real module guard in every
public header (checking only "is it present" passes a version nested inside its own
#if, where the macro is not yet defined); no handwrittenuvcpp_config.hundersrc/; the packaged header byte-identical to the build tree's and agreeing with that tree's exportedINTERFACE_COMPILE_DEFINITIONS; and every.pcin the package (uvcpp.pcanduvcpp-debug.pc) points at a library that is actually in the package's ownlib/. That last one iterates aglobrather than naminguvcpp.pc: with a hardcoded name the debug.pcwas checked zero times, and it is the one whose-luvcppdexists nowhere else in the package — measured, not assumed: mutating it to-luvcppdxused to leave the gate green (1.1.35). - C surface — compile a pure C translation unit (C compiler derived from
--cxxthroughCC_OF_CXX, same table ascheck_doc_snippets.py) that includes only<capi/uvcpp_c.h>, with zero-D, against the same package; run it and assert the header'sUVCPP_C_ABI_VERSIONequals the library'suvcpp_c_abi_version(), thatuvcpp_c_tcp_client_new()→_free()→ a second_free()returnsE_STALE(not 0, not a crash), and thatuvcpp_c_live_handle_count() == 0at the end. Criterion 1 only ever exercises the C++/webapp face and cannot touchinclude/capi/; this one covers the hole that the C ABI is the layer's whole outward promise yet no gate had looked at it from outside a package (tests/capi/compiles the build tree's headers, so a missing or misplaced install stays green there). On a tree whoseUVCPP_CAPI_ENABLEis 0 it prints[跳]and returns 3, like criterion 1 with webapp off — and the runner takesminof the two, so a run is only "not judged" when neither criterion could run.
It is not compared against CMakeCache.txt: OpenSSL/nghttp2/webapp are silently
downgraded to OFF when their dependency is missing (the set(UVCPP_ENABLE_OPENSSL OFF)
/ set(UVCPP_ENABLE_NGHTTP2 OFF) / set(UVCPP_BUILD_WEBAPP OFF) lines in the top-level
CMakeLists.txt — plain set() calls, so the cache still reads ON). The export file holds
the post-downgrade values, generated from the same literals as the header.
Toolchains covered: ubuntu (gcc/ELF), ci-windows-msvc.yml's config-contract (MSVC/PE —
it needs ilammy/msvc-dev-cmd because cl.exe is not on the Windows runner's default
PATH), and mingw64 (MinGW/PE; that job gained mingw-w64-x86_64-python for this).
check_ci_layout.py pins the --platform / --cxx pair of each of the two standalone
config-contract jobs, because a wrong pair means the gate silently tests another toolchain.
The mingw64 tail configures with -DUVCPP_ENABLE_CAPI=ON, the way release.yml's two
MinGW legs do — without it criterion 4 would print [跳] there, and the package would keep
shipping include/capi/ (package_release.py's MODULES is independent of the switch)
next to a DLL that exports no uvcpp_c_* symbol at all.
Known gap: the chain cannot run on macOS — package_release.py's PLATFORMS has no
macOS key (mingw-x64/arm64, msvc-x64/arm64, linux-x64/arm64 only). So the macro
contract is never verified against a clang/macOS toolchain.
Dependencies are installed per feature entry, never as a union — installing
libssl-dev for the Ubuntu quic entry would put the system's 3.0.13 into the candidate
set and manufacture a fresh silent-downgrade path. Each file has one install step with a
case "${{ matrix.feature }}" in it, and an unknown feature name is a hard error there.
# basic-static | basic-shared
sudo apt-get install -y libuv1-dev ninja-build
# web
sudo apt-get install -y libuv1-dev zlib1g-dev ninja-build
# zlib-off — deliberately identical to web: the only delta is -DUVCPP_ENABLE_ZLIB=OFF,
# so a red/green difference is attributable to that switch and nothing else
sudo apt-get install -y libuv1-dev zlib1g-dev ninja-build
# wsdl | yyjson — deliberately identical to web again: pugixml and yyjson both come from
# FetchContent, so there is nothing to install and the delta is exactly the switch
sudo apt-get install -y libuv1-dev zlib1g-dev ninja-build
# brotli — web's list plus libbrotli-dev; on this leg brotli comes from the system package
# (pkg-config) rather than FetchContent, which is the point: the two discovery paths must
# both be exercised, and the from-source one is what macOS and MSVC run
sudo apt-get install -y libuv1-dev zlib1g-dev libbrotli-dev ninja-build
# brotli-no-zlib — the same list minus zlib1g-dev: it is the (zlib OFF + brotli ON) cell,
# so it must not have zlib available to find
sudo apt-get install -y libuv1-dev libbrotli-dev ninja-build
# ssl | h2
sudo apt-get install -y libuv1-dev libssl-dev zlib1g-dev ninja-build
# full
sudo apt-get install -y libuv1-dev libssl-dev zlib1g-dev ninja-build
# quic — no libssl-dev on purpose: it builds OpenSSL 3.5 from source
sudo apt-get install -y libuv1-dev zlib1g-dev
# http3 — same as quic (HTTP/3 needs QUIC, so it needs the same 3.5)
sudo apt-get install -y libuv1-dev zlib1g-dev
# capi — same as web: the C layer itself adds no external dependency
sudo apt-get install -y libuv1-dev zlib1g-dev ninja-build
# db — SQLite only (the two remote backends are OFF in this entry, so their client
# libraries are deliberately NOT installed: installing them would turn "explicitly
# off" into "found and compiled", against two tests that have no server to reach)
sudo apt-get install -y libuv1-dev libsqlite3-dev ninja-build
# db-servers — the mirror image: the remote client libraries, no libsqlite3-dev
# (libmongoc-dev Depends libbson-dev; both headers and the runtime libs come with it)
sudo apt-get install -y libuv1-dev libmysqlclient-dev libpq-dev libmongoc-dev ninja-build
# config-contract — the tree is a copy of the release configuration, so its SQLite comes
# from the pinned amalgamation (UVCPP_DB_SQLITE_FROM_SOURCE) and needs no package at all
sudo apt-get install -y libssl-dev zlib1g-dev ninja-build pkg-configdb-servers also declares the four services: (MySQL 8.0, PostgreSQL 16, MongoDB 7 and
Redis 7, each with its --health-cmd wait), which is why it is a separate job —
services: is job-level, and putting it on the matrix job would start four containers for
each of the twelve Ubuntu cells.
# basic-static | basic-shared
brew install libuv ninja
# web
brew install libuv zlib ninja
# yyjson — identical to web: yyjson comes from FetchContent, nothing to install
brew install libuv zlib ninja
# brotli — again identical to web, and here that is on purpose twice over: Homebrew does
# have a `brotli` package, and *not* installing it is what keeps this the from-source leg
brew install libuv zlib ninja
# ssl | h2 | full
brew install libuv openssl zlib ninja
# quic — explicit openssl@3, and -DOPENSSL_ROOT_DIR points at it
brew install libuv zlib ninja openssl@3
# http3 — same as quic (same explicit openssl@3)
brew install libuv zlib ninja openssl@3
# capi — same as web: the C layer itself adds no external dependency
brew install libuv zlib ninja
# db — same as basic: SQLite comes from the macOS SDK, and Homebrew's `sqlite` is
# keg-only (installing it would only mean pinning a root)
brew install libuv ninja# basic-static | basic-shared | web | capi | wsdl | yyjson | brotli — no system deps
# (libuv + llhttp + pugixml + yyjson + brotli all via FetchContent)
# db — no system deps either: its SQLite is built *from source* (UVCPP_DB_SQLITE_FROM_SOURCE)
# ssl | h2 | quic | http3
bash .github/scripts/win-openssl-deps.sh
# OpenSSL DLL path: C:/Program Files/OpenSSL/bin/ (or OpenSSL-Win64)Why the MSVC entries call a script rather than choco install openssl directly. From
2026-09-29 20:46 UTC onwards that command exits non-zero every time on both windows-latest
and windows-2022 (exit 148, for which no documented meaning could be found), while
git diff 0addc23f 699d27e -- .github/workflows/ci-windows-msvc.yml is empty — the workflow did
not change, the runners did. What those entries actually need is not "choco exited 0" but "this
machine has an OpenSSL that find_package(OpenSSL) will find", and choco's exit code lies in
both directions (against a 503 from the community feed it has been seen to print Unable to find
package and still exit 0). So the decision moved onto the file system: the script looks in the
same four directories the DLL-copy step uses, falls back to choco install openssl --no-progress --yes with three backoff retries, and re-checks — ::error plus exit 1 if there is still
nothing, naming the directories it searched and quoting choco. It is shared with
release.yml's msvc-x64, which has the same problem and the same requirement (see
doc/release-process.md). check_ci_layout.py's criterion 7 pins both directions: the call site
must be in ci-windows-msvc.yml, and the bare choco install openssl --no-progress line must
appear in no platform file.
msys2/setup-msys2 installs mingw-w64-x86_64-{gcc,cmake,ninja,openssl,python}.
The single job's tail also enables the database module, with SQLite built from source
(-DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON, both remote backends OFF) — the same
configuration release.yml's mingw-x64 / mingw-arm64 legs use, so this leg is where "the
database module compiles on the toolchain we ship" first gets asked. mingw-w64-x86_64-sqlite3
is deliberately not installed: find_library would pick its libsqlite3.dll.a (the same
.dll.a-first rule the OpenSSL comment in that file records), the DLL would import sqlite3.dll,
and the self-containment assertion a step below would (correctly) fail.
- Add a
UVCPP_BUILD_<MODULE>option inCMakeLists.txt - Add corresponding
UVCPP_<MODULE>_ENABLEcompile definition - Filter sources with
list(FILTER ... REGEX "src/<module>/")when disabled - Add a
feature:entry to the platform files where the module must be covered —feature+flags, plusgate_*if the entry can go green without the module. Then update the §1 table, and update §1's "Gaps" table for the platforms you are not adding. - If the module pulls new FetchContent deps, update the shared Windows DLL copy step
- Add functional tests in
tests/functional/
-
All tests timeout at exactly
--timeoutseconds: DLL copy is missing or incomplete. Check that ALL transitive DLL dependencies are copied. -
CMake configure fails with "could not find any instance of Visual Studio": The runner's VS version changed. Remove hardcoded
-G "Visual Studio XX YYYY"and let CMake pick. -
Single test hangs: Check if it has an internal watchdog timer. If not, add one first, then investigate the root cause.
-
A whole feature entry stopped running: check the §1 table against the file with
python tests/tools/check_ci_layout.py. A renamed entry makes every step guarded byif: matrix.feature == '<old name>'disappear without an error — the leg still reports success, having done nothing. -
|| trueat end of ctest — removed 2026-09-17. It had been added to tolerate the intermittent failures listed in §4, but it applies to the wholectestinvocation, so a real test failure was swallowed exactly like a flake and the job still went green. That is what made the stale exclusions in §4 survivable for months: nothing could go red to contradict them. A failing test now fails the job. If you are here because CI just went red, that is the intended behaviour — the failure was always there, it just was not being reported.The one remaining justification is
test_shutdown_func(~1% flake), which stays excluded. If you would rather it ran, replace the exclusion withctest --repeat until-pass:3rather than reinstating|| true—--repeattargets the known flake,|| truedisables the gate.
Splitting the file renamed every check. A check's name is the job's name: alone —
GitHub does not prefix it with the workflow name: (verified against the last single-file
run: it reported basic (ubuntu-latest), not CI / basic (ubuntu-latest)). That is why every
job in these four files spells the platform out itself: with a bare name: ${{ matrix.feature }}
the four platforms would all report basic-static and collide in the checks list.
The left column below is the complete old list, read off the last run of ci.yml (19
checks, commit 34a7162), so the mapping is exhaustive rather than illustrative:
Old check (ci.yml, 19) |
New check |
|---|---|
basic (ubuntu-latest) |
CI · Linux · Ubuntu / basic-static and CI · Linux · Ubuntu / basic-shared |
basic (macos-latest) |
CI · macOS / basic-static and CI · macOS / basic-shared |
windows-basic |
CI · Windows · MSVC / basic-shared (the entry carrying EXPAND=ON) |
windows-static |
CI · Windows · MSVC / basic-static |
web (ubuntu-latest) |
CI · Linux · Ubuntu / web |
web (macos-latest) |
CI · macOS / web |
web (windows-latest) |
CI · Windows · MSVC / web |
ssl (ubuntu-latest) |
CI · Linux · Ubuntu / ssl |
ssl (macos-latest) |
CI · macOS / ssl |
ssl (windows-latest) |
CI · Windows · MSVC / ssl |
h2 (ubuntu-latest) |
CI · Linux · Ubuntu / h2 |
h2 (macos-latest) |
CI · macOS / h2 |
h2 (windows-latest) |
CI · Windows · MSVC / h2 |
full (ubuntu-latest) |
CI · Linux · Ubuntu / full |
full (macos-latest) |
CI · macOS / full |
quic |
CI · Linux · Ubuntu / quic |
mingw64 |
CI · Windows · MinGW-w64 / mingw64 |
config-contract (ubuntu-latest, linux-x64, g++, python3) |
CI · Linux · Ubuntu / config-contract |
config-contract (windows-2022, msvc-x64, cl, python) |
CI · Windows · MSVC / config-contract |
New-only checks (no old counterpart, so nothing to re-point away from):
CI · Windows · MSVC / quic and CI · macOS / quic are the legs this batch adds; basic-shared /
basic-static are the old single basic leg split in two (it becomes two required entries
where there was one).
If branch protection requires status checks by name, they must be re-pointed on GitHub before the first pull request against the new layout, or every PR parks at "Expected — waiting for status to be reported". There is nothing in the repository that can do this step for you: it is a repository setting, not a file.
The four badges are 404 until the new files are on the default branch.
- CI always builds from the
masterbranch. - Version is read from
src/uvcpp/uvcpp_version.hat configure time. - Release versions have
UVCPP_VERSION_IS_RELEASE=1; development versions have0with-devsuffix.
Last updated: 2026-09-29. This document should be updated whenever CI rules change.