Skip to content

fix: assemble a build context for uv path sources and pin the uv that resolves it - #56

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
fix/carry-uv-path-sources-and-pin-uv
Sep 23, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
fix/carry-uv-path-sources-and-pin-uv

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Two independent defects made this agent emit a build recipe that resolved on a
developer's machine and failed inside the builder stage. Both are fixes, in
the repository that owns the recipe — neither is a hack, and neither asks a
consumer to restructure a project around the recipe.

1. A [tool.uv.sources] path leaving the project was not in the context

uv resolves such a path against the directory of the pyproject.toml that
declares it, and records the same relative path in uv.lock. The recipe's
context was the service directory and nothing beside it, so a path naming a
sibling service was simply absent from the builder stage and
uv sync --frozen failed there with

error: Could not compute absolute path from workspace root and lockfile path

while the identical resolution succeeded on the host. Two services sharing one
Python project in a repository is a layout this recipe has to build.

uvsources.go assembles a context when — and only when — the project declares a
path that leaves it: the service's own tree and each escaping target are copied
to their repository-relative paths under one root, and the builder stage's
working directory is the project's own repository-relative path under that root.
Every relative path then resolves in the image to what it resolves to on the
host, so nothing in pyproject.toml or uv.lock is rewritten — a rewrite
would have to keep two files agreeing about a path uv reads from both, and a
lockfile is a resolver's output, not an input a recipe gets to edit.

Containment is the rule: an escaping path must resolve inside the repository
that owns the service. One pointing outside it, or at a directory that is not
there, is reported as an error naming the declaration rather than skipped — a
silently dropped source would build the image against a different dependency
than the developer builds against.

The declared inventory scope now matches what the build did. A build that
assembled a context claims the whole tree (RECIPE_INVENTORY_SCOPE_TREE), which
is what tells the CLI to root the docker context at the assembled output rather
than at the service directory. A build that only rendered a Dockerfile and its
ignore keeps claiming exactly those (RECIPE_INVENTORY_SCOPE_EMITTED) and keeps
the service directory as its context — that path renders the recipe this agent
has always rendered, minus the uv pin below.

2. The recipe did not state the uv it resolves with

The builder stage ran whichever uv the base image had been built with. That
version is stated nowhere a project or this agent can read, so a project written
against a newer [tool.uv] key resolves locally and is diagnosed in the builder
stage as an unknown field:

TOML parse error
unknown field `build-constraint-dependencies`

Measured, not assumed — codeflydev/python:0.0.1 carries uv 0.5.29, and
that uv reports build-constraint-dependencies as an unknown [tool.uv] field
(the run log of TestRecipeBuilderStageResolvesCurrentUVKeys records it).

A build recipe is meant to be a durable, reproducible artifact, and one whose
resolver version is a property of its base image is not: the recipe stated its
base image, its platforms and its install flags, and said nothing about the tool
that reads pyproject.toml and uv.lock. The Go recipe already keeps the
opposite contract — it states its Go and Alpine versions outright. The builder
stage now copies a pinned uv (uvVersion in main.go) before anything resolves,
which makes the resolver a declared input of the recipe.

This deliberately does not touch the module's build-constraint-dependencies
— the key is there to constrain build backends, and removing it would be a fix
in the wrong repository for a defect that is not the module's.

The base image is still worth bumping, separately

The image's own uv is pinned (by install-script URL) but is old, and it is
declared nowhere outside its Dockerfile — nothing can depend on a version. This
agent also names codeflydev/python:0.0.1 by hand while that companion's
manifest has moved on several versions. Both belong to whoever publishes that
image, and both need an image republish, which is not on this change's path: the
recipe's pin makes the build reproducible without one.

Tests

  • uv_sources_recipe_test.go drives the real uv: the context as the old
    recipe built it does not resolve the path source, and the context this recipe
    assembles does, in a location where the original relative path leads nowhere.
    Beside it: the no-op case renders the previous recipe and claims only what it
    emitted; a reused recipe directory re-emits rather than tripping over its own
    output; a path outside the repository and a path that is not there are both
    reported, naming the declaration.
  • uv_toolchain_recipe_test.go pins the toolchain half: the recipe names an
    exact uv version and copies it in before resolving, and — against a real
    Docker daemon — a project using a current [tool.uv] key resolves in the
    builder stage, with the uv --version there being the one the recipe names.
    The base image's own answer to the same project is logged as the evidence for
    the pin rather than asserted, since a newer base image knowing the key would
    be an improvement and never a regression.

Both new regression tests were confirmed to fail against the unfixed code.

Not verified

  • The nix backend, as always here: CI has no nix.
  • The end-to-end deploy this was found in. The CLI half of the same class of
    fix — rooting a TREE-scope plan's docker context at the assembled output —
    is on the CLI's main but is not in any CLI release yet, so a consumer
    needs a CLI carrying it before an assembled Python context is built from the
    right root.
  • Builder.Update passes its template option where core's Templates expects
    the template object, so it renders nothing. That is pre-existing, unrelated
    and untouched here, but it means codefly update does not refresh a service's
    Dockerfile; Build does.

🤖 Generated with Claude Code

@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the fix/carry-uv-path-sources-and-pin-uv branch from 88cfcb6 to fc55443 Compare September 23, 2026 12:27
… resolves it

Two independent defects made this recipe emit an image build that resolved on
the developer's machine and failed inside the builder stage.

A `[tool.uv.sources]` path is resolved against the directory of the
pyproject.toml that declares it, and uv.lock records the same relative path. The
recipe's context was the service directory and nothing beside it, so a path
naming a sibling service was absent from the builder stage and `uv sync
--frozen` failed there with

    error: Could not compute absolute path from workspace root and lockfile path

Two services sharing one Python project in a repository is a layout this recipe
has to build. It now assembles a context when, and only when, a path leaves the
project: the service's own tree and each escaping target are placed at their
repository-relative paths under one root, and the builder stage's working
directory is the project's own path under it, so every relative path resolves
there to what it resolves to on the host. Nothing in pyproject.toml or uv.lock
is rewritten. Containment is the rule: a path leaving the repository, or naming
a directory that is not there, is reported with the declaration rather than
skipped, because a silently dropped source would build the image against a
different dependency than the developer builds against.

That build claims the whole tree it assembled (RECIPE_INVENTORY_SCOPE_TREE),
which is what roots the docker context at the assembled output; a build that
only renders a Dockerfile and its ignore still claims exactly those and still
builds the service directory.

Second, the builder stage ran whichever uv the base image had been built with,
a version stated nowhere a project or this agent can read. A project written
against a newer `[tool.uv]` key was diagnosed there as an unknown field — the
base image carries uv 0.5.29, measured, and that uv rejects
build-constraint-dependencies. A recipe meant to be durable and reproducible
cannot leave its resolver implicit, and the Go recipe already states its Go and
Alpine versions outright. The builder stage now copies a pinned uv in before
anything resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the fix/carry-uv-path-sources-and-pin-uv branch from fc55443 to d1681ff Compare September 23, 2026 12:29
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 22d4fc3 into main Sep 23, 2026
3 checks passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the fix/carry-uv-path-sources-and-pin-uv branch September 23, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant