Public Access
Compare commits
18
Commits
85a36d42cd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c26a288c9 | ||
|
|
cc90aa0328 | ||
|
|
e47778473c | ||
|
|
02a977c53f | ||
|
|
cd5931a344 | ||
|
|
affd4d12ec | ||
|
|
e1ee03d129 | ||
|
|
c523a80848 | ||
|
|
ae0be43368 | ||
|
|
e6f30928bc | ||
|
|
3aca6024cf | ||
|
|
60ba4aabc4 | ||
|
|
b85fa86ccd | ||
|
|
ca55cfd29a | ||
|
|
6d3622465e | ||
|
|
8633980062 | ||
|
|
693e7962da | ||
|
|
9408111088 |
@@ -21,6 +21,9 @@ name: Post-merge
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# Implements: REQ-1 — manual recovery when a [skip ci] squash title
|
||||
# suppresses the push-triggered post-merge run.
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: post-merge-${{ github.ref }}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"feed": "https://github.com/errata-ai/Readability/releases.atom",
|
||||
"vale_version": ">=2.13.0"
|
||||
}
|
||||
"feed": "https://github.com/vale-cli/readability/releases.atom",
|
||||
"vale_version": ">=2.13.0",
|
||||
"license_files": [
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ Every change starts with a spec. No spec, no code.
|
||||
**CI gates (pre-merge):**
|
||||
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked
|
||||
- `devx.ci.check_pr_size` — max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
|
||||
- `devx.ci.fast_molecule` — converge+verify only for changed roles, single platform
|
||||
- `devx.ci.fast_molecule` — full `molecule test` for changed roles only (scoped, single platform)
|
||||
|
||||
**Nightly (infra only):**
|
||||
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.56.0] - 2026-09-30
|
||||
|
||||
### Features
|
||||
|
||||
- *(molecule)* Log lease owner and UTC expiry on emission
|
||||
|
||||
## [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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.55.2",
|
||||
"devx>=0.56.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (for example, `"devx==0.55.2"`) or use a version constraint
|
||||
> (for example, `"devx>=0.55.2,<0.56"`).
|
||||
> `dependencies` (for example, `"devx==0.56.0"`) or use a version constraint
|
||||
> (for example, `"devx>=0.56.0,<0.57"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.55.2",
|
||||
"devx>=0.56.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.55.2"` or `"devx>=0.55.2,<0.56"`.
|
||||
Pin a specific version if needed: `"devx==0.56.0"` or `"devx>=0.56.0,<0.57"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,48 @@
|
||||
# DEVX-181: Emit runner resource leases for CI molecule jobs (S08 producer side)
|
||||
|
||||
## Problem
|
||||
GRM-173 (S08) shipped consumer-side runner leases: `runner-cleanup.sh`
|
||||
honors `org.oblachno.lease-until` / `org.oblachno.owner` labels on
|
||||
containers, volumes, and networks. But no CI producer emits them:
|
||||
molecule containers/networks created on the runner's host rootless Docker
|
||||
socket carry only `owner=molecule`, so a pressure/critical cleanup can
|
||||
still wipe a molecule object that is stopped for a moment mid-run.
|
||||
Docker labels cannot be added post-creation — producers must emit them
|
||||
at create time.
|
||||
|
||||
## Approach
|
||||
REQ-1: New `devx.molecule.lease` module computing `MOLECULE_LEASE_UNTIL`
|
||||
(epoch = now + TTL, default 4h) and `MOLECULE_OWNER` (`run-<id>-<job>`
|
||||
from GITEA_*/GITHUB_* env, else `local-<hostname>`), with
|
||||
`emit_github_env()` appending them to `$GITHUB_ENV` and a Click CLI
|
||||
printing `KEY=value` lines.
|
||||
REQ-2: `start_docker.main()` emits the lease env after a successful
|
||||
Docker start — every molecule CI job already calls it, so no workflow
|
||||
edits are needed.
|
||||
REQ-3: Consumers (infra/grm `molecule.yml`) add platform labels
|
||||
`org.oblachno.lease-until: "${MOLECULE_LEASE_UNTIL:-0}"` and
|
||||
`org.oblachno.owner: "${MOLECULE_OWNER:-molecule-local}"` — `0` is
|
||||
already expired, so local/unset runs keep today's unleased behavior.
|
||||
Downstream changes land in the infra and grm repos.
|
||||
|
||||
## Test Plan
|
||||
- `tests/unit/test_molecule_lease.py` covers `lease_owner` env precedence
|
||||
and fallbacks, `lease_env` arithmetic, `emit_github_env` write/no-op,
|
||||
and the CLI — 100% coverage enforced by `make pytest-cov`.
|
||||
- Existing `test_start_docker.py` still green (emission is additive).
|
||||
|
||||
## Deploy Plan
|
||||
- Merge via auto-merge → post-merge releases and publishes devx; infra
|
||||
and grm pin bumps consume it via their molecule.yml label additions.
|
||||
|
||||
## Rollback Plan
|
||||
- Revert the merge; `start_docker` stops exporting the vars and molecule
|
||||
interpolation falls back to expired leases (`:-0`).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] REQ-1: `devx.molecule.lease` computes both env vars and writes them
|
||||
to `$GITHUB_ENV` when set.
|
||||
- [x] REQ-2: `start_docker` exports lease env on success without changing
|
||||
its existing behavior contract.
|
||||
- [x] REQ-3: the label contract (`org.oblachno.lease-until`,
|
||||
`org.oblachno.owner`) is exposed as module constants for consumers.
|
||||
@@ -0,0 +1,49 @@
|
||||
# DEVX-182: feat(molecule): release lease emission (ships DEVX-181)
|
||||
|
||||
- Task: DEVX-182
|
||||
- Status: draft
|
||||
|
||||
## Problem
|
||||
|
||||
DEVX-181 landed on master but was never released: the squash subject
|
||||
came out `DEVX-181: ci: retrigger validation` because the branch's last
|
||||
commit was an empty `ci:` retrigger. git-cliff found no bump-worthy
|
||||
commit, so `v0.55.5` is still latest and the S08 lease producer is
|
||||
unshipped. A `feat:` merge is needed to cut the release — and while
|
||||
shipping, the emission log line is upgraded from a bare count to the
|
||||
actual lease owner and UTC expiry, which is what an operator needs when
|
||||
debugging a cleanup race.
|
||||
|
||||
## Approach
|
||||
|
||||
REQ-1: `start_docker.main()` computes `lease_env()` once and logs
|
||||
`owner=` + ISO-8601 `until=` alongside the export confirmation. The
|
||||
single call also removes a latent second-boundary inconsistency where
|
||||
`emit_github_env` and the count message each re-derived `now`.
|
||||
|
||||
REQ-2: Merge squash commit carries `feat(molecule):` so git-cliff cuts
|
||||
the next minor release containing DEVX-181's emitter and this change.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Update `test_emits_lease_on_success` + `test_prints_and_exports` for
|
||||
the new log format; keep 100 % coverage.
|
||||
- `make pre-push` — full devx suite green.
|
||||
- Post-merge: verify a `v*` tag > v0.55.5 exists and
|
||||
`pip index versions devx` (Gitea PyPI) lists it.
|
||||
|
||||
## Deploy Plan
|
||||
|
||||
- Auto-merge; post-merge release job tags + publishes.
|
||||
- Infra (OBL-INFRA-658) and GRM (GRM-174) consume the new pin.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- Revert merge; the unreleased module remains dormant regardless.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] REQ-1: emission log includes owner and ISO-8601 expiry; lease env
|
||||
computed once — covered by updated unit tests.
|
||||
- [x] REQ-2: merge commit uses `feat(molecule):` so the release job
|
||||
cuts a version bump.
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.55.2",
|
||||
"devx>=0.56.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.55.2",
|
||||
"devx>=0.56.0",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
|
||||
molecule testing helpers for Ansible projects.
|
||||
"""
|
||||
|
||||
__version__ = "0.55.2"
|
||||
__version__ = "0.56.0"
|
||||
|
||||
@@ -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:
|
||||
"""Extract the conventional commit message from PR commits.
|
||||
|
||||
Picks the highest-priority conventional commit message from the PR.
|
||||
Priority: feat > fix > refactor > docs > chore > other.
|
||||
Falls back to the newest commit message if none match.
|
||||
Priority: feat > fix > refactor > docs > chore > other. The Gitea
|
||||
``/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}
|
||||
best_msg = ""
|
||||
best_score = 0
|
||||
for commit in reversed(commits):
|
||||
for commit in commits:
|
||||
commit_info = commit.get("commit", {})
|
||||
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
|
||||
# conventional commit matching works on the remainder.
|
||||
stripped = _TASK_ID_PREFIX_RE.sub("", message)
|
||||
# conventional commit matching works on the remainder; drop CI-skip
|
||||
# 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)
|
||||
if m:
|
||||
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
|
||||
if 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:
|
||||
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]
|
||||
return _TASK_ID_PREFIX_RE.sub("", raw)
|
||||
return _SKIP_CI_RE.sub("", _TASK_ID_PREFIX_RE.sub("", raw)).strip()
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
|
||||
playbook→role mapping and shared infrastructure paths).
|
||||
|
||||
Fast molecule = converge + verify only, single platform, no idempotence
|
||||
check. Used in pre-merge CI to get quick feedback on Ansible changes
|
||||
without running the full molecule suite (which runs nightly).
|
||||
Fast molecule = full ``molecule test`` for every scenario of each changed
|
||||
role, on the scenario's configured platform. "Fast" means *scoped* (only
|
||||
affected roles, single platform) — never skipped phases: create, converge,
|
||||
idempotence (when the scenario defines it), verify, and destroy all run,
|
||||
exactly as ``scripts/run_molecule_scenario.py`` executes them in CI.
|
||||
|
||||
Used in pre-merge CI to get quick feedback on Ansible changes without
|
||||
running the full all-roles/all-platforms suite (which runs nightly).
|
||||
|
||||
Usage:
|
||||
python -m devx.ci.fast_molecule --base origin/master --head HEAD
|
||||
|
||||
Outputs the list of changed roles and the molecule commands to run.
|
||||
Outputs the list of changed roles and the molecule commands CI runs.
|
||||
In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
|
||||
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
|
||||
"""
|
||||
@@ -48,19 +53,19 @@ def build_molecule_commands(
|
||||
roles_dir: str = "ansible/roles",
|
||||
platform: str = "ubuntu-2604",
|
||||
) -> list[str]:
|
||||
"""Build molecule test commands for changed roles.
|
||||
"""Build the molecule commands CI executes for changed roles.
|
||||
|
||||
For each role, runs each scenario with converge + verify only
|
||||
(skip create/destroy between scenarios, skip idempotence).
|
||||
Emits exactly what ``scripts/run_molecule_scenario.py`` runs:
|
||||
``molecule test -s <scenario>`` — the full sequence (create, converge,
|
||||
idempotence, verify, destroy). The ``platform`` argument is accepted
|
||||
for interface stability but is informational: the scenario's
|
||||
``molecule.yml`` selects the platform, and CI distributes scenarios so
|
||||
each runs on a single platform.
|
||||
"""
|
||||
commands: list[str] = []
|
||||
for role in sorted(roles):
|
||||
scenarios = get_molecule_scenarios(role, roles_dir)
|
||||
if not scenarios:
|
||||
continue
|
||||
for scenario in scenarios:
|
||||
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
|
||||
commands.append(cmd)
|
||||
for scenario in get_molecule_scenarios(role, roles_dir):
|
||||
commands.append(f"molecule test -s {scenario}")
|
||||
return commands
|
||||
|
||||
|
||||
@@ -93,7 +98,7 @@ def cli(
|
||||
write_github_output("fast-molecule-roles", "")
|
||||
return
|
||||
|
||||
roles = detect_changed_roles(files)
|
||||
roles = detect_changed_roles(files, roles_dir)
|
||||
if not roles:
|
||||
click.echo("[fast-molecule] No Ansible roles changed.")
|
||||
if github_output:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Emit bounded runner-resource lease env for CI molecule jobs.
|
||||
|
||||
Producer side of the S08 lease contract (consumer: GRM-173
|
||||
``runner-cleanup.sh``). The runner's scoped cleanup honors
|
||||
``org.oblachno.lease-until`` (epoch seconds) and ``org.oblachno.owner``
|
||||
labels on containers, volumes, and networks. Molecule runs on the runner
|
||||
host's rootless Docker socket, so objects it creates must carry the lease
|
||||
labels at creation time — Docker labels cannot be added post-hoc.
|
||||
|
||||
This module computes ``MOLECULE_LEASE_UNTIL`` and ``MOLECULE_OWNER``,
|
||||
which ``molecule.yml`` platform ``labels:`` entries consume via
|
||||
``${MOLECULE_LEASE_UNTIL:-0}`` / ``${MOLECULE_OWNER:-molecule-local}``
|
||||
interpolation, and appends them to ``$GITHUB_ENV`` so every later CI step
|
||||
inherits them. A lease-until of ``0`` (the interpolation default for
|
||||
local runs) is already expired, keeping today's unleased behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
#: Docker label contract consumed by runner-cleanup.sh (GRM-173).
|
||||
LEASE_UNTIL_LABEL = "org.oblachno.lease-until"
|
||||
OWNER_LABEL = "org.oblachno.owner"
|
||||
|
||||
#: Env vars consumed by molecule.yml platform ``labels:`` entries.
|
||||
LEASE_UNTIL_ENV = "MOLECULE_LEASE_UNTIL"
|
||||
OWNER_ENV = "MOLECULE_OWNER"
|
||||
|
||||
#: Default lease duration — covers the longest molecule job window.
|
||||
DEFAULT_TTL_SECONDS = 4 * 3600
|
||||
|
||||
|
||||
def lease_owner() -> str:
|
||||
"""Identify the owning CI run for the ``org.oblachno.owner`` label.
|
||||
|
||||
act_runner exposes GitHub-compatible ``GITHUB_*`` env vars;
|
||||
``GITEA_*`` names are checked first for forward compatibility.
|
||||
"""
|
||||
run_id = os.environ.get("GITEA_RUN_ID") or os.environ.get("GITHUB_RUN_ID")
|
||||
job = os.environ.get("GITEA_JOB") or os.environ.get("GITHUB_JOB")
|
||||
if run_id:
|
||||
return f"run-{run_id}-{job or 'job'}"
|
||||
return f"local-{socket.gethostname()}"
|
||||
|
||||
|
||||
def lease_env(ttl: int = DEFAULT_TTL_SECONDS, now: int | None = None) -> dict[str, str]:
|
||||
"""Return the lease env dict for the current run."""
|
||||
base = int(time.time()) if now is None else now
|
||||
return {
|
||||
LEASE_UNTIL_ENV: str(base + ttl),
|
||||
OWNER_ENV: lease_owner(),
|
||||
}
|
||||
|
||||
|
||||
def emit_github_env(env: dict[str, str]) -> int:
|
||||
"""Append lease env to ``$GITHUB_ENV``; return lines written (0 if unset)."""
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if not github_env:
|
||||
return 0
|
||||
with open(github_env, "a", encoding="utf-8") as f:
|
||||
for key, value in env.items():
|
||||
f.write(f"{key}={value}\n")
|
||||
return len(env)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--ttl",
|
||||
default=DEFAULT_TTL_SECONDS,
|
||||
type=int,
|
||||
help=f"Lease duration in seconds (default: {DEFAULT_TTL_SECONDS}).",
|
||||
)
|
||||
def main(ttl: int) -> None:
|
||||
"""Print lease env vars and export them to $GITHUB_ENV when set."""
|
||||
env = lease_env(ttl)
|
||||
for key, value in env.items():
|
||||
click.echo(f"{key}={value}")
|
||||
if emit_github_env(env):
|
||||
click.echo(f"Exported {len(env)} lease vars to GITHUB_ENV")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -17,8 +17,13 @@ nextcloud, postgres-upgrade, simple-app), the base target runs all
|
||||
scenarios for that role.
|
||||
|
||||
Playbooks that change also trigger molecule for the roles they include.
|
||||
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
|
||||
trigger all scenarios.
|
||||
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/,
|
||||
group_vars/) trigger all scenarios. Any ``ansible/playbooks/**`` file not
|
||||
in the explicit map fails open to all testable roles — targeted selection
|
||||
must never silently skip coverage.
|
||||
|
||||
Only roles that exist on disk and contain a ``molecule/`` directory are
|
||||
selected, so emitted make targets always resolve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,26 +35,29 @@ import click
|
||||
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
# Map role names to make targets.
|
||||
ROLE_TARGET_MAP: dict[str, str] = {
|
||||
"app_container": "molecule-app-container",
|
||||
"app_hardening": "molecule-app-hardening",
|
||||
"crowdsec": "molecule-crowdsec",
|
||||
"disk_cleanup": "molecule-disk-cleanup",
|
||||
"docker_base": "molecule-docker-base",
|
||||
"observability": "molecule-observability",
|
||||
"restore": "molecule-restore",
|
||||
"sso_config": "molecule-sso-config",
|
||||
"storage": "molecule-storage",
|
||||
"zitadel": "molecule-zitadel",
|
||||
}
|
||||
|
||||
def role_to_target(role: str) -> str:
|
||||
"""Make target for a role: docker_base -> molecule-docker-base."""
|
||||
return f"molecule-{role.replace('_', '-')}"
|
||||
|
||||
|
||||
# Playbooks that map to molecule scenarios (via roles they include).
|
||||
# Any ansible/playbooks/** file NOT listed here fails open to all testable
|
||||
# roles (REQ-1) — a missing entry must never mean "no coverage".
|
||||
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
|
||||
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
|
||||
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening", "sso_config"],
|
||||
"ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"],
|
||||
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening"],
|
||||
"ansible/playbooks/deploy-controller.yml": ["deploy_controller"],
|
||||
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"],
|
||||
"ansible/playbooks/restore.yml": ["restore"],
|
||||
"ansible/playbooks/upgrade-postgres.yml": ["app_container"],
|
||||
"ansible/playbooks/rolling-update-gitea.yml": ["app_container"],
|
||||
"ansible/playbooks/update-alertmanager.yml": ["observability"],
|
||||
"ansible/playbooks/build-image.yml": ["docker_base", "crowdsec", "disk_cleanup"],
|
||||
# sso_bridge role is checked out from the sso-bridge repo at deploy
|
||||
# time — no molecule coverage exists in the consuming repo, so the
|
||||
# explicit empty list documents "mapped, nothing local to test".
|
||||
"ansible/playbooks/deploy-sso-bridge.yml": [],
|
||||
}
|
||||
|
||||
# Shared infrastructure that affects all molecule tests.
|
||||
@@ -57,6 +65,7 @@ SHARED_PATHS = (
|
||||
"ansible/ansible.cfg",
|
||||
"ansible/requirements.yml",
|
||||
"ansible/molecule/",
|
||||
"ansible/group_vars/",
|
||||
)
|
||||
|
||||
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
|
||||
@@ -85,8 +94,30 @@ def get_changed_files(base: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def detect_changed_roles(changed_files: list[str]) -> set[str]:
|
||||
"""Detect which roles have changed files."""
|
||||
def _testable_roles(roles_dir: Path) -> set[str]:
|
||||
"""Roles present on disk that carry a molecule/ dir (i.e. have scenarios)."""
|
||||
if not roles_dir.is_dir():
|
||||
return set()
|
||||
return {d.name for d in roles_dir.iterdir() if d.is_dir() and (d / "molecule").is_dir()}
|
||||
|
||||
|
||||
def _is_playbook_file(filepath: str) -> bool:
|
||||
"""True for any file under ansible/playbooks/ (yml tasks included)."""
|
||||
return filepath.startswith("ansible/playbooks/")
|
||||
|
||||
|
||||
def detect_changed_roles(
|
||||
changed_files: list[str],
|
||||
roles_dir: str | Path = "ansible/roles",
|
||||
) -> set[str]:
|
||||
"""Detect which roles have changed files.
|
||||
|
||||
Implements: REQ-1, REQ-2 — fail open on unmapped ansible playbook or
|
||||
shared-path changes; only roles that exist under ``roles_dir`` are
|
||||
returned, so emitted targets always resolve.
|
||||
"""
|
||||
roles_path = Path(roles_dir)
|
||||
all_roles = _testable_roles(roles_path)
|
||||
roles: set[str] = set()
|
||||
|
||||
for filepath in changed_files:
|
||||
@@ -99,23 +130,23 @@ def detect_changed_roles(changed_files: list[str]) -> set[str]:
|
||||
# Check if file is a playbook that maps to roles
|
||||
if filepath in PLAYBOOK_ROLE_MAP:
|
||||
roles.update(PLAYBOOK_ROLE_MAP[filepath])
|
||||
elif _is_playbook_file(filepath):
|
||||
# Unmapped playbook/_tasks file — fail open to all testable roles.
|
||||
return set(all_roles)
|
||||
|
||||
# Check shared infrastructure — triggers all roles
|
||||
for shared in SHARED_PATHS:
|
||||
if filepath.startswith(shared):
|
||||
return set(ROLE_TARGET_MAP.keys())
|
||||
return set(all_roles)
|
||||
|
||||
return roles
|
||||
# REQ-2: drop roles that don't exist on disk (e.g. sso_config after the
|
||||
# role moved to the sso-bridge repo) so targets always resolve.
|
||||
return {r for r in roles if (roles_path / r).is_dir()}
|
||||
|
||||
|
||||
def roles_to_targets(roles: set[str]) -> list[str]:
|
||||
"""Convert role names to make targets."""
|
||||
targets = []
|
||||
for role in sorted(roles):
|
||||
target = ROLE_TARGET_MAP.get(role)
|
||||
if target:
|
||||
targets.append(target)
|
||||
return targets
|
||||
"""Convert role names to make targets (conventional molecule-<role>)."""
|
||||
return [role_to_target(role) for role in sorted(roles)]
|
||||
|
||||
|
||||
@click.command()
|
||||
@@ -134,14 +165,19 @@ def roles_to_targets(roles: set[str]) -> list[str]:
|
||||
is_flag=True,
|
||||
help="Print role names (default if no --print-targets).",
|
||||
)
|
||||
def main(base: str, print_targets: bool, print_roles: bool) -> None:
|
||||
@click.option(
|
||||
"--roles-dir",
|
||||
default="ansible/roles",
|
||||
help="Directory containing Ansible roles (default: ansible/roles).",
|
||||
)
|
||||
def main(base: str, print_targets: bool, print_roles: bool, roles_dir: str) -> None:
|
||||
"""Detect which Ansible roles changed and output molecule scenarios."""
|
||||
changed_files = get_changed_files(base)
|
||||
if not changed_files:
|
||||
click.echo("No changed files detected.", err=True)
|
||||
return
|
||||
|
||||
roles = detect_changed_roles(changed_files)
|
||||
roles = detect_changed_roles(changed_files, roles_dir)
|
||||
if not roles:
|
||||
click.echo("No molecule scenarios affected by changes.", err=True)
|
||||
return
|
||||
|
||||
@@ -31,10 +31,12 @@ import subprocess # nosec B404
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.molecule.lease import LEASE_UNTIL_ENV, OWNER_ENV, emit_github_env, lease_env
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DOCKER_SOCK = "/var/run/docker.sock"
|
||||
@@ -384,6 +386,15 @@ def main(timeout: int) -> None:
|
||||
with open(github_env, "a", encoding="utf-8") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
# Implements: REQ-2 (DEVX-181) — emit the S08 producer lease env so
|
||||
# molecule-created objects carry org.oblachno.lease-until/owner
|
||||
# labels that runner-cleanup.sh honors.
|
||||
# Implements: REQ-1 (DEVX-182) — one lease_env() call; log the
|
||||
# owner and UTC expiry an operator needs for cleanup debugging.
|
||||
env = lease_env()
|
||||
if emit_github_env(env):
|
||||
until = datetime.fromtimestamp(int(env[LEASE_UNTIL_ENV]), tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
click.echo(f"Exported lease vars to GITHUB_ENV (owner={env[OWNER_ENV]}, until={until})")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -232,6 +232,40 @@ class TestExtractConventionalMsg:
|
||||
]
|
||||
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 --
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ class TestBuildMoleculeCommands:
|
||||
|
||||
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
|
||||
assert len(commands) == 2
|
||||
assert all("molecule test -s default" in c for c in commands)
|
||||
assert all("--destroy=never" in c for c in commands)
|
||||
assert all("ubuntu-2604" in c for c in commands)
|
||||
# REQ-3: emitted commands are exactly what run_molecule_scenario
|
||||
# executes — full `molecule test -s <scenario>`, no synthetic flags.
|
||||
assert commands == ["molecule test -s default", "molecule test -s default"]
|
||||
assert all("--destroy" not in c and "--platform-name" not in c for c in commands)
|
||||
|
||||
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
|
||||
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
|
||||
|
||||
@@ -1,107 +1,195 @@
|
||||
"""Unit tests for devx.molecule.molecule_changed.
|
||||
|
||||
Verifies that the script correctly detects changed roles and maps
|
||||
them to make targets.
|
||||
Verifies that the script correctly detects changed roles, fails open on
|
||||
unmapped ansible paths, and only emits roles that exist on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.molecule_changed import (
|
||||
PLAYBOOK_ROLE_MAP,
|
||||
detect_changed_roles,
|
||||
get_changed_files,
|
||||
main,
|
||||
role_to_target,
|
||||
roles_to_targets,
|
||||
)
|
||||
|
||||
TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability")
|
||||
|
||||
def test_detect_role_change():
|
||||
|
||||
@pytest.fixture()
|
||||
def roles_dir(tmp_path: Path) -> Path:
|
||||
"""Fake roles dir: 4 testable roles (molecule/ present) + 1 untestable."""
|
||||
for role in TESTABLE_ROLES:
|
||||
(tmp_path / role / "molecule" / "default").mkdir(parents=True)
|
||||
(tmp_path / "untested_role").mkdir() # exists but no molecule/ dir
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_detect_role_change(roles_dir: Path):
|
||||
"""A file in ansible/roles/<role>/ maps to that role."""
|
||||
files = ["ansible/roles/docker_base/tasks/main.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "docker_base" in roles
|
||||
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
|
||||
assert roles == {"docker_base"}
|
||||
|
||||
|
||||
def test_detect_playbook_change():
|
||||
"""A playbook change maps to its included roles."""
|
||||
def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
|
||||
"""Changed role absent from roles_dir selects nothing (REQ-2)."""
|
||||
roles = detect_changed_roles(["ansible/roles/sso_config/tasks/main.yml"], roles_dir)
|
||||
assert roles == set()
|
||||
|
||||
|
||||
def test_detect_playbook_change(roles_dir: Path):
|
||||
"""A mapped playbook maps to its included roles, filtered to disk."""
|
||||
files = ["ansible/playbooks/deploy-observability.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "observability" in roles
|
||||
assert "docker_base" in roles
|
||||
assert "zitadel" in roles
|
||||
roles = detect_changed_roles(files, roles_dir)
|
||||
# zitadel + crowdsec are mapped but absent from the fake roles dir.
|
||||
assert roles == {"observability", "docker_base"}
|
||||
|
||||
|
||||
def test_detect_shared_infra_triggers_all():
|
||||
"""ansible.cfg change triggers all roles."""
|
||||
files = ["ansible/ansible.cfg"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10 # all roles
|
||||
def test_detect_deploy_controller_playbook(roles_dir: Path):
|
||||
"""The deploy-controller playbook maps to the deploy_controller role."""
|
||||
assert PLAYBOOK_ROLE_MAP["ansible/playbooks/deploy-controller.yml"] == ["deploy_controller"]
|
||||
(roles_dir / "deploy_controller" / "molecule" / "default").mkdir(parents=True)
|
||||
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."""
|
||||
files = ["scripts/molecule_changed.py", "Makefile"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 0
|
||||
roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
|
||||
assert roles == set()
|
||||
|
||||
|
||||
def test_detect_environments_not_molecule_covered(roles_dir: Path):
|
||||
"""ansible/environments/ data is not molecule-covered (documented)."""
|
||||
roles = detect_changed_roles(["ansible/environments/staging/customers.yml"], roles_dir)
|
||||
assert roles == set()
|
||||
|
||||
|
||||
def test_detect_missing_roles_dir():
|
||||
"""A nonexistent roles_dir yields no roles (honest: nothing testable)."""
|
||||
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], "/nonexistent")
|
||||
assert roles == set()
|
||||
|
||||
|
||||
def test_role_to_target():
|
||||
"""Role names map to conventional make targets."""
|
||||
assert role_to_target("docker_base") == "molecule-docker-base"
|
||||
assert role_to_target("app_hardening") == "molecule-app-hardening"
|
||||
|
||||
|
||||
def test_roles_to_targets():
|
||||
"""Role names map to make targets."""
|
||||
targets = roles_to_targets({"docker_base", "zitadel"})
|
||||
assert "molecule-docker-base" in targets
|
||||
assert "molecule-zitadel" in targets
|
||||
|
||||
|
||||
def test_roles_to_targets_unknown_role():
|
||||
"""Unknown roles are silently skipped."""
|
||||
targets = roles_to_targets({"docker_base", "unknown_role"})
|
||||
assert targets == ["molecule-docker-base"]
|
||||
assert targets == ["molecule-docker-base", "molecule-zitadel"]
|
||||
|
||||
|
||||
def test_main_no_changes():
|
||||
"""When no files changed, outputs message to stderr."""
|
||||
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
result = CliRunner().invoke(main, ["--print-targets"])
|
||||
assert result.exit_code == 0
|
||||
assert "No changed files" in result.output
|
||||
|
||||
|
||||
def test_main_print_targets():
|
||||
"""--print-targets outputs make targets."""
|
||||
def test_main_print_targets(roles_dir: Path):
|
||||
"""--print-targets outputs make targets for existing roles."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/docker_base/tasks/main.yml"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "molecule-docker-base" in result.output
|
||||
|
||||
|
||||
def test_main_print_roles():
|
||||
def test_main_print_roles(roles_dir: Path):
|
||||
"""--print-roles outputs role names."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||
return_value=["ansible/roles/restore/tasks/main.yml"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-roles"])
|
||||
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "zitadel" in result.output
|
||||
assert "restore" in result.output
|
||||
|
||||
|
||||
def test_main_no_ansible_changes():
|
||||
def test_main_no_ansible_changes(roles_dir: Path):
|
||||
"""When only non-Ansible files changed, outputs no scenarios message."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["scripts/molecule_changed.py"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "No molecule scenarios" in result.output
|
||||
|
||||
@@ -119,7 +207,6 @@ def test_get_changed_files_falls_back_to_master():
|
||||
|
||||
def mock_git(args):
|
||||
calls.append(args)
|
||||
# First call (origin/master) returns empty, second (master) returns files
|
||||
if "origin/master...HEAD" in args[2]:
|
||||
return ""
|
||||
return "ansible/roles/docker_base/tasks/main.yml\n"
|
||||
@@ -137,56 +224,12 @@ def test_get_changed_files_empty():
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_detect_molecule_shared_path():
|
||||
"""ansible/molecule/ change triggers all roles."""
|
||||
files = ["ansible/molecule/Dockerfile"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10
|
||||
|
||||
|
||||
def test_detect_requirements_yml_triggers_all():
|
||||
"""ansible/requirements.yml change triggers all roles."""
|
||||
files = ["ansible/requirements.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10
|
||||
|
||||
|
||||
def test_detect_configure_oidc_playbook():
|
||||
"""configure-oidc.yml maps to sso_config and app_container."""
|
||||
files = ["ansible/playbooks/configure-oidc.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "sso_config" in roles
|
||||
assert "app_container" in roles
|
||||
|
||||
|
||||
def test_detect_prepare_vms_playbook():
|
||||
"""prepare-vms.yml maps to all base roles."""
|
||||
files = ["ansible/playbooks/prepare-vms.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "docker_base" in roles
|
||||
assert "app_hardening" in roles
|
||||
assert "storage" in roles
|
||||
assert "disk_cleanup" in roles
|
||||
assert "crowdsec" in roles
|
||||
|
||||
|
||||
def test_detect_deploy_customer_playbook():
|
||||
"""deploy-customer.yml maps to its roles."""
|
||||
files = ["ansible/playbooks/deploy-customer.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "app_container" in roles
|
||||
assert "docker_base" in roles
|
||||
assert "app_hardening" in roles
|
||||
assert "sso_config" in roles
|
||||
|
||||
|
||||
def test_main_default_base():
|
||||
def test_main_default_base(roles_dir: Path):
|
||||
"""main() with no --base uses origin/master."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||
return_value=["ansible/roles/restore/tasks/main.yml"],
|
||||
) as mock:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-roles"])
|
||||
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
||||
assert result.exit_code == 0
|
||||
mock.assert_called_once_with("origin/master")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit tests for devx.molecule.lease."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.lease import (
|
||||
DEFAULT_TTL_SECONDS,
|
||||
LEASE_UNTIL_ENV,
|
||||
OWNER_ENV,
|
||||
emit_github_env,
|
||||
lease_env,
|
||||
lease_owner,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestLeaseOwner:
|
||||
def test_gitea_env_preferred(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_RUN_ID": "123", "GITEA_JOB": "tests", "GITHUB_RUN_ID": "999"},
|
||||
):
|
||||
assert lease_owner() == "run-123-tests"
|
||||
|
||||
def test_github_env_fallback(self) -> None:
|
||||
env = {"GITHUB_RUN_ID": "777", "GITHUB_JOB": "molecule"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert lease_owner() == "run-777-molecule"
|
||||
|
||||
def test_job_defaults_when_missing(self) -> None:
|
||||
with patch.dict(os.environ, {"GITHUB_RUN_ID": "5"}, clear=True):
|
||||
assert lease_owner() == "run-5-job"
|
||||
|
||||
def test_local_fallback(self) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("devx.molecule.lease.socket.gethostname", return_value="devbox"),
|
||||
):
|
||||
assert lease_owner() == "local-devbox"
|
||||
|
||||
|
||||
class TestLeaseEnv:
|
||||
def test_arithmetic(self) -> None:
|
||||
env = lease_env(ttl=3600, now=1000)
|
||||
assert env[LEASE_UNTIL_ENV] == str(1000 + 3600)
|
||||
assert OWNER_ENV in env
|
||||
|
||||
def test_default_now_uses_time(self) -> None:
|
||||
with patch("devx.molecule.lease.time.time", return_value=2000.9):
|
||||
env = lease_env(ttl=60)
|
||||
assert env[LEASE_UNTIL_ENV] == str(2000 + 60)
|
||||
|
||||
def test_default_ttl(self) -> None:
|
||||
env = lease_env(now=0)
|
||||
assert env[LEASE_UNTIL_ENV] == str(DEFAULT_TTL_SECONDS)
|
||||
|
||||
|
||||
class TestEmitGithubEnv:
|
||||
def test_writes_lines(self) -> None:
|
||||
m = mock_open()
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env"}, clear=False),
|
||||
patch("builtins.open", m),
|
||||
):
|
||||
written = emit_github_env({"A": "1", "B": "2"})
|
||||
assert written == 2
|
||||
m.assert_called_once_with("/tmp/env", "a", encoding="utf-8")
|
||||
m().write.assert_any_call("A=1\n")
|
||||
m().write.assert_any_call("B=2\n")
|
||||
|
||||
def test_noop_without_env(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert emit_github_env({"A": "1"}) == 0
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_prints_and_exports(self) -> None:
|
||||
runner = CliRunner()
|
||||
m = mock_open()
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "42"}, clear=True),
|
||||
patch("builtins.open", m),
|
||||
patch("devx.molecule.lease.time.time", return_value=100.0),
|
||||
):
|
||||
result = runner.invoke(main, ["--ttl", "600"])
|
||||
assert result.exit_code == 0
|
||||
assert f"{LEASE_UNTIL_ENV}=700" in result.output
|
||||
assert f"{OWNER_ENV}=run-42-job" in result.output
|
||||
assert "Exported 2 lease vars" in result.output
|
||||
|
||||
def test_prints_without_github_env(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_RUN_ID": "9", "GITHUB_JOB": "tests"}, clear=True),
|
||||
patch("devx.molecule.lease.time.time", return_value=50.0),
|
||||
):
|
||||
result = runner.invoke(main)
|
||||
assert result.exit_code == 0
|
||||
assert f"{LEASE_UNTIL_ENV}={50 + DEFAULT_TTL_SECONDS}" in result.output
|
||||
assert f"{OWNER_ENV}=run-9-tests" in result.output
|
||||
assert "GITHUB_ENV" not in result.output
|
||||
|
||||
|
||||
class TestStartDockerIntegration:
|
||||
"""start_docker.main() must emit lease env on success (REQ-2)."""
|
||||
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_emits_lease_on_success(self, _mock: MagicMock) -> None:
|
||||
from devx.molecule.start_docker import main as start_docker_main
|
||||
|
||||
runner = CliRunner()
|
||||
m = mock_open()
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "7"}, clear=True),
|
||||
patch("builtins.open", m),
|
||||
):
|
||||
result = runner.invoke(start_docker_main, ["--timeout", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "Exported lease vars to GITHUB_ENV" in result.output
|
||||
assert "owner=run-7-job" in result.output
|
||||
assert "until=" in result.output
|
||||
Reference in New Issue
Block a user