Compare commits

...
15 Commits
Author SHA1 Message Date
gitea-actions-bot 60ba4aabc4 chore: update badge URLs to commit 4422c70b [skip ci] 2026-09-23 08:11:33 +00:00
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
22 changed files with 699 additions and 219 deletions
+25 -13
View File
@@ -33,21 +33,40 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 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 - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
# --- quality steps --- # --- quality steps ---
- name: Lint all - name: Lint all
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
make lint-all make lint-all
- name: Unit tests with 100% coverage - name: Unit tests with 100% coverage
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
make pytest-cov make pytest-cov
- name: Check unit test speed - name: Check unit test speed
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5 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" export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check make devx-docs-check
- name: Translation completeness check - name: Translation completeness check
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations python3 -m devx.ci.check_translations
- name: Dependency security scan - name: Dependency security scan
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit) # Install pip in venv if missing (needed by pip-audit)
@@ -71,6 +92,7 @@ jobs:
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \ PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation - name: Workflow dry-run validation
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -137,19 +159,9 @@ jobs:
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run python3 -m devx.ci.release --dry-run
- name: Notify on failure # Implements: REQ-2 (DEVX-176) — no failure-issue step in PR CI;
if: failure() # auto-created issues are for deploy-pipeline failures only
env: # (post-merge keeps its notification).
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
auto-merge: auto-merge:
# Auto-merge runs after validate passes. It reads the task ID # Auto-merge runs after validate passes. It reads the task ID
+3 -1
View File
@@ -24,7 +24,9 @@ on:
concurrency: concurrency:
group: post-merge-${{ github.ref }} 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: env:
PIP_BREAK_SYSTEM_PACKAGES: "1" 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):** **CI gates (pre-merge):**
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked - `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.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):** **Nightly (infra only):**
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests - Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
+24
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file. 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 ## [0.55.0] - 2026-09-19
### Features ### 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) [![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) [![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/9eabfdd80f0a4232448178062a4dc63af23c84d4/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.55.0", "devx>=0.55.4",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.55.0"`) or use a version constraint > `dependencies` (for example, `"devx==0.55.4"`) or use a version constraint
> (for example, `"devx>=0.55.0,<0.56"`). > (for example, `"devx>=0.55.4,<0.56"`).
### Optional extras ### 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) [![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) [![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/9eabfdd80f0a4232448178062a4dc63af23c84d4/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4422c70b23b4a707c24ab91f80bb4fd2e2fff921/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.55.0", "devx>=0.55.4",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.55.0"` or `"devx>=0.55.0,<0.56"`. Pin a specific version if needed: `"devx==0.55.4"` or `"devx>=0.55.4,<0.56"`.
### Optional extras ### Optional extras
+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 ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.55.0", "devx>=0.55.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.55.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. molecule testing helpers for Ansible projects.
""" """
__version__ = "0.55.0" __version__ = "0.55.4"
+69 -17
View File
@@ -96,18 +96,25 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve
return changed return changed
_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: def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str:
"""Resolve the OCI digest for a container image tag via the packages API. """Verify the container tag exists, then resolve its pullable OCI digest.
Implements REQ-1: dependency PRs must only be opened after the producer The packages API proves the tag was published (and 404s while the
artifact exists — this raises ClickException when the tag is missing or producer's image-build workflow races us — ``timeout_s`` retries every
the registry call fails, so the PR is never opened against an artifact 15s). The packages-API ``manifest.json`` blob sha256 is NOT pullable
that has not been published. The sha256 of the stored ``manifest.json`` via ``repo@sha256:...``, so the digest comes from the registry v2
blob is the manifest content digest (what ``docker pull`` reports). ``Docker-Content-Digest`` header instead.
Implements REQ-2: ``timeout_s`` > 0 retries the lookup every 15s until
the deadline — the dep-PR step races the producer's image-build
workflow, which pushes the tag concurrently.
""" """
url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files" url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files"
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}
@@ -150,17 +157,47 @@ def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, toke
) )
) from e ) from e
break break
for f in resp.json(): return _resolve_registry_digest(api_url, owner, name, tag, token)
if f.get("name") == "manifest.json" and f.get("sha256"):
return f"sha256:{f['sha256']}"
# 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( raise click.ClickException(
_( _(
"Registry returned no manifest blob for {owner}/{name}:{tag}.", "Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
owner=owner, repo=repo,
name=name,
tag=tag, 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: def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool:
@@ -468,7 +505,22 @@ def cli(
add_files.append(spec_rel) add_files.append(spec_rel)
subprocess.run(["git", "add", *add_files], check=True, cwd=workdir) # nosec B603 B607 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}" 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 subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "push", "origin", branch_name], ["git", "-c", auth_cfg, "push", "origin", branch_name],
check=True, check=True,
+19 -14
View File
@@ -5,14 +5,19 @@
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
playbook→role mapping and shared infrastructure paths). playbook→role mapping and shared infrastructure paths).
Fast molecule = converge + verify only, single platform, no idempotence Fast molecule = full ``molecule test`` for every scenario of each changed
check. Used in pre-merge CI to get quick feedback on Ansible changes role, on the scenario's configured platform. "Fast" means *scoped* (only
without running the full molecule suite (which runs nightly). 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: Usage:
python -m devx.ci.fast_molecule --base origin/master --head HEAD 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- In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
separated) and ``fast-molecule-needed`` (true/false) for downstream steps. separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
""" """
@@ -48,19 +53,19 @@ def build_molecule_commands(
roles_dir: str = "ansible/roles", roles_dir: str = "ansible/roles",
platform: str = "ubuntu-2604", platform: str = "ubuntu-2604",
) -> list[str]: ) -> 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 Emits exactly what ``scripts/run_molecule_scenario.py`` runs:
(skip create/destroy between scenarios, skip idempotence). ``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] = [] commands: list[str] = []
for role in sorted(roles): for role in sorted(roles):
scenarios = get_molecule_scenarios(role, roles_dir) for scenario in get_molecule_scenarios(role, roles_dir):
if not scenarios: commands.append(f"molecule test -s {scenario}")
continue
for scenario in scenarios:
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
commands.append(cmd)
return commands return commands
@@ -93,7 +98,7 @@ def cli(
write_github_output("fast-molecule-roles", "") write_github_output("fast-molecule-roles", "")
return return
roles = detect_changed_roles(files) roles = detect_changed_roles(files, roles_dir)
if not roles: if not roles:
click.echo("[fast-molecule] No Ansible roles changed.") click.echo("[fast-molecule] No Ansible roles changed.")
if github_output: 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. scenarios for that role.
Playbooks that change also trigger molecule for the roles they include. Playbooks that change also trigger molecule for the roles they include.
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/) Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/,
trigger all scenarios. 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 from __future__ import annotations
@@ -30,26 +35,28 @@ import click
REPO_ROOT = Path.cwd() REPO_ROOT = Path.cwd()
# Map role names to make targets.
ROLE_TARGET_MAP: dict[str, str] = { def role_to_target(role: str) -> str:
"app_container": "molecule-app-container", """Make target for a role: docker_base -> molecule-docker-base."""
"app_hardening": "molecule-app-hardening", return f"molecule-{role.replace('_', '-')}"
"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",
}
# Playbooks that map to molecule scenarios (via roles they include). # 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]] = { PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"], "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/deploy-customer.yml": ["app_container", "docker_base", "app_hardening"],
"ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"],
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"], "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. # Shared infrastructure that affects all molecule tests.
@@ -57,6 +64,7 @@ SHARED_PATHS = (
"ansible/ansible.cfg", "ansible/ansible.cfg",
"ansible/requirements.yml", "ansible/requirements.yml",
"ansible/molecule/", "ansible/molecule/",
"ansible/group_vars/",
) )
# Minimum path parts for a role file: ansible/roles/<role> (3 parts). # 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 [] return []
def detect_changed_roles(changed_files: list[str]) -> set[str]: def _testable_roles(roles_dir: Path) -> set[str]:
"""Detect which roles have changed files.""" """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() roles: set[str] = set()
for filepath in changed_files: 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 # Check if file is a playbook that maps to roles
if filepath in PLAYBOOK_ROLE_MAP: if filepath in PLAYBOOK_ROLE_MAP:
roles.update(PLAYBOOK_ROLE_MAP[filepath]) 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 # Check shared infrastructure — triggers all roles
for shared in SHARED_PATHS: for shared in SHARED_PATHS:
if filepath.startswith(shared): 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]: def roles_to_targets(roles: set[str]) -> list[str]:
"""Convert role names to make targets.""" """Convert role names to make targets (conventional molecule-<role>)."""
targets = [] return [role_to_target(role) for role in sorted(roles)]
for role in sorted(roles):
target = ROLE_TARGET_MAP.get(role)
if target:
targets.append(target)
return targets
@click.command() @click.command()
@@ -134,14 +164,19 @@ def roles_to_targets(roles: set[str]) -> list[str]:
is_flag=True, is_flag=True,
help="Print role names (default if no --print-targets).", 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.""" """Detect which Ansible roles changed and output molecule scenarios."""
changed_files = get_changed_files(base) changed_files = get_changed_files(base)
if not changed_files: if not changed_files:
click.echo("No changed files detected.", err=True) click.echo("No changed files detected.", err=True)
return return
roles = detect_changed_roles(changed_files) roles = detect_changed_roles(changed_files, roles_dir)
if not roles: if not roles:
click.echo("No molecule scenarios affected by changes.", err=True) click.echo("No molecule scenarios affected by changes.", err=True)
return return
+8 -1
View File
@@ -346,11 +346,18 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results: list[bool] = [] results: list[bool] = []
tool_names: list[str] = [] 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 [ for cmd, name in [
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"), ([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"), ([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
([sys.executable, "-m", "pyright"], "pyright"), ([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"), (bandit_cmd, "bandit"),
]: ]:
rc, _stdout, stderr = run_command(cmd, cwd=repo_root) rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
if rc == 0: if rc == 0:
+15 -7
View File
@@ -2967,13 +2967,21 @@
"ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}", "ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}",
"zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}" "zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}"
}, },
"Registry returned no manifest blob for {owner}/{name}:{tag}.": { "Registry returned no Docker-Content-Digest for {repo}:{tag}.": {
"bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.", "bg": "Регистърът не върна Docker-Content-Digest за {repo}:{tag}.",
"de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.", "de": "Registry hat keinen Docker-Content-Digest für {repo}:{tag} zurückgegeben.",
"en": "Registry returned no manifest blob for {owner}/{name}:{tag}.", "en": "Registry returned no Docker-Content-Digest for {repo}:{tag}.",
"pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.", "pl": "Rejestr nie zwrócił Docker-Content-Digest dla {repo}:{tag}.",
"ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.", "ru": "Реестр не вернул Docker-Content-Digest для {repo}:{tag}.",
"zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。" "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.": { "Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.", "bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
+61 -17
View File
@@ -200,23 +200,39 @@ class TestCreateVikunjaTask:
assert mock_client.create_task.call_args.args[0] == 3 assert mock_client.create_task.call_args.args[0] == 3
class TestResolveContainerDigest: def _v2_mocks(digest: str = "sha256:deadbeef") -> list[MagicMock]:
"""REQ-1: pre-PR artifact verification via the packages API.""" """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 from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock() files = MagicMock()
mock_resp.raise_for_status = MagicMock() files.raise_for_status = MagicMock()
mock_resp.json.return_value = [ files.json.return_value = [{"name": "manifest.json", "sha256": "blob-not-pullable"}]
{"name": "sha256_layer", "sha256": "abc"}, with patch(
{"name": "manifest.json", "sha256": "deadbeef"}, "devx.ci.create_dependency_pr.requests.get",
] side_effect=[files, *_v2_mocks()],
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): ) as mock_get:
digest = resolve_container_digest( digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok" "https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
) )
assert digest == "sha256:deadbeef" 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: def test_raises_when_version_missing(self) -> None:
import requests import requests
@@ -231,14 +247,38 @@ class TestResolveContainerDigest:
with pytest.raises(click.ClickException, match="unpublished artifact"): with pytest.raises(click.ClickException, match="unpublished artifact"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok") 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 from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock() files = MagicMock()
mock_resp.raise_for_status = MagicMock() files.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}] files.json.return_value = []
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): tok, man = _v2_mocks(digest="")
with pytest.raises(click.ClickException, match="no manifest blob"): 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") resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_on_connection_error(self) -> None: def test_raises_on_connection_error(self) -> None:
@@ -621,6 +661,10 @@ class TestBranchCreation:
# PR title carries the task ID # PR title carries the task ID
assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1" assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1"
mock_task.assert_called_once() 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: def test_retries_404_until_published(self) -> None:
import requests import requests
@@ -635,7 +679,7 @@ class TestBranchCreation:
ok.raise_for_status = MagicMock() ok.raise_for_status = MagicMock()
ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}] ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}]
with ( with (
patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok]), patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok, *_v2_mocks("sha256:cafe")]),
patch("devx.ci.create_dependency_pr.time.sleep"), patch("devx.ci.create_dependency_pr.time.sleep"),
): ):
digest = resolve_container_digest( digest = resolve_container_digest(
+4 -3
View File
@@ -40,9 +40,10 @@ class TestBuildMoleculeCommands:
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir)) commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
assert len(commands) == 2 assert len(commands) == 2
assert all("molecule test -s default" in c for c in commands) # REQ-3: emitted commands are exactly what run_molecule_scenario
assert all("--destroy=never" in c for c in commands) # executes — full `molecule test -s <scenario>`, no synthetic flags.
assert all("ubuntu-2604" in c for c in commands) 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: def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles")) commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
+17
View File
@@ -365,6 +365,23 @@ class TestCollectQuality:
badge = collect_quality(tmp_path) badge = collect_quality(tmp_path)
assert badge["message"] == "A" 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: class TestGenerateBadges:
@patch("devx.tools.generate_badges.collect_quality") @patch("devx.tools.generate_badges.collect_quality")
+125 -91
View File
@@ -1,107 +1,186 @@
"""Unit tests for devx.molecule.molecule_changed. """Unit tests for devx.molecule.molecule_changed.
Verifies that the script correctly detects changed roles and maps Verifies that the script correctly detects changed roles, fails open on
them to make targets. unmapped ansible paths, and only emits roles that exist on disk.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.molecule.molecule_changed import ( from devx.molecule.molecule_changed import (
detect_changed_roles, detect_changed_roles,
get_changed_files, get_changed_files,
main, main,
role_to_target,
roles_to_targets, 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.""" """A file in ansible/roles/<role>/ maps to that role."""
files = ["ansible/roles/docker_base/tasks/main.yml"] roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
roles = detect_changed_roles(files) assert roles == {"docker_base"}
assert "docker_base" in roles
def test_detect_playbook_change(): def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
"""A playbook change maps to its included roles.""" """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"] files = ["ansible/playbooks/deploy-observability.yml"]
roles = detect_changed_roles(files) roles = detect_changed_roles(files, roles_dir)
assert "observability" in roles # zitadel + crowdsec are mapped but absent from the fake roles dir.
assert "docker_base" in roles assert roles == {"observability", "docker_base"}
assert "zitadel" in roles
def test_detect_shared_infra_triggers_all(): def test_detect_shared_infra_triggers_all(roles_dir: Path):
"""ansible.cfg change triggers all roles.""" """ansible.cfg change triggers all testable roles only."""
files = ["ansible/ansible.cfg"] roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
roles = detect_changed_roles(files) assert roles == set(TESTABLE_ROLES)
assert len(roles) == 10 # all 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.""" """Non-Ansible files don't trigger any roles."""
files = ["scripts/molecule_changed.py", "Makefile"] roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
roles = detect_changed_roles(files) assert roles == set()
assert len(roles) == 0
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(): def test_roles_to_targets():
"""Role names map to make targets.""" """Role names map to make targets."""
targets = roles_to_targets({"docker_base", "zitadel"}) targets = roles_to_targets({"docker_base", "zitadel"})
assert "molecule-docker-base" in targets assert targets == ["molecule-docker-base", "molecule-zitadel"]
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"]
def test_main_no_changes(): def test_main_no_changes():
"""When no files changed, outputs message to stderr.""" """When no files changed, outputs message to stderr."""
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]): with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
runner = CliRunner() result = CliRunner().invoke(main, ["--print-targets"])
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "No changed files" in result.output assert "No changed files" in result.output
def test_main_print_targets(): def test_main_print_targets(roles_dir: Path):
"""--print-targets outputs make targets.""" """--print-targets outputs make targets for existing roles."""
with patch( with patch(
"devx.molecule.molecule_changed.get_changed_files", "devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/docker_base/tasks/main.yml"], return_value=["ansible/roles/docker_base/tasks/main.yml"],
): ):
runner = CliRunner() result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "molecule-docker-base" in result.output 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.""" """--print-roles outputs role names."""
with patch( with patch(
"devx.molecule.molecule_changed.get_changed_files", "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 = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
result = runner.invoke(main, ["--print-roles"])
assert result.exit_code == 0 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.""" """When only non-Ansible files changed, outputs no scenarios message."""
with patch( with patch(
"devx.molecule.molecule_changed.get_changed_files", "devx.molecule.molecule_changed.get_changed_files",
return_value=["scripts/molecule_changed.py"], return_value=["scripts/molecule_changed.py"],
): ):
runner = CliRunner() result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "No molecule scenarios" in result.output assert "No molecule scenarios" in result.output
@@ -119,7 +198,6 @@ def test_get_changed_files_falls_back_to_master():
def mock_git(args): def mock_git(args):
calls.append(args) calls.append(args)
# First call (origin/master) returns empty, second (master) returns files
if "origin/master...HEAD" in args[2]: if "origin/master...HEAD" in args[2]:
return "" return ""
return "ansible/roles/docker_base/tasks/main.yml\n" return "ansible/roles/docker_base/tasks/main.yml\n"
@@ -137,56 +215,12 @@ def test_get_changed_files_empty():
assert files == [] assert files == []
def test_detect_molecule_shared_path(): def test_main_default_base(roles_dir: 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():
"""main() with no --base uses origin/master.""" """main() with no --base uses origin/master."""
with patch( with patch(
"devx.molecule.molecule_changed.get_changed_files", "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: ) as mock:
runner = CliRunner() result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
result = runner.invoke(main, ["--print-roles"])
assert result.exit_code == 0 assert result.exit_code == 0
mock.assert_called_once_with("origin/master") mock.assert_called_once_with("origin/master")