The strongest argument for expanding a CI matrix is that you do not know what it will find. We added Windows and macOS legs to the Python suite alongside 3.12 and 3.13, mostly on principle: file watchers and path handling are environment-dependent, and the project had shipped watcher bugs before. Within one pull request, the Windows leg caught a first-run crash affecting every Windows user of pyrpc dev. Here is the anatomy.
The crash
Three Windows legs failed identically in under forty seconds. Five tsconfig tests died with:
FileNotFoundError: [WinError 2] The system cannot find the file specified
raised from inside jsonc-edit, the dependency that lets pyRPC inject its @pyrpc/types alias into comment-bearing tsconfig files without destroying them. On Linux and macOS the same tests passed. Classic matrix payoff: same code, different platform, different truth.
The mechanism
jsonc-edit bootstraps a persistent Node daemon for its underlying parser. Before starting, it installs a pinned parser version by invoking npm install as an unqualified name in a fresh cache directory. That is where the platforms part ways:
- POSIX: npm is a script with a node shebang; exec resolves it fine.
- Windows: npm is npm.cmd, a batch file. CreateProcess cannot execute batch files directly, so the spawn fails with WinError 2 before npm ever runs.
The failure only manifests when the bootstrap cache is cold, which is precisely the situation of every new user running pyrpc dev on Windows for the first time. Warm caches hid the bug from anyone with an established checkout.
The fix went upstream
Because jsonc-edit is ours, the right fix lived there, not in a compatibility shim around it. Version 0.2.1 resolves the npm executable through shutil.which (finding npm.cmd) and routes execution through COMSPEC on Windows. pyrpc-core’s dependency floor moved to 0.2.1 so the shipped package cannot resolve the broken line. A tempting local patch existed, but wrapping a library’s internals to hide its bug just distributes the bug to every other consumer while making ours look healthy.
The rest of the haul
- Error paths now print forward slashes everywhere. Windows users saw
app\\main.py:2in the actionable import-error hint; greppable output should not depend on os.sep. Normalized once at the reporting boundary. - Symlink fixtures skip on Windows, where creating them requires elevated privileges. The autocomplete jail tests assert everything else unconditionally and symlink behavior conditionally.
- The gate pattern kept merges possible: nine matrix legs feed one aggregate job named test-python, which is the exact check-run name branch protection requires. Renaming checks silently breaks every open PR, so the aggregate exists to absorb future matrix growth.
Cost of the whole expansion: one workflow file, two test guards, one path normalization. Return: a shipped-user-facing crash fixed within the hour of first exposure. Matrices are cheap insurance until they are expensive evidence.

pyRPC