diff --git a/docs/specs/DEVX-165-deps-commit-type-historical.md b/docs/specs/DEVX-165-deps-commit-type-historical.md new file mode 100644 index 0000000..5f4424c --- /dev/null +++ b/docs/specs/DEVX-165-deps-commit-type-historical.md @@ -0,0 +1,31 @@ +# DEVX-165: Accept deps: as valid conventional commit type + +## Problem +The commit validator rejects `deps:` as a conventional commit type, causing +post-merge CI failures on grm and sso-bridge repos where automated dependency +bump PRs use `deps: bump devx...` as the commit message. + +## Approach +REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py` +REQ-2: Update the allowed types list in the error message in + `src/devx/ci/validate_commit_msg.py` +REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py` + and `tests/unit/test_validate_commit_msg.py` + +## Test Plan +- `make pytest-cov` passes with 100% coverage +- `make lint-all` passes + +## Deploy Plan +- Merge to master → post-merge auto-publishes new devx version +- grm and sso-bridge bump devx version to pick up the fix + +## Rollback Plan +- Revert the merge commit + +## Acceptance Criteria +- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py` +- [x] REQ-2: Update the allowed types list in the error message in + `src/devx/ci/validate_commit_msg.py` +- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py` + and `tests/unit/test_validate_commit_msg.py` diff --git a/docs/specs/DEVX-165.md b/docs/specs/DEVX-165.md index 5f4424c..e05aba9 100644 --- a/docs/specs/DEVX-165.md +++ b/docs/specs/DEVX-165.md @@ -1,31 +1,63 @@ -# DEVX-165: Accept deps: as valid conventional commit type +# DEVX-165: Manifest-aware dependency PRs and registry cleanup protection ## Problem -The commit validator rejects `deps:` as a conventional commit type, causing -post-merge CI failures on grm and sso-bridge repos where automated dependency -bump PRs use `deps: bump devx...` as the commit message. + +S03 (OBL-INFRA-548 REQ-3) requires an immutable delivery contract: infra +pins the sso-bridge release as {version, git ref, image tag, OCI digest} +in a JSON manifest. Two gaps in devx block that: + +1. `create_dependency_pr` only regex-bumps a version string in + pyproject/ansible vars — it cannot update a structured manifest with + the resolved image digest, and it does not verify the producer + artifact exists before opening the PR. +2. `clean_images` deletes all but the newest N tags — a tag/digest that + infra still pins gets deleted once newer releases land, breaking + deploys. ## Approach -REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py` -REQ-2: Update the allowed types list in the error message in - `src/devx/ci/validate_commit_msg.py` -REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py` - and `tests/unit/test_validate_commit_msg.py` + +REQ-1: `create_dependency_pr` gains `--manifest ` + +`--verify-container ` + `--container-tag` + +`--source-ref`: before opening the PR it resolves the container tag's +OCI digest via the packages API (`manifest.json` blob sha256), then +updates manifest fields `{version, git_ref, image_tag, image_digest, +source_run_id, updated_at}` in the PR branch instead of a regex bump. +`--container-tag` decouples the image tag from the release version +(sso-bridge images tag `__init__.py.__version__`, not the git tag). + +REQ-1b: `create_dependency_pr` clones the *target* repo into a tempdir +and performs all file lookups and git operations inside it. Previously +it operated on CWD — the producer repo's own checkout — so file updates +silently targeted the wrong repo and the whole dep-PR path no-oped +behind `|| echo warning`. + +REQ-2: `clean_images` gains `--protect` (repeatable): named versions are +never deleted regardless of `--keep` trimming. + +REQ-3: Regression tests for manifest update, digest resolution, +verify-then-PR ordering, and protect filtering. + +## Files Affected + +- `src/devx/ci/create_dependency_pr.py` +- `src/devx/tools/clean_images.py` +- `tests/unit/test_create_dependency_pr.py` +- `tests/unit/test_clean_images.py` ## Test Plan -- `make pytest-cov` passes with 100% coverage -- `make lint-all` passes + +- New unit tests per REQ; `make pytest-cov`, `make lint-all`. ## Deploy Plan -- Merge to master → post-merge auto-publishes new devx version -- grm and sso-bridge bump devx version to pick up the fix + +- Merge → devx release → consumer repos pick up via dependency PRs. ## Rollback Plan -- Revert the merge commit + +- Revert; regex version bump and unprotected cleanup return. ## Acceptance Criteria -- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py` -- [x] REQ-2: Update the allowed types list in the error message in - `src/devx/ci/validate_commit_msg.py` -- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py` - and `tests/unit/test_validate_commit_msg.py` + +- [x] REQ-1: Manifest update + pre-PR OCI digest verification +- [x] REQ-2: `--protect` exempts versions from cleanup +- [x] REQ-3: Regression tests added and passing diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index a5fd5a7..9efbe08 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -22,6 +22,7 @@ from __future__ import annotations import re import subprocess # nosec B404 import tempfile +from datetime import UTC, datetime from pathlib import Path import click @@ -143,6 +144,54 @@ def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, toke ) +def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool: + """Update a release-manifest JSON section in place. Returns True if changed. + + Implements REQ-1: manifest fields record the immutable release contract + (version, git ref, image tag, resolved OCI digest) in the target repo. + """ + import json as _json + + path = Path(file_path) + if not path.exists(): + raise click.ClickException(_("Manifest file not found: {file}", file=file_path)) + try: + manifest = _json.loads(path.read_text(encoding="utf-8")) + except _json.JSONDecodeError as e: + raise click.ClickException(_("Manifest file {file} is not valid JSON: {error}", file=file_path, error=e)) from e + existing = manifest.get(section) + if not isinstance(existing, dict): + raise click.ClickException( + _("Manifest file {file} has no object section {section}", file=file_path, section=section) + ) + changed = False + for k, v in fields.items(): + if existing.get(k) != v: + existing[k] = v + changed = True + if changed: + path.write_text(_json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return changed + + +def read_manifest_version(file_path: str, section: str) -> str | None: + """Read the currently pinned version from a release manifest section.""" + import json as _json + + path = Path(file_path) + if not path.exists(): + return None + try: + manifest = _json.loads(path.read_text(encoding="utf-8")) + except _json.JSONDecodeError: + return None + existing = manifest.get(section) + if isinstance(existing, dict): + version = existing.get("version") + return str(version) if version is not None else None + return None + + def create_vikunja_task(title: str, description: str) -> str | None: """Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531).""" try: @@ -163,15 +212,28 @@ def create_vikunja_task(title: str, description: str) -> str | None: @click.option("--new-version", required=True, help=_("New version to pin")) @click.option("--source-repo", required=True, help=_("Source repo that published (owner/name)")) @click.option("--source-run-id", default="", help=_("CI run ID that triggered the publish")) +@click.option( + "--manifest", + "manifest_path", + default="", + help=_( + "Path to a release-manifest JSON in the target repo. When set, the PR updates " + "the manifest section named after --package instead of a regex version bump." + ), +) @click.option( "--verify-container", default="", help=_( "Container to verify before opening the PR (owner/name). Resolves the OCI " - "digest of the tag matching --new-version (or --container-tag); the PR is " - "refused when the artifact is missing or unreadable." + "digest of the tag matching --new-version and records it in the manifest." ), ) +@click.option( + "--source-ref", + default="", + help=_("Git ref of the producer release (default: v), recorded in the manifest."), +) @click.option( "--container-tag", default="", @@ -188,7 +250,9 @@ def cli( new_version: str, source_repo: str, source_run_id: str, + manifest_path: str, verify_container: str, + source_ref: str, container_tag: str, dry_run: bool, ) -> None: @@ -234,11 +298,15 @@ def cli( # Find current pinned version old_version = None changed_file = None - for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]: - old_version = find_pinned_version(package, str(workdir / f)) - if old_version: - changed_file = f - break + if manifest_path: + old_version = read_manifest_version(str(workdir / manifest_path), package) + changed_file = manifest_path if old_version else None + else: + for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]: + old_version = find_pinned_version(package, str(workdir / f)) + if old_version: + changed_file = f + break if not old_version: click.echo(_("[dep-pr] Could not find pinned version for {pkg} in infra repo.", pkg=package)) @@ -291,8 +359,23 @@ def cli( subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True, cwd=workdir) # nosec B603 B607 subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True, cwd=workdir) # nosec B603 B607 - if not changed_file or not update_pinned_version(str(workdir / changed_file), package, old_version, new_version): + if manifest_path: + fields = { + "version": new_version, + "git_ref": source_ref or f"v{new_version}", + "image_tag": container_tag or new_version, + "updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + if image_digest: + fields["image_digest"] = image_digest + if source_run_id: + fields["source_run_id"] = source_run_id + if not update_manifest(str(workdir / manifest_path), package, fields): + raise click.ClickException(_("Failed to update {file}", file=manifest_path)) + elif not changed_file or not update_pinned_version(str(workdir / changed_file), package, old_version, new_version): raise click.ClickException(_("Failed to update {file}", file=changed_file)) + + assert changed_file is not None # nosec B101 — narrowed by the early exit above subprocess.run(["git", "add", changed_file], check=True, cwd=workdir) # nosec B603 B607 commit_msg = f"deps: bump {package} from {old_version} to {new_version}" subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607 diff --git a/src/devx/tools/clean_images.py b/src/devx/tools/clean_images.py index 05ef755..1646a76 100644 --- a/src/devx/tools/clean_images.py +++ b/src/devx/tools/clean_images.py @@ -150,15 +150,20 @@ def sort_versions_by_date( def select_for_deletion( versions: list[dict[str, Any]], keep: int, + protect: frozenset[str] = frozenset(), ) -> list[dict[str, Any]]: """Select versions to delete, keeping the most recent ``keep`` versions. - Versions named ``latest`` are always preserved. + Versions named ``latest`` and any version listed in ``protect`` are + always preserved. Implements REQ-2 (DEVX-165): tags/digests that a + release manifest still pins must survive registry cleanup. """ sorted_versions = sort_versions_by_date(versions) to_delete = sorted_versions[keep:] # Always preserve 'latest' tag to_delete = [v for v in to_delete if v.get("version") != "latest"] + # Preserve explicitly protected versions (e.g., pinned by a release manifest) + to_delete = [v for v in to_delete if v.get("version") not in protect] return to_delete @@ -182,6 +187,12 @@ def select_for_deletion( show_default=True, help="Number of recent versions to keep (excluding 'latest').", ) +@click.option( + "--protect", + "protect", + multiple=True, + help="Version/tag to never delete (e.g., pinned by a release manifest). Can be repeated.", +) @click.option( "--dry-run", is_flag=True, @@ -197,6 +208,7 @@ def main( owner: str | None, names: tuple[str, ...], keep: int, + protect: tuple[str, ...], dry_run: bool, api_url: str | None, ) -> None: @@ -238,7 +250,7 @@ def main( _(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?")) ) - to_delete = select_for_deletion(versions, keep) + to_delete = select_for_deletion(versions, keep, frozenset(protect)) kept_count = len(versions) - len(to_delete) click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete))) diff --git a/src/devx/translations.json b/src/devx/translations.json index 2a39203..ef1b95d 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1359,13 +1359,13 @@ "ru": "Тег контейнера для проверки с --verify-container (по умолчанию: --new-version). Используйте, когда тег образа отличается от версии релиза, например тег __version__ пакета против git-тега релиза.", "zh": "用 --verify-container 验证的容器标签(默认:--new-version)。当镜像标签与发布版本不同时使用,例如包的 __version__ 标签与发布 git 标签。" }, - "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version (or --container-tag); the PR is refused when the artifact is missing or unreadable.": { - "bg": "Контейнер за проверка преди отваряне на PR (собственик/име). Разрешава OCI дайджеста на тага, отговарящ на --new-version (или --container-tag); PR се отказва, ако артефактът липсва или е нечетим.", - "de": "Container zur Verifizierung vor dem Öffnen des PR (owner/name). Löst den OCI-Digest des zu --new-version (oder --container-tag) passenden Tags auf; der PR wird abgelehnt, wenn das Artefakt fehlt oder unlesbar ist.", - "en": "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version (or --container-tag); the PR is refused when the artifact is missing or unreadable.", - "pl": "Kontener do weryfikacji przed otwarciem PR (właściciel/nazwa). Rozwiązuje skrót OCI tagu pasującego do --new-version (lub --container-tag); PR jest odrzucany, gdy artefakt nie istnieje lub jest nieczytelny.", - "ru": "Контейнер для проверки перед открытием PR (владелец/имя). Разрешает OCI-дайджест тега, соответствующего --new-version (или --container-tag); PR отклоняется, если артефакт отсутствует или недоступен.", - "zh": "在打开 PR 前要验证的容器(所有者/名称)。解析与 --new-version(或 --container-tag)匹配标签的 OCI 摘要;当工件缺失或不可读时拒绝创建 PR。" + "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.": { + "bg": "Контейнер за проверка преди отваряне на PR (owner/name). Разрешава OCI дайджеста на тага, съвпадащ с --new-version, и го записва в манифеста.", + "de": "Container, der vor dem Öffnen des PR verifiziert wird (owner/name). Ermittelt den OCI-Digest des zu --new-version passenden Tags und trägt ihn ins Manifest ein.", + "en": "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.", + "pl": "Kontener do zweryfikowania przed otwarciem PR (owner/name). Rozwiązuje skrót OCI tagu pasującego do --new-version i zapisuje go w manifeście.", + "ru": "Контейнер для проверки перед открытием PR (owner/name). Разрешает OCI-дайджест тега, соответствующего --new-version, и записывает его в манифест.", + "zh": "在打开 PR 前要验证的容器 (owner/name)。解析与 --new-version 匹配的标签的 OCI 摘要并记录到清单中。" }, "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": { "bg": "Не може да се определи номерът на PR. Използвайте --pr, за да го зададете изрично,\nили изпълнете командата от клон с отворен PR.", @@ -1847,6 +1847,14 @@ "ru": "Генерация значков в {out}...", "zh": "正在 {out} 中生成徽章..." }, + "Git ref of the producer release (default: v), recorded in the manifest.": { + "bg": "Git референция на продуцентското издание (по подразбиране: v), записана в манифеста.", + "de": "Git-Ref des Producer-Releases (Standard: v), im Manifest verzeichnet.", + "en": "Git ref of the producer release (default: v), recorded in the manifest.", + "pl": "Referencja git wydania producenta (domyślnie: v), zapisana w manifeście.", + "ru": "Git-ссылка релиза производителя (по умолчанию: v), записанная в манифест.", + "zh": "生产者发布的 git 引用(默认:v),记录在清单中。" + }, "Git tag or ref that was deployed": { "bg": "Git таг или референция, която беше разгърната", "de": "Git-Tag oder Ref, der bereitgestellt wurde", @@ -2103,6 +2111,14 @@ "ru": "Цикл с {count} итерациями в тесте '{test}' — используйте property-based тестирование (hypothesis) или уменьшите до <= {max} итераций.", "zh": "测试 '{test}' 中有 {count} 次迭代的循环 — 考虑使用基于属性的测试 (hypothesis) 或减少到 <= {max} 次迭代。" }, + "Manifest file not found: {file}": { + "bg": "Файлът на манифеста не е намерен: {file}", + "de": "Manifest-Datei nicht gefunden: {file}", + "en": "Manifest file not found: {file}", + "pl": "Nie znaleziono pliku manifestu: {file}", + "ru": "Файл манифеста не найден: {file}", + "zh": "未找到清单文件:{file}" + }, "Manifest file not found: {path}": { "bg": "Файлът на манифеста не е намерен: {path}", "de": "Manifestdatei nicht gefunden: {path}", @@ -2111,6 +2127,22 @@ "ru": "Файл манифеста не найден: {path}", "zh": "未找到清单文件:{path}" }, + "Manifest file {file} has no object section {section}": { + "bg": "Файлът на манифеста {file} няма обектна секция {section}", + "de": "Manifest-Datei {file} hat keinen Objektabschnitt {section}", + "en": "Manifest file {file} has no object section {section}", + "pl": "Plik manifestu {file} nie ma sekcji obiektu {section}", + "ru": "Файл манифеста {file} не содержит объектного раздела {section}", + "zh": "清单文件 {file} 没有对象节 {section}" + }, + "Manifest file {file} is not valid JSON: {error}": { + "bg": "Файлът на манифеста {file} не е валиден JSON: {error}", + "de": "Manifest-Datei {file} ist kein gültiges JSON: {error}", + "en": "Manifest file {file} is not valid JSON: {error}", + "pl": "Plik manifestu {file} nie jest prawidłowym JSON: {error}", + "ru": "Файл манифеста {file} не является допустимым JSON: {error}", + "zh": "清单文件 {file} 不是有效的 JSON:{error}" + }, "Manifest must be a JSON list": { "bg": "Манифестът трябва да е JSON списък", "de": "Manifest muss eine JSON-Liste sein", @@ -2703,6 +2735,14 @@ "ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME", "zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}" }, + "Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.": { + "bg": "Път към release-manifest JSON в целевото хранилище. Когато е зададен, PR актуализира секцията на манифеста, наречена след --package, вместо regex bump на версията.", + "de": "Pfad zu einer Release-Manifest-JSON im Ziel-Repo. Wenn gesetzt, aktualisiert der PR den nach --package benannten Manifest-Abschnitt statt eines Regex-Versionsbumps.", + "en": "Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.", + "pl": "Ścieżka do pliku JSON manifestu wydania w docelowym repozytorium. Po ustawieniu PR aktualizuje sekcję manifestu nazwaną po --package zamiast podbicia wersji regexem.", + "ru": "Путь к JSON манифеста релиза в целевом репозитории. Если задан, PR обновляет раздел манифеста, названный по --package, вместо повышения версии по regex.", + "zh": "目标仓库中发布清单 JSON 的路径。设置后,PR 更新以 --package 命名的清单节,而不是正则版本提升。" + }, "Path to pyproject.toml (default: pyproject.toml in CWD).": { "bg": "Път до pyproject.toml (по подразбиране: pyproject.toml в CWD).", "de": "Pfad zu pyproject.toml (Standard: pyproject.toml im CWD).", diff --git a/tests/unit/test_build_image.py b/tests/unit/test_build_image.py index b516250..35f0db1 100644 --- a/tests/unit/test_build_image.py +++ b/tests/unit/test_build_image.py @@ -485,6 +485,38 @@ class TestSelectForDeletion: to_delete = select_for_deletion(versions, keep=5) assert len(to_delete) == 0 + def test_protect_exempts_version(self) -> None: + """REQ-2: a manifest-pinned version survives cleanup beyond --keep.""" + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + {"version": "0.4.0", "created_at": "2025-04-01"}, + ] + to_delete = select_for_deletion(versions, keep=2, protect=frozenset({"0.1.0"})) + deleted = {v["version"] for v in to_delete} + assert "0.1.0" not in deleted + assert deleted == {"0.2.0"} + + def test_protect_multiple_versions(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + {"version": "0.4.0", "created_at": "2025-04-01"}, + ] + to_delete = select_for_deletion(versions, keep=1, protect=frozenset({"0.1.0", "0.2.0"})) + assert {v["version"] for v in to_delete} == {"0.3.0"} + + def test_protect_default_empty_behaves_as_before(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + ] + to_delete = select_for_deletion(versions, keep=2) + assert {v["version"] for v in to_delete} == {"0.1.0"} + class TestCleanImagesAPI: """Tests for the clean_images module's API functions.""" diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 80ec748..5c4525a 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -244,14 +244,119 @@ class TestResolveContainerDigest: resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok") -class TestCliVerifyContainer: - """REQ-1: artifact verification gates the dependency PR.""" +class TestManifestHelpers: + """REQ-1: manifest read/update helpers.""" + + def test_read_version(self, tmp_path: Path) -> None: + import json + + from devx.ci.create_dependency_pr import read_manifest_version + + p = tmp_path / "m.json" + p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}})) + assert read_manifest_version(str(p), "sso_bridge") == "0.9.0" + + def test_read_version_missing_file(self, tmp_path: Path) -> None: + from devx.ci.create_dependency_pr import read_manifest_version + + assert read_manifest_version(str(tmp_path / "nope.json"), "sso_bridge") is None + + def test_read_version_bad_json(self, tmp_path: Path) -> None: + from devx.ci.create_dependency_pr import read_manifest_version + + p = tmp_path / "m.json" + p.write_text("not json{") + assert read_manifest_version(str(p), "sso_bridge") is None + + def test_read_version_missing_section(self, tmp_path: Path) -> None: + import json + + from devx.ci.create_dependency_pr import read_manifest_version + + p = tmp_path / "m.json" + p.write_text(json.dumps({"schema_version": 1, "other": "x"})) + assert read_manifest_version(str(p), "sso_bridge") is None + + def test_update_manifest_fields(self, tmp_path: Path) -> None: + import json + + from devx.ci.create_dependency_pr import update_manifest + + p = tmp_path / "m.json" + p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}})) + changed = update_manifest( + str(p), + "sso_bridge", + {"version": "0.9.1", "git_ref": "v0.9.1", "image_digest": "sha256:x"}, + ) + assert changed is True + data = json.loads(p.read_text()) + assert data["sso_bridge"]["version"] == "0.9.1" + assert data["sso_bridge"]["git_ref"] == "v0.9.1" + assert data["sso_bridge"]["image_digest"] == "sha256:x" + + def test_update_manifest_no_change(self, tmp_path: Path) -> None: + import json + + from devx.ci.create_dependency_pr import update_manifest + + p = tmp_path / "m.json" + p.write_text(json.dumps({"sso_bridge": {"version": "0.9.1"}})) + assert update_manifest(str(p), "sso_bridge", {"version": "0.9.1"}) is False + + def test_update_manifest_missing_file(self, tmp_path: Path) -> None: + from devx.ci.create_dependency_pr import update_manifest + + with pytest.raises(click.ClickException, match="not found"): + update_manifest(str(tmp_path / "nope.json"), "sso_bridge", {"version": "1"}) + + def test_update_manifest_bad_json(self, tmp_path: Path) -> None: + from devx.ci.create_dependency_pr import update_manifest + + p = tmp_path / "m.json" + p.write_text("broken{") + with pytest.raises(click.ClickException, match="not valid JSON"): + update_manifest(str(p), "sso_bridge", {"version": "1"}) + + def test_update_manifest_missing_section(self, tmp_path: Path) -> None: + import json + + from devx.ci.create_dependency_pr import update_manifest + + p = tmp_path / "m.json" + p.write_text(json.dumps({"schema_version": 1})) + with pytest.raises(click.ClickException, match="no object section"): + update_manifest(str(p), "sso_bridge", {"version": "1"}) + + +class TestCliManifestMode: + @patch("devx.ci.create_dependency_pr.read_manifest_version") + @patch("devx.ci.create_dependency_pr.get_ci_token") + def test_manifest_same_version_no_pr(self, mock_token: MagicMock, mock_read: MagicMock) -> None: + mock_token.return_value = "fake-token" + mock_read.return_value = "0.9.1" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--manifest", + "deploy/sso-bridge-release.json", + ], + ) + assert result.exit_code == 0 + assert "no pr needed" in result.output.lower() @patch("devx.ci.create_dependency_pr.resolve_container_digest") - @patch("devx.ci.create_dependency_pr.find_pinned_version") + @patch("devx.ci.create_dependency_pr.read_manifest_version") @patch("devx.ci.create_dependency_pr.get_ci_token") def test_verify_container_runs_before_lookup( - self, mock_token: MagicMock, mock_find: MagicMock, mock_digest: MagicMock + self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock ) -> None: """Verification failure aborts before the version lookup/PR steps.""" mock_token.return_value = "fake-token" @@ -266,21 +371,23 @@ class TestCliVerifyContainer: "0.9.1", "--source-repo", "oblachno/sso-bridge", + "--manifest", + "deploy/m.json", "--verify-container", "oblachno/sso-bridge", ], ) assert result.exit_code != 0 - mock_find.assert_not_called() + mock_read.assert_not_called() @patch("devx.ci.create_dependency_pr.resolve_container_digest") - @patch("devx.ci.create_dependency_pr.find_pinned_version") + @patch("devx.ci.create_dependency_pr.read_manifest_version") @patch("devx.ci.create_dependency_pr.get_ci_token") def test_verify_container_resolves_digest( - self, mock_token: MagicMock, mock_find: MagicMock, mock_digest: MagicMock + self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock ) -> None: mock_token.return_value = "fake-token" - mock_find.return_value = "0.9.1" + mock_read.return_value = "0.9.1" mock_digest.return_value = "sha256:abc" runner = CliRunner() result = runner.invoke( @@ -292,6 +399,8 @@ class TestCliVerifyContainer: "0.9.1", "--source-repo", "oblachno/sso-bridge", + "--manifest", + "deploy/m.json", "--verify-container", "oblachno/sso-bridge", ], @@ -321,14 +430,14 @@ class TestCliVerifyContainer: assert result.exit_code != 0 @patch("devx.ci.create_dependency_pr.resolve_container_digest") - @patch("devx.ci.create_dependency_pr.find_pinned_version") + @patch("devx.ci.create_dependency_pr.read_manifest_version") @patch("devx.ci.create_dependency_pr.get_ci_token") def test_container_tag_overrides_new_version( - self, mock_token: MagicMock, mock_find: MagicMock, mock_digest: MagicMock + self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock ) -> None: """--container-tag selects the image tag when it differs from version.""" mock_token.return_value = "fake-token" - mock_find.return_value = "0.9.1" + mock_read.return_value = "0.9.1" mock_digest.return_value = "sha256:abc" runner = CliRunner() result = runner.invoke( @@ -340,6 +449,8 @@ class TestCliVerifyContainer: "0.9.1", "--source-repo", "oblachno/sso-bridge", + "--manifest", + "deploy/m.json", "--verify-container", "oblachno/sso-bridge", "--container-tag",