Compare commits

...
13 Commits
Author SHA1 Message Date
gitea-actions-bot affd4d12ec chore: update badge URLs to commit 5574c266 [skip ci] 2026-09-28 17:44:20 +00:00
devx-ci-bot e1ee03d129 release: v0.55.5 [skip ci] 2026-09-28 17:43:40 +00:00
kireto c523a80848 DEVX-179: fix: strip [skip ci] tokens and prefer newest commit in squash titles 2026-09-28 17:38:51 +00:00
gitea-actions-bot ae0be43368 chore: update badge URLs to commit 32e239ca [skip ci] 2026-09-28 14:48:17 +00:00
kireto e6f30928bc DEVX-180: ci: retrigger validation
Post-merge / detect-and-configure (push) Successful in 14s
Post-merge / release-and-maintain (push) Successful in 1m19s
2026-09-28 14:46:42 +00:00
kireto 3aca6024cf DEVX-178: chore: update badge URLs to commit 4422c70b [skip ci] 2026-09-28 14:34:58 +00:00
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
22 changed files with 650 additions and 184 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
+6 -1
View File
@@ -21,10 +21,15 @@ name: Post-merge
on: on:
push: push:
branches: [master] branches: [master]
# Implements: REQ-1 — manual recovery when a [skip ci] squash title
# suppresses the push-triggered post-merge run.
workflow_dispatch:
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"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joseph Kato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,38 @@
# The words behind a SMOG or Gunning fog score: each one of three or more
# syllables. The scores say a paragraph reads hard; this says which words
# made it so. Syllables are counted by vowel groups, with the silent e and
# the -ed and -es endings taken off, which agrees with the scores' count on
# nearly every word and errs on the long side for the rest.
#
# A suggestion, since a paragraph can carry a few. Turn it on while bringing
# a score down, and off again after. See issue 8.
extends: script
message: "'%s' has three or more syllables. It counts toward the SMOG and Gunning fog scores."
link: https://en.wikipedia.org/wiki/SMOG
level: suggestion
scope: paragraph
script: |
text := import("text")
matches := []
for m in text.re_find(`[A-Za-z]+(?:'[A-Za-z]+)?`, scope, -1) {
w := text.to_lower(m[0].text)
if len(w) < 5 {
continue
}
groups := text.re_find(`[aeiouy]+`, w, -1)
n := groups == undefined ? 0 : len(groups)
if text.has_suffix(w, "e") && !text.has_suffix(w, "le") && !text.has_suffix(w, "ee") {
n--
}
if text.has_suffix(w, "ed") && !text.re_match(`[td]ed$`, w) {
n--
}
if text.has_suffix(w, "es") && !text.re_match(`[sxz]es$|[cs]hes$`, w) {
n--
}
if n >= 3 {
matches = append(matches, {begin: m[0].begin, end: m[0].end})
}
}
+5 -2
View File
@@ -1,4 +1,7 @@
{ {
"feed": "https://github.com/errata-ai/Readability/releases.atom", "feed": "https://github.com/vale-cli/readability/releases.atom",
"vale_version": ">=2.13.0" "vale_version": ">=2.13.0",
"license_files": [
"LICENSE"
]
} }
+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
+18
View File
@@ -2,6 +2,24 @@
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.5] - 2026-09-28
### Bug Fixes
- Strip [skip ci] tokens and prefer newest commit in squash titles
## [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 ## [0.55.2] - 2026-09-19
### Bug Fixes ### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/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.2", "devx>=0.55.5",
] ]
[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.2"`) or use a version constraint > `dependencies` (for example, `"devx==0.55.5"`) or use a version constraint
> (for example, `"devx>=0.55.2,<0.56"`). > (for example, `"devx>=0.55.5,<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/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/5574c26619153f506b2159b98fe9cee987405999/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.2", "devx>=0.55.5",
] ]
[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.2"` or `"devx>=0.55.2,<0.56"`. Pin a specific version if needed: `"devx==0.55.5"` or `"devx>=0.55.5,<0.56"`.
### Optional extras ### Optional extras
+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.
+38
View File
@@ -0,0 +1,38 @@
# DEVX-178: PLAYBOOK_ROLE_MAP entry for deploy-controller playbook
## Problem
OBL-INFRA-655 adds the `deploy-controller.yml` playbook in the infra repo to the infra
repo (drives the new `deploy_controller` role). The infra molecule
coverage guard (`test_every_playbook_is_mapped`) requires every playbook
under `ansible/playbooks/` to appear in `PLAYBOOK_ROLE_MAP`. Without the
entry, the guard fails and unmapped playbooks fail open to all testable
roles — correct but wasteful.
## Approach
REQ-1: Add a `deploy-controller.yml` → `['deploy_controller']` entry
to `PLAYBOOK_ROLE_MAP` in `src/devx/molecule/molecule_changed.py` so
changes to the playbook select the `deploy_controller` molecule scenario.
## Test Plan
- Existing `test_molecule_changed.py` mapping tests cover new entries
generically; add an explicit assertion that the deploy-controller
playbook maps to `deploy_controller`.
- `make pytest-cov` (100% gate), `make lint-all`.
## Deploy Plan
Merge → devx release publishes automatically → infra dep-PR bumps the
pin; the mapping takes effect on the next infra CI run.
## Rollback Plan
Revert the squash commit; infra playbook falls back to fail-open all-role
selection (safe, slower).
## Acceptance Criteria
- [x] REQ-1: the deploy-controller playbook key maps to `["deploy_controller"]` in `PLAYBOOK_ROLE_MAP`.
- [x] Unit test pins the mapping; 100% coverage maintained.
+49
View File
@@ -0,0 +1,49 @@
# DEVX-179: auto-merge must never select `[skip ci]` squash titles
## Problem
PR #351 squash-merged as `DEVX-178: chore: update badge URLs to commit
4422c70b [skip ci]`. `extract_conventional_msg` picked a commit subject
carrying `[skip ci]` — a master badge commit surfaced in the PR's commit
list after force-push/amend rewrote branch history. The resulting merge
commit suppressed the post-merge push run: no release, no publish, no
wiki sync, no Vikunja close.
PR #352 shows the secondary defect: two `ci:` commits on the branch and
the older one won the tie, so the squash title described a throwaway
retrigger commit instead of the real change.
## Approach
REQ-1: Subjects containing `[skip ci]`, `[ci skip]`, or `[skip actions]`
(case-insensitive) are ineligible merge titles. They are excluded before
priority scoring, so a badge/chore/`[skip ci]` commit can never suppress
the post-merge pipeline again.
REQ-2: Equal-priority ties resolve to the newest commit in the returned
list (Gitea returns PR commits newest-first; iterate in returned order
and keep the first best candidate).
## Test Plan
- `test_auto_merge.py`: commits `[ci retrigger, badge-chore-skipci]` →
the `ci` subject wins; commits `[older-ci, newer-ci]` → newest wins;
all commits `[skip ci]` → falls back to newest non-skipped subject.
- `make pytest-cov` (100% gate), `make lint-all`.
## Deploy Plan
Merge as `fix:` → post-merge cuts a release that also ships DEVX-178's
PLAYBOOK_ROLE_MAP entry.
## Rollback Plan
Revert the squash commit; extract behavior returns to the previous
(unhardened) selection.
## Acceptance Criteria
- [x] REQ-1: `[skip ci]`/`[ci skip]`/`[skip actions]` subjects are never
selected.
- [x] REQ-2: Equal-priority ties pick the newest commit.
- [x] Unit tests cover both; 100% coverage maintained.
+35
View File
@@ -0,0 +1,35 @@
# DEVX-180: Add workflow_dispatch trigger to post-merge workflow
## Problem
When auto-merge produces a `[skip ci]` squash title (DEVX-179), the
post-merge push run is suppressed — release, publish, wiki sync, Vikunja
close, and badges are all skipped with no manual recovery path, because
`post-merge.yml` only triggers on `push`.
## Approach
REQ-1: Add `workflow_dispatch:` to the `on:` block of
`.gitea/workflows/post-merge.yml` so a missed or skipped post-merge run
can be dispatched manually on master.
## Test Plan
- `make workflow-check` (actionlint + act_runner dryrun) validates the
trigger syntax.
- Post-merge: dispatch the workflow manually once and confirm it runs
on master.
## Deploy Plan
Merge → post-merge push run fires normally → then dispatch
`post-merge.yml` on master to recover the suppressed DEVX-178 release.
## Rollback Plan
Revert the one-line trigger addition.
## Acceptance Criteria
- [x] REQ-1: `workflow_dispatch` present in `post-merge.yml` `on:` block;
actionlint and act_runner dryrun pass.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.55.2", "devx>=0.55.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.55.2", "devx>=0.55.5",
] ]
``` ```
+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.2" __version__ = "0.55.5"
+17 -8
View File
@@ -184,22 +184,31 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
) )
# Subjects carrying these tokens suppress post-merge CI entirely — strip them
# before a commit message can become the squash title (see DEVX-179).
_SKIP_CI_RE = re.compile(r"\s*\[(?:ci skip|skip ci|skip actions|actions skip|skip)\]", re.IGNORECASE)
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str: def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits. """Extract the conventional commit message from PR commits.
Picks the highest-priority conventional commit message from the PR. Picks the highest-priority conventional commit message from the PR.
Priority: feat > fix > refactor > docs > chore > other. Priority: feat > fix > refactor > docs > chore > other. The Gitea
Falls back to the newest commit message if none match. ``/pulls/{n}/commits`` endpoint returns commits newest-first, so the
first best-scoring subject wins equal-priority ties.
``[skip ci]``-style tokens are stripped from every candidate so the
merge title can never suppress the post-merge release pipeline.
""" """
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1} priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
best_msg = "" best_msg = ""
best_score = 0 best_score = 0
for commit in reversed(commits): for commit in commits:
commit_info = commit.get("commit", {}) commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0] message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so # Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
# conventional commit matching works on the remainder. # conventional commit matching works on the remainder; drop CI-skip
stripped = _TASK_ID_PREFIX_RE.sub("", message) # tokens so they never reach the merge title.
stripped = _SKIP_CI_RE.sub("", _TASK_ID_PREFIX_RE.sub("", message)).strip()
m = CONVENTIONAL_RE.match(stripped) m = CONVENTIONAL_RE.match(stripped)
if m: if m:
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)" prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
@@ -209,11 +218,11 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
best_msg = stripped best_msg = stripped
if best_msg: if best_msg:
return best_msg return best_msg
# Fallback: use the newest commit's first line (strip task ID prefix if present) # Fallback: the newest commit's first line (newest-first API order).
if commits: if commits:
commit_info = commits[-1].get("commit", {}) commit_info = commits[0].get("commit", {})
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0] raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return _TASK_ID_PREFIX_RE.sub("", raw) return _SKIP_CI_RE.sub("", _TASK_ID_PREFIX_RE.sub("", raw)).strip()
return "" return ""
+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:
+66 -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,29 @@ 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/deploy-controller.yml": ["deploy_controller"],
"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 +65,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 +94,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 +130,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 +165,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
+34
View File
@@ -232,6 +232,40 @@ class TestExtractConventionalMsg:
] ]
assert extract_conventional_msg(commits) == "random message" assert extract_conventional_msg(commits) == "random message"
def test_skip_ci_subjects_are_ineligible(self) -> None:
"""[skip ci] commits must never become the merge title (REQ-1)."""
commits = [
{"commit": {"message": "ci: retrigger validation"}},
{"commit": {"message": "chore: update badge URLs to commit abc123 [skip ci]"}},
]
assert extract_conventional_msg(commits) == "ci: retrigger validation"
def test_skip_ci_variants_excluded(self) -> None:
"""All skip-token spellings are ineligible."""
for token in ("[skip ci]", "[ci skip]", "[skip actions]", "[actions skip]", "[SKIP CI]"):
commits = [
{"commit": {"message": "feat: real change"}},
{"commit": {"message": f"chore: noise {token}"}},
]
assert extract_conventional_msg(commits) == "feat: real change"
def test_tie_prefers_newest_commit(self) -> None:
"""Equal-priority ties resolve to the newest commit (REQ-2); the
API returns commits newest-first."""
commits = [
{"commit": {"message": "ci: add workflow_dispatch trigger"}},
{"commit": {"message": "ci: retrigger validation"}},
]
assert extract_conventional_msg(commits) == "ci: add workflow_dispatch trigger"
def test_all_skip_ci_fallback_strips_token(self) -> None:
"""When every commit carries [skip ci], the token is stripped so
the merge still triggers post-merge."""
commits = [
{"commit": {"message": "chore: noise [skip ci]"}},
]
assert extract_conventional_msg(commits) == "chore: noise"
# -- run_cmd -- # -- run_cmd --
+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"))
+134 -91
View File
@@ -1,107 +1,195 @@
"""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 (
PLAYBOOK_ROLE_MAP,
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_deploy_controller_playbook(roles_dir: Path):
"""ansible.cfg change triggers all roles.""" """The deploy-controller playbook maps to the deploy_controller role."""
files = ["ansible/ansible.cfg"] assert PLAYBOOK_ROLE_MAP["ansible/playbooks/deploy-controller.yml"] == ["deploy_controller"]
roles = detect_changed_roles(files) (roles_dir / "deploy_controller" / "molecule" / "default").mkdir(parents=True)
assert len(roles) == 10 # all roles roles = detect_changed_roles(["ansible/playbooks/deploy-controller.yml"], roles_dir)
assert roles == {"deploy_controller"}
def test_detect_no_ansible_changes(): 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_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 +207,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 +224,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")