Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5ce33a12 | ||
|
|
0fae419584 | ||
|
|
70b011d4a6 | ||
|
|
a5c16a92df | ||
|
|
e6f022ae96 | ||
|
|
1a27983750 |
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.27.2] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry release push on non-fast-forward with rebase loop
|
||||
|
||||
## [0.27.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
|
||||
|
||||
## [0.27.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.27.0"
|
||||
__version__ = "0.27.2"
|
||||
|
||||
+27
-39
@@ -49,13 +49,31 @@ REQUIRED_DOC_FILES = ["index.md"]
|
||||
# Maximum age for docs before they're considered stale (days)
|
||||
STALE_THRESHOLD_DAYS = 180
|
||||
|
||||
# Files excluded from duplicate heading checks (auto-generated or structured)
|
||||
DUPLICATE_HEADING_EXCLUDES = {"CHANGELOG.md"}
|
||||
# Files excluded from duplicate heading checks (auto-generated or structured
|
||||
# with repeated subsections under different parent sections)
|
||||
DUPLICATE_HEADING_EXCLUDES = {
|
||||
"CHANGELOG.md",
|
||||
"incident-response-sso.md",
|
||||
"role-sync-design.md",
|
||||
}
|
||||
|
||||
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
|
||||
# Does NOT match references to the word "TODO" in rules/documentation
|
||||
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
|
||||
|
||||
# Directories excluded from markdown file scanning
|
||||
_EXCLUDE_DIRS = {
|
||||
".venv",
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".devin",
|
||||
".terraform",
|
||||
"site-packages",
|
||||
"dist-info",
|
||||
}
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert heading text to a GitHub-style slug."""
|
||||
@@ -155,11 +173,7 @@ def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||
issues: list[str] = []
|
||||
md_files = list(root.rglob("*.md"))
|
||||
# Exclude .venv, .git, node_modules
|
||||
md_files = [
|
||||
f
|
||||
for f in md_files
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
# Load wiki page names from mapping.json — these are valid link targets
|
||||
wiki_pages: set[str] = set()
|
||||
@@ -216,11 +230,7 @@ def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||
def check_heading_hierarchy(root: Path) -> list[str]:
|
||||
"""Check that headings don't skip levels."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
@@ -242,11 +252,7 @@ def check_todo_fixme(root: Path) -> list[str]:
|
||||
references to the word "TODO" in rules or documentation about TODOs.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
@@ -262,11 +268,7 @@ def check_todo_fixme(root: Path) -> list[str]:
|
||||
def check_trailing_whitespace(root: Path) -> list[str]:
|
||||
"""Check for trailing whitespace in markdown files."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
@@ -282,11 +284,7 @@ def check_stale_docs(root: Path) -> list[str]:
|
||||
"""Check for stale documentation (not modified in >180 days)."""
|
||||
issues: list[str] = []
|
||||
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
@@ -301,11 +299,7 @@ def check_stale_docs(root: Path) -> list[str]:
|
||||
def check_duplicate_headings(root: Path) -> list[str]:
|
||||
"""Check for duplicate headings within the same file."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
@@ -386,13 +380,7 @@ def main(
|
||||
ws_issues = check_trailing_whitespace(root_path)
|
||||
if fix and ws_issues:
|
||||
fixed = 0
|
||||
md_files = [
|
||||
f
|
||||
for f in root_path.rglob("*.md")
|
||||
if not any(
|
||||
part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts
|
||||
)
|
||||
]
|
||||
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
for md_file in md_files:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
fixed_content = _TRAILING_WS_RE.sub("", content)
|
||||
|
||||
+34
-3
@@ -38,6 +38,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("Created release commit."))
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
||||
# Retry up to 3 times to handle concurrent pushes.
|
||||
push_succeeded = False
|
||||
for attempt in range(3):
|
||||
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
if rebase.returncode != 0:
|
||||
# Rebase failed (likely conflicts). Abort and retry.
|
||||
click.echo(
|
||||
_(
|
||||
"Rebase attempt {n}/3 failed: {err}",
|
||||
n=attempt + 1,
|
||||
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
|
||||
)
|
||||
)
|
||||
run_cmd(["git", "rebase", "--abort"], check=False)
|
||||
# Brief delay before retry to let concurrent pushes settle.
|
||||
time.sleep(5)
|
||||
continue
|
||||
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
|
||||
if push.returncode == 0:
|
||||
push_succeeded = True
|
||||
break
|
||||
click.echo(
|
||||
_(
|
||||
"Push attempt {n}/3 failed: {err}",
|
||||
n=attempt + 1,
|
||||
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
|
||||
)
|
||||
)
|
||||
time.sleep(5)
|
||||
if not push_succeeded:
|
||||
raise click.ClickException(
|
||||
_("Failed to push release commit after 3 attempts. Manual intervention required.")
|
||||
)
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
@@ -2886,5 +2886,29 @@
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||
},
|
||||
"Push attempt {n}/3 failed: {err}": {
|
||||
"bg": "Push attempt {n}/3 failed: {err}",
|
||||
"de": "Push attempt {n}/3 failed: {err}",
|
||||
"en": "Push attempt {n}/3 failed: {err}",
|
||||
"pl": "Push attempt {n}/3 failed: {err}",
|
||||
"ru": "Push attempt {n}/3 failed: {err}",
|
||||
"zh": "Push attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Rebase attempt {n}/3 failed: {err}": {
|
||||
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,6 +1234,153 @@ class TestMain:
|
||||
assert "already existed" in result.output
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_retry_succeeds_after_rebase_failure(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Push should retry after rebase failure and succeed on second attempt."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
|
||||
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
|
||||
push_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
# git rev-parse → ok, git log -1 → ok (non-release msg)
|
||||
# pull --rebase → fail, rebase --abort → ok
|
||||
# pull --rebase → ok, push → ok
|
||||
mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Rebase attempt 1/3 failed" in result.output
|
||||
assert "Pushed release commit to master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_fails_after_all_retries(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Push should fail after 3 unsuccessful rebase attempts."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
|
||||
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
|
||||
# git rev-parse → ok, git log -1 → ok
|
||||
# 3 attempts: pull --rebase → fail, rebase --abort → ok
|
||||
mock_run_cmd.side_effect = [
|
||||
ok,
|
||||
ok,
|
||||
rebase_fail,
|
||||
rebase_abort, # attempt 1
|
||||
rebase_fail,
|
||||
rebase_abort, # attempt 2
|
||||
rebase_fail,
|
||||
rebase_abort, # attempt 3
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to push release commit after 3 attempts" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@patch("devx.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_retry_succeeds_after_push_failure(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_update_init: MagicMock,
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Push should retry after push rejection and succeed on second attempt."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
rebase_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward")
|
||||
push_ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
# git rev-parse → ok, git log -1 → ok
|
||||
# attempt 1: pull --rebase → ok, push → fail
|
||||
# attempt 2: pull --rebase → ok, push → ok
|
||||
mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Push attempt 1/3 failed" in result.output
|
||||
assert "Pushed release commit to master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
|
||||
Reference in New Issue
Block a user