fix(install): migrate deployments off the retired chopratejas image repo (#2427)

## Description

Fixes #2426.

Persistent Docker deployments store their image in the deployment
manifest. The image org moved from the personal
`ghcr.io/chopratejas/headroom` repo to the project org
`ghcr.io/headroomlabs-ai/headroom`, and the personal repo is frozen at
0.27.0. Because the manifest image is only ever read back verbatim
(`build_runtime_command`, `docker run`, status output), a deployment
created before the move keeps pulling 0.27.0 forever, several minor
versions behind the CLI, with no drift signal to the user.

Two related gaps:

- `headroom/install/state.py` reads the recorded image straight back
with no migration, so an old manifest is stuck on the dead repo.
- `headroom/cli/install.py` `deploy --image` still defaulted to
`ghcr.io/chopratejas/headroom:latest`, so brand new deploys through that
command also pinned the retired repo (the `install-apply` default was
already correct).

## Fix

- Rewrite the retired repo to the org repo when a manifest is loaded, in
both `load_manifest` and `list_manifests`, preserving whatever tag was
recorded. The rewrite is surgical: it only matches the exact retired
`ghcr.io/chopratejas/headroom` repo and leaves already-current images
and any third-party image untouched. The migrated value persists on the
next apply/save.
- Change the `deploy --image` default to
`ghcr.io/headroomlabs-ai/headroom:latest` so it matches `install-apply`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/install/state.py`: add `_migrate_deprecated_image` and apply
it in `load_manifest` and `list_manifests` before constructing the
manifest.
- `headroom/cli/install.py`: `deploy --image` default now points at the
org repo.
- `tests/test_install/test_state.py`: new tests covering load and list
migrating the retired repo (tag preserved) and leaving
current/third-party images untouched.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ uvx ruff@0.15.17 check headroom/install/state.py headroom/cli/install.py tests/test_install/test_state.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/install/state.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`.
- Exact command / steps: wrote a manifest.json pinning
`ghcr.io/chopratejas/headroom:latest` (and `:0.27.0`) under a temp home,
then called the real `load_manifest` and `list_manifests`.
- Observed result: both returned a manifest with `image ==
ghcr.io/headroomlabs-ai/headroom:latest` (tag preserved on the `0.27.0`
case too); an already-current image and a third-party image passed
through unchanged. Ran against the actual module.
- Not tested: a live `docker run` against the migrated image.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
This commit is contained in:
Abhay Singh 2026-07-20 10:45:01 +05:30 committed by GitHub
parent b976378c3e
commit 17ff13ccbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 1 deletions

View file

@ -608,7 +608,7 @@ def install_apply(
)
@click.option(
"--image",
default="ghcr.io/chopratejas/headroom:latest",
default="ghcr.io/headroomlabs-ai/headroom:latest",
show_default=True,
help="Docker image to use when Docker is selected.",
)

View file

@ -9,6 +9,7 @@ import shutil
import tempfile
from dataclasses import asdict
from pathlib import Path
from typing import Any
from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
from .paths import deploy_root, manifest_path, profile_root
@ -60,6 +61,23 @@ def save_manifest(manifest: DeploymentManifest) -> None:
logger.warning("Cannot save deployment manifest: %s — continuing without persistence", e)
# The Docker image org moved from a personal repo to the project org. The old
# ``ghcr.io/chopratejas/headroom`` repo is frozen at 0.27.0, so a manifest that
# still pins it silently runs ~5 minor versions behind the CLI with no drift
# signal (#2426). Rewrite it to the org repo on load, preserving the tag.
_DEPRECATED_IMAGE_REPO = "ghcr.io/chopratejas/headroom"
_CURRENT_IMAGE_REPO = "ghcr.io/headroomlabs-ai/headroom"
def _migrate_deprecated_image(image: Any) -> Any:
"""Rewrite the retired ``chopratejas`` Docker repo to the org repo (#2426)."""
if isinstance(image, str) and image.startswith(_DEPRECATED_IMAGE_REPO):
migrated = _CURRENT_IMAGE_REPO + image[len(_DEPRECATED_IMAGE_REPO) :]
logger.info("Migrating deployment image from retired repo %s to %s", image, migrated)
return migrated
return image
def load_manifest(profile: str = "default") -> DeploymentManifest | None:
"""Load a deployment manifest when present."""
@ -74,6 +92,8 @@ def load_manifest(profile: str = "default") -> DeploymentManifest | None:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
if "image" in payload:
payload["image"] = _migrate_deprecated_image(payload["image"])
return DeploymentManifest(**payload)
except (json.JSONDecodeError, ValueError, TypeError, OSError) as e:
raise ManifestError(f"deployment profile '{profile}' is corrupt ({path}): {e}") from e
@ -94,6 +114,8 @@ def list_manifests() -> list[DeploymentManifest]:
ManagedMutation(**item) for item in payload.get("mutations", [])
]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
if "image" in payload:
payload["image"] = _migrate_deprecated_image(payload["image"])
manifests.append(DeploymentManifest(**payload))
except (OSError, ValueError, TypeError):
continue

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
@ -85,6 +86,63 @@ def test_list_manifests_ignores_invalid_payloads(monkeypatch, tmp_path: Path) ->
assert [manifest.profile for manifest in manifests] == ["default"]
def _write_manifest_with_image(profile_dir: Path, image: str) -> None:
profile_dir.mkdir(parents=True, exist_ok=True)
payload = {
"profile": profile_dir.name,
"preset": "persistent-docker",
"runtime_kind": "docker",
"supervisor_kind": "none",
"scope": "user",
"provider_mode": "manual",
"targets": ["claude"],
"port": 8787,
"host": "127.0.0.1",
"backend": "anthropic",
"image": image,
}
(profile_dir / "manifest.json").write_text(json.dumps(payload), encoding="utf-8")
def test_load_manifest_migrates_retired_image_repo(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
profile_dir = tmp_path / ".headroom" / "deploy" / "default"
# A manifest written before the org move still pins the retired personal
# repo, which is frozen at 0.27.0. Loading it must rewrite the repo while
# preserving the tag, so the deployment tracks the current image (#2426).
_write_manifest_with_image(profile_dir, "ghcr.io/chopratejas/headroom:latest")
loaded = load_manifest("default")
assert loaded is not None
assert loaded.image == "ghcr.io/headroomlabs-ai/headroom:latest"
def test_load_manifest_leaves_unrelated_image_untouched(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
profile_dir = tmp_path / ".headroom" / "deploy" / "default"
_write_manifest_with_image(profile_dir, "ghcr.io/headroomlabs-ai/headroom:0.31.0")
loaded = load_manifest("default")
assert loaded is not None
# An already-current image, and any third-party image, must pass through
# unchanged so the migration only ever rewrites the one retired repo.
assert loaded.image == "ghcr.io/headroomlabs-ai/headroom:0.31.0"
def test_list_manifests_migrates_retired_image_repo(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
_write_manifest_with_image(
tmp_path / ".headroom" / "deploy" / "default",
"ghcr.io/chopratejas/headroom:0.27.0",
)
manifests = list_manifests()
assert [m.image for m in manifests] == ["ghcr.io/headroomlabs-ai/headroom:0.27.0"]
def test_delete_manifest_removes_profile_root(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest()