Compare commits

..
6 Commits
Author SHA1 Message Date
devx-ci-bot 9f30f5b9fd release: v0.52.0 [skip ci] 2026-09-19 02:44:38 +00:00
kireto f15a8beb66 DEVX-167: feat(ci): verify producer container artifact before opening dependency PR
Post-merge / detect-and-configure (push) Successful in 10s
Post-merge / release-and-maintain (push) Successful in 1m15s
2026-09-19 02:43:55 +00:00
gitea-actions-bot 779aa0dfa4 chore: update badge URLs to commit 9f6cf573 [skip ci] 2026-09-19 02:34:58 +00:00
devx-ci-bot 5c4959b67a release: v0.51.12 [skip ci] 2026-09-19 02:34:17 +00:00
kireto 4ce25f16b2 DEVX-166: fix: create_dependency_pr clones target repo instead of editing producer checkout
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-09-19 02:33:30 +00:00
devx-ci-bot f0b9b71811 release: v0.51.11 [skip ci] 2026-09-19 02:05:46 +00:00
15 changed files with 228 additions and 475 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
## [0.52.0] - 2026-09-19
### Features
- *(ci)* Verify producer container artifact before opening dependency PR
## [0.51.12] - 2026-09-19
### Bug Fixes
- Create_dependency_pr clones target repo instead of editing producer checkout
## [0.51.11] - 2026-09-19
### Bug Fixes
- *(ci)* Retry Vikunja lookups and surface self-approval merge failures
## [0.51.10] - 2026-09-17
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.51.10",
"devx>=0.52.0",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.51.10"`) or use a version constraint
> (for example, `"devx>=0.51.10,<0.52"`).
> `dependencies` (for example, `"devx==0.52.0"`) or use a version constraint
> (for example, `"devx>=0.52.0,<0.53"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9f6cf5738cdf4edb7be7691a0bd7a4c4584ff9a2/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.51.10",
"devx>=0.52.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.51.10"` or `"devx>=0.51.10,<0.52"`.
Pin a specific version if needed: `"devx==0.52.0"` or `"devx>=0.52.0,<0.53"`.
### Optional extras
@@ -1,31 +0,0 @@
# 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`
+19 -51
View File
@@ -1,63 +1,31 @@
# DEVX-165: Manifest-aware dependency PRs and registry cleanup protection
# DEVX-165: Accept deps: as valid conventional commit type
## Problem
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.
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: `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`
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
- New unit tests per REQ; `make pytest-cov`, `make lint-all`.
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
## Deploy Plan
- Merge → devx release → consumer repos pick up via dependency PRs.
- 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; regex version bump and unprotected cleanup return.
- Revert the merge commit
## Acceptance Criteria
- [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
- [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`
+35 -15
View File
@@ -1,27 +1,47 @@
# DEVX-166: Exclude docs/plans/* from PR size check
# DEVX-166: create_dependency_pr must clone the target repo
## Problem
Planning docs in `docs/plans/` are legitimately large (700+ lines) but
fail the PR size check (max 500 lines). This blocks PRs that only add
planning documents.
`create_dependency_pr` resolves the pinned-version file and runs all
git operations in the current working directory. Producer post-merge
workflows (grm, sso-bridge) invoke it from the *producer* checkout, so
it searches/modifies the wrong repository: `find_pinned_version` reads
files that do not exist there, and the git fetch/checkout/commit/push
sequence runs in the producer clone. The failure is silent — producer
workflows append `|| echo warning`, so a no-op looks like success.
## Approach
REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py`
REQ-2: Add test coverage for the new exclusion pattern
REQ-1: Clone the target repo (`--repo`) into a temporary directory with
an authenticated `http.extraHeader`, then run every file lookup and git
operation (fetch, checkout, add, commit, push) inside that clone. The
push uses the same auth header config.
REQ-2: Tests mock `subprocess.run` so no real clone happens in the unit
suite (test-isolation gate).
## Files Affected
- `src/devx/ci/create_dependency_pr.py`
- `tests/unit/test_create_dependency_pr.py`
## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
- Existing CLI tests keep passing with the subprocess mock in place.
- Verify the clone command targets the `--repo` URL and that git ops
run with `cwd=<clone>` (asserted via the mock's call list).
## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- Infra PR #1179 picks up the fix once devx is bumped
Merge via auto-merge after green CI. The fix takes effect the next time
a producer post-merge workflow invokes `create_dependency_pr`.
## Rollback Plan
- Revert the merge commit
Revert the squash-merge commit on master; the previous (broken) CWD
behavior returns, which is strictly worse — no state is created.
## Acceptance Criteria
- [x] REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py`
- [x] REQ-2: Add test coverage for the new exclusion pattern
- [x] REQ-1: target repo cloned to tempdir; all file/git ops run in the clone
- [x] REQ-2: unit tests never spawn a real git subprocess
+64
View File
@@ -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
+40 -47
View File
@@ -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 <owner/name>`
and `--container-tag <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
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.51.10",
"devx>=0.52.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.51.10",
"devx>=0.52.0",
]
```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects.
"""
__version__ = "0.51.10"
__version__ = "0.52.0"
+11 -95
View File
@@ -21,7 +21,7 @@ from __future__ import annotations
import re
import subprocess # nosec B404
from datetime import UTC, datetime
import tempfile
from pathlib import Path
import click
@@ -143,54 +143,6 @@ 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:
@@ -211,28 +163,15 @@ 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."
"digest of the tag matching --new-version (or --container-tag); the PR is "
"refused when the artifact is missing or unreadable."
),
)
@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="",
@@ -249,9 +188,7 @@ 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:
@@ -283,10 +220,8 @@ def cli(
)
# Clone the target repo — this tool runs from the *producer* repo's CI,
# so every file lookup and git operation must happen in a clone of the
# target repo, not the producer checkout in CWD.
import tempfile
# 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}"
@@ -299,15 +234,11 @@ def cli(
# Find current pinned version
old_version = None
changed_file = None
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
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))
@@ -360,23 +291,8 @@ 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 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):
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))
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
+2 -14
View File
@@ -150,20 +150,15 @@ 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`` 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.
Versions named ``latest`` are always preserved.
"""
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
@@ -187,12 +182,6 @@ 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,
@@ -208,7 +197,6 @@ def main(
owner: str | None,
names: tuple[str, ...],
keep: int,
protect: tuple[str, ...],
dry_run: bool,
api_url: str | None,
) -> None:
@@ -250,7 +238,7 @@ def main(
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
)
to_delete = select_for_deletion(versions, keep, frozenset(protect))
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
+7 -47
View File
@@ -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 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 摘要并记录到清单中。"
"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.",
@@ -1847,14 +1847,6 @@
"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",
@@ -2111,14 +2103,6 @@
"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}",
@@ -2127,22 +2111,6 @@
"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",
@@ -2735,14 +2703,6 @@
"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).",
-32
View File
@@ -485,38 +485,6 @@ 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."""
+12 -123
View File
@@ -17,7 +17,7 @@ from devx.ci.create_dependency_pr import (
@pytest.fixture(autouse=True)
def _mock_subprocess() -> MagicMock:
def _mock_subprocess():
"""Mock subprocess so CLI tests never run a real git clone."""
with patch("devx.ci.create_dependency_pr.subprocess.run") as m:
yield m
@@ -244,119 +244,14 @@ class TestResolveContainerDigest:
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()
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.read_manifest_version")
@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_read: MagicMock, mock_digest: MagicMock
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"
@@ -371,23 +266,21 @@ class TestCliManifestMode:
"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()
mock_find.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.find_pinned_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
self, mock_token: MagicMock, mock_find: MagicMock, mock_digest: MagicMock
) -> None:
mock_token.return_value = "fake-token"
mock_read.return_value = "0.9.1"
mock_find.return_value = "0.9.1"
mock_digest.return_value = "sha256:abc"
runner = CliRunner()
result = runner.invoke(
@@ -399,8 +292,6 @@ class TestCliManifestMode:
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
],
@@ -430,14 +321,14 @@ class TestCliManifestMode:
assert result.exit_code != 0
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
@patch("devx.ci.create_dependency_pr.read_manifest_version")
@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_read: MagicMock, mock_digest: MagicMock
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_read.return_value = "0.9.1"
mock_find.return_value = "0.9.1"
mock_digest.return_value = "sha256:abc"
runner = CliRunner()
result = runner.invoke(
@@ -449,8 +340,6 @@ class TestCliManifestMode:
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
"--container-tag",