Public Access
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95b611729 | ||
|
|
213831f77b | ||
|
|
ae0be43368 | ||
|
|
e6f30928bc | ||
|
|
3aca6024cf | ||
|
|
60ba4aabc4 |
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 --
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user