diff --git a/docs/specs/DEVX-167-skills-historical.md b/docs/specs/DEVX-167-skills-historical.md new file mode 100644 index 0000000..f1186bd --- /dev/null +++ b/docs/specs/DEVX-167-skills-historical.md @@ -0,0 +1,64 @@ +# DEVX-167: Add dependency-graph, deployment-coordination, and skill-creation skills + +## Problem +Agents working across the oblachno ecosystem lack shared, persistent +context for three recurring pain points: + +1. **Cross-repo dependency ordering** — agents frequently merge + downstream PRs before the upstream publish job completes, or forget + to bump infra. There is no single reference for which repo produces + what and in what order changes must propagate. +2. **devx release coordination** — devx is the base package pinned by + grm, sso-bridge, and infra. Agents repeatedly merge devx PRs and + immediately merge downstream bumps without waiting for the PyPI + publish job, or bump only one consumer when a change affects all + three. +3. **Skill quality drift** — skills are created ad hoc with inconsistent + structure, vague advice, and no automated validation reference. New + skills miss required sections, reference nonexistent make targets, + and drift across repos. + +## Approach +Add three SKILL.md files under `.devin/skills/`: + +REQ-1: `dependency-graph` — shared skill mapping the oblachno ecosystem +(repos, what each produces, consumers, release triggers, correct +cross-repo change order, state verification checklist) + +REQ-2: `deployment-coordination` — devx-specific skill covering the +devx release flow, downstream consumers, manual bump procedure, and +common mistakes when coordinating a devx change + +REQ-3: `skill-creation` — shared skill defining skill structure, +quality standards, scope rules, automated validation reference, and a +creation checklist + +## Files Affected +- `.devin/skills/dependency-graph/SKILL.md` (new) +- `.devin/skills/deployment-coordination/SKILL.md` (new) +- `.devin/skills/skill-creation/SKILL.md` (new) +- `docs/specs/DEVX-167.md` (new) + +## Test Plan +- Verify all three SKILL.md files follow the required structure (H1 + title, When to Invoke, Prerequisites sections) +- Verify referenced make targets and file paths exist +- Run `make pytest-cov` — skill validation tests must pass + +## Deploy Plan +- Merge to master; skills are consumed by agents immediately on next + invocation — no build or deploy step required + +## Rollback Plan +- Revert the merge commit; remove the three skill directories + +## Acceptance Criteria +- [x] REQ-1: dependency-graph skill exists with ecosystem map, repo + table, dependency chain, cross-repo change order, and state + verification checklist +- [x] REQ-2: deployment-coordination skill exists with devx release + flow, downstream consumer table, coordination steps, and common + mistakes +- [x] REQ-3: skill-creation skill exists with structure template, + quality standards, scope rules, validation reference, and + creation checklist diff --git a/docs/specs/DEVX-167.md b/docs/specs/DEVX-167.md index f1186bd..273096a 100644 --- a/docs/specs/DEVX-167.md +++ b/docs/specs/DEVX-167.md @@ -1,64 +1,57 @@ -# DEVX-167: Add dependency-graph, deployment-coordination, and skill-creation skills +# DEVX-167: S03 artifact integrity — verify producer artifact + cleanup protection ## Problem -Agents working across the oblachno ecosystem lack shared, persistent -context for three recurring pain points: -1. **Cross-repo dependency ordering** — agents frequently merge - downstream PRs before the upstream publish job completes, or forget - to bump infra. There is no single reference for which repo produces - what and in what order changes must propagate. -2. **devx release coordination** — devx is the base package pinned by - grm, sso-bridge, and infra. Agents repeatedly merge devx PRs and - immediately merge downstream bumps without waiting for the PyPI - publish job, or bump only one consumer when a change affects all - three. -3. **Skill quality drift** — skills are created ad hoc with inconsistent - structure, vague advice, and no automated validation reference. New - skills miss required sections, reference nonexistent make targets, - and drift across repos. +S03 (OBL-INFRA-548 REQ-3) requires that dependency PRs only open after +the producer artifact exists and is content-addressable, and that +registry cleanup never deletes a version pinned by a release manifest. +Two gaps: + +1. `create_dependency_pr` opens a bump PR unconditionally — if the + producer's publish job lagged or failed, the consumer pins a + nonexistent artifact. +2. The sso-bridge image tag is derived from `__init__.py.__version__`, + which does not always equal the release git tag, so the tag to + verify must be decoupled from `--new-version`. ## Approach -Add three SKILL.md files under `.devin/skills/`: -REQ-1: `dependency-graph` — shared skill mapping the oblachno ecosystem -(repos, what each produces, consumers, release triggers, correct -cross-repo change order, state verification checklist) +REQ-1: `create_dependency_pr` gains `--verify-container ` +and `--container-tag `: before any branch/PR work it resolves the +OCI digest of the image tag via the Gitea packages API (`manifest.json` +blob sha256) and refuses the PR when the artifact is missing or +unreadable. `--container-tag` decouples the image tag from the release +version (sso-bridge tags images from `__init__.py.__version__`, not the +git tag). -REQ-2: `deployment-coordination` — devx-specific skill covering the -devx release flow, downstream consumers, manual bump procedure, and -common mistakes when coordinating a devx change - -REQ-3: `skill-creation` — shared skill defining skill structure, -quality standards, scope rules, automated validation reference, and a -creation checklist +REQ-2: Regression tests cover digest resolution, verification-failure +aborts, invalid container format, and the `--container-tag` override. ## Files Affected -- `.devin/skills/dependency-graph/SKILL.md` (new) -- `.devin/skills/deployment-coordination/SKILL.md` (new) -- `.devin/skills/skill-creation/SKILL.md` (new) -- `docs/specs/DEVX-167.md` (new) + +- `src/devx/ci/create_dependency_pr.py` +- `src/devx/translations.json` +- `tests/unit/test_create_dependency_pr.py` ## Test Plan -- Verify all three SKILL.md files follow the required structure (H1 - title, When to Invoke, Prerequisites sections) -- Verify referenced make targets and file paths exist -- Run `make pytest-cov` — skill validation tests must pass + +- Unit tests for `resolve_container_digest` (digest from manifest blob, + missing tag, missing blob, connection error). +- CLI tests: verify runs before version lookup, digest resolution, + invalid format rejection, container-tag override. ## Deploy Plan -- Merge to master; skills are consumed by agents immediately on next - invocation — no build or deploy step required + +Merge via auto-merge after green CI. Producer post-merge workflows adopt +the new flags in their own PRs (sso-bridge SSO-22 already passes them). ## Rollback Plan -- Revert the merge commit; remove the three skill directories + +Revert the squash-merge commit; the new options disappear and callers +without them behave exactly as before. ## Acceptance Criteria -- [x] REQ-1: dependency-graph skill exists with ecosystem map, repo - table, dependency chain, cross-repo change order, and state - verification checklist -- [x] REQ-2: deployment-coordination skill exists with devx release - flow, downstream consumer table, coordination steps, and common - mistakes -- [x] REQ-3: skill-creation skill exists with structure template, - quality standards, scope rules, validation reference, and - creation checklist + +- [x] REQ-1: pre-PR OCI digest verification with --verify-container/--container-tag +- [x] REQ-2: regression tests for all new behavior + diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index 45dbd28..a5fd5a7 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -25,6 +25,7 @@ import tempfile from pathlib import Path import click +import requests from dotenv import load_dotenv from devx.api_clients import GiteaClient @@ -93,6 +94,55 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve return changed +def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str: + """Resolve the OCI digest for a container image tag via the packages API. + + Implements REQ-1: dependency PRs must only be opened after the producer + artifact exists — this raises ClickException when the tag is missing or + the registry call fails, so the PR is never opened against an artifact + that has not been published. The sha256 of the stored ``manifest.json`` + blob is the manifest content digest (what ``docker pull`` reports). + """ + url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files" + headers = {"Authorization": f"token {token}"} + try: + resp = requests.get(url, headers=headers, timeout=30) # nosec B310 + resp.raise_for_status() + except requests.HTTPError as e: + status = e.response.status_code if e.response is not None else "?" + raise click.ClickException( + _( + "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). " + "Refusing to open a dependency PR for an unpublished artifact.", + owner=owner, + name=name, + tag=tag, + status=status, + ) + ) from e + except requests.RequestException as e: + raise click.ClickException( + _( + "Registry lookup failed for {owner}/{name}:{tag}: {error}", + owner=owner, + name=name, + tag=tag, + error=e, + ) + ) from e + for f in resp.json(): + if f.get("name") == "manifest.json" and f.get("sha256"): + return f"sha256:{f['sha256']}" + raise click.ClickException( + _( + "Registry returned no manifest blob for {owner}/{name}:{tag}.", + owner=owner, + name=name, + tag=tag, + ) + ) + + def create_vikunja_task(title: str, description: str) -> str | None: """Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531).""" try: @@ -113,6 +163,24 @@ 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( + "--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." + ), +) +@click.option( + "--container-tag", + default="", + help=_( + "Container tag to verify with --verify-container (default: --new-version). " + "Use when the image tag differs from the release version, e.g. a package " + "__version__ tag vs a release git tag." + ), +) @click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR")) def cli( repo: str, @@ -120,6 +188,8 @@ def cli( new_version: str, source_repo: str, source_run_id: str, + verify_container: str, + container_tag: str, dry_run: bool, ) -> None: """Create an infra PR to bump a pinned dependency version.""" @@ -129,9 +199,29 @@ def cli( owner, repo_name = repo.split("/", 1) client = GiteaClient(GITEA_API_URL, token, owner, repo_name) - # Implements: REQ-1 — this tool runs from the *producer* repo's CI, so - # every file lookup and git operation must happen inside a clone of the - # target repo, not the producer checkout in CWD. + # Implements: REQ-1 — verify the producer artifact exists and resolve its + # digest before any branch/PR work begins. + image_digest = "" + if verify_container: + if "/" not in verify_container: + raise click.ClickException( + _("Invalid container format: {container} (expected owner/name)", container=verify_container) + ) + c_owner, c_name = verify_container.split("/", 1) + image_tag = container_tag or new_version + image_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, image_tag, token) + click.echo( + _( + "[dep-pr] Verified {container}:{version} -> {digest}", + container=verify_container, + version=image_tag, + digest=image_digest, + ) + ) + + # Clone the target repo — this tool runs from the *producer* repo's CI, + # so every file lookup and git operation must happen inside a clone of + # the target repo, not the producer checkout in CWD. workdir = Path(tempfile.mkdtemp(prefix="dep-pr-")) clone_url = f"{GITEA_API_URL.removesuffix('/api/v1')}/{repo}.git" auth_cfg = f"http.extraHeader=Authorization: token {token}" @@ -203,7 +293,6 @@ def cli( if 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)) - 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/translations.json b/src/devx/translations.json index 4ca4642..2a39203 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1343,6 +1343,30 @@ "ru": "Настройка входа tea '{name}' для {url}...", "zh": "正在为 {url} 配置 tea 登录 '{name}'..." }, + "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.": { + "bg": "Артефактът на контейнера {owner}/{name}:{tag} не е намерен или не може да бъде прочетен (HTTP {status}). Отказвам да отворя PR за зависимост за непубликуван артефакт.", + "de": "Container-Artefakt {owner}/{name}:{tag} nicht gefunden oder nicht lesbar (HTTP {status}). Kein Dependency-PR für ein unveröffentlichtes Artefakt.", + "en": "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.", + "pl": "Artefakt kontenera {owner}/{name}:{tag} nie został znaleziony lub jest nieczytelny (HTTP {status}). Odmawiam otwarcia PR zależności dla nieopublikowanego artefaktu.", + "ru": "Артефакт контейнера {owner}/{name}:{tag} не найден или недоступен для чтения (HTTP {status}). Отказ открывать PR зависимости для неопубликованного артефакта.", + "zh": "容器构件 {owner}/{name}:{tag} 未找到或不可读 (HTTP {status})。拒绝为未发布的构件创建依赖 PR。" + }, + "Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.": { + "bg": "Таг на контейнер за проверка с --verify-container (по подразбиране: --new-version). Използвайте, когато тагът на изображението се различава от версията на изданието, напр. таг на __version__ на пакет срещу git таг на издание.", + "de": "Container-Tag, der mit --verify-container geprüft wird (Standard: --new-version). Zu verwenden, wenn sich das Image-Tag von der Release-Version unterscheidet, z. B. ein __version__-Tag eines Pakets vs. ein Release-Git-Tag.", + "en": "Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.", + "pl": "Tag kontenera do weryfikacji z --verify-container (domyślnie: --new-version). Użyj, gdy tag obrazu różni się od wersji wydania, np. tag __version__ pakietu a tag git wydania.", + "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。" + }, "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.", "de": "PR-Nummer konnte nicht ermittelt werden. Mit --pr explizit angeben,\noder den Befehl von einem Branch mit offenem PR ausführen.", @@ -1991,6 +2015,14 @@ "ru": "Интеграционные тесты пройдены.", "zh": "集成测试通过。" }, + "Invalid container format: {container} (expected owner/name)": { + "bg": "Невалиден формат на контейнер: {container} (очаква се owner/name)", + "de": "Ungültiges Container-Format: {container} (erwartet owner/name)", + "en": "Invalid container format: {container} (expected owner/name)", + "pl": "Nieprawidłowy format kontenera: {container} (oczekiwano owner/name)", + "ru": "Неверный формат контейнера: {container} (ожидается owner/name)", + "zh": "容器格式无效:{container}(应为 owner/name)" + }, "Invalid repo format: {repo}": { "bg": "Невалиден формат на репозитория: {repo}", "de": "Ungültiges Repo-Format: {repo}", @@ -2887,6 +2919,22 @@ "ru": "Вход в реестр не удался: {error}", "zh": "注册表登录失败:{error}" }, + "Registry lookup failed for {owner}/{name}:{tag}: {error}": { + "bg": "Неуспешно търсене в регистъра за {owner}/{name}:{tag}: {error}", + "de": "Registry-Abfrage für {owner}/{name}:{tag} fehlgeschlagen: {error}", + "en": "Registry lookup failed for {owner}/{name}:{tag}: {error}", + "pl": "Wyszukiwanie w rejestrze nie powiodło się dla {owner}/{name}:{tag}: {error}", + "ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}", + "zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}" + }, + "Registry returned no manifest blob for {owner}/{name}:{tag}.": { + "bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.", + "de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.", + "en": "Registry returned no manifest blob for {owner}/{name}:{tag}.", + "pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.", + "ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.", + "zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。" + }, "Regular merge commit — running all post-merge jobs.": { "bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.", "de": "Regulärer Merge-Commit — alle Post-Merge-Jobs werden ausgeführt.", @@ -3727,6 +3775,14 @@ "ru": "[dep-pr] PR уже существует: #{number}", "zh": "[dep-pr] PR 已存在:#{number}" }, + "[dep-pr] Verified {container}:{version} -> {digest}": { + "bg": "[dep-pr] Проверено {container}:{version} -> {digest}", + "de": "[dep-pr] Verifiziert {container}:{version} -> {digest}", + "en": "[dep-pr] Verified {container}:{version} -> {digest}", + "pl": "[dep-pr] Zweryfikowano {container}:{version} -> {digest}", + "ru": "[dep-pr] Проверено {container}:{version} -> {digest}", + "zh": "[dep-pr] 已验证 {container}:{version} -> {digest}" + }, "[dep-pr] {pkg} already at {version} — no PR needed.": { "bg": "[dep-pr] {pkg} вече е на {version} — не е нужен PR.", "de": "[dep-pr] {pkg} bereits auf {version} — kein PR nötig.", diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 541cc99..80ec748 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -188,3 +188,164 @@ class TestCreateVikunjaTask: mock_client.create_task.return_value = {"identifier": "OBL-INFRA-999"} result = create_vikunja_task("Test", "desc") assert result == "OBL-INFRA-999" + + +class TestResolveContainerDigest: + """REQ-1: pre-PR artifact verification via the packages API.""" + + def test_returns_digest_from_manifest_blob(self) -> None: + from devx.ci.create_dependency_pr import resolve_container_digest + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = [ + {"name": "sha256_layer", "sha256": "abc"}, + {"name": "manifest.json", "sha256": "deadbeef"}, + ] + with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): + digest = resolve_container_digest( + "https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok" + ) + assert digest == "sha256:deadbeef" + + def test_raises_when_version_missing(self) -> None: + import requests + + from devx.ci.create_dependency_pr import resolve_container_digest + + mock_resp = MagicMock() + http_err = requests.HTTPError("404") + http_err.response = MagicMock(status_code=404) + mock_resp.raise_for_status.side_effect = http_err + with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): + with pytest.raises(click.ClickException, match="unpublished artifact"): + resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok") + + def test_raises_when_no_manifest_blob(self) -> None: + from devx.ci.create_dependency_pr import resolve_container_digest + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}] + with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): + with pytest.raises(click.ClickException, match="no manifest blob"): + resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok") + + def test_raises_on_connection_error(self) -> None: + import requests + + from devx.ci.create_dependency_pr import resolve_container_digest + + with patch( + "devx.ci.create_dependency_pr.requests.get", + side_effect=requests.ConnectionError("refused"), + ): + with pytest.raises(click.ClickException, match="Registry lookup failed"): + 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.""" + + @patch("devx.ci.create_dependency_pr.resolve_container_digest") + @patch("devx.ci.create_dependency_pr.find_pinned_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 + ) -> None: + """Verification failure aborts before the version lookup/PR steps.""" + mock_token.return_value = "fake-token" + mock_digest.side_effect = click.ClickException("unpublished artifact") + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--verify-container", + "oblachno/sso-bridge", + ], + ) + assert result.exit_code != 0 + mock_find.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.get_ci_token") + def test_verify_container_resolves_digest( + self, mock_token: MagicMock, mock_find: MagicMock, mock_digest: MagicMock + ) -> None: + mock_token.return_value = "fake-token" + mock_find.return_value = "0.9.1" + mock_digest.return_value = "sha256:abc" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--verify-container", + "oblachno/sso-bridge", + ], + ) + assert result.exit_code == 0 + mock_digest.assert_called_once() + args = mock_digest.call_args[0] + assert args[1:4] == ("oblachno", "sso-bridge", "0.9.1") + + @patch("devx.ci.create_dependency_pr.get_ci_token") + def test_verify_container_invalid_format(self, mock_token: MagicMock) -> None: + mock_token.return_value = "fake-token" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--verify-container", + "no-slash", + ], + ) + 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.get_ci_token") + def test_container_tag_overrides_new_version( + self, mock_token: MagicMock, mock_find: 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_digest.return_value = "sha256:abc" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--verify-container", + "oblachno/sso-bridge", + "--container-tag", + "0.2.4", + ], + ) + assert result.exit_code == 0 + args = mock_digest.call_args[0] + assert args[1:4] == ("oblachno", "sso-bridge", "0.2.4")