feat(ci): manifest-aware dependency PRs and cleanup protection
CI / validate (pull_request) Failing after 49s
CI / auto-merge (pull_request) Skipped

Implements: DEVX-165 REQ-1..3

- create_dependency_pr gains --manifest/--verify-container/--source-ref:
  resolves the producer image's OCI digest via the packages API before
  opening the PR, then writes structured manifest fields
  (version/git_ref/image_tag/image_digest/updated_at) in the PR branch
- clean_images gains --protect: pinned versions survive --keep trimming
  so a manifest-referenced digest can't be garbage-collected
- regression tests for digest resolution, manifest helpers, verify
  ordering, and protect filtering
This commit is contained in:
Emil Simeonov
2026-09-19 04:13:11 +02:00
parent 9b90816be4
commit 8cbf148745
7 changed files with 618 additions and 27 deletions
@@ -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`
+43 -19
View File
@@ -1,31 +1,55 @@
# 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 <path>` +
`--verify-container <owner/name>`: before opening the PR it resolves the
container tag's OCI digest via the registry `manifests` API
(`Docker-Content-Digest`), then updates manifest fields
`{version, git_ref, image_tag, image_digest, source_commit, updated_at}`
in the PR branch instead of a regex bump.
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
+169 -6
View File
@@ -21,9 +21,11 @@ from __future__ import annotations
import re
import subprocess # nosec B404
from datetime import UTC
from pathlib import Path
import click
import requests
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
@@ -92,6 +94,103 @@ 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 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:
@@ -112,6 +211,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 and records it in the manifest."
),
)
@click.option(
"--source-ref",
default="",
help=_("Git ref of the producer release (default: v<new-version>), recorded in the manifest."),
)
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
def cli(
repo: str,
@@ -119,6 +240,9 @@ def cli(
new_version: str,
source_repo: str,
source_run_id: str,
manifest_path: str,
verify_container: str,
source_ref: str,
dry_run: bool,
) -> None:
"""Create an infra PR to bump a pinned dependency version."""
@@ -128,14 +252,37 @@ def cli(
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# 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_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, new_version, token)
click.echo(
_(
"[dep-pr] Verified {container}:{version} -> {digest}",
container=verify_container,
version=new_version,
digest=image_digest,
)
)
# 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, f)
if old_version:
changed_file = f
break
if manifest_path:
old_version = read_manifest_version(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, 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))
@@ -188,9 +335,25 @@ def cli(
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True) # nosec B603 B607
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True) # nosec B603 B607
if not changed_file or not update_pinned_version(changed_file, package, old_version, new_version):
if manifest_path:
from datetime import datetime
fields = {
"version": new_version,
"git_ref": source_ref or f"v{new_version}",
"image_tag": 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(manifest_path, package, fields):
raise click.ClickException(_("Failed to update {file}", file=manifest_path))
elif not changed_file or not update_pinned_version(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) # nosec B603 B607
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
subprocess.run(["git", "commit", "-m", commit_msg], check=True) # nosec B603 B607
+14 -2
View File
@@ -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)))
+88
View File
@@ -1343,6 +1343,22 @@
"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 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.",
"de": "PR-Nummer konnte nicht ermittelt werden. Mit --pr explizit angeben,\noder den Befehl von einem Branch mit offenem PR ausführen.",
@@ -1823,6 +1839,14 @@
"ru": "Генерация значков в {out}...",
"zh": "正在 {out} 中生成徽章..."
},
"Git ref of the producer release (default: v<new-version>), recorded in the manifest.": {
"bg": "Git референция на продуцентското издание (по подразбиране: v<new-version>), записана в манифеста.",
"de": "Git-Ref des Producer-Releases (Standard: v<new-version>), im Manifest verzeichnet.",
"en": "Git ref of the producer release (default: v<new-version>), recorded in the manifest.",
"pl": "Referencja git wydania producenta (domyślnie: v<new-version>), zapisana w manifeście.",
"ru": "Git-ссылка релиза производителя (по умолчанию: v<new-version>), записанная в манифест.",
"zh": "生产者发布的 git 引用(默认:v<new-version>),记录在清单中。"
},
"Git tag or ref that was deployed": {
"bg": "Git таг или референция, която беше разгърната",
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
@@ -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}",
@@ -2071,6 +2103,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}",
@@ -2079,6 +2119,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",
@@ -2671,6 +2727,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).",
@@ -2887,6 +2951,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 +3807,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.",
+32
View File
@@ -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."""
+241
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.create_dependency_pr import (
@@ -180,3 +181,243 @@ 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 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.read_manifest_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_verify_container_runs_before_lookup(
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"
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",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
],
)
assert result.exit_code != 0
mock_read.assert_not_called()
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
@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_read: MagicMock, mock_digest: MagicMock
) -> None:
mock_token.return_value = "fake-token"
mock_read.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",
"--manifest",
"deploy/m.json",
"--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