Lock dependencies with uv.lock + uv sync, and fix the Python floor - #320
Conversation
|
CI green, and confirming from the log that both new mechanisms actually engaged
The 90ms resolve is itself the evidence the constraints took effect: with the whole Worth stating plainly what this run does not prove: that the lock protects |
cff8fbd to
7c81cdb
Compare
There was a problem hiding this comment.
Pull request overview
Commit 7c81cdb adds reproducible Python dependency resolution for CI and aligns the supported Python floor with memu-py.
Changes:
- Adds a universal pinned constraints file and CI staleness check.
- Raises the Python requirement to 3.13.
- Documents lock regeneration and reproducible installs.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/ci.yml |
Validates and consumes the lock in CI. |
docs/setup.md |
Documents Python 3.13 and lock usage. |
install.sh |
Enforces the new Python floor. |
pyproject.toml |
Declares Python 3.13+. |
requirements.lock |
Pins Python runtime and test dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
7c81cdb to
4c8d469
Compare
|
Added the deps-PR trigger for The reasoning in one line: pinning Two things I deliberately did not do:
And one honest limitation: this PR's own run doesn't prove the new path filters |
4c8d469 to
fcae65d
Compare
|
Reworked to the native uv workflow — The log shows the two jobs doing genuinely opposite things, which is the design: That ~1400× resolve-time gap is the clearest signal they aren't doing the same work: Why the rewrite. The constraints revision left
Two side benefits worth noting:
Also verified the full user path rather than just CI: fresh Two gaps I'd rather state than leave implied: Docker installs are still unpinned |
Known gap:
|
| Path | Locked? |
|---|---|
fresh install (uv sync, installer, docs) |
✅ |
| CI | ✅ |
nerve upgrade |
❌ still resolves unpinned |
| Docker | ❌ (out of scope, documented) |
That matters because nerve upgrade is how existing users move forward — the group
most likely to be bitten by drift, since they already have a working install to lose.
The fix looks contained
uv sync --project <source_root> --active --frozen --inexact
when uv is present and <source_root>/uv.lock exists, falling back to the current
behaviour otherwise. Each flag is load-bearing:
--project— locate the project regardless of cwd--active— sync into the venv Nerve is actually installed in, rather than uv's default<project>/.venv, which may be somewhere else entirely--frozen— use the lock as-is; never re-resolve or rewrite it during an upgrade--inexact— don't remove packages absent from the lock.uv syncis exact by default, which would strip optional extras a user installed deliberately. That would be a regression against today's additivepip install -e .
tests/test_cli_upgrade.py asserts on _pip_install_cmd directly, so it needs updating
alongside.
Happy to do it either way
I left it out of this PR because it's a user-facing CLI behaviour change with a couple
of non-obvious flag decisions (particularly --inexact) that deserve their own review
focus, and this PR is already carrying the lock, the workflows, the docs and the Python
floor. But it's a genuine hole in the "reproducible installs" claim, so it shouldn't sit
indefinitely — say the word and I'll add it here or open it as a follow-up.
fcae65d to
10dc456
Compare
|
Folded in the
I tested each flag choice rather than reasoning about it, because two of them are the
Proven end to end rather than only in unit tests: uninstalled a locked package
Docker remains the one uncovered path, deliberately and now documented. Same for the |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (4)
docs/setup.md:56
- This Docker setup path invokes
nerve initimmediately afteruv sync, butuv syncdoes not expose the venv's scripts onPATH. A fresh host therefore cannot run the wizard unless this section activates.venv(or usesuv run nerve init).
uv sync # Needed to run the wizard on the host
docs/setup.md:10
uv synccreates.venvbut does not add.venv/binto the current shell'sPATH. As written, the followingnerve init/nerve startcommands fail withcommand not foundon a fresh clone. Activate the environment here, as the README and server-install section already do.
This issue also appears on line 56 of the same file.
uv sync # creates .venv from uv.lock
.github/workflows/fresh-deps.yml:78
- This comment still says
pyproject.tomldeclares>=3.12, but this PR changes it to>=3.13. Update the workflow documentation so it no longer describes the old mismatch.
# Mirrors ci.yml. memu-py==1.4.0 requires >=3.13, so 3.13 is the real
# floor despite pyproject's requires-python being >=3.12.
.github/workflows/fresh-deps.yml:149
workflow_dispatchcan run against a manually selected branch or tag, but it reaches this sameelsebranch and is told that the tree did not change and no PR can be responsible. Handlescheduleseparately; the manual-dispatch message must remain neutral about whether code or upstream dependencies changed.
echo "### The tree did not change — an upstream release did"
10dc456 to
252833b
Compare
|
Removed the separate unpinned "fresh resolve" workflow, and I was wrong to add it. Its justification was that So the loop already closes without a second workflow: change a bound → lock invalidated → This PR is now a single commit on top of #319, with two CI checks instead of three. What genuinely remains uncovered, stated plainly rather than papered over with a job that Docker also remains unpinned, as documented. |
252833b to
602a485
Compare
Independent architectural review (Codex), and the fixes it producedRan an adversarial architecture-level review of this PR with Codex (~12 min). It endorsed It caught me being wrong about why the drift job could goI removed the separate unpinned workflow on the grounds that So widening a bound produces a relock that still pins the old version — CI tests the old It still doesn't bring the job back, because the gap only bites consumers that resolve Fixed here
One place I didn't follow it: it wanted a missing uv or venv to fail loudly rather than Deferred, with tasks filed
3312 tests pass; lock in sync; |
A fresh install has been failing at startup with
ImportError: cannot import name 'request_ctx'
from 'mcp.server.lowlevel.server'
mcp was never declared in pyproject.toml — it arrived transitively via
claude-agent-sdk, whose constraint is `mcp<3.0.0,>=1.23.0`. Nothing else in
the tree capped it, so once mcp 2.0.0 landed on PyPI every fresh resolve
picked it up. mcp 2.0 is an API redesign of the lowlevel server, so this ports
the endpoint and declares `mcp>=2,<3` explicitly. Declaring it is correct
regardless: three modules under nerve/mcp_server/ import mcp directly.
What changed upstream, and what it forced here:
- Handler registration moved from `@server.list_tools()` / `@server.call_tool()`
decorators to `Server(..., on_list_tools=, on_call_tool=)` constructor
callbacks. `Server.call_tool` no longer exists.
- The request context is now an argument, not a contextvar. `request_ctx` is
gone with no replacement, so `_resolve_client_info` and
`_bound_identity_from_request` take a `ServerRequestContext` explicitly and
`build_ctx_resolver`'s closure threads it down from the call handler.
- `on_list_tools` returns `ListToolsResult` rather than `list[Tool]`, and
`on_call_tool` receives `CallToolRequestParams` rather than positional
`(name, arguments)`.
- Model attributes are snake_case in Python now (the camelCase wire aliases are
unchanged). Constructing with `isError=` still works via `populate_by_name`,
but reads had to move: `client_params.clientInfo` -> `.client_info`.
Two things worth a reviewer's attention:
mcp 1.x validated tool arguments against `inputSchema` inside its `call_tool`
decorator (`validate_input=True` by default). The 2.x callback does not, so
porting mechanically would have silently dropped argument validation on an
endpoint external clients can reach. `build_mcp_server` now validates
explicitly with jsonschema — declared as a direct dependency, since we import
it — and returns the same isError shape as the other failure paths. Tests pin
the behaviour, including that a rejected call never reaches the handler.
The `clientInfo` -> `client_info` rename was invisible to the entire existing
suite: every unit test builds a context with `session=None`, so that line never
executed, and all 3303 tests passed with it broken. Over real HTTP it raised
AttributeError inside the resolver, which `_call_tool` turns into "Context
error" — every external MCP tool call would have failed. The two new
`test_mcp_http_integration` cases drive `initialize` -> `tools/call` through the
real transport and assert satellite attribution, which is what caught it. The
module docstring had claimed a tools/call flow that was never actually there.
Verified: 3310 tests pass under mcp 2.0.0 on a fresh `--refresh` resolve;
`import nerve.gateway.server` clean; the SDK's in-process MCP bridge (which
serves every agent tool, not just /mcp/v1) round-trips a real tool call on its
mcp-2.x branch; and forcing mcp 1.29.0 fails at import, confirming the `>=2`
floor is load-bearing.
Fixes #316
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
602a485 to
e6ef980
Compare
|
Thanks — three of these were live and are fixed in Fixed — unpinned Fixed — build backend unpinned. Confirmed: Already fixed — the two Not a problem, verified — existing 3.12
Moot — the two |
Adds uv.lock (123 packages) and makes `uv sync` the way Nerve is installed and upgraded, so a given commit resolves to the same versions wherever it is installed. CI installs the same way, with --locked. The mcp 2.0.0 breakage (#316) was not really a versioning accident: pyproject declared ~15 dependencies with lower bounds only and no lock, so what a fresh install resolved changed silently as upstream published. CI could not see it either, because it keyed its uv cache on pyproject.toml and a restored cache carried stale index metadata — three weeks green while every fresh install was broken. `uv sync --locked` in CI is what makes a single job sufficient for dependency changes. It fails if uv.lock disagrees with pyproject.toml for any reason — a dependency added, removed, or merely re-bounded — so a change cannot merge without a relock, and CI then installs the relocked set. Note this does NOT mean a widened bound gets its new range exercised: `uv lock` is pin-preserving, so relocking after a widening keeps the old pin. Bounds still matter to consumers that resolve from them (plain pip, downstream packagers, and Docker until it is locked), which is why locking Docker is the immediate follow-up rather than carrying a permanent unpinned canary job. `nerve upgrade` installs from the lock as well, which matters because it was named in #316 as the reason a pin had to be reapplied after every upgrade. `_dep_install_cmd` returns `uv sync --project <root> --locked --inexact` when uv is present and the checkout has a uv.lock. Each flag is load-bearing: * --locked: install the lock, and refuse if it no longer matches pyproject. `--frozen` would also avoid rewriting the lock but skips that check, so a drifted checkout would be upgraded to a lock that doesn't describe it. * --inexact: `uv sync` is exact by default and would uninstall anything absent from the lock, silently removing optional extras the user added. Verified: without --inexact an extraneous package is removed, with it it survives. The command this replaces was purely additive, so exact syncing is a regression. * UV_PROJECT_ENVIRONMENT=sys.prefix: target the venv Nerve is installed in. uv otherwise syncs <project>/.venv, which needn't be the same directory, and VIRTUAL_ENV is unset when the binary runs directly as `.venv/bin/nerve`. Guarded on sys.prefix != sys.base_prefix, since pointing uv at a system prefix could remove or downgrade packages well outside Nerve's environment. When uv or the lock is unavailable the previous unpinned behaviour remains — installs predating the lockfile depend on it — but it now warns explicitly that the upgrade is not reproducible instead of degrading silently. Fixing requires-python was a prerequisite: `uv lock` refused to resolve at all because requires-python said >=3.12 while the pinned memu-py==1.4.0 needs >=3.13. That inconsistency was already user-facing — `uv pip install -e .` on 3.12 fails with an opaque transitive conflict. So the floor moves to >=3.13, which is what it has always effectively been (ci.yml's own matrix comment said as much), and install.sh stops accepting 3.12 and now validates the version it actually provisioned. Because install.sh now lets `uv sync --python` manage the environment rather than creating it separately, an existing 3.12 .venv is recreated at 3.13 rather than reused — verified. Two things pinned that the lock cannot pin for us: * setup-uv is pinned to a uv version. uv owns the lockfile format and is also the tool asserting the lock is current, so an unpinned uv is a moving part underneath the reproducibility this adds. * hatchling is bounded `<2`. uv.lock covers runtime dependencies, but build backends resolve fresh in an isolated build environment on every install, so an unpinned backend was a hole in the same claim. Install docs updated to `uv sync`: README, docs/setup.md, docs/worker-guide.md, docs/observability.md, install.sh, and the nerve-dev skill template shipped in nerve/templates/. `uv sync` creates .venv but does not activate it, so the examples activate (or use `uv run`) before calling `nerve`. docs/setup.md also gains a table of which paths install from the lock, an honest note that the lock is platform/marker dependent and does not vendor artifacts, and a migration section for installs predating it — the first `nerve upgrade` across this commit still runs the old in-memory updater, so it installs unpinned, and on Python 3.12 it fails after `git pull` has already advanced the checkout. Docker installs from the lock too, so no first-class deployment path is left re-resolving. It was resolving dependencies twice, both unpinned: the image synthesised a requirements.txt from `project.dependencies` and pip-installed it, and the entrypoint then ran `pip install -e .`. Both are replaced by uv: * uv enters the image pinned to the same version CI uses. * The build layer runs `uv sync --locked --no-install-project --no-dev` from just pyproject.toml + uv.lock, so it stays cached until deps really change and the project itself can arrive later via the mount. * The entrypoint runs `uv sync --locked --inexact`, which in the normal case only installs the project. * UV_PROJECT_ENVIRONMENT=/opt/nerve-venv puts the environment OUTSIDE /nerve. That is load-bearing, not tidiness: /nerve is a bind mount at runtime, so a .venv beneath it would be shadowed by the host's checkout — and a host venv may not be Linux-compatible at all. Verified in real containers rather than by reading: the build layer installs the locked versions (mcp 2.0.0, claude-agent-sdk 0.2.140, matching uv.lock), and mounting a source tree and running the entrypoint's sync installs the project editable in ~0.6s with `nerve` resolving to /opt/nerve-venv/bin/nerve. One migration wrinkle, documented in docs/setup.md: `nerve init` does not overwrite Docker files that already exist, so pre-existing installs keep their pip-based Dockerfile and entrypoint until the user deletes those two files and regenerates. Verified: `uv sync --extra test --locked` from scratch, then 3312 tests pass; a fresh `git clone` plus bare `uv sync` yields a working editable install with the nerve CLI on PATH; with uv.lock pinning jsonschema 4.25.0 while pyproject allows >=4.20, `uv sync` installs 4.25.0 where `uv pip install -e .` installs 4.26.0, confirming the lock governs the documented path; a real `nerve upgrade` restores a deliberately broken target venv at the locked version; `bash -n install.sh`. Fixes the dependency-management half of #316; the code port is #319. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e6ef980 to
4316b3d
Compare
|
Folded the Docker locking in ( Docker turned out to be resolving dependencies twice, both unpinned — the image The non-obvious constraint, and the reason this wasn't a one-liner: Verified in real containers, not by reading the template: Scope note: I verified the layers I changed (dependency install, runtime project sync) in Migration is documented in Coverage table is now: 3314 tests pass (+2 covering the locked Docker build and the venv-outside-the-mount Also, for the record on the earlier red run: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (3)
docs/setup.md:376
- The implementation and tests use
--locked, not--frozen;--lockedalso checks thatuv.lockis current. This paragraph additionally omits the code's outside-a-virtualenv fallback.
`nerve upgrade` uses `--inexact`, so it won't uninstall optional extras you added
yourself, and `--frozen`, so it never rewrites `uv.lock` as a side effect of
upgrading. If uv is missing, or the checkout has no `uv.lock`, it falls back to the
previous `pip install -e .` behaviour.
.github/workflows/ci.yml:62
- A normal
uv lockpreferentially preserves an existing compatible version (asdocs/setup.md:327also states). Widening or otherwise changing a bound can therefore update the lock metadata while retaining the old pin, so this single job does not necessarily test newly admitted versions. Restore a fresh/upgrade resolution check, or enforce an explicituv lock --upgrade-packagepolicy for changed bounds; otherwise the “one job sufficient” premise leaves those bounds untested.
# one job sufficient: any dependency change — added, removed, or
# re-bounded — invalidates the lock, so it cannot be merged without a
# relock, and the relock moves the pins that this job then tests. It
docs/setup.md:343
- This contradicts the immediately preceding statement that relocking preserves existing pins.
uv lockcan retain a still-compatible pin after a bound is widened, so CI will test that old pin rather than necessarily testing a newly admitted version. Document the required--upgrade-packageworkflow or restore fresh-resolution coverage instead of claiming the pin always moves.
This issue also appears on line 373 of the same file.
- A dependency change cannot merge without a relock. `--locked` fails if `uv.lock` disagrees with `pyproject.toml` for *any* reason: a dependency added, removed, or simply re-bounded. Since the relock then moves the pins, the versions CI tests are always the ones the change actually selects.
That second property is why no separate unpinned CI job is needed. Widen a bound and
the lock is invalidated; relock and the pin moves; CI tests the moved pin.
Makes Nerve's dependency versions reproducible across every install path, and corrects a
Python floor that has been wrong since the beginning.
The problem
pyproject.tomldeclared ~15 dependencies with lower bounds only and there was nolockfile, so what a fresh install resolved changed silently as upstream published. When
mcp2.0.0 removed an API Nerve imports at module scope, every fresh install broke atstartup (#316).
CI could not see it. It keyed its uv cache on
pyproject.toml, so a restored cachecarried stale index metadata and kept resolving
mcp==1.29.0:run 32061993270 was green
on
mainthree weeks after installs had broken. The report came from a user, not from us.The uncomfortable summary: a green CI run had never been evidence that a fresh install
of Nerve worked.
What this changes
uv.lock(123 packages) is committed, anduv syncbecomes how Nerve is installed.Every install path that Nerve controls now reads the lock:
uv.lock?install.shuv sync --locked --inexactnerve upgradeuv sync --project <root> --locked --inexactuv sync --extra test --lockeduv pip install -e ./ plainpip install -e .pyproject.tomlboundsThat last row is deliberate and worth knowing: uv's pip-compatible layer has no
lockfile awareness at all.
uv pip install -e .ignoresuv.lockentirely, which isexactly why making it the documented install would have left the lock decorative. It
remains the right command when you want to test against current upstream.
nerve upgrade#316 called this out specifically — "
nerve upgradereinstalls dependencies and can pull2.x back in, so the pin has to be reapplied after upgrades." Upgrades are how existing
users move, so leaving that path unpinned would have missed the group with a working
install to lose. Three flag choices carry weight:
--lockedrather than--frozen. Both avoid rewriting the lock, but--frozenskips the freshness check, so a checkout whosepyproject.tomlhad drifted would be upgraded to a lock that doesn't describe it — silently.--inexact.uv syncis exact by default and would uninstall anything absent from the lock, quietly removing optional extras a user added. The command this replaces was purely additive, so exact syncing would be a regression.UV_PROJECT_ENVIRONMENT=sys.prefix. uv otherwise syncs<project>/.venv, which needn't be the venv Nerve is installed in, andVIRTUAL_ENVis unset when the console script runs directly as.venv/bin/nerve upgrade(so--activealone is unreliable). Guarded onsys.prefix != sys.base_prefix, since pointing uv at a system prefix could disturb packages far outside Nerve's environment.Where uv or the lock is unavailable, the previous unpinned behaviour remains — installs
predating the lockfile depend on it — but it now prints an explicit "this upgrade is not
reproducible" warning naming the reason, rather than degrading quietly.
Docker
Docker was resolving dependencies twice, both unpinned: the image synthesised a
requirements.txtfromproject.dependenciesand pip-installed it, then the entrypointran
pip install -e .. A rebuild of an unchanged commit could therefore pick up a newer,untested dependency — #316's failure mode on a first-class deployment path.
uv sync --locked --no-install-project --no-devfrom onlypyproject.toml+uv.lock, so it stays cached until dependencies actually change and the source can arrive later via the mount.uv sync --locked --inexact, which normally installs just the project.UV_PROJECT_ENVIRONMENT=/opt/nerve-venvkeeps the environment outside/nerve. Load-bearing, not tidiness:/nerveis a bind mount at runtime, so a.venvbeneath it would be shadowed by the host's checkout — and a host-created venv may not be Linux-compatible at all. A test pins this so it doesn't get "simplified" back into the project directory.Python floor:
>=3.12→>=3.13uv lockrefused to resolve the project at all:This was never only a locking obstacle — 3.12 has never been installable.
uv pip install -e .on 3.12 fails today with exactly that opaque transitive conflict, andci.yml's own matrix comment already said "3.13 is the real floor". The declaration wassimply a false promise.
install.shfollows: it stops accepting 3.12, and it now validates the interpreter itactually provisioned rather than only checking that some
python3exists — thednf/zypper fallback chains could previously settle on 3.12 and report success. Because the
script now lets
uv sync --pythonown the environment instead of creating it separately,an existing 3.12
.venvis recreated at 3.13 rather than reused.Two things the lock cannot pin for us
uv.lockrecords aversionandrevision) and is the tool asserting the lock is current, so an unpinned uv is a moving part underneath the reproducibility this adds.hatchling>=1.27,<2.uv.lockcovers runtime dependencies, but build backends resolve fresh in an isolated build environment on every install, so an unpinned backend was a hole in the same claim. A major bound rather than--build-constraint, because the plain-pip path wouldn't honour a uv-specific constraints file.One CI job, and its limit
ci.ymlinstallsuv sync --extra test --locked.--lockedfails whenuv.lockdisagrees with
pyproject.tomlfor any reason — dependency added, removed, or re-bounded —so a dependency change cannot merge without a relock, and CI then tests the relocked set.
The limit worth stating plainly:
uv lockis pin-preserving, so relocking after awidened bound keeps the old pin. Verified — narrowing
anthropicto<0.120forced thepin to 0.119.0, and then removing the ceiling again left it at 0.119.0 rather than
returning to 0.123.0. So a widened bound does not get its new range exercised. That only
affects consumers resolving from bounds, which after this PR is plain
pip install -e .and downstream packagers — both outside the reproducibility contract. It is the reason
Docker is locked here rather than watched by a monitoring job.
Migration
Existing Docker deployments need one manual step.
nerve initdoes not overwriteDocker files that already exist, so an install generated before this change keeps its
pip-based
Dockerfileand entrypoint:rm Dockerfile docker-entrypoint.sh nerve init # choose "docker" again; regenerates both docker compose build --no-cachedocker-compose.ymlis unchanged. Check for local edits before deleting.The first
nerve upgradeafter this merges is still unpinned, becausenerve upgraderuns the updater already loaded in memory and only then pulls. On Python 3.12 it will fail
after
git pullhas advanced the checkout, leaving new source against an oldenvironment.
docs/setup.mddocuments the recovery (uv sync --locked --inexacton 3.13+).Upgrades from the second onward install from the lock.
Testing
uv sync --extra test --lockedfrom clean, then 3314 tests passgit clone+ bareuv sync, exactly as the README reads: working editable install,nerveCLI onPATH,nerve --helprunsuv.lockpinningjsonschema==4.25.0whilepyproject.tomlallows>=4.20:uv syncinstalls 4.25.0,uv pip install -e .installs 4.26.0--lockedcatches every kind of dependency change — added, widened, narrowed; and relocking after a narrowed bound moved the pinnerve upgrade— uninstalled a locked package from a target venv, rannerve upgrade --no-pull --no-frontend, watched it invokeuv sync --locked --inexactand restore that venv at the locked versionmcp 2.0.0/claude-agent-sdk 0.2.140(exact lock match) into/opt/nerve-venv; mounting a source tree and running the entrypoint's sync installs the project editable in 636ms withwhich nerve=/opt/nerve-venv/bin/nerve--inexactverified both ways: an extraneous package survives with it, is removed without ituv.lock, and outside a venv, warning in the cases where a lock exists but can't be used.venvis recreated at 3.13 byuv sync --python 3.13(3.12.3 → 3.13.14)bash -n install.sh; both workflows parseNot verified: a full Docker image build including the unchanged Node/gh/gog layers — only
the layers this PR touches were built and run.
Follow-ups (not here)
uv sync --locked.requires-pythonis unbounded above, souv.lockcarries a Python 3.14 resolution that CI never exercises — add 3.14 or cap the declaration. A periodicuv sync --locked --no-cachewould catch a pinned artifact that has been yanked. Nothing yet proves a plain-pip/wheel build, which is what downstream packagers do.🤖 Generated with Claude Code