Public Access
DEVX-165: S03 devx: manifest-aware dependency PRs and cleanup protection #327
@@ -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`
|
||||
+51
-19
@@ -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 <path>` +
|
||||
`--verify-container <owner/name>` + `--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
|
||||
|
||||
@@ -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<new-version>), 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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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<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",
|
||||
@@ -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).",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user