fix(cli): pin internal_docker deploy images by digest (#7924)

## Summary

`langgraph deploy` now pins images by digest when handing the URI to the
LangGraph host backend. After `docker push`, the CLI reads the manifest
digest from the local Docker daemon's `RepoDigests` and sends
`registry/repo@sha256:<hex>` to the host backend instead of the
tag-based reference. Mutable tags cause downstream inconsistency — the
same revision can refer to different images over time.

The image is still pushed under the user-supplied `--tag` (default
`:latest`) so it stays discoverable by tag in the registry — only the
URI persisted with the revision changes.

## Behavior on failure

If the digest can't be resolved (empty `RepoDigests`, or no entry
matching the just-pushed repo), the CLI warns and falls back to the
tag-based reference. Deploys never fail on a digest-resolution issue.

## Test plan

- [x] `make test` passes (new `TestResolvePushedImageDigest` cases
included)
- [x] `--verbose` deploy shows the `docker image inspect` call resolving
  the digest
- [x] Deploy with a clean Docker daemon (no matching `RepoDigests`)
  emits the fallback warning and still completes successfully

---------

Co-authored-by: Josh Rogers <josh@langchain.dev>
This commit is contained in:
Asamu David
2026-05-27 18:26:39 -04:00
committed by GitHub
co-authored by Josh Rogers
parent b4018e8222
commit 6e4a295ba5
2 changed files with 200 additions and 1 deletions
+45 -1
View File
@@ -849,6 +849,41 @@ def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
# ---------------------------------------------------------------------------
def _resolve_pushed_image_digest(
runner,
*,
remote_image: str,
docker_config_dir: str | None,
verbose: bool,
) -> str:
"""Return ``{registry}/{repo}@sha256:<hex>`` for a freshly-pushed image.
Reads ``RepoDigests`` via ``docker image inspect`` — the local daemon
records the registry's manifest digest there after a successful push.
Falls back to ``remote_image`` with a warning if no matching digest is
found, rather than failing the deploy.
"""
# rsplit preserves ``:port`` in the registry host.
repo_no_tag = remote_image.rsplit(":", 1)[0]
args: list[str] = ["docker"]
if docker_config_dir:
args += ["--config", docker_config_dir]
args += ["image", "inspect", "--format", "{{json .RepoDigests}}", remote_image]
stdout, _ = runner.run(subp_exec(*args, collect=True, verbose=verbose))
try:
digests = json_mod.loads(stdout or "[]") or []
except json_mod.JSONDecodeError:
digests = []
for d in digests:
if isinstance(d, str) and d.startswith(f"{repo_no_tag}@sha256:"):
return d
_get_emitter().warn(
f"Could not resolve image digest for {remote_image}; "
"falling back to the tag-based reference. Re-run with --verbose for details."
)
return remote_image
def _run_local_build(
*,
client: HostBackendClient,
@@ -1015,9 +1050,18 @@ def _run_local_build(
raise
step += 1
resolved_image = _resolve_pushed_image_digest(
runner,
remote_image=remote_image,
docker_config_dir=None,
verbose=verbose,
)
# -- Step: Update deployment --
_log_deploy_step(step, f"Updating deployment {deployment_id}")
updated = client.update_deployment(deployment_id, remote_image, secrets=secrets)
updated = client.update_deployment(
deployment_id, resolved_image, secrets=secrets
)
return BuildResult(
updated=updated if isinstance(updated, dict) else {},