Compare commits

...
25 Commits
Author SHA1 Message Date
gitea-actions-bot 5e0befe777 chore: update badge URLs to commit ee346d97 [skip ci] 2026-09-19 20:20:40 +00:00
devx-ci-bot fcc171183c release: v0.55.1 [skip ci] 2026-09-19 20:20:01 +00:00
kireto 9d0e4cf429 DEVX-173: fix(ci): resolve dep-PR container digest via registry v2 API
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-09-19 20:19:22 +00:00
gitea-actions-bot 203d6971b3 chore: update badge URLs to commit 954c28d4 [skip ci] 2026-09-19 19:50:47 +00:00
devx-ci-bot f1dc00682d release: v0.55.0 [skip ci] 2026-09-19 19:49:53 +00:00
kireto af24a6a771 DEVX-172: feat(ci): retry dep-PR container verification until publish lands
Post-merge / detect-and-configure (push) Successful in 11s
Post-merge / release-and-maintain (push) Successful in 1m25s
2026-09-19 19:49:09 +00:00
gitea-actions-bot 457f52cba7 chore: update badge URLs to commit 9eabfdd8 [skip ci] 2026-09-19 19:31:24 +00:00
devx-ci-bot 5a097aa91f release: v0.54.0 [skip ci] 2026-09-19 19:30:43 +00:00
kireto f9e43ce268 DEVX-171: feat(ci): dep PRs carry target-repo task ID and generated spec
Post-merge / detect-and-configure (push) Successful in 10s
Post-merge / release-and-maintain (push) Successful in 1m8s
2026-09-19 19:30:05 +00:00
gitea-actions-bot 32730d551d chore: update badge URLs to commit a281b56d [skip ci] 2026-09-19 19:18:58 +00:00
devx-ci-bot b75ab6f833 release: v0.53.3 [skip ci] 2026-09-19 19:18:17 +00:00
kireto 4b87d0b423 DEVX-170: fix(ci): check out API-created dep branch via remote-tracking ref
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m11s
2026-09-19 19:17:35 +00:00
gitea-actions-bot 36422592fd chore: update badge URLs to commit b7c6d5dd [skip ci] 2026-09-19 19:14:34 +00:00
devx-ci-bot c8b5af3173 release: v0.53.2 [skip ci] 2026-09-19 19:13:55 +00:00
kireto 85f1bd9304 DEVX-169: fix(ci): create dep-PR branches via POST /branches
Post-merge / detect-and-configure (push) Successful in 59s
Post-merge / release-and-maintain (push) Successful in 1m6s
2026-09-19 19:12:27 +00:00
gitea-actions-bot c6e895b230 chore: update badge URLs to commit 984f4527 [skip ci] 2026-09-19 19:07:22 +00:00
devx-ci-bot d5a0f09b71 release: v0.53.1 [skip ci] 2026-09-19 19:06:46 +00:00
kireto a68df7d785 DEVX-168: fix(ci): handle list-shaped ref response in create_dependency_pr
Post-merge / detect-and-configure (push) Successful in 10s
Post-merge / release-and-maintain (push) Successful in 1m3s
2026-09-19 19:06:07 +00:00
gitea-actions-bot d1e05db642 chore: update badge URLs to commit a421bc18 [skip ci] 2026-09-19 02:50:12 +00:00
devx-ci-bot 4dfd616cfd release: v0.53.0 [skip ci] 2026-09-19 02:49:23 +00:00
kireto 49ad1868bc DEVX-165: feat(ci): manifest-aware dependency PRs and cleanup protection
Post-merge / detect-and-configure (push) Successful in 15s
Post-merge / release-and-maintain (push) Successful in 1m24s
2026-09-19 02:48:24 +00:00
gitea-actions-bot 7268c2b4ac chore: update badge URLs to commit cc45ecc4 [skip ci] 2026-09-19 02:45:26 +00:00
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
20 changed files with 1461 additions and 120 deletions
+48
View File
@@ -2,6 +2,54 @@
All notable changes to this project will be documented in this file.
## [0.55.1] - 2026-09-19
### Bug Fixes
- *(ci)* Resolve dep-PR container digest via registry v2 API
## [0.55.0] - 2026-09-19
### Features
- *(ci)* Retry dep-PR container verification until publish lands
## [0.54.0] - 2026-09-19
### Features
- *(ci)* Dep PRs carry target-repo task ID and generated spec
## [0.53.3] - 2026-09-19
### Bug Fixes
- *(ci)* Check out API-created dep branch via remote-tracking ref
## [0.53.2] - 2026-09-19
### Bug Fixes
- *(ci)* Create dep-PR branches via POST /branches
## [0.53.1] - 2026-09-19
### Bug Fixes
- *(ci)* Handle list-shaped ref response in create_dependency_pr
## [0.53.0] - 2026-09-19
### Features
- *(ci)* Manifest-aware dependency PRs and cleanup protection
## [0.52.0] - 2026-09-19
### Features
- *(ci)* Verify producer container artifact before opening dependency PR
## [0.51.12] - 2026-09-19
### 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/ee346d97986c30678c5a0dc49ce50f95d9731a1c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/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.12",
"devx>=0.55.1",
]
[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.12"`) or use a version constraint
> (for example, `"devx>=0.51.12,<0.52"`).
> `dependencies` (for example, `"devx==0.55.1"`) or use a version constraint
> (for example, `"devx>=0.55.1,<0.56"`).
### 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/ee346d97986c30678c5a0dc49ce50f95d9731a1c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ee346d97986c30678c5a0dc49ce50f95d9731a1c/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.12",
"devx>=0.55.1",
]
[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.12"` or `"devx>=0.51.12,<0.52"`.
Pin a specific version if needed: `"devx==0.55.1"` or `"devx>=0.55.1,<0.56"`.
### Optional extras
@@ -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
View File
@@ -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
+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
+35
View File
@@ -0,0 +1,35 @@
# DEVX-168: Fix create_dependency_pr master-ref parsing
## Problem
`devx.ci.create_dependency_pr` crashes with
`AttributeError: 'list' object has no attribute 'get'` when creating the
dependency branch. Gitea's `GET /repos/{o}/{r}/git/refs/heads/master`
returns a JSON **array** of matching refs, not a single object. Observed
in production when sso-bridge v0.4.1's post-merge dep-PR step ran.
## Approach
REQ-1: Normalize the ref response — if it is a list, select the entry
whose `ref` field equals `refs/heads/master` or fall back to the first
entry; proceed to extract `object.sha` as before. An empty or absent
SHA still fails closed.
## Test Plan
- Unit test: list-shaped response resolves SHA and creates the PR.
- Unit test: empty list fails with the master-SHA error message.
## Deploy Plan
devx releases as a version tag; sso-bridge/infra pin bumps pick it up via
their normal dep-PR flow.
## Rollback Plan
Revert the commit; dep-PR creation stays broken on Gitea (status quo).
## Acceptance Criteria
- [x] REQ-1: list-shaped ref response is handled; SHA extraction works;
empty list still errors. Covered by unit tests at 100% coverage.
+33
View File
@@ -0,0 +1,33 @@
# DEVX-169: create_dependency_pr uses branches API
## Problem
After DEVX-168, dep-PR creation fails at `POST /git/refs` with
`HTTP 405 Method Not Allowed` — this Gitea version does not implement the
createRef endpoint. Observed creating the sso_bridge 0.4.1 infra
dependency PR.
## Approach
REQ-1: Create the dependency branch via `POST /branches` with
`new_branch_name`/`old_branch_name` (from master). An already-exists
error is tolerated; other API errors fail closed.
## Test Plan
- Branch creation calls `POST /branches` with the expected payload.
- 422 already-exists is tolerated and the PR is still created.
- Other API errors abort with a branch-creation failure.
## Deploy Plan
devx release tag; producers pick it up via pin bumps.
## Rollback Plan
Revert; dep-PR creation stays broken (status quo).
## Acceptance Criteria
- [x] REQ-1: branch creation uses the branches API; already-exists
tolerated; failures surface. Covered by unit tests at 100% coverage.
+33
View File
@@ -0,0 +1,33 @@
# DEVX-170: create_dependency_pr checks out the API-created branch
## Problem
After DEVX-169 the dependency branch is created via `POST /branches`, but
`git fetch origin <branch>` only populates `FETCH_HEAD`. The follow-up
`git checkout <branch>` fails silently (`check=False`), commits land on
the wrong ref, and `git push origin <branch>` fails with
`src refspec does not match any`. Observed creating the sso_bridge 0.4.1
infra dependency PR.
## Approach
REQ-1: Fetch the API-created branch into `refs/remotes/origin/<branch>`
and force-create the local branch with `git checkout -B <branch>
origin/<branch>`, both with `check=True` so failures surface.
## Test Plan
- Unit test asserts the fetch refspec and `checkout -B` invocations.
## Deploy Plan
devx release tag; producers pick it up via pin bumps.
## Rollback Plan
Revert; dep-PR creation stays broken (status quo).
## Acceptance Criteria
- [x] REQ-1: the clone checks out the API-created branch so commit and
push target `deps/<pkg>-<version>`; covered by unit tests.
+36
View File
@@ -0,0 +1,36 @@
# DEVX-171: create_dependency_pr uses target-project task ID + generated spec
## Problem
Dep PRs fail target-repo validation: infra requires `OBL-INFRA-N` in the
branch name and `docs/specs/<TASK>.md` in the diff. The tool created the
Vikunja task in the *producer's* project and named the branch
`deps/<pkg>-<ver>` with no task ID, and wrote no spec.
## Approach
REQ-1: Create the tracking task before branch creation, in the target
repo's Vikunja project via `--task-project-id` (default:
DEVX_VIKUNJA_PROJECT_ID). Embed the returned identifier in the branch
name (`deps/<TASK>-<pkg>-<ver>`) and PR title, and commit a generated
`docs/specs/<TASK>.md` with the required sections alongside the pin bump.
## Test Plan
- Branch name and PR title carry the target task ID; spec file added.
- `create_vikunja_task(project_id=N)` passes N to VikunjaClient.
- Existing no-task behavior preserved when Vikunja is unreachable.
## Deploy Plan
devx release tag; producer workflows pass `--task-project-id` for infra
(project 3).
## Rollback Plan
Revert; dep PRs keep failing target validation (status quo).
## Acceptance Criteria
- [x] REQ-1: `--task-project-id` supported; branch/spec/PR title embed
the target task ID; covered by unit tests at 100% coverage.
+32
View File
@@ -0,0 +1,32 @@
# DEVX-172: dep-PR retries container verification until publish lands
## Problem
Post-merge dep-PR runs concurrently with the producer's image-build
workflow. `--verify-container` hits HTTP 404 before the push lands and
skips PR creation — observed for sso-bridge v0.4.1 and v0.4.3.
## Approach
REQ-1: `resolve_container_digest(..., timeout_s)` retries a 404 lookup
every 15s until the deadline.
REQ-2: `--verify-timeout` CLI option (default 600s, 0 disables) wires the
retry into the dep-PR step.
## Test Plan
- Unit test: 404-then-200 resolves the digest without raising.
- Existing no-retry path (timeout_s=0) still fails immediately.
## Deploy Plan
devx release tag; producers inherit the 600s default on next pin bump.
## Rollback Plan
Revert; dep-PR verification fails fast on 404 again (status quo).
## Acceptance Criteria
- [x] REQ-1: 404 responses retry until `timeout_s` deadline.
- [x] REQ-2: `--verify-timeout` option exposed, default 600.
+49
View File
@@ -0,0 +1,49 @@
# DEVX-173: Resolve container digest via registry v2 API
## Problem
`resolve_container_digest` returns the sha256 of the `manifest.json`
blob listed by the Gitea packages API (`/packages/<owner>/container/
<name>/<tag>/files`). That blob digest is NOT the OCI manifest digest —
`docker pull repo@sha256:<blob>` fails with "not found." Production
deploy run 6251 died pulling `sso-bridge@sha256:5ca9...` which the dep
PR had recorded in `deploy/sso-bridge-release.json`. The registry serves
0.4.1 as `sha256:c0212ed1...` — different digest entirely.
## Approach
REQ-1: Keep the packages-API call as the existence check (it has the
404-retry semantics needed for the publish race) but resolve the
pullable digest via the registry v2 API: `GET /v2/token` with
`scope=repository:<owner>/<name>:pull` (basic-auth with the Gitea
token), then `GET /v2/<owner>/<name>/manifests/<tag>` with OCI/Docker
manifest Accept headers and read `Docker-Content-Digest`. Registry base
URL is derived from `GITEA_API_URL` (strip `/api/v1`).
REQ-2: If the v2 digest lookup fails (non-2xx, missing header), fail
closed with a ClickException — never record a blob sha256 as a pullable
digest.
## Test Plan
- Mocked v2 token + manifest endpoints return digest; recorded value is
the `Docker-Content-Digest` header.
- 404 retry semantics on the packages-API existence check unchanged.
- Missing digest header / non-2xx manifest response → ClickException.
- Unit tests cover token request scope and Accept headers.
## Deploy Plan
devx release tag; the sso-bridge dep-PR pin bump follows in its own PR.
The wrong digest already recorded in infra's manifest is corrected by
re-running the dep-PR with the fixed version.
## Rollback Plan
Revert; dep-PR records the (unpullable) blob digest again — deploys must
then use tag pulls until a corrected manifest lands.
## Acceptance Criteria
- [x] REQ-1: Digest resolved from `Docker-Content-Digest` via v2 API.
- [x] REQ-2: Lookup failures fail closed; no blob-sha256 fallback.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.51.12",
"devx>=0.55.1",
]
[project.optional-dependencies]
dev = [
"devx>=0.51.12",
"devx>=0.55.1",
]
```
+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.12"
__version__ = "0.55.1"
+316 -27
View File
@@ -22,9 +22,12 @@ from __future__ import annotations
import re
import subprocess # nosec B404
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
import click
import requests
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
@@ -93,7 +96,159 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve
return changed
def create_vikunja_task(title: str, description: str) -> str | None:
_MANIFEST_ACCEPT = (
"application/vnd.oci.image.index.v1+json, "
"application/vnd.docker.distribution.manifest.list.v2+json, "
"application/vnd.oci.image.manifest.v1+json, "
"application/vnd.docker.distribution.manifest.v2+json"
)
# Implements: REQ-1 — existence check via packages API (keeps the 404-retry
# semantics for the publish race); the pullable digest is then resolved via
# the registry v2 API's Docker-Content-Digest header.
def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str:
"""Verify the container tag exists, then resolve its pullable OCI digest.
The packages API proves the tag was published (and 404s while the
producer's image-build workflow races us — ``timeout_s`` retries every
15s). The packages-API ``manifest.json`` blob sha256 is NOT pullable
via ``repo@sha256:...``, so the digest comes from the registry v2
``Docker-Content-Digest`` header instead.
"""
url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files"
headers = {"Authorization": f"token {token}"}
deadline = time.monotonic() + timeout_s
while True:
try:
resp = requests.get(url, headers=headers, timeout=30) # nosec B310
resp.raise_for_status()
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404 and time.monotonic() < deadline:
click.echo(
_(
"[dep-pr] {owner}/{name}:{tag} not published yet — retrying.",
owner=owner,
name=name,
tag=tag,
)
)
time.sleep(15)
continue
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
break
return _resolve_registry_digest(api_url, owner, name, tag, token)
# Implements: REQ-2 — v2 digest resolution fails closed: non-2xx, missing
# token, or absent Docker-Content-Digest all raise; a blob sha256 is never
# recorded as a pullable digest.
def _resolve_registry_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str:
registry = api_url.removesuffix("/api/v1").removesuffix("/")
repo = f"{owner}/{name}"
try:
tok_resp = requests.get( # nosec B310
f"{registry}/v2/token",
params={"service": "container_registry", "scope": f"repository:{repo}:pull"},
auth=("ci", token),
timeout=30,
)
tok_resp.raise_for_status()
bearer = tok_resp.json().get("token", "")
man_resp = requests.get( # nosec B310
f"{registry}/v2/{repo}/manifests/{tag}",
headers={"Authorization": f"Bearer {bearer}", "Accept": _MANIFEST_ACCEPT},
timeout=30,
)
man_resp.raise_for_status()
except requests.RequestException as e:
status = getattr(getattr(e, "response", None), "status_code", "?")
raise click.ClickException(
_(
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
repo=repo,
tag=tag,
status=status,
error=e,
)
) from e
digest = man_resp.headers.get("Docker-Content-Digest", "")
if not digest:
raise click.ClickException(
_("Registry returned no Docker-Content-Digest for {repo}:{tag}.", repo=repo, tag=tag)
)
return digest
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, project_id: int = 0) -> str | None:
"""Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531)."""
try:
token = get_vikunja_token()
@@ -103,7 +258,7 @@ def create_vikunja_task(title: str, description: str) -> str | None:
from devx.api_clients import VikunjaClient
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.create_task(VIKUNJA_PROJECT_ID, title=title, description=description)
task = client.create_task(project_id or VIKUNJA_PROJECT_ID, title=title, description=description)
return str(task.get("identifier", ""))
@@ -113,6 +268,55 @@ 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(
"--container-tag",
default="",
help=_(
"Container tag to verify with --verify-container (default: --new-version). "
"Use when the image tag differs from the release version, e.g. a package "
"__version__ tag vs a release git tag."
),
)
@click.option(
"--verify-timeout",
type=int,
default=600,
help=_(
"Seconds to keep retrying --verify-container while the artifact returns 404 "
"(the image build races this step). 0 disables retries."
),
)
@click.option(
"--task-project-id",
type=int,
default=0,
help=_(
"Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). "
"Use the target repo's project so the generated branch/spec satisfy its validation."
),
)
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
def cli(
repo: str,
@@ -120,6 +324,12 @@ def cli(
new_version: str,
source_repo: str,
source_run_id: str,
manifest_path: str,
verify_container: str,
source_ref: str,
container_tag: str,
verify_timeout: int,
task_project_id: int,
dry_run: bool,
) -> None:
"""Create an infra PR to bump a pinned dependency version."""
@@ -129,9 +339,29 @@ def cli(
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Implements: REQ-1 — this tool runs from the *producer* repo's CI, so
# every file lookup and git operation must happen inside a clone of the
# target repo, not the producer checkout in CWD.
# Implements: REQ-1 — verify the producer artifact exists and resolve its
# digest before any branch/PR work begins.
image_digest = ""
if verify_container:
if "/" not in verify_container:
raise click.ClickException(
_("Invalid container format: {container} (expected owner/name)", container=verify_container)
)
c_owner, c_name = verify_container.split("/", 1)
image_tag = container_tag or new_version
image_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, image_tag, token, verify_timeout)
click.echo(
_(
"[dep-pr] Verified {container}:{version} -> {digest}",
container=verify_container,
version=image_tag,
digest=image_digest,
)
)
# Clone the target repo — this tool runs from the *producer* repo's CI,
# so every file lookup and git operation must happen inside a clone of
# the target repo, not the producer checkout in CWD.
workdir = Path(tempfile.mkdtemp(prefix="dep-pr-"))
clone_url = f"{GITEA_API_URL.removesuffix('/api/v1')}/{repo}.git"
auth_cfg = f"http.extraHeader=Authorization: token {token}"
@@ -144,6 +374,10 @@ 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:
@@ -174,8 +408,20 @@ def cli(
click.echo(f"[dep-pr] DRY RUN: would update {changed_file} and create PR")
return
# Create a branch
branch_name = f"deps/{package}-{new_version}"
# Implements: REQ-1 — create the tracking task in the *target* repo's
# Vikunja project so its identifier satisfies the target's branch/PR-title
# validation (e.g., OBL-INFRA-N for oblachno/infra).
task_title = f"Bump {package} to {new_version}"
task_desc = (
f"<p>Auto-created dependency bump PR.</p>"
f"<p>Package: {package}</p>"
f"<p>Version: {old_version}{new_version}</p>"
f"<p>Source: {source_repo} (run #{source_run_id})</p>"
)
task_id = create_vikunja_task(task_title, task_desc, task_project_id)
# Create a branch — embed the task ID so target-repo validation accepts it.
branch_name = f"deps/{task_id}-{package}-{new_version}" if task_id else f"deps/{package}-{new_version}"
base_branch = "master"
# Check for existing PR (reuse from tools.create_pr)
@@ -186,25 +432,78 @@ def cli(
# Create branch via API
try:
master_ref = client._request("GET", "/git/refs/heads/master").json()
master_sha = master_ref.get("object", {}).get("sha", "")
if not master_sha:
raise click.ClickException("Could not get master SHA")
client._request("POST", "/git/refs", json={"ref": f"refs/heads/{branch_name}", "sha": master_sha})
# Implements: REQ-1 — Gitea lacks POST /git/refs; create the branch
# from master via the branches API.
client._request(
"POST",
"/branches",
json={"new_branch_name": branch_name, "old_branch_name": base_branch},
)
except APIError as e:
if "already exists" in str(e).lower():
click.echo(f"[dep-pr] Branch {branch_name} already exists")
else:
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
# Check out the API-created branch inside the target clone.
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
# Check out the API-created branch inside the target clone. A plain
# fetch only populates FETCH_HEAD — fetch into the remote-tracking ref
# and force-create the local branch from it.
subprocess.run( # nosec B603 B607
["git", "fetch", "origin", f"{branch_name}:refs/remotes/origin/{branch_name}"],
check=True,
capture_output=True,
cwd=workdir,
)
subprocess.run( # nosec B603 B607
["git", "checkout", "-B", branch_name, f"origin/{branch_name}"],
check=True,
capture_output=True,
cwd=workdir,
)
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))
subprocess.run(["git", "add", changed_file], check=True, cwd=workdir) # nosec B603 B607
assert changed_file is not None # nosec B101 — narrowed by the early exit above
add_files = [changed_file]
if task_id:
# Implements: REQ-1 — spec-driven validation requires a spec file.
spec_rel = f"docs/specs/{task_id}.md"
spec_file = workdir / spec_rel
spec_file.parent.mkdir(parents=True, exist_ok=True)
spec_file.write_text(
f"# {task_id}: {task_title}\n\n"
f"## Problem\n\n"
f"{source_repo} released {package} {new_version}; this repo pins {old_version}.\n\n"
f"## Approach\n\n"
f"REQ-1: Update `{changed_file}` to pin {package} {new_version} "
f"(auto-generated dependency PR).\n\n"
f"## Test Plan\n\n"
f"- Producer release CI verified the artifact "
f"({source_repo} run #{source_run_id or 'n/a'}).\n\n"
f"## Deploy Plan\n\n"
f"Merge updates the pin; the next deploy applies it.\n\n"
f"## Rollback Plan\n\n"
f"Revert the pin bump.\n\n"
f"## Acceptance Criteria\n\n"
f"- [x] REQ-1: `{changed_file}` pins {package} {new_version}.\n",
encoding="utf-8",
)
add_files.append(spec_rel)
subprocess.run(["git", "add", *add_files], 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
subprocess.run( # nosec B603 B607
@@ -213,16 +512,6 @@ def cli(
cwd=workdir,
)
# Create Vikunja task for tracking
task_title = f"Bump {package} to {new_version}"
task_desc = (
f"<p>Auto-created dependency bump PR.</p>"
f"<p>Package: {package}</p>"
f"<p>Version: {old_version}{new_version}</p>"
f"<p>Source: {source_repo} (run #{source_run_id})</p>"
)
task_id = create_vikunja_task(task_title, task_desc)
# Create PR directly (dependency PRs have custom titles, not Vikunja-derived)
pr_title = f"{task_id}: {task_title}" if task_id else task_title
pr_body = (
+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)))
+128
View File
@@ -1343,6 +1343,30 @@
"ru": "Настройка входа tea '{name}' для {url}...",
"zh": "正在为 {url} 配置 tea 登录 '{name}'..."
},
"Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.": {
"bg": "Артефактът на контейнера {owner}/{name}:{tag} не е намерен или не може да бъде прочетен (HTTP {status}). Отказвам да отворя PR за зависимост за непубликуван артефакт.",
"de": "Container-Artefakt {owner}/{name}:{tag} nicht gefunden oder nicht lesbar (HTTP {status}). Kein Dependency-PR für ein unveröffentlichtes Artefakt.",
"en": "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.",
"pl": "Artefakt kontenera {owner}/{name}:{tag} nie został znaleziony lub jest nieczytelny (HTTP {status}). Odmawiam otwarcia PR zależności dla nieopublikowanego artefaktu.",
"ru": "Артефакт контейнера {owner}/{name}:{tag} не найден или недоступен для чтения (HTTP {status}). Отказ открывать PR зависимости для неопубликованного артефакта.",
"zh": "容器构件 {owner}/{name}:{tag} 未找到或不可读 (HTTP {status})。拒绝为未发布的构件创建依赖 PR。"
},
"Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.": {
"bg": "Таг на контейнер за проверка с --verify-container (по подразбиране: --new-version). Използвайте, когато тагът на изображението се различава от версията на изданието, напр. таг на __version__ на пакет срещу git таг на издание.",
"de": "Container-Tag, der mit --verify-container geprüft wird (Standard: --new-version). Zu verwenden, wenn sich das Image-Tag von der Release-Version unterscheidet, z. B. ein __version__-Tag eines Pakets vs. ein Release-Git-Tag.",
"en": "Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.",
"pl": "Tag kontenera do weryfikacji z --verify-container (domyślnie: --new-version). Użyj, gdy tag obrazu różni się od wersji wydania, np. tag __version__ pakietu a tag git wydania.",
"ru": "Тег контейнера для проверки с --verify-container (по умолчанию: --new-version). Используйте, когда тег образа отличается от версии релиза, например тег __version__ пакета против git-тега релиза.",
"zh": "用 --verify-container 验证的容器标签(默认:--new-version)。当镜像标签与发布版本不同时使用,例如包的 __version__ 标签与发布 git 标签。"
},
"Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version 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 +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",
@@ -1991,6 +2023,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 +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}",
@@ -2079,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",
@@ -2671,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).",
@@ -2887,6 +2959,30 @@
"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 Docker-Content-Digest for {repo}:{tag}.": {
"bg": "Регистърът не върна Docker-Content-Digest за {repo}:{tag}.",
"de": "Registry hat keinen Docker-Content-Digest für {repo}:{tag} zurückgegeben.",
"en": "Registry returned no Docker-Content-Digest for {repo}:{tag}.",
"pl": "Rejestr nie zwrócił Docker-Content-Digest dla {repo}:{tag}.",
"ru": "Реестр не вернул Docker-Content-Digest для {repo}:{tag}.",
"zh": "注册表未返回 {repo}:{tag} 的 Docker-Content-Digest。"
},
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}": {
"bg": "Неуспешна заявка за дайджест към регистър v2 за {repo}:{tag} (HTTP {status}): {error}",
"de": "Registry-v2-Digest-Abfrage für {repo}:{tag} fehlgeschlagen (HTTP {status}): {error}",
"en": "Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
"pl": "Zapytanie o skrót do rejestru v2 dla {repo}:{tag} nie powiodło się (HTTP {status}): {error}",
"ru": "Ошибка запроса дайджеста к реестру v2 для {repo}:{tag} (HTTP {status}): {error}",
"zh": "注册表 v2 摘要查询 {repo}:{tag} 失败 (HTTP {status}){error}"
},
"Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
"de": "Regulärer Merge-Commit — alle Post-Merge-Jobs werden ausgeführt.",
@@ -3071,6 +3167,14 @@
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
},
"Seconds to keep retrying --verify-container while the artifact returns 404 (the image build races this step). 0 disables retries.": {
"bg": "Секунди за повторни опити на --verify-container, докато артефактът връща 404 (компилацията на изображението е конкурентна). 0 изключва повторните опити.",
"de": "Sekunden, die --verify-container bei HTTP 404 weiter versucht wird (der Image-Build läuft parallel). 0 deaktiviert Wiederholungen.",
"en": "Seconds to keep retrying --verify-container while the artifact returns 404 (the image build races this step). 0 disables retries.",
"pl": "Sekundy ponawiania --verify-container, gdy artefakt zwraca 404 (budowa obrazu jest współbieżna). 0 wyłącza ponawianie.",
"ru": "Секунды повторных попыток --verify-container, пока артефакт возвращает 404 (сборка образа идёт параллельно). 0 отключает повторы.",
"zh": "当构件返回 404 时 --verify-container 的重试秒数(镜像构建与此步骤并行)。0 禁用重试。"
},
"Show what would be done without creating PR": {
"bg": "Покажи какво би било направено без създаване на PR",
"de": "Zeigen, was getan würde, ohne PR zu erstellen",
@@ -3407,6 +3511,14 @@
"ru": "Ошибка Vikunja API (HTTP {status}): {message}. Задача {task_id} НЕ была обновлена. Слияние прошло успешно, но задачу Vikunja нужно обновить вручную.",
"zh": "Vikunja API 错误(HTTP {status}):{message}。任务 {task_id} 未更新。合并成功,但 Vikunja 任务需要手动更新。"
},
"Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). Use the target repo's project so the generated branch/spec satisfy its validation.": {
"bg": "Vikunja проект за задачата за проследяване (по подразбиране: DEVX_VIKUNJA_PROJECT_ID). Използвайте проекта на целевото хранилище, за да отговарят генерираните клон/спецификация на неговата валидация.",
"de": "Vikunja-Projekt für die Tracking-Aufgabe (Standard: DEVX_VIKUNJA_PROJECT_ID). Verwenden Sie das Projekt des Ziel-Repos, damit generierter Branch/Spec dessen Validierung bestehen.",
"en": "Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). Use the target repo's project so the generated branch/spec satisfy its validation.",
"pl": "Projekt Vikunja dla zadania śledzącego (domyślnie: DEVX_VIKUNJA_PROJECT_ID). Użyj projektu docelowego repozytorium, aby wygenerowana gałąź/specyfikacja przeszła jego walidację.",
"ru": "Проект Vikunja для задачи отслеживания (по умолчанию: DEVX_VIKUNJA_PROJECT_ID). Используйте проект целевого репозитория, чтобы сгенерированные ветка/спецификация прошли его валидацию.",
"zh": "用于跟踪任务的 Vikunja 项目(默认:DEVX_VIKUNJA_PROJECT_ID)。使用目标仓库的项目,以便生成的分支/规范通过其验证。"
},
"Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.": {
"bg": "Заглавието на задачата във Vikunja '{title}' започва с '{prefix}:'. Заглавието на задачата НЕ трябва да съдържа префикса '{prefix}' — той се добавя автоматично към заглавието на PR. Актуализирайте заглавието на задачата във Vikunja, за да премахнете префикса.",
"de": "Der Vikunja-Aufgabentitel '{title}' beginnt mit '{prefix}:'. Der Aufgabentitel darf NICHT den Präfix '{prefix}' enthalten — er wird automatisch zum PR-Titel hinzugefügt. Aktualisieren Sie den Vikunja-Aufgabentitel, um den Präfix zu entfernen.",
@@ -3727,6 +3839,22 @@
"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] {owner}/{name}:{tag} not published yet — retrying.": {
"bg": "[dep-pr] {owner}/{name}:{tag} още не е публикуван — повторен опит.",
"de": "[dep-pr] {owner}/{name}:{tag} noch nicht veröffentlicht — neuer Versuch.",
"en": "[dep-pr] {owner}/{name}:{tag} not published yet — retrying.",
"pl": "[dep-pr] {owner}/{name}:{tag} jeszcze nie opublikowano — ponawianie.",
"ru": "[dep-pr] {owner}/{name}:{tag} ещё не опубликован — повторная попытка.",
"zh": "[dep-pr] {owner}/{name}:{tag} 尚未发布 — 正在重试。"
},
"[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."""
+494
View File
@@ -188,3 +188,497 @@ class TestCreateVikunjaTask:
mock_client.create_task.return_value = {"identifier": "OBL-INFRA-999"}
result = create_vikunja_task("Test", "desc")
assert result == "OBL-INFRA-999"
def test_explicit_project_id_used(self) -> None:
with (
patch("devx.ci.create_dependency_pr.get_vikunja_token", return_value="fake-token"),
patch("devx.api_clients.VikunjaClient") as mock_client_cls,
):
mock_client = mock_client_cls.return_value
mock_client.create_task.return_value = {"identifier": "OBL-INFRA-1"}
create_vikunja_task("Test", "desc", project_id=3)
assert mock_client.create_task.call_args.args[0] == 3
def _v2_mocks(digest: str = "sha256:deadbeef") -> list[MagicMock]:
"""Token + manifest responses for the registry v2 digest lookup."""
tok = MagicMock()
tok.raise_for_status = MagicMock()
tok.json.return_value = {"token": "bearer-tok"}
man = MagicMock()
man.raise_for_status = MagicMock()
man.headers = {"Docker-Content-Digest": digest}
return [tok, man]
class TestResolveContainerDigest:
"""REQ-1: existence check via packages API; digest via registry v2."""
def test_returns_digest_from_registry_v2(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = [{"name": "manifest.json", "sha256": "blob-not-pullable"}]
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, *_v2_mocks()],
) as mock_get:
digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
)
assert digest == "sha256:deadbeef"
man_call = mock_get.call_args_list[2]
assert "manifests/0.9.1" in man_call.args[0]
assert "oci.image.index" in man_call.kwargs["headers"]["Accept"]
tok_call = mock_get.call_args_list[1]
assert tok_call.kwargs["params"]["scope"] == "repository:oblachno/sso-bridge:pull"
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_v2_digest_missing(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = []
tok, man = _v2_mocks(digest="")
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="no Docker-Content-Digest"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_when_v2_manifest_request_fails(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = []
tok, _ = _v2_mocks()
err = requests.HTTPError("500")
err.response = MagicMock(status_code=500)
man = MagicMock()
man.raise_for_status.side_effect = err
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="v2 digest lookup failed"):
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
@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_container_tag_overrides_new_version(
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_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",
"--container-tag",
"0.2.4",
],
)
assert result.exit_code == 0
args = mock_digest.call_args[0]
assert args[1:4] == ("oblachno", "sso-bridge", "0.2.4")
class TestBranchCreation:
"""Branch creation uses the branches API (Gitea lacks POST /git/refs)."""
def _invoke(self) -> object:
runner = CliRunner()
return runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
],
)
def _client(self, mock_client_cls: MagicMock) -> MagicMock:
client = mock_client_cls.return_value
client.create_pr.return_value = {"number": 1}
return client
@patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None)
@patch("devx.ci.create_dependency_pr.update_manifest", return_value=True)
@patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0")
@patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None)
@patch("devx.ci.create_dependency_pr.GiteaClient")
@patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok")
def test_creates_branch_via_branches_api(
self,
_token: MagicMock,
mock_client_cls: MagicMock,
_find: MagicMock,
_read: MagicMock,
_update: MagicMock,
_task: MagicMock,
) -> None:
client = self._client(mock_client_cls)
result = self._invoke()
assert result.exit_code == 0
assert "Created PR" in result.output
post = client._request.call_args
assert post.args[:2] == ("POST", "/branches")
assert post.kwargs["json"] == {
"new_branch_name": "deps/sso_bridge-0.9.1",
"old_branch_name": "master",
}
@patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None)
@patch("devx.ci.create_dependency_pr.update_manifest", return_value=True)
@patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0")
@patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None)
@patch("devx.ci.create_dependency_pr.GiteaClient")
@patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok")
def test_existing_branch_tolerated(
self,
_token: MagicMock,
mock_client_cls: MagicMock,
_find: MagicMock,
_read: MagicMock,
_update: MagicMock,
_task: MagicMock,
) -> None:
from devx.exceptions import APIError
client = self._client(mock_client_cls)
client._request.side_effect = APIError(422, "branch already exists")
result = self._invoke()
assert result.exit_code == 0
assert "already exists" in result.output.lower()
@patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None)
@patch("devx.ci.create_dependency_pr.update_manifest", return_value=True)
@patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0")
@patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None)
@patch("devx.ci.create_dependency_pr.GiteaClient")
@patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok")
def test_branch_api_error_fails(
self,
_token: MagicMock,
mock_client_cls: MagicMock,
_find: MagicMock,
_read: MagicMock,
_update: MagicMock,
_task: MagicMock,
) -> None:
from devx.exceptions import APIError
client = self._client(mock_client_cls)
client._request.side_effect = APIError(500, "boom")
result = self._invoke()
assert result.exit_code != 0
assert "Failed to create branch" in result.output
@patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None)
@patch("devx.ci.create_dependency_pr.update_manifest", return_value=True)
@patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0")
@patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None)
@patch("devx.ci.create_dependency_pr.GiteaClient")
@patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok")
def test_checks_out_remote_tracking_branch(
self,
_token: MagicMock,
mock_client_cls: MagicMock,
_find: MagicMock,
_read: MagicMock,
_update: MagicMock,
_task: MagicMock,
) -> None:
import subprocess
self._client(mock_client_cls)
result = self._invoke()
assert result.exit_code == 0
calls = [c.args[0] for c in subprocess.run.call_args_list]
assert ["git", "fetch", "origin", "deps/sso_bridge-0.9.1:refs/remotes/origin/deps/sso_bridge-0.9.1"] in calls
assert ["git", "checkout", "-B", "deps/sso_bridge-0.9.1", "origin/deps/sso_bridge-0.9.1"] in calls
@patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value="OBL-INFRA-581")
@patch("devx.ci.create_dependency_pr.update_manifest", return_value=True)
@patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0")
@patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None)
@patch("devx.ci.create_dependency_pr.GiteaClient")
@patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok")
def test_task_id_in_branch_and_spec_committed(
self,
_token: MagicMock,
mock_client_cls: MagicMock,
_find: MagicMock,
_read: MagicMock,
_update: MagicMock,
mock_task: MagicMock,
) -> None:
import subprocess
client = self._client(mock_client_cls)
result = self._invoke()
assert result.exit_code == 0
# Branch embeds the target-repo task ID
post = client._request.call_args
assert post.kwargs["json"]["new_branch_name"] == "deps/OBL-INFRA-581-sso_bridge-0.9.1"
# Spec file added alongside the manifest change
add = next(c for c in subprocess.run.call_args_list if c.args[0][:2] == ["git", "add"])
assert "docs/specs/OBL-INFRA-581.md" in add.args[0]
# PR title carries the task ID
assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1"
mock_task.assert_called_once()
def test_retries_404_until_published(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
err = requests.HTTPError("404")
err.response = MagicMock(status_code=404)
fail = MagicMock()
fail.raise_for_status.side_effect = err
ok = MagicMock()
ok.raise_for_status = MagicMock()
ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}]
with (
patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok, *_v2_mocks("sha256:cafe")]),
patch("devx.ci.create_dependency_pr.time.sleep"),
):
digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok", timeout_s=60
)
assert digest == "sha256:cafe"