Compare commits

...
Author SHA1 Message Date
Emil Simeonov f95b611729 ci: retrigger validation 2026-09-28 17:01:02 +02:00
Emil Simeonov 213831f77b fix: strip [skip ci] tokens and prefer newest commit in squash titles 2026-09-28 16:55:10 +02: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
13 changed files with 263 additions and 23 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"
]
}
+6 -6
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/32e239ca6b5c1d1a1918c31ae536917b9914f847/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/python.svg)](https://www.python.org/downloads/)
## Why devx?
+6 -6
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/32e239ca6b5c1d1a1918c31ae536917b9914f847/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/python.svg)](https://www.python.org/downloads/)
## Overview
+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.
+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 ""
+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"],
+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)