Compare commits

...
12 Commits
Author SHA1 Message Date
gitea-actions-bot 0c26a288c9 chore: update badge URLs to commit d547d1fa [skip ci] 2026-09-30 16:20:23 +00:00
devx-ci-bot cc90aa0328 release: v0.56.0 [skip ci] 2026-09-30 16:18:59 +00:00
kireto e47778473c DEVX-182: feat(molecule): log lease owner and UTC expiry on emission
Post-merge / detect-and-configure (push) Successful in 1m58s
Post-merge / release-and-maintain (push) Successful in 2m45s
2026-09-30 16:15:40 +00:00
gitea-actions-bot 02a977c53f chore: update badge URLs to commit 93ce4062 [skip ci] 2026-09-30 16:00:32 +00:00
kireto cd5931a344 DEVX-181: ci: retrigger validation
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 59s
2026-09-30 15:59:16 +00:00
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
21 changed files with 602 additions and 31 deletions
+3
View File
@@ -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 }}
+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})
}
}
+6 -3
View File
@@ -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"
]
}
+12
View File
@@ -2,6 +2,18 @@
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
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.55.4",
"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.4"`) or use a version constraint
> (for example, `"devx>=0.55.4,<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
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.55.4",
"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.4"` or `"devx>=0.55.4,<0.56"`.
Pin a specific version if needed: `"devx==0.56.0"` or `"devx>=0.56.0,<0.57"`.
### Optional extras
+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.
+48
View File
@@ -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.
+49
View File
@@ -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.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.55.4",
"devx>=0.56.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.55.4",
"devx>=0.56.0",
]
```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects.
"""
__version__ = "0.55.4"
__version__ = "0.56.0"
+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:
"""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 ""
+88
View File
@@ -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()
+1
View File
@@ -47,6 +47,7 @@ def role_to_target(role: str) -> str:
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"],
"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"],
+11
View File
@@ -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)
+34
View File
@@ -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 --
+9
View File
@@ -13,6 +13,7 @@ import pytest
from click.testing import CliRunner
from devx.molecule.molecule_changed import (
PLAYBOOK_ROLE_MAP,
detect_changed_roles,
get_changed_files,
main,
@@ -52,6 +53,14 @@ def test_detect_playbook_change(roles_dir: Path):
assert roles == {"observability", "docker_base"}
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_shared_infra_triggers_all(roles_dir: Path):
"""ansible.cfg change triggers all testable roles only."""
roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
+123
View File
@@ -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