From 2d88b1c958fcfbb03e86c1e9de9dc4000bc5d0b1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 10 May 2026 03:16:55 -0700 Subject: [PATCH] fix(registry): keep release tags after changelog formatting --- src/aio_fleet/registry.py | 38 +++++++++++++++-- tests/test_registry.py | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/aio_fleet/registry.py b/src/aio_fleet/registry.py index 6b5c3ce..e577f18 100644 --- a/src/aio_fleet/registry.py +++ b/src/aio_fleet/registry.py @@ -14,6 +14,8 @@ from aio_fleet.manifest import RepoConfig from aio_fleet.release import ( find_release_target_commit, + git, + git_is_ancestor, latest_changelog_version, read_upstream_version, ) @@ -102,7 +104,7 @@ def _is_dockerhub_tag(tag: str) -> bool: return first in {"docker.io", "index.docker.io"} or "." not in first -def _verify_dockerhub_tag(tag: str, *, attempts: int = 3) -> str | None: +def _verify_dockerhub_tag(tag: str, *, attempts: int = 8) -> str | None: parsed = _dockerhub_tag_parts(tag) if parsed is None: return f"{tag}: unsupported Docker Hub tag format" @@ -124,12 +126,15 @@ def _verify_dockerhub_tag(tag: str, *, attempts: int = 3) -> str | None: last_error = f"invalid Docker Hub JSON response: {error}" except urllib.error.HTTPError as error: if error.code == 404: - return f"{tag}: tag not found on Docker Hub" - last_error = f"HTTP {error.code}: {error.reason}" + last_error = "tag not found on Docker Hub" + else: + last_error = f"HTTP {error.code}: {error.reason}" except urllib.error.URLError as error: last_error = str(error.reason) if attempt < attempts: time.sleep(2 * attempt) + if last_error == "tag not found on Docker Hub": + return f"{tag}: {last_error}" return f"{tag}: Docker Hub tag lookup failed: {last_error or 'unknown error'}" @@ -189,7 +194,7 @@ def _release_package_tag(repo: RepoConfig, *, sha: str, component: str) -> str: release_target_commit = find_release_target_commit(repo.path, changelog_version) except (Exception, SystemExit): release_target_commit = "" - if release_target_commit != sha: + if not _release_tag_sha_allowed(repo, release_target_commit, sha): return "" upstream_version = _read_component_upstream_version(repo, component) @@ -201,3 +206,28 @@ def _release_package_tag(repo: RepoConfig, *, sha: str, component: str) -> str: if repo.publish_profile == "upstream-aio-track": return f"{upstream_version}-aio.{revision}" return changelog_version + + +_RELEASE_FORMAT_SUBJECT = re.compile( + r"^chore\(release\): format .+ changelog(?: \(#\d+\))?$" +) + + +def _release_tag_sha_allowed( + repo: RepoConfig, release_target_commit: str, sha: str +) -> bool: + if release_target_commit == sha: + return True + try: + if not git_is_ancestor(repo.path, release_target_commit, sha): + return False + subjects = git( + repo.path, "log", "--format=%s", f"{release_target_commit}..{sha}" + ) + except (Exception, SystemExit): + return False + return bool(subjects.strip()) and all( + _RELEASE_FORMAT_SUBJECT.match(subject.strip()) + for subject in subjects.splitlines() + if subject.strip() + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index ed26b5d..39041eb 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -82,6 +82,58 @@ def test_upstream_aio_track_release_tag_matches_changelog(monkeypatch) -> None: assert "ghcr.io/jsonbored/sure-aio:0.7.0-aio.1" in tags.ghcr # nosec B101 +def test_release_tag_allows_changelog_format_followup(monkeypatch) -> None: + repo = load_manifest(ROOT / "fleet.yml").repo("sure-aio") + release_sha = "c" * 40 + publish_sha = "d" * 40 + + monkeypatch.setattr( + registry, "_read_component_upstream_version", lambda *_: "0.7.0" + ) + monkeypatch.setattr( + registry, "latest_changelog_version", lambda *_args, **_kwargs: "0.7.0-aio.2" + ) + monkeypatch.setattr( + registry, "find_release_target_commit", lambda *_args, **_kwargs: release_sha + ) + monkeypatch.setattr(registry, "git_is_ancestor", lambda *_args: True) + monkeypatch.setattr( + registry, + "git", + lambda *_args: "chore(release): format sure changelog", + ) + + tags = registry.compute_registry_tags(repo, sha=publish_sha) + + assert tags.release_package_tag == "0.7.0-aio.2" # nosec B101 + assert "jsonbored/sure-aio:0.7.0-aio.2" in tags.dockerhub # nosec B101 + assert "ghcr.io/jsonbored/sure-aio:0.7.0-aio.2" in tags.ghcr # nosec B101 + + +def test_release_tag_rejects_arbitrary_post_release_commit(monkeypatch) -> None: + repo = load_manifest(ROOT / "fleet.yml").repo("sure-aio") + release_sha = "c" * 40 + publish_sha = "d" * 40 + + monkeypatch.setattr( + registry, "_read_component_upstream_version", lambda *_: "0.7.0" + ) + monkeypatch.setattr( + registry, "latest_changelog_version", lambda *_args, **_kwargs: "0.7.0-aio.2" + ) + monkeypatch.setattr( + registry, "find_release_target_commit", lambda *_args, **_kwargs: release_sha + ) + monkeypatch.setattr(registry, "git_is_ancestor", lambda *_args: True) + monkeypatch.setattr(registry, "git", lambda *_args: "fix(runtime): later change") + + tags = registry.compute_registry_tags(repo, sha=publish_sha) + + assert tags.release_package_tag == "" # nosec B101 + assert "jsonbored/sure-aio:0.7.0-aio.2" not in tags.dockerhub # nosec B101 + assert "ghcr.io/jsonbored/sure-aio:0.7.0-aio.2" not in tags.ghcr # nosec B101 + + def test_signoz_agent_publish_command_uses_component_context(monkeypatch) -> None: repo = load_manifest(ROOT / "fleet.yml").repo("signoz-aio") @@ -203,6 +255,7 @@ def read(self) -> bytes: def test_dockerhub_verification_reports_missing_tag(monkeypatch) -> None: monkeypatch.setattr(registry.shutil, "which", lambda _name: "docker") + monkeypatch.setattr(registry.time, "sleep", lambda _seconds: None) def fake_urlopen(url: str, timeout: int): raise HTTPError(url, 404, "Not Found", {}, None) @@ -216,6 +269,40 @@ def fake_urlopen(url: str, timeout: int): ] +def test_dockerhub_verification_retries_new_tag_404(monkeypatch) -> None: + attempts = 0 + sleeps: list[int] = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> bytes: + return b"{}" + + def fake_urlopen(url: str, timeout: int): + nonlocal attempts + attempts += 1 + if attempts < 3: + raise HTTPError(url, 404, "Not Found", {}, None) + return Response() + + monkeypatch.setattr(registry.shutil, "which", lambda _name: "docker") + monkeypatch.setattr(registry.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(registry.time, "sleep", sleeps.append) + + assert ( + registry.verify_registry_tags(["jsonbored/sure-aio:sha-new"]) == [] + ) # nosec B101 + assert attempts == 3 # nosec B101 + assert sleeps == [2, 4] # nosec B101 + + def test_ghcr_verification_uses_docker_imagetools(monkeypatch) -> None: seen_commands: list[list[str]] = [] inspect_env = {"DOCKER_CONFIG": "/workspace/aio-fleet-docker"}