Compare commits

..
17 Commits
Author SHA1 Message Date
devx-ci-bot b85fa86ccd release: v0.55.4 [skip ci] 2026-09-23 08:10:56 +00:00
kireto ca55cfd29a DEVX-177: fix: fail-open molecule selection and honest fast-path contract
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 1m3s
2026-09-23 08:10:19 +00:00
gitea-actions-bot 6d3622465e chore: update badge URLs to commit 81d1d948 [skip ci] 2026-09-21 19:39:47 +00:00
emil 8633980062 DEVX-176: ci: docs fast-path, notify-failure scoping, post-merge queue
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 55s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-09-21 19:38:37 +00:00
gitea-actions-bot 693e7962da chore: update badge URLs to commit 22bda97e [skip ci] 2026-09-21 19:37:21 +00:00
devx-ci-bot 9408111088 release: v0.55.3 [skip ci] 2026-09-21 19:36:44 +00:00
emil 349057bf5d DEVX-175: fix: honor repo bandit config in badge quality check
Post-merge / release-and-maintain (push) Successful in 1m5s
Post-merge / detect-and-configure (push) Successful in 13s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-09-21 19:36:00 +00:00
gitea-actions-bot 3b2a278f87 chore: update badge URLs to commit b5c21b71 [skip ci] 2026-09-19 20:57:01 +00:00
devx-ci-bot 127f781076 release: v0.55.2 [skip ci] 2026-09-19 20:56:24 +00:00
kireto d41972093b DEVX-174: fix(ci): supply git identity for dep-PR manifest commits
Post-merge / release-and-maintain (push) Successful in 1m4s
Post-merge / detect-and-configure (push) Successful in 9s
2026-09-19 20:55:42 +00:00
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
23 changed files with 817 additions and 233 deletions
+25 -13
View File
@@ -33,21 +33,40 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Implements: REQ-1 (DEVX-176) — docs-only changes skip the heavy
# quality steps. Detection needs only git, so it runs before setup.
- name: Detect docs-only change
id: docs-only
if: github.event_name == 'pull_request'
run: |
HEAD="${{ github.event.pull_request.head.sha || github.sha }}"
DOCS_ONLY=true
while IFS= read -r f; do
case "$f" in
docs/*|*.md|.devin/*) ;;
*) DOCS_ONLY=false; break;;
esac
done < <(git diff --name-only "origin/master...$HEAD")
echo "docs-only=$DOCS_ONLY" >> "$GITHUB_OUTPUT"
echo "docs-only=$DOCS_ONLY"
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image
# --- quality steps ---
- name: Lint all
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
@@ -60,10 +79,12 @@ jobs:
export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check
- name: Translation completeness check
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
- name: Dependency security scan
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
@@ -71,6 +92,7 @@ jobs:
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
if: steps.docs-only.outputs.docs-only != 'true'
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
@@ -137,19 +159,9 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
# Implements: REQ-2 (DEVX-176) — no failure-issue step in PR CI;
# auto-created issues are for deploy-pipeline failures only
# (post-merge keeps its notification).
auto-merge:
# Auto-merge runs after validate passes. It reads the task ID
+3 -1
View File
@@ -24,7 +24,9 @@ on:
concurrency:
group: post-merge-${{ github.ref }}
cancel-in-progress: true
# Implements: REQ-3 (DEVX-176) — queue instead of killing an in-flight
# release/publish; a cancelled release can leave tag-without-publish.
cancel-in-progress: false
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
+1 -1
View File
@@ -172,7 +172,7 @@ Every change starts with a spec. No spec, no code.
**CI gates (pre-merge):**
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked
- `devx.ci.check_pr_size` — max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
- `devx.ci.fast_molecule`converge+verify only for changed roles, single platform
- `devx.ci.fast_molecule`full `molecule test` for changed roles only (scoped, single platform)
**Nightly (infra only):**
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
+30
View File
@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file.
## [0.55.4] - 2026-09-23
### Bug Fixes
- Fail-open molecule selection and honest fast-path contract
## [0.55.3] - 2026-09-21
### Bug Fixes
- Honor repo bandit config in badge quality check
## [0.55.2] - 2026-09-19
### Bug Fixes
- *(ci)* Supply git identity for dep-PR manifest commits
## [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
+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/a281b56d1ab5db23f55813a38900549d740ea527/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/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.54.0",
"devx>=0.55.4",
]
[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.54.0"`) or use a version constraint
> (for example, `"devx>=0.54.0,<0.55"`).
> `dependencies` (for example, `"devx==0.55.4"`) or use a version constraint
> (for example, `"devx>=0.55.4,<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/a281b56d1ab5db23f55813a38900549d740ea527/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a281b56d1ab5db23f55813a38900549d740ea527/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/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.54.0",
"devx>=0.55.4",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.54.0"` or `"devx>=0.54.0,<0.55"`.
Pin a specific version if needed: `"devx==0.55.4"` or `"devx>=0.55.4,<0.56"`.
### Optional extras
+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.
+31
View File
@@ -0,0 +1,31 @@
# DEVX-174: Set git identity in dep-PR target clone
## Problem
`create_dependency_pr` commits the manifest bump in a fresh clone of the
target repo. The clone has no `user.name`/`user.email`, so `git commit`
exits 128 ("Please tell me who you are") — observed in sso-bridge
post-merge run 6284 after the digest and manifest bump both succeeded.
## Approach
REQ-1: Pass a CI bot identity inline (`git -c user.name=... -c
user.email=... commit`) so the commit works in any environment without
mutating global git config.
## Test Plan
- Unit test asserting the commit invocation carries `-c user.name` /
`-c user.email` arguments.
## Deploy Plan
devx release; sso-bridge picks it up via its devx pin on next bump.
## Rollback Plan
Revert; dep-PR commit fails again in containers without git identity.
## Acceptance Criteria
- [x] REQ-1: dep-PR commit supplies explicit identity flags.
+40
View File
@@ -0,0 +1,40 @@
# DEVX-175: Badge generation must honor repo bandit configuration
## Problem
`collect_quality()` in `generate_badges.py` runs `bandit -r src/` with no
config file. Repos that declare a `[tool.bandit]` section in
`pyproject.toml` (skip lists aligned with infrastructure patterns — for
example sso-bridge skips B404/B603/B501) lint clean via `make lint-all`, but the
quality badge still reports `bandit: FAIL` — a false-FAIL badge that
contradicts the actual lint gate.
## Approach
REQ-1: When `pyproject.toml` exists in the target repo and contains a
`[tool.bandit]` section, pass `-c pyproject.toml` to the bandit invocation
in `collect_quality()`.
REQ-2: Repos without `[tool.bandit]` keep the current invocation unchanged.
## Test Plan
- Unit test: repo fixture with `[tool.bandit]` → command includes
`-c pyproject.toml`; without the section → plain invocation.
- Manual: `python -m devx.tools.generate_badges` in sso-bridge reports
`bandit: pass`.
## Deploy Plan
devx release → repos pick it up on next devx pin bump; no infra changes.
## Rollback Plan
Revert the commit; badges revert to unconfigured bandit runs.
## Acceptance Criteria
- [x] REQ-1 implemented with `# Implements: REQ-1` comment
- [x] REQ-2 preserved (no config → unchanged command)
- [x] Unit tests cover both branches
- [x] `make lint-all` + `make pytest-cov` pass in devx
+43
View File
@@ -0,0 +1,43 @@
# DEVX-176: CI hygiene — docs fast-path, failure-notify scoping, post-merge cancel
## Problem
devx CI has the same inefficiencies fixed in infra (OBL-INFRA-613/615/616):
docs-only PRs run the full quality suite (~10 min), CI failures auto-create
issues (noise — the user decided issues are for deploy failures only), and
post-merge `cancel-in-progress: true` can kill a release mid-publish.
## Approach
REQ-1: Docs-only PRs skip heavy validate steps (lint-all, unit tests,
test-speed, translation check, security scan, workflow dry-run). Docs gate,
spec validation, PR size, and auto-merge preconditions still run.
Restricted to pull_request events.
REQ-2: Remove the failure-issue step from `ci.yml` validate job.
Post-merge keeps failure notification (release/publish failures are
deploy-pipeline events).
REQ-3: post-merge `cancel-in-progress: false` — a new push queues instead
of killing an in-flight release/publish.
## Test Plan
- `make workflow-lint` passes.
- Docs-only PR: quality steps skipped, docs gate + spec + size + merge run.
- Non-docs PR: unchanged behavior.
## Deploy Plan
Workflow-only change; takes effect on merge. No release needed.
## Rollback Plan
Revert the commit.
## Acceptance Criteria
- [x] REQ-1 implemented — early docs-only step + step-level `if` gates
- [x] REQ-2 implemented — notify step removed from ci.yml only
- [x] REQ-3 implemented — post-merge concurrency flipped
- [x] `make workflow-lint` passes
+76
View File
@@ -0,0 +1,76 @@
# DEVX-177: Honest molecule selection — fail-open coverage and real command contract
## Problem
`devx.molecule.molecule_changed` and `devx.ci.fast_molecule` violate the
S09/REQ-9 honesty contract in three ways:
1. **Silent skips.** `detect_changed_roles` only knows four playbooks and
`ansible/roles/`. Changes to `restore.yml`, `deploy-sso-bridge.yml`,
`upgrade-postgres.yml`, `rolling-update-gitea.yml`,
`update-alertmanager.yml`, `build-image.yml`, `playbooks/_tasks/`,
`playbooks/tasks/`, or `ansible/group_vars/` trigger zero molecule
coverage — the fast path passes by skipping work.
2. **Phantom targets.** `ROLE_TARGET_MAP` includes `sso_config` (role
moved to the sso-bridge repo) and emits `molecule-crowdsec` /
`molecule-disk-cleanup` targets that do not exist in infra's Makefile.
Playbook mappings also inject absent roles, so `make molecule-changed`
can select nonexistent targets.
3. **Dishonest contract.** `fast_molecule` documents "converge + verify
only, no idempotence" and builds `molecule test -s X --destroy=never
--platform-name=...` commands that nothing executes — CI actually runs
full `molecule test -s X` via `run_molecule_scenario.py` (which does
include idempotence where the scenario defines it).
## Approach
REQ-1: Fail-open coverage in `detect_changed_roles` — add the missing
playbook→role mappings; `ansible/group_vars/**` and any other
`ansible/playbooks/**` file (including `_tasks/`/`tasks/`) not explicitly
mapped select all molecule-covered roles. `ansible/environments/` stays
unmapped (env data is covered by unit/deploy tests, not molecule) and is
documented as such.
REQ-2: Selection only emits roles that exist — `detect_changed_roles`
gains a `roles_dir` parameter; role names from file paths and playbook
maps are kept only when `<roles_dir>/<role>` exists. Shared-path and
fail-open "all roles" resolution returns only directories under
`roles_dir` containing a `molecule/` dir (untestable roles select
nothing rather than phantom targets).
REQ-3: Honest fast-molecule contract — `build_molecule_commands` emits
exactly what the CI runner executes (`molecule test -s <scenario>`);
docstrings state the real sequence (full `molecule test` per scenario on
changed roles, single platform as configured by the scenario) instead of
the old "converge + verify only" claim.
## Test Plan
- Update `test_molecule_changed.py`: unmapped playbook → all present
roles; `group_vars` change → all; mapped playbook → mapped roles only;
role absent from `roles_dir` → filtered out; shared path → only roles
with `molecule/` dirs.
- Update `test_fast_molecule.py`: emitted commands are `molecule test -s
<scenario>` with no `--destroy`/`--platform-name` flags.
- `make pytest-cov` (100% gate), `make lint-all`.
## Deploy Plan
Merge → devx release publishes automatically → infra dep-PR bumps the
pin; the honest selection takes effect on the next infra CI run.
## Rollback Plan
Revert the squash commit; previous (under-covering) selection returns —
acceptable short-term because coverage only widens with this change.
## Acceptance Criteria
- [x] REQ-1: All `ansible/playbooks/**` and `ansible/group_vars/**`
changes select molecule coverage; unmapped files fail open to all
testable roles.
- [x] REQ-2: No nonexistent roles or make targets are emitted; `sso_config`
no longer appears when absent from `roles_dir`.
- [x] REQ-3: `build_molecule_commands` output matches the executed CI
command shape; module docstrings describe the real sequence.
- [x] Unit tests cover every new behavior; 100% coverage maintained.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.54.0",
"devx>=0.55.4",
]
[project.optional-dependencies]
dev = [
"devx>=0.54.0",
"devx>=0.55.4",
]
```
+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.54.0"
__version__ = "0.55.4"
+117 -36
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import re
import subprocess # nosec B404
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -95,53 +96,108 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve
return changed
def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str:
"""Resolve the OCI digest for a container image tag via the packages API.
_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: dependency PRs must only be opened after the producer
artifact exists — this raises ClickException when the tag is missing or
the registry call fails, so the PR is never opened against an artifact
that has not been published. The sha256 of the stored ``manifest.json``
blob is the manifest content digest (what ``docker pull`` reports).
# 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:
resp = requests.get(url, headers=headers, timeout=30) # nosec B310
resp.raise_for_status()
except requests.HTTPError as e:
status = e.response.status_code if e.response is not None else "?"
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(
_(
"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,
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
repo=repo,
tag=tag,
status=status,
)
) from e
except requests.RequestException as e:
raise click.ClickException(
_(
"Registry lookup failed for {owner}/{name}:{tag}: {error}",
owner=owner,
name=name,
tag=tag,
error=e,
)
) from e
for f in resp.json():
if f.get("name") == "manifest.json" and f.get("sha256"):
return f"sha256:{f['sha256']}"
raise click.ClickException(
_(
"Registry returned no manifest blob for {owner}/{name}:{tag}.",
owner=owner,
name=name,
tag=tag,
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:
@@ -243,6 +299,15 @@ def create_vikunja_task(title: str, description: str, project_id: int = 0) -> st
"__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,
@@ -263,6 +328,7 @@ def cli(
verify_container: str,
source_ref: str,
container_tag: str,
verify_timeout: int,
task_project_id: int,
dry_run: bool,
) -> None:
@@ -283,7 +349,7 @@ def cli(
)
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)
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}",
@@ -439,7 +505,22 @@ def cli(
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
# Implements: REQ-1 — the fresh clone has no git identity in CI
# containers; supply it inline (same bot identity as push_badges).
subprocess.run( # nosec B603 B607
[
"git",
"-c",
"user.name=gitea-actions-bot",
"-c",
"user.email=actions@oblachno.fyi",
"commit",
"-m",
commit_msg,
],
check=True,
cwd=workdir,
)
subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "push", "origin", branch_name],
check=True,
+19 -14
View File
@@ -5,14 +5,19 @@
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
playbookrole mapping and shared infrastructure paths).
Fast molecule = converge + verify only, single platform, no idempotence
check. Used in pre-merge CI to get quick feedback on Ansible changes
without running the full molecule suite (which runs nightly).
Fast molecule = full ``molecule test`` for every scenario of each changed
role, on the scenario's configured platform. "Fast" means *scoped* (only
affected roles, single platform) never skipped phases: create, converge,
idempotence (when the scenario defines it), verify, and destroy all run,
exactly as ``scripts/run_molecule_scenario.py`` executes them in CI.
Used in pre-merge CI to get quick feedback on Ansible changes without
running the full all-roles/all-platforms suite (which runs nightly).
Usage:
python -m devx.ci.fast_molecule --base origin/master --head HEAD
Outputs the list of changed roles and the molecule commands to run.
Outputs the list of changed roles and the molecule commands CI runs.
In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
"""
@@ -48,19 +53,19 @@ def build_molecule_commands(
roles_dir: str = "ansible/roles",
platform: str = "ubuntu-2604",
) -> list[str]:
"""Build molecule test commands for changed roles.
"""Build the molecule commands CI executes for changed roles.
For each role, runs each scenario with converge + verify only
(skip create/destroy between scenarios, skip idempotence).
Emits exactly what ``scripts/run_molecule_scenario.py`` runs:
``molecule test -s <scenario>`` the full sequence (create, converge,
idempotence, verify, destroy). The ``platform`` argument is accepted
for interface stability but is informational: the scenario's
``molecule.yml`` selects the platform, and CI distributes scenarios so
each runs on a single platform.
"""
commands: list[str] = []
for role in sorted(roles):
scenarios = get_molecule_scenarios(role, roles_dir)
if not scenarios:
continue
for scenario in scenarios:
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
commands.append(cmd)
for scenario in get_molecule_scenarios(role, roles_dir):
commands.append(f"molecule test -s {scenario}")
return commands
@@ -93,7 +98,7 @@ def cli(
write_github_output("fast-molecule-roles", "")
return
roles = detect_changed_roles(files)
roles = detect_changed_roles(files, roles_dir)
if not roles:
click.echo("[fast-molecule] No Ansible roles changed.")
if github_output:
+65 -30
View File
@@ -17,8 +17,13 @@ nextcloud, postgres-upgrade, simple-app), the base target runs all
scenarios for that role.
Playbooks that change also trigger molecule for the roles they include.
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
trigger all scenarios.
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/,
group_vars/) trigger all scenarios. Any ``ansible/playbooks/**`` file not
in the explicit map fails open to all testable roles targeted selection
must never silently skip coverage.
Only roles that exist on disk and contain a ``molecule/`` directory are
selected, so emitted make targets always resolve.
"""
from __future__ import annotations
@@ -30,26 +35,28 @@ import click
REPO_ROOT = Path.cwd()
# Map role names to make targets.
ROLE_TARGET_MAP: dict[str, str] = {
"app_container": "molecule-app-container",
"app_hardening": "molecule-app-hardening",
"crowdsec": "molecule-crowdsec",
"disk_cleanup": "molecule-disk-cleanup",
"docker_base": "molecule-docker-base",
"observability": "molecule-observability",
"restore": "molecule-restore",
"sso_config": "molecule-sso-config",
"storage": "molecule-storage",
"zitadel": "molecule-zitadel",
}
def role_to_target(role: str) -> str:
"""Make target for a role: docker_base -> molecule-docker-base."""
return f"molecule-{role.replace('_', '-')}"
# Playbooks that map to molecule scenarios (via roles they include).
# Any ansible/playbooks/** file NOT listed here fails open to all testable
# roles (REQ-1) — a missing entry must never mean "no coverage".
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening", "sso_config"],
"ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"],
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening"],
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"],
"ansible/playbooks/restore.yml": ["restore"],
"ansible/playbooks/upgrade-postgres.yml": ["app_container"],
"ansible/playbooks/rolling-update-gitea.yml": ["app_container"],
"ansible/playbooks/update-alertmanager.yml": ["observability"],
"ansible/playbooks/build-image.yml": ["docker_base", "crowdsec", "disk_cleanup"],
# sso_bridge role is checked out from the sso-bridge repo at deploy
# time — no molecule coverage exists in the consuming repo, so the
# explicit empty list documents "mapped, nothing local to test".
"ansible/playbooks/deploy-sso-bridge.yml": [],
}
# Shared infrastructure that affects all molecule tests.
@@ -57,6 +64,7 @@ SHARED_PATHS = (
"ansible/ansible.cfg",
"ansible/requirements.yml",
"ansible/molecule/",
"ansible/group_vars/",
)
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
@@ -85,8 +93,30 @@ def get_changed_files(base: str) -> list[str]:
return []
def detect_changed_roles(changed_files: list[str]) -> set[str]:
"""Detect which roles have changed files."""
def _testable_roles(roles_dir: Path) -> set[str]:
"""Roles present on disk that carry a molecule/ dir (i.e. have scenarios)."""
if not roles_dir.is_dir():
return set()
return {d.name for d in roles_dir.iterdir() if d.is_dir() and (d / "molecule").is_dir()}
def _is_playbook_file(filepath: str) -> bool:
"""True for any file under ansible/playbooks/ (yml tasks included)."""
return filepath.startswith("ansible/playbooks/")
def detect_changed_roles(
changed_files: list[str],
roles_dir: str | Path = "ansible/roles",
) -> set[str]:
"""Detect which roles have changed files.
Implements: REQ-1, REQ-2 fail open on unmapped ansible playbook or
shared-path changes; only roles that exist under ``roles_dir`` are
returned, so emitted targets always resolve.
"""
roles_path = Path(roles_dir)
all_roles = _testable_roles(roles_path)
roles: set[str] = set()
for filepath in changed_files:
@@ -99,23 +129,23 @@ def detect_changed_roles(changed_files: list[str]) -> set[str]:
# Check if file is a playbook that maps to roles
if filepath in PLAYBOOK_ROLE_MAP:
roles.update(PLAYBOOK_ROLE_MAP[filepath])
elif _is_playbook_file(filepath):
# Unmapped playbook/_tasks file — fail open to all testable roles.
return set(all_roles)
# Check shared infrastructure — triggers all roles
for shared in SHARED_PATHS:
if filepath.startswith(shared):
return set(ROLE_TARGET_MAP.keys())
return set(all_roles)
return roles
# REQ-2: drop roles that don't exist on disk (e.g. sso_config after the
# role moved to the sso-bridge repo) so targets always resolve.
return {r for r in roles if (roles_path / r).is_dir()}
def roles_to_targets(roles: set[str]) -> list[str]:
"""Convert role names to make targets."""
targets = []
for role in sorted(roles):
target = ROLE_TARGET_MAP.get(role)
if target:
targets.append(target)
return targets
"""Convert role names to make targets (conventional molecule-<role>)."""
return [role_to_target(role) for role in sorted(roles)]
@click.command()
@@ -134,14 +164,19 @@ def roles_to_targets(roles: set[str]) -> list[str]:
is_flag=True,
help="Print role names (default if no --print-targets).",
)
def main(base: str, print_targets: bool, print_roles: bool) -> None:
@click.option(
"--roles-dir",
default="ansible/roles",
help="Directory containing Ansible roles (default: ansible/roles).",
)
def main(base: str, print_targets: bool, print_roles: bool, roles_dir: str) -> None:
"""Detect which Ansible roles changed and output molecule scenarios."""
changed_files = get_changed_files(base)
if not changed_files:
click.echo("No changed files detected.", err=True)
return
roles = detect_changed_roles(changed_files)
roles = detect_changed_roles(changed_files, roles_dir)
if not roles:
click.echo("No molecule scenarios affected by changes.", err=True)
return
+8 -1
View File
@@ -346,11 +346,18 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results: list[bool] = []
tool_names: list[str] = []
# Implements: REQ-1 (DEVX-175) — honor the repo's [tool.bandit] config so
# the badge matches the project's actual lint gate (skip lists etc.).
bandit_cmd = [sys.executable, "-m", "bandit", "-r", "src/"]
pyproject = repo_root / "pyproject.toml"
if pyproject.exists() and "[tool.bandit]" in pyproject.read_text():
bandit_cmd += ["-c", "pyproject.toml"]
for cmd, name in [
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
(bandit_cmd, "bandit"),
]:
rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
if rc == 0:
+31 -7
View File
@@ -2967,13 +2967,21 @@
"ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}",
"zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}"
},
"Registry returned no manifest blob for {owner}/{name}:{tag}.": {
"bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.",
"de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.",
"en": "Registry returned no manifest blob for {owner}/{name}:{tag}.",
"pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.",
"ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.",
"zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。"
"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 задачи.",
@@ -3159,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",
@@ -3831,6 +3847,14 @@
"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.",
+81 -16
View File
@@ -200,23 +200,39 @@ class TestCreateVikunjaTask:
assert mock_client.create_task.call_args.args[0] == 3
class TestResolveContainerDigest:
"""REQ-1: pre-PR artifact verification via the packages API."""
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]
def test_returns_digest_from_manifest_blob(self) -> None:
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
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [
{"name": "sha256_layer", "sha256": "abc"},
{"name": "manifest.json", "sha256": "deadbeef"},
]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
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
@@ -231,14 +247,38 @@ class TestResolveContainerDigest:
with pytest.raises(click.ClickException, match="unpublished artifact"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok")
def test_raises_when_no_manifest_blob(self) -> None:
def test_raises_when_v2_digest_missing(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
with pytest.raises(click.ClickException, match="no manifest blob"):
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:
@@ -621,3 +661,28 @@ class TestBranchCreation:
# 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()
# Commit supplies an explicit identity (fresh clones have none)
commit = next(c for c in subprocess.run.call_args_list if "commit" in c.args[0])
assert "user.name=gitea-actions-bot" in commit.args[0]
assert "user.email=actions@oblachno.fyi" in commit.args[0]
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"
+4 -3
View File
@@ -40,9 +40,10 @@ class TestBuildMoleculeCommands:
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
assert len(commands) == 2
assert all("molecule test -s default" in c for c in commands)
assert all("--destroy=never" in c for c in commands)
assert all("ubuntu-2604" in c for c in commands)
# REQ-3: emitted commands are exactly what run_molecule_scenario
# executes — full `molecule test -s <scenario>`, no synthetic flags.
assert commands == ["molecule test -s default", "molecule test -s default"]
assert all("--destroy" not in c and "--platform-name" not in c for c in commands)
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
+17
View File
@@ -365,6 +365,23 @@ class TestCollectQuality:
badge = collect_quality(tmp_path)
assert badge["message"] == "A"
@patch("devx.tools.generate_badges.run_command")
def test_bandit_uses_repo_config_when_present(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[tool.bandit]\nskips = ['B501']\n")
mock_run.return_value = (0, "", "")
collect_quality(tmp_path)
bandit_call = [c for c in mock_run.call_args_list if "bandit" in c.args[0]][0]
assert "-c" in bandit_call.args[0]
assert "pyproject.toml" in bandit_call.args[0]
@patch("devx.tools.generate_badges.run_command")
def test_bandit_plain_invocation_without_repo_config(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n")
mock_run.return_value = (0, "", "")
collect_quality(tmp_path)
bandit_call = [c for c in mock_run.call_args_list if "bandit" in c.args[0]][0]
assert "-c" not in bandit_call.args[0]
class TestGenerateBadges:
@patch("devx.tools.generate_badges.collect_quality")
+125 -91
View File
@@ -1,107 +1,186 @@
"""Unit tests for devx.molecule.molecule_changed.
Verifies that the script correctly detects changed roles and maps
them to make targets.
Verifies that the script correctly detects changed roles, fails open on
unmapped ansible paths, and only emits roles that exist on disk.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.molecule.molecule_changed import (
detect_changed_roles,
get_changed_files,
main,
role_to_target,
roles_to_targets,
)
TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability")
def test_detect_role_change():
@pytest.fixture()
def roles_dir(tmp_path: Path) -> Path:
"""Fake roles dir: 4 testable roles (molecule/ present) + 1 untestable."""
for role in TESTABLE_ROLES:
(tmp_path / role / "molecule" / "default").mkdir(parents=True)
(tmp_path / "untested_role").mkdir() # exists but no molecule/ dir
return tmp_path
def test_detect_role_change(roles_dir: Path):
"""A file in ansible/roles/<role>/ maps to that role."""
files = ["ansible/roles/docker_base/tasks/main.yml"]
roles = detect_changed_roles(files)
assert "docker_base" in roles
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
assert roles == {"docker_base"}
def test_detect_playbook_change():
"""A playbook change maps to its included roles."""
def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
"""Changed role absent from roles_dir selects nothing (REQ-2)."""
roles = detect_changed_roles(["ansible/roles/sso_config/tasks/main.yml"], roles_dir)
assert roles == set()
def test_detect_playbook_change(roles_dir: Path):
"""A mapped playbook maps to its included roles, filtered to disk."""
files = ["ansible/playbooks/deploy-observability.yml"]
roles = detect_changed_roles(files)
assert "observability" in roles
assert "docker_base" in roles
assert "zitadel" in roles
roles = detect_changed_roles(files, roles_dir)
# zitadel + crowdsec are mapped but absent from the fake roles dir.
assert roles == {"observability", "docker_base"}
def test_detect_shared_infra_triggers_all():
"""ansible.cfg change triggers all roles."""
files = ["ansible/ansible.cfg"]
roles = detect_changed_roles(files)
assert len(roles) == 10 # all roles
def test_detect_shared_infra_triggers_all(roles_dir: Path):
"""ansible.cfg change triggers all testable roles only."""
roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_no_ansible_changes():
def test_detect_molecule_shared_path(roles_dir: Path):
"""ansible/molecule/ change triggers all testable roles."""
roles = detect_changed_roles(["ansible/molecule/Dockerfile"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_requirements_yml_triggers_all(roles_dir: Path):
"""ansible/requirements.yml change triggers all testable roles."""
roles = detect_changed_roles(["ansible/requirements.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_group_vars_triggers_all(roles_dir: Path):
"""ansible/group_vars/ change triggers all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/group_vars/all/images.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_unmapped_playbook_fails_open(roles_dir: Path):
"""An unmapped playbook selects all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/playbooks/new-deploy.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_playbook_tasks_dir_fails_open(roles_dir: Path):
"""Shared playbook task files select all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/playbooks/_tasks/upgrade-postgres-database.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_mapped_playbooks(roles_dir: Path):
"""Each newly mapped playbook selects its roles (REQ-1)."""
expectations = {
"ansible/playbooks/restore.yml": {"restore"},
"ansible/playbooks/upgrade-postgres.yml": {"app_container"},
"ansible/playbooks/rolling-update-gitea.yml": {"app_container"},
"ansible/playbooks/update-alertmanager.yml": {"observability"},
"ansible/playbooks/build-image.yml": {"docker_base"},
"ansible/playbooks/deploy-sso-bridge.yml": set(),
}
for playbook, expected in expectations.items():
assert detect_changed_roles([playbook], roles_dir) == expected, playbook
def test_detect_prepare_vms_playbook(roles_dir: Path):
"""prepare-vms.yml maps to all base roles present on disk."""
roles = detect_changed_roles(["ansible/playbooks/prepare-vms.yml"], roles_dir)
assert roles == {"docker_base"}
def test_detect_deploy_customer_playbook(roles_dir: Path):
"""deploy-customer.yml maps to its roles present on disk."""
roles = detect_changed_roles(["ansible/playbooks/deploy-customer.yml"], roles_dir)
assert roles == {"app_container", "docker_base"}
def test_detect_no_ansible_changes(roles_dir: Path):
"""Non-Ansible files don't trigger any roles."""
files = ["scripts/molecule_changed.py", "Makefile"]
roles = detect_changed_roles(files)
assert len(roles) == 0
roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
assert roles == set()
def test_detect_environments_not_molecule_covered(roles_dir: Path):
"""ansible/environments/ data is not molecule-covered (documented)."""
roles = detect_changed_roles(["ansible/environments/staging/customers.yml"], roles_dir)
assert roles == set()
def test_detect_missing_roles_dir():
"""A nonexistent roles_dir yields no roles (honest: nothing testable)."""
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], "/nonexistent")
assert roles == set()
def test_role_to_target():
"""Role names map to conventional make targets."""
assert role_to_target("docker_base") == "molecule-docker-base"
assert role_to_target("app_hardening") == "molecule-app-hardening"
def test_roles_to_targets():
"""Role names map to make targets."""
targets = roles_to_targets({"docker_base", "zitadel"})
assert "molecule-docker-base" in targets
assert "molecule-zitadel" in targets
def test_roles_to_targets_unknown_role():
"""Unknown roles are silently skipped."""
targets = roles_to_targets({"docker_base", "unknown_role"})
assert targets == ["molecule-docker-base"]
assert targets == ["molecule-docker-base", "molecule-zitadel"]
def test_main_no_changes():
"""When no files changed, outputs message to stderr."""
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
result = CliRunner().invoke(main, ["--print-targets"])
assert result.exit_code == 0
assert "No changed files" in result.output
def test_main_print_targets():
"""--print-targets outputs make targets."""
def test_main_print_targets(roles_dir: Path):
"""--print-targets outputs make targets for existing roles."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/docker_base/tasks/main.yml"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "molecule-docker-base" in result.output
def test_main_print_roles():
def test_main_print_roles(roles_dir: Path):
"""--print-roles outputs role names."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/zitadel/tasks/main.yml"],
return_value=["ansible/roles/restore/tasks/main.yml"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-roles"])
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "zitadel" in result.output
assert "restore" in result.output
def test_main_no_ansible_changes():
def test_main_no_ansible_changes(roles_dir: Path):
"""When only non-Ansible files changed, outputs no scenarios message."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["scripts/molecule_changed.py"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "No molecule scenarios" in result.output
@@ -119,7 +198,6 @@ def test_get_changed_files_falls_back_to_master():
def mock_git(args):
calls.append(args)
# First call (origin/master) returns empty, second (master) returns files
if "origin/master...HEAD" in args[2]:
return ""
return "ansible/roles/docker_base/tasks/main.yml\n"
@@ -137,56 +215,12 @@ def test_get_changed_files_empty():
assert files == []
def test_detect_molecule_shared_path():
"""ansible/molecule/ change triggers all roles."""
files = ["ansible/molecule/Dockerfile"]
roles = detect_changed_roles(files)
assert len(roles) == 10
def test_detect_requirements_yml_triggers_all():
"""ansible/requirements.yml change triggers all roles."""
files = ["ansible/requirements.yml"]
roles = detect_changed_roles(files)
assert len(roles) == 10
def test_detect_configure_oidc_playbook():
"""configure-oidc.yml maps to sso_config and app_container."""
files = ["ansible/playbooks/configure-oidc.yml"]
roles = detect_changed_roles(files)
assert "sso_config" in roles
assert "app_container" in roles
def test_detect_prepare_vms_playbook():
"""prepare-vms.yml maps to all base roles."""
files = ["ansible/playbooks/prepare-vms.yml"]
roles = detect_changed_roles(files)
assert "docker_base" in roles
assert "app_hardening" in roles
assert "storage" in roles
assert "disk_cleanup" in roles
assert "crowdsec" in roles
def test_detect_deploy_customer_playbook():
"""deploy-customer.yml maps to its roles."""
files = ["ansible/playbooks/deploy-customer.yml"]
roles = detect_changed_roles(files)
assert "app_container" in roles
assert "docker_base" in roles
assert "app_hardening" in roles
assert "sso_config" in roles
def test_main_default_base():
def test_main_default_base(roles_dir: Path):
"""main() with no --base uses origin/master."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/zitadel/tasks/main.yml"],
return_value=["ansible/roles/restore/tasks/main.yml"],
) as mock:
runner = CliRunner()
result = runner.invoke(main, ["--print-roles"])
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
mock.assert_called_once_with("origin/master")