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( @click.option(
"--image", "--image",
default="ghcr.io/chopratejas/headroom:latest", default="ghcr.io/headroomlabs-ai/headroom:latest",
show_default=True, show_default=True,
help="Docker image to use when Docker is selected.", help="Docker image to use when Docker is selected.",
) )

View file

@ -9,6 +9,7 @@ import shutil
import tempfile import tempfile
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
from typing import Any
from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
from .paths import deploy_root, manifest_path, profile_root 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) 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: def load_manifest(profile: str = "default") -> DeploymentManifest | None:
"""Load a deployment manifest when present.""" """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 = json.loads(path.read_text(encoding="utf-8"))
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])] payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])] payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
if "image" in payload:
payload["image"] = _migrate_deprecated_image(payload["image"])
return DeploymentManifest(**payload) return DeploymentManifest(**payload)
except (json.JSONDecodeError, ValueError, TypeError, OSError) as e: except (json.JSONDecodeError, ValueError, TypeError, OSError) as e:
raise ManifestError(f"deployment profile '{profile}' is corrupt ({path}): {e}") from 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", []) ManagedMutation(**item) for item in payload.get("mutations", [])
] ]
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])] 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)) manifests.append(DeploymentManifest(**payload))
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
continue continue

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
import pytest 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"] 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: def test_delete_manifest_removes_profile_root(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = _manifest() manifest = _manifest()