Skip to content

feat(builder): a recipe declares the locked git sources it fetches (contract v6) - #832

Merged
antoinetoussaint-byte merged 6 commits into
codefly-dev:mainfrom
saurabhmaheshwari-obin:feat/git-source-downloads
Oct 23, 2026
Merged

antoinetoussaint-byte merged 6 commits into
codefly-dev:mainfrom
saurabhmaheshwari-obin:feat/git-source-downloads

Conversation

@saurabhmaheshwari-obin

@saurabhmaheshwari-obin saurabhmaheshwari-obin commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

A build recipe can now declare the locked git sources its build needs: DockerBuildRecipe.git_source_downloads, a list of GitSourceDownload { repository, commit, mirror_context }. The caller fetches each commit into a bare mirror on its own machine, with its own credentials, before any image build, and supplies the mirrors as a named build context. The build then resolves those sources from the mirrors and needs no credential.

This is the git counterpart of GoModuleDownload (core#677, contract v5), built the same way:

Go module download (v5) git source download (v6, this PR)
declared in DockerBuildRecipe.go_module_downloads (field 10), WithGoModuleDownloads DockerBuildRecipe.git_source_downloads (field 11), WithGitSourceDownloads
fetched by the caller, on the host, with the host's credentials the same
reaches the build as a named build context (gomodproxy), no secret a named build context (for example uvsources), no secret
contract v5, emitted only when declared v6, emitted only when declared, and above v5 when both are
digest module root and proxy context, in order repository, commit and mirror context, in order (v6 also covers v5's and v4's fields)
validation validateGoModuleDownloads, in validateRecipes validateGitSourceDownloads, in validateRecipes
old host refuses a v5 plan (fails closed) refuses a v6 plan (fails closed)

Also in this PR:

  • GitSourceMirrorPath(repository), which returns <sha256 hex>.git, and GitSourceMirrorRef(commit), which returns refs/commit/<commit>. They give the host and the Dockerfile one definition of the mirror layout.
  • GitSourceRepositoryProblem(repository) and IsGitSourceCommit(commit), the validator's own checks exported. An agent reading a lockfile checks a source before declaring it, and leaves one the contract cannot carry to the build's own fetch, instead of failing the plan after rendering.
  • SingleImageBuildResponse takes variadic RecipeOptions; existing callers are unchanged.
  • agents/contract/contract.json and docs/agent-contract.md record recipe v5 and v6.

Why

Issue: codefly-dev/service-python-fastapi#102. A Python service whose uv.lock pins a private git source cannot be built into an image today. The builder stage runs uv sync --frozen with no credential and no prefetched input. The caller usually holds the credential, but nothing that credential can read reaches the build.

Go services with private modules build today because the caller prefetches the module graph. This PR adds the same declaration for locked git sources.

We tested carrying a prefetched uv cache into the build first. It works only when the host's uv matches the image's uv exactly and the wheels are fetched per target platform; caches from uv 0.9 and 0.13 both failed in a 0.12 image. A bare git mirror at the locked commit, mapped in with url.<mirror>.insteadOf=<locked url>, worked with an empty cache, no network route to the source and the lockfile untouched. It depends on nothing in uv's internals, and it carries only the part that needs the credential. Public package indexes stay on the network, as in every build today.

The message is generic rather than Python-specific. Any lockfile that pins git sources by commit (Cargo, npm, Go replace to a git URL) can declare the same thing.

Deviations from the Go module download path, with reasons

  • Uniqueness on (repository, commit, context). Go refuses a repeated proxy context, because one context holds one module graph. One mirror context holds many repositories, and a repository can be locked at several commits, so only an exact repeat is refused.
  • A mirror context must not equal a Go proxy context in the same recipe: one named build context cannot be both a module proxy and a mirror set.
  • A strict URL allowlist, and errors never echo the URL. A Go module root is a relative path that never leaves the recipe. A repository URL is handed to the host's git and written verbatim into the Dockerfile's git configuration, and a pasted URL may carry a token.
    • Only lowercase https:// and ssh:// are accepted, with no userinfo over https, no password, and no query or fragment.
    • The path is limited to [A-Za-z0-9._~/:+-]. Percent-escapes are refused anywhere in the URL, and @ is refused after the authority, because git decodes escapes before it splits an ssh URL into user, host and path; an encoded @, [ or / could otherwise change the host after validation.
    • A lockfile URL that really needs an escape (a repository name with a space, for example) is refused at plan time, never fetched over the network.
    • Errors name the declaration by its position.
  • git+ssh is not accepted. Lockfiles record the URL git is handed, without a git+ prefix, and the build maps exactly that URL onto the mirror, so an alias would never match.
  • v6 always digests the Go section and the context root. This follows v5, which digests v4's root: each version's digest layout is a superset of the one before.
  • SingleImageBuildResponse gains variadic RecipeOptions. Existing callers compile unchanged, and agents that use the single-image helper can declare downloads without copying the plan code.
  • GitSourceMirrorPath / GitSourceMirrorRef helpers. Go's proxy layout is defined by Go. A mirror layout is not, so the host and the Dockerfile share one definition.
  • contract.json and docs/agent-contract.md record v5 as well as v6. v5 was supported but never recorded.

Release order (please read)

A CLI must not pick up this core without the matching host fetch. Otherwise it would accept v6 plans without supplying the mirrors, and the build would fall back to the network. The Go change closed the same gap by landing core#677 and the CLI change together. Companion PRs:

Testing

  • go test ./agents/services/ ./agents/contract/ and go test -race ./agents/services/. The new docker_recipe_git_sources_test.go covers:
    • v6 only when declared;
    • verifier acceptance, and refusal of downgraded plans (v3, and v5 when Go modules are also declared);
    • the digest catches a change to every field;
    • combination with Go modules;
    • each refusal, including credential URLs whose secret must never appear in the error, and percent-encoded authority tricks;
    • accepted forms, and mirror path and ref determinism.
  • v3 to v5 contract versions and digests are byte-identical to main for recipes that declare no git sources.
  • make buf-lint, and make buf-breaking ("no breaking change against origin/main").
  • The Python contract tests (test_*_contract.py).
  • Generated code comes from codefly generate proto --proto ./proto --output ./generated --local (plus the Python template).

Not in this PR, and not verified here:

  • The generation run also moves proto/buf.lock (a googleapis pin) and regenerates runtime_pb2.py, which is already out of date on main. Both were reverted, to keep this change focused.
  • Locally, agents/manager TestNewNixStoreFromEnv_SetReturnsStore needs nix, and three internal/ciguard tests need bash 4 (mapfile). They fail the same way on main on this machine.
  • The CLI host fetch and the agent's BuildKit build are in the companion PRs.

CI notes (run on the fork, head abdc7077: https://github.com/saurabhmaheshwari-obin/core/actions/runs/39681236215)

Every test step in Build passes, and pnpm source evidence passes. Two steps fail, neither because of this change:

  • Proto, breaking-change guard: the script refuses to run unless origin is this repository ("BASE_REMOTE=origin is not codefly-dev/core"), so it can only pass here. Locally, against this repository's main, it reports "proto: no breaking change"; the change is additive (field 11 and a new message).
  • Build, govulncheck: GO-2026-6809 (crypto/x509) is fixed in go1.27.3, and go.yml installs 1.27.2. The finding is in code this PR does not touch. main's last run predates the advisory, so main would report the same today. Bumping the CI Go version is left to a separate change.

Review

  • Side-by-side check against the Go module download path, at this head: aligned. Every dimension matches, citing core#677's code and the CLI and go-grpc consumers: where it is declared, the plan phase, the host's credentials, the named build context with no secret, digest, validation, contract versioning, old-host behaviour (fails closed) and tests. The deviations above are the complete list.
  • Codex (xhigh), three rounds.
    • Round 1: a credential echo in errors, and scheme-case acceptance. Both fixed.
    • Round 2: percent-encoded brackets that change the ssh authority. Fixed.
    • Round 3: no P1 or P2. Two P3s (port range, a login or host starting with -) fixed with tests.
    • Round 4, the exported checks: none; the wrappers match the validator exactly.

Classification: a fix. It closes a gap where a declared build input could not reach the build.

🤖 Generated with Claude Code

…ontract v6)

A build whose lockfile pins a dependency to a commit in a private git
repository fetches it inside BuildKit, where the credentials of the machine
running the build do not reach. DockerBuildRecipe.git_source_downloads lets a
recipe declare each locked repository URL and commit, and the named build
context it reads them from, so the caller can fetch every commit once, before
any image build, into a bare mirror the Dockerfile maps the URL onto. The build
then needs no credential, and the lockfile is unchanged.

GitSourceMirrorPath names each mirror inside the context (the hex SHA-256 of
the URL plus ".git"), so the caller that fills the context and the template
that reads it share one layout. Validation accepts https, ssh and git+ssh URLs
with no password, token, query or fragment (an ssh login name is allowed), a
full 40- or 64-character lowercase commit, and a build-context name that is
not also a Go module proxy context. One repository may be declared at several
commits.

A recipe that declares git sources emits the v6 contract; v6 digests the
declaration on top of v5's Go module downloads and v4's explicit context root.
A host that predates the field expects v3 to v5 for the same recipes and
refuses the plan rather than building without supplying the mirrors. Recipes
without git sources keep v3 to v5.

SingleImageBuildResponse takes the same variadic recipe options as
SingleImageBuildPlan; existing callers emit exactly what they did.

Regenerated with the pinned generators (Go and Python).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord recipe v5 and v6

A refused git source repository may be a credential pasted into a URL, and a
builder error reaches the caller's RPC status and logs. Errors now name the
declaration by position and the reason, never the URL or any part of it.

The repository must be spelled exactly https:// or ssh:// (git rejects the
spellings url.Parse normalizes, such as SSH://), with a host, an optional port
and a path whose every character is in [A-Za-z0-9._~/@:%+-], so it can be
written into a Dockerfile or a git config value verbatim. git+ssh is no longer
accepted: a contract widens more easily than it narrows.

GitSourceMirrorRef names the ref a mirror holds a commit under, beside
GitSourceMirrorPath, so a host does not hard-code it. The option and proto
documentation state that a host fetches every declared source and refuses an
unfetched declaration before it accepts a v6 plan, and that declarations are
digested in order and exact duplicates refused, so they are declared sorted
and de-duplicated.

agents/contract/contract.json and docs/agent-contract.md now record
docker-build-recipe v5, which was never recorded, and v6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
git decodes percent-encoding before it splits an ssh URL into user, host and
path, so ssh://host/team/x@%5B::1%5D/repo passed validation and plan
verification while git read the user as host/team/x, the host as ::1 and the
path as /repo. A git source repository now carries no "%" at all, for https
as well as ssh: no lockfile URL needs an escape, and refusing every one is
simpler to reason about than refusing the escapes of "/", "@", "[", "]", ":"
and "%". No "@" may appear after the authority either; the only "@" accepted
is an ssh login name's. Both refusals are tested through plan construction
(WithGitSourceDownloads) and through VerifyDockerBuildPlan.

A recipe declaring both Go module downloads and git sources is now also
tested to be refused by a host that expects v5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in or host starting with -

git cannot fetch either: it rejects the port, and blocks a login or host that
would read as an option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… agents

An agent reading a lockfile can check a source before declaring it, and leave
one the contract cannot carry to the build's own fetch instead of failing the
plan after rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 51aa81a into codefly-dev:main Oct 23, 2026
9 checks passed
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.

3 participants