diff --git a/.taskid b/.taskid index 272a5b4..8cf990a 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-9 +DEVX-10 diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c6adf..744214d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,27 +31,30 @@ All notable changes to this project will be documented in this file. ### Features - Add DEFAULT_INFRASTRUCTURE and configurable task prefix + ## [0.3.0] - 2026-06-22 ### Features - Add --no-ansible-collections option to setup tool + ## [0.2.0] - 2026-06-22 ### Features - Pluggable change classification framework + ## [0.1.2] - 2026-06-22 ### Bug Fixes - Make sync-wiki and vikunja depend on release + ## [0.1.1] - 2026-06-22 ### Bug Fixes - Disable push whitelist, allow direct pushes to master -## [0.1.0] - 2026-06-22 ## [0.1.0] - 2026-06-22 diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 4e781db..0b97316 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -74,6 +74,17 @@ Configure repository: branch protection + labels via Gitea API. Generate self-contained SVG badge files from project metrics. +### `devx tools generate-cliff-config` + +Generate a `cliff.toml` configuration file with the correct task ID prefix. +Eliminates the need to manually duplicate and maintain cliff.toml across +repos that use devx. + +```bash +python -m devx.tools.generate_cliff_config --prefix GRM +python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing +``` + ### `devx tools install-checkmake` Install checkmake (Makefile linter) if not already present. diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index 9a0e799..ea19520 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -23,8 +23,14 @@ This script is idempotent: if there are no new conventional commits since the last tag, it exits with a message and does nothing. If the tag already exists (e.g., from a partial previous run), it skips tag creation and only pushes. +**Tag consistency**: Before releasing, the script fetches remote tags and +verifies all existing tags point to commits whose message matches the tag +version. This prevents duplicate release commits (a common issue when CI +checkouts don't fetch tags) and ensures tag/version/commit alignment. + Usage: REPO_TOKEN= python3 -m devx.ci.release [--dry-run] [--skip-tests] + python3 -m devx.ci.release --verify # Check tag/version/release alignment """ from __future__ import annotations @@ -32,6 +38,7 @@ from __future__ import annotations import os import re import subprocess # nosec B404 +import sys import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] @@ -79,6 +86,81 @@ def tag_exists(tag: str) -> bool: return bool(result.stdout.strip()) +def get_tag_commit(tag: str) -> str: + """Get the commit hash a tag points to.""" + result = run_cmd(["git", "rev-list", "-n1", tag], check=False) + return result.stdout.strip() + + +def get_head_commit() -> str: + """Get the current HEAD commit hash.""" + result = run_cmd(["git", "rev-parse", "HEAD"], check=False) + return result.stdout.strip() + + +def fetch_tags() -> None: + """Fetch tags from remote to ensure local tag state is current. + + This is critical in CI environments where a fresh checkout may not + include tags from previous runs. Without this, the script may + create duplicate release commits because ``tag_exists`` returns False + for a tag that exists on the remote but wasn't fetched. + """ + result = run_cmd(["git", "fetch", "--tags", "origin"], check=False) + if result.returncode != 0: + # Don't fail hard — maybe there's no remote (local-only repo) + click.echo(_("Warning: could not fetch tags from origin.")) + + +def get_all_tags() -> list[str]: + """Get all git tags sorted by version (newest first).""" + result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False) + if result.returncode != 0: + return [] + return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()] + + +def get_commit_version(commit: str) -> str | None: + """Extract version from a release commit message. + + Returns the version string (e.g., '0.4.4') or None if the commit + is not a release commit. + """ + result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False) + match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip()) + return match.group(1) if match else None + + +def verify_tag_consistency() -> list[str]: + """Verify all tags point to commits with matching version in message. + + Returns a list of error messages for inconsistent tags. + An empty list means all tags are consistent. + + The first release (v0.1.0 or earliest tag) is exempt — initial releases + often don't have a "release:" commit message (e.g., the initial commit + serves as the first release). + """ + errors: list[str] = [] + tags = get_all_tags() + # Sort oldest first to identify the first tag + sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")]) + first_tag = sorted_tags[0] if sorted_tags else None + for tag in tags: + tag_version = tag.lstrip("v") + commit_version = get_commit_version(tag) + if commit_version is None: + # First tag is allowed to point to a non-release commit (initial release) + if tag == first_tag: + continue + errors.append( + f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)" + ) + elif commit_version != tag_version: + errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')") + return errors + + def get_bumped_version() -> str: """Use git-cliff to calculate the next version from conventional commits.""" result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG]) @@ -123,9 +205,12 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool: latest = get_latest_tag() if not latest: return True - # Check for any commits since the last tag + # Check for any commits since the last tag, excluding release commits + # (release commits themselves are not "unreleased changes" — they ARE + # the release). This prevents duplicate release commits when the + # script runs multiple times. result = run_cmd( - ["git", "log", f"{latest}..HEAD", "--oneline"], + ["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"], check=False, ) if result.returncode != 0: @@ -239,10 +324,27 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool """Create an annotated tag with the changelog as message and push it. Returns True if the tag was created/pushed, False if it already existed. + Raises an error if the tag exists but points to a different commit than HEAD. """ tag = f"v{new_version}" if tag_exists(tag): - click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag)) + # Verify the tag points to HEAD — if it points elsewhere, that's + # a consistency error, not a skip condition. + tag_commit = get_tag_commit(tag) + head_commit = get_head_commit() + if tag_commit != head_commit: + raise click.ClickException( + _( + "Tag {tag} already exists but points to {tag_commit} " + "(expected HEAD {head_commit}). " + "This indicates a tag/commit misalignment. " + "Run 'python3 -m devx.ci.release --verify' for details.", + tag=tag, + tag_commit=tag_commit[:7], + head_commit=head_commit[:7], + ) + ) + click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag)) if not dry_run: # Ensure the existing tag is pushed run_cmd(["git", "push", "origin", tag], check=False) @@ -256,6 +358,178 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool return True +# --------------------------------------------------------------------------- +# Verification mode +# --------------------------------------------------------------------------- + + +def get_init_version() -> str | None: + """Read __version__ from the version file.""" + try: + with open(INIT_FILE) as f: + content = f.read() + match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE) + return match.group(1) if match else None + except FileNotFoundError: + return None + + +def get_changelog_versions() -> list[str]: + """Extract version numbers from CHANGELOG.md headers, in order.""" + try: + with open(CHANGELOG_FILE) as f: + content = f.read() + return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE) + except FileNotFoundError: + return [] + + +def verify_alignment() -> int: + """Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues).""" + click.echo(_("=== Release Alignment Verification ===\n")) + + has_issues = False + + # 1. Check __version__ matches latest tag + init_version = get_init_version() + latest_tag = get_latest_tag() + latest_tag_version = latest_tag.lstrip("v") if latest_tag else None + + click.echo(_("Version file: {file}", file=INIT_FILE)) + if init_version: + click.echo(f' __version__ = "{init_version}"') + else: + click.echo(" __version__ = NOT FOUND") + has_issues = True + + click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)")) + if latest_tag_version and init_version: + if latest_tag_version == init_version: + click.echo(f" ✓ Tag version matches __version__ ({init_version})") + else: + click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}") + has_issues = True + + # 2. Check all tags point to commits with matching version + click.echo(_("\nTag → Commit alignment:")) + tag_errors = verify_tag_consistency() + all_tags = get_all_tags() + if not all_tags: + click.echo(" (no tags)") + elif not tag_errors: + click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits") + else: + has_issues = True + for err in tag_errors: + click.echo(f" ✗ {err}") + + # 3. Check CHANGELOG versions are in descending order + click.echo(_("\nCHANGELOG version ordering:")) + changelog_versions = get_changelog_versions() + if not changelog_versions: + click.echo(" (no versions in CHANGELOG)") + else: + # Check for duplicates + seen: set[str] = set() + duplicates: list[str] = [] + for v in changelog_versions: + if v in seen: + duplicates.append(v) + seen.add(v) + + # Check ordering (should be descending) + is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1)) + + if duplicates: + has_issues = True + click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}") + elif not is_ordered: + has_issues = True + click.echo(f" ✗ Versions not in descending order: {changelog_versions}") + else: + click.echo(f" ✓ {len(changelog_versions)} versions, all in descending order") + + # Check latest CHANGELOG version matches latest tag. + # The CHANGELOG may have one unreleased section ahead of the latest tag + # (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased). + if changelog_versions and latest_tag_version: + if changelog_versions[0] == latest_tag_version: + click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})") + elif latest_tag_version in changelog_versions: + tag_idx = changelog_versions.index(latest_tag_version) + # Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead) + if tag_idx == 1: + click.echo( + f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, " + f"latest tag is {latest_tag_version}" + ) + else: + click.echo( + f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, " + f"tag={latest_tag_version} (tag is at position {tag_idx})" + ) + has_issues = True + else: + click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}") + has_issues = True + + # 4. Check for untagged release commits. + # Distinguish between: + # - Truly untagged: no tag exists for that version (needs a tag) + # - Duplicates: a tag for that version exists but on a different commit + # (historical artifact from buggy release script — informational, not an error) + click.echo(_("\nUntagged release commits:")) + result = run_cmd( + ["git", "log", "--all", "--format=%h %s", "--grep=^release: v"], + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + all_release_commits = result.stdout.strip().split("\n") + all_tags_set = {t.lstrip("v") for t in get_all_tags()} + truly_untagged: list[str] = [] + duplicates: list[str] = [] + for line in all_release_commits: + short_hash = line.split()[0] + tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False) + if not tags_at.stdout.strip(): + # Check if a tag for this version exists elsewhere + match = re.search(r"release: v(\d+\.\d+\.\d+)", line) + if match and match.group(1) in all_tags_set: + duplicates.append(line) + else: + truly_untagged.append(line) + if truly_untagged: + has_issues = True + click.echo(f" ✗ {len(truly_untagged)} untagged release commits (no tag for version):") + for c in truly_untagged[:10]: + click.echo(f" {c}") + if len(truly_untagged) > 10: + click.echo(f" ... and {len(truly_untagged) - 10} more") + else: + click.echo(" ✓ All release commits have tags") + if duplicates: + click.echo(f" ℹ {len(duplicates)} duplicate release commits (tag exists on different commit):") + for c in duplicates[:5]: + click.echo(f" {c}") + if len(duplicates) > 5: + click.echo(f" ... and {len(duplicates) - 5} more") + else: + click.echo(" (no release commits found)") + + # Summary + click.echo(_("\n=== Summary ===")) + if has_issues: + click.echo("✗ Issues found — see above for details.") + return 1 + click.echo("✓ All checks passed — tags, versions, and changelog are aligned.") + return 0 + + +# --------------------------------------------------------------------------- +# Main command +# --------------------------------------------------------------------------- + + @click.command() @click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.") @click.option( @@ -264,7 +538,20 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool default=False, help="Skip lint and test verification (NOT recommended — only for emergency releases).", ) -def main(dry_run: bool, skip_tests: bool) -> None: +@click.option( + "--verify", + is_flag=True, + default=False, + help="Verify tag/version/changelog alignment and exit (no changes made).", +) +def main(dry_run: bool, skip_tests: bool, verify: bool) -> None: + """Automated release: calculate next version, update files, tag, and push. + + Use --verify to check tag/version/changelog alignment without making changes. + """ + if verify: + sys.exit(verify_alignment()) + # Ensure we're on master (skip this check in dry-run mode for PR validation) branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() if branch != "master" and not dry_run: @@ -277,19 +564,56 @@ def main(dry_run: bool, skip_tests: bool) -> None: ) ) + # Fetch tags from remote to ensure local tag state is current. + # This is critical in CI where a fresh checkout may not include tags + # from previous runs. Without this, tag_exists() returns False for + # tags that exist on the remote, leading to duplicate release commits. + if not dry_run: + fetch_tags() + + # Pre-flight: verify existing tags are consistent. If any tag points + # to a commit with a mismatched version, abort before creating more + # inconsistencies. + tag_errors = verify_tag_consistency() + if tag_errors: + click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:")) + for err in tag_errors: + click.echo(err) + click.echo( + _( + "\nFix the misaligned tags before creating new releases. " + "Run 'python3 -m devx.ci.release --verify' for a full report." + ) + ) + raise click.ClickException(_("Tag consistency check failed.")) + # Release lock: if HEAD is already a release commit, check if the tag - # exists. If the tag is missing (e.g., tag push failed in a previous run), - # create and push it instead of skipping — this recovers from the - # common failure mode where the commit was pushed but the tag was not. + # exists AND points to HEAD. If the tag is missing (e.g., tag push + # failed in a previous run), create and push it. If the tag exists + # but points elsewhere, that's an error. head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip() release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg) if release_match: release_version = release_match.group(1) release_tag = f"v{release_version}" if tag_exists(release_tag): + tag_commit = get_tag_commit(release_tag) + head_commit = get_head_commit() + if tag_commit != head_commit: + raise click.ClickException( + _( + "HEAD is a release commit for v{version} but tag {tag} " + "points to a different commit ({tag_commit} vs HEAD {head_commit}). " + "This indicates a tag/commit misalignment.", + version=release_version, + tag=release_tag, + tag_commit=tag_commit[:7], + head_commit=head_commit[:7], + ) + ) click.echo( _( - "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", + "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", msg=head_msg, tag=release_tag, ) diff --git a/src/devx/cli.py b/src/devx/cli.py index 73b213f..09615fd 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -177,6 +177,13 @@ def tools_generate_badges(args: tuple[str, ...]) -> None: _run_module("devx.tools.generate_badges", list(args)) +@tools.command("generate-cliff-config") +@click.argument("args", nargs=-1) +def tools_generate_cliff_config(args: tuple[str, ...]) -> None: + """Generate a cliff.toml configuration file for the project.""" + _run_module("devx.tools.generate_cliff_config", list(args)) + + @tools.command("install-checkmake") @click.argument("args", nargs=-1) def tools_install_checkmake(args: tuple[str, ...]) -> None: diff --git a/src/devx/tools/generate_cliff_config.py b/src/devx/tools/generate_cliff_config.py new file mode 100644 index 0000000..70cd37b --- /dev/null +++ b/src/devx/tools/generate_cliff_config.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Generate a cliff.toml configuration file for a project. + +Produces a git-cliff configuration with the correct task ID prefix +preprocessor, matching the format used by devx itself. Downstream +repos can use this to avoid duplicating the entire cliff.toml by hand. + +Usage:: + + python -m devx.tools.generate_cliff_config --prefix GRM + python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml + python -m devx.tools.generate_cliff_config --prefix GRM --force +""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from devx.config import TASK_PREFIX +from devx.i18n import _ + +# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid +# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body. +CLIFF_TEMPLATE = """\ +# git-cliff configuration for __PREFIX__ +# https://git-cliff.org/docs/configuration +# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__ + +[changelog] +header = \"\"\" +# Changelog\\n +All notable changes to this project will be documented in this file.\\n +\"\"\" +body = \"\"\" +{% if version %}\\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\\ + ## [unreleased] +{% endif %}\\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\ + {% if commit.breaking %}[**breaking**] {% endif %}\\ + {{ commit.message | upper_first }}\\ + {% endfor %} +{% endfor %} +\"\"\" +trim = true +render_always = true + +[git] +conventional_commits = true +filter_unconventional = true +require_conventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +fail_on_unmatched_commit = false +use_branch_tags = false +topo_order = false +topo_order_commits = true +sort_commits = "oldest" +recurse_submodules = false + +commit_preprocessors = [ + # Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits + { pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" }, +] + +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + # Skip infrastructure-only commits — they don't affect users + { message = "^doc", skip = true }, + { message = "^test", skip = true }, + { message = "^style", skip = true }, + { message = "^chore", skip = true }, + { message = "^ci", skip = true }, + # Skip release commits — they are release artifacts, not features + { message = "^release:", skip = true }, + { body = ".*security", group = "Security" }, + { message = "^revert", group = "Revert" }, + # Skip anything that doesn't match above — safe default + { message = ".*", skip = true }, +] + +[bump] +features_always_bump_minor = true +breaking_always_bump_major = false +initial_tag = "0.1.0" +# Refactor commits bump patch — structural changes to src/ or pyproject.toml +# affect users even though no new feature was added. +refactor_always_bump_patch = true +""" + + +def _generate(prefix: str) -> str: + """Generate cliff.toml content for the given prefix.""" + prefix_regex = prefix.replace("\\", "\\\\") + return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex) + + +@click.command() +@click.option( + "--prefix", + default=TASK_PREFIX, + help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').", +) +@click.option( + "--output", + "-o", + default="cliff.toml", + type=click.Path(), + help="Output file path (default: cliff.toml).", +) +@click.option( + "--force", + is_flag=True, + help="Overwrite existing file without prompting.", +) +def main(prefix: str, output: str, force: bool) -> None: + """Generate a cliff.toml configuration file.""" + output_path = Path(output) + + if output_path.exists() and not force: + raise click.ClickException( + _( + "{file} already exists. Use --force to overwrite.", + file=str(output_path), + ) + ) + + content = _generate(prefix) + output_path.write_text(content) + click.echo( + _( + "Generated {file} with prefix '{prefix}'.", + file=str(output_path), + prefix=prefix, + ) + ) + + +if __name__ == "__main__": # pragma: no cover + main() # pragma: no cover diff --git a/src/devx/translations.json b/src/devx/translations.json index c37afef..b34e4ec 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1,4 +1,11 @@ { + "\n=== Summary ===": { + "en": "\n=== Summary ===", + "bg": "\n=== Summary ===", + "de": "\n=== Summary ===", + "ru": "\n=== Summary ===", + "zh": "\n=== Summary ===" + }, "\nAll documentation coverage checks passed!": { "en": "\nAll documentation coverage checks passed!", "bg": "\nAll documentation coverage checks passed!", @@ -6,6 +13,13 @@ "ru": "\nAll documentation coverage checks passed!", "zh": "\nAll documentation coverage checks passed!" }, + "\nCHANGELOG version ordering:": { + "en": "\nCHANGELOG version ordering:", + "bg": "\nCHANGELOG version ordering:", + "de": "\nCHANGELOG version ordering:", + "ru": "\nCHANGELOG version ordering:", + "zh": "\nCHANGELOG version ordering:" + }, "\nChecking CI script documentation in ci-cd-workflow.md...": { "en": "\nChecking CI script documentation in ci-cd-workflow.md...", "bg": "\nChecking CI script documentation in ci-cd-workflow.md...", @@ -41,6 +55,13 @@ "ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce." }, + "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": { + "en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report." + }, "\nIntegrity check FAILED ({count} issues):": { "en": "\nIntegrity check FAILED ({count} issues):", "bg": "\nIntegrity check FAILED ({count} issues):", @@ -55,6 +76,13 @@ "ru": "\nIntegrity check passed — all {count} pages verified.", "zh": "\nIntegrity check passed — all {count} pages verified." }, + "\nLatest tag: {tag}": { + "en": "\nLatest tag: {tag}", + "bg": "\nLatest tag: {tag}", + "de": "\nLatest tag: {tag}", + "ru": "\nLatest tag: {tag}", + "zh": "\nLatest tag: {tag}" + }, "\nMissing documentation:": { "en": "\nMissing documentation:", "bg": "\nMissing documentation:", @@ -83,6 +111,20 @@ "ru": "\nRunning full wiki integrity check...", "zh": "\nRunning full wiki integrity check..." }, + "\nTag → Commit alignment:": { + "en": "\nTag → Commit alignment:", + "bg": "\nTag → Commit alignment:", + "de": "\nTag → Commit alignment:", + "ru": "\nTag → Commit alignment:", + "zh": "\nTag → Commit alignment:" + }, + "\nUntagged release commits:": { + "en": "\nUntagged release commits:", + "bg": "\nUntagged release commits:", + "de": "\nUntagged release commits:", + "ru": "\nUntagged release commits:", + "zh": "\nUntagged release commits:" + }, "\nUser-facing changes ({count}):": { "en": "\nUser-facing changes ({count}):", "bg": "\nUser-facing changes ({count}):", @@ -125,6 +167,20 @@ "ru": "\n[dry-run] Changelog:\n{changelog}", "zh": "\n[dry-run] Changelog:\n{changelog}" }, + "\n{label} files changed ({count}):": { + "en": "\n{label} files changed ({count}):", + "bg": "\n{label} files changed ({count}):", + "de": "\n{label} files changed ({count}):", + "ru": "\n{label} files changed ({count}):", + "zh": "\n{label} files changed ({count}):" + }, + "\n{tag} files ({count}):": { + "en": "\n{tag} files ({count}):", + "bg": "\n{tag} files ({count}):", + "de": "\n{tag} files ({count}):", + "ru": "\n{tag} files ({count}):", + "zh": "\n{tag} files ({count}):" + }, " - Auto-delete branch after merge: yes": { "en": " - Auto-delete branch after merge: yes", "bg": " - Автоматично изтриване на клон след сливане: да", @@ -244,6 +300,13 @@ "ru": " Updated: {title}", "zh": " Updated: {title}" }, + "=== Release Alignment Verification ===\n": { + "en": "=== Release Alignment Verification ===\n", + "bg": "=== Release Alignment Verification ===\n", + "de": "=== Release Alignment Verification ===\n", + "ru": "=== Release Alignment Verification ===\n", + "zh": "=== Release Alignment Verification ===\n" + }, "API poll warning: {exc}": { "en": "API poll warning: {exc}", "bg": "API poll warning: {exc}", @@ -363,13 +426,6 @@ "ru": "ОШИБКА: REPO_TOKEN не задан.", "zh": "错误:未设置 REPO_TOKEN。" }, - "ERROR: VIKUNJA_TOKEN is not set.": { - "en": "ERROR: VIKUNJA_TOKEN is not set.", - "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", - "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", - "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", - "zh": "错误:未设置 VIKUNJA_TOKEN。" - }, "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { "en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.", "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", @@ -377,6 +433,20 @@ "ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.", "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" }, + "ERROR: Tag consistency check failed. Existing tags are misaligned:": { + "en": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "de": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:" + }, + "ERROR: VIKUNJA_TOKEN is not set.": { + "en": "ERROR: VIKUNJA_TOKEN is not set.", + "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", + "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", + "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", + "zh": "错误:未设置 VIKUNJA_TOKEN。" + }, "ERROR: mapping.json not found at {path}": { "en": "ERROR: mapping.json not found at {path}", "bg": "ERROR: mapping.json not found at {path}", @@ -412,6 +482,34 @@ "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." }, + "Generated {file} with prefix '{prefix}'.": { + "en": "Generated {file} with prefix '{prefix}'.", + "bg": "Generated {file} with prefix '{prefix}'.", + "de": "Generated {file} with prefix '{prefix}'.", + "ru": "Generated {file} with prefix '{prefix}'.", + "zh": "Generated {file} with prefix '{prefix}'." + }, + "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { + "en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." + }, + "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": { + "en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment." + }, + "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": { + "en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping." + }, "HTTP error: {status} — {message}": { "en": "HTTP error: {status} — {message}", "bg": "HTTP грешка: {status} — {message}", @@ -454,6 +552,20 @@ "ru": "Lint passed.", "zh": "Lint passed." }, + "Mapped file {file} is empty. Update the content or remove from mapping.json.": { + "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." + }, + "Mapped file {file} not found. Update mapping.json or create the file.": { + "en": "Mapped file {file} not found. Update mapping.json or create the file.", + "bg": "Mapped file {file} not found. Update mapping.json or create the file.", + "de": "Mapped file {file} not found. Update mapping.json or create the file.", + "ru": "Mapped file {file} not found. Update mapping.json or create the file.", + "zh": "Mapped file {file} not found. Update mapping.json or create the file." + }, "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", @@ -531,6 +643,13 @@ "ru": "No tags found — treating all changes as user-facing.", "zh": "No tags found — treating all changes as user-facing." }, + "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { + "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." + }, "No unreleased changes found. Nothing to release.": { "en": "No unreleased changes found. Nothing to release.", "bg": "No unreleased changes found. Nothing to release.", @@ -559,6 +678,13 @@ "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: : \n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: : \n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE" }, + "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { + "en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI." + }, "Oops! Gitea PyPI registry publish failed:\n{stderr}": { "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", @@ -566,6 +692,20 @@ "ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}", "zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}" }, + "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}": { + "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}" + }, + "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}": { + "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}" + }, "Oops! No task ID found in .taskid file or branch name '{branch}'.": { "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", @@ -573,6 +713,13 @@ "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." }, + "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { + "en": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "bg": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "de": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "ru": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "zh": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}" + }, "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", @@ -601,6 +748,13 @@ "ru": "PASSED: {pair}", "zh": "PASSED: {pair}" }, + "PR number must be an integer, got: {pr_number}": { + "en": "PR number must be an integer, got: {pr_number}", + "bg": "PR number must be an integer, got: {pr_number}", + "de": "PR number must be an integer, got: {pr_number}", + "ru": "PR number must be an integer, got: {pr_number}", + "zh": "PR number must be an integer, got: {pr_number}" + }, "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", @@ -657,6 +811,13 @@ "ru": "Release must be run on master, currently on '{branch}'.", "zh": "Release must be run on master, currently on '{branch}'." }, + "Repo must be in 'owner/name' format, got: {repo}": { + "en": "Repo must be in 'owner/name' format, got: {repo}", + "bg": "Repo must be in 'owner/name' format, got: {repo}", + "de": "Repo must be in 'owner/name' format, got: {repo}", + "ru": "Repo must be in 'owner/name' format, got: {repo}", + "zh": "Repo must be in 'owner/name' format, got: {repo}" + }, "Repository configuration complete.": { "en": "Repository configuration complete.", "bg": "Конфигурирането на хранилището е завършено.", @@ -706,6 +867,13 @@ "ru": "Syncing {count} documentation pages to wiki...", "zh": "Syncing {count} documentation pages to wiki..." }, + "Tag consistency check failed.": { + "en": "Tag consistency check failed.", + "bg": "Tag consistency check failed.", + "de": "Tag consistency check failed.", + "ru": "Tag consistency check failed.", + "zh": "Tag consistency check failed." + }, "Tag v{version} already existed. Publish workflow should already have been triggered.": { "en": "Tag v{version} already existed. Publish workflow should already have been triggered.", "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", @@ -713,12 +881,19 @@ "ru": "Tag v{version} already existed. Publish workflow should already have been triggered.", "zh": "Tag v{version} already existed. Publish workflow should already have been triggered." }, - "Tag {tag} already exists, skipping creation.": { - "en": "Tag {tag} already exists, skipping creation.", - "bg": "Tag {tag} already exists, skipping creation.", - "de": "Tag {tag} already exists, skipping creation.", - "ru": "Tag {tag} already exists, skipping creation.", - "zh": "Tag {tag} already exists, skipping creation." + "Tag {tag} already exists and points to HEAD. Skipping creation.": { + "en": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "bg": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "de": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "ru": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "zh": "Tag {tag} already exists and points to HEAD. Skipping creation." + }, + "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": { + "en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details." }, "Task ID: {task_id}": { "en": "Task ID: {task_id}", @@ -755,6 +930,13 @@ "ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.", "zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures." }, + "Unknown check category '{check}'. Available: all, user-facing{tags}": { + "en": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "de": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" + }, "Updated version in {init}": { "en": "Updated version in {init}", "bg": "Updated version in {init}", @@ -769,6 +951,27 @@ "ru": "Updated {changelog_file}", "zh": "Updated {changelog_file}" }, + "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { + "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." + }, + "Version file: {file}": { + "en": "Version file: {file}", + "bg": "Version file: {file}", + "de": "Version file: {file}", + "ru": "Version file: {file}", + "zh": "Version file: {file}" + }, + "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { + "en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." + }, "WARNING: --skip-tests passed — skipping test verification.": { "en": "WARNING: --skip-tests passed — skipping test verification.", "bg": "WARNING: --skip-tests passed — skipping test verification.", @@ -776,6 +979,13 @@ "ru": "WARNING: --skip-tests passed — skipping test verification.", "zh": "WARNING: --skip-tests passed — skipping test verification." }, + "Warning: could not fetch tags from origin.": { + "en": "Warning: could not fetch tags from origin.", + "bg": "Warning: could not fetch tags from origin.", + "de": "Warning: could not fetch tags from origin.", + "ru": "Warning: could not fetch tags from origin.", + "zh": "Warning: could not fetch tags from origin." + }, "Wiki integrity check failed — {count} issue(s)": { "en": "Wiki integrity check failed — {count} issue(s)", "bg": "Wiki integrity check failed — {count} issue(s)", @@ -867,6 +1077,13 @@ "ru": "git command failed ({cmd}): {stderr}", "zh": "git command failed ({cmd}): {stderr}" }, + "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { + "en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." + }, "git-cliff returned empty version.": { "en": "git-cliff returned empty version.", "bg": "git-cliff returned empty version.", @@ -874,12 +1091,12 @@ "ru": "git-cliff returned empty version.", "zh": "git-cliff returned empty version." }, - "inactive": { - "en": "inactive", - "bg": "неактивен", - "de": "inaktiv", - "ru": "неактивен", - "zh": "未激活" + "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { + "en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." }, "in_progress": { "en": "in progress", @@ -888,103 +1105,12 @@ "ru": "в процессе", "zh": "进行中" }, - "pending": { - "en": "pending", - "bg": "в очакване", - "de": "ausstehend", - "ru": "ожидает", - "zh": "待处理" - }, - "unknown": { - "en": "unknown", - "bg": "неизвестен", - "de": "unbekannt", - "ru": "неизвестно", - "zh": "未知" - }, - "\n{label} files changed ({count}):": { - "en": "\n{label} files changed ({count}):", - "bg": "\n{label} files changed ({count}):", - "de": "\n{label} files changed ({count}):", - "ru": "\n{label} files changed ({count}):", - "zh": "\n{label} files changed ({count}):" - }, - "\n{tag} files ({count}):": { - "en": "\n{tag} files ({count}):", - "bg": "\n{tag} files ({count}):", - "de": "\n{tag} files ({count}):", - "ru": "\n{tag} files ({count}):", - "zh": "\n{tag} files ({count}):" - }, - "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { - "en": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "bg": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "de": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "ru": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "zh": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}" - }, - "Unknown check category '{check}'. Available: all, user-facing{tags}": { - "en": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "de": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" - }, - "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { - "en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." - }, - "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.": { - "en": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", - "bg": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", - "de": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", - "ru": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", - "zh": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping." - }, - "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { - "en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." - }, - "PR number must be an integer, got: {pr_number}": { - "en": "PR number must be an integer, got: {pr_number}", - "bg": "PR number must be an integer, got: {pr_number}", - "de": "PR number must be an integer, got: {pr_number}", - "ru": "PR number must be an integer, got: {pr_number}", - "zh": "PR number must be an integer, got: {pr_number}" - }, - "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { - "en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." - }, - "Repo must be in 'owner/name' format, got: {repo}": { - "en": "Repo must be in 'owner/name' format, got: {repo}", - "bg": "Repo must be in 'owner/name' format, got: {repo}", - "de": "Repo must be in 'owner/name' format, got: {repo}", - "ru": "Repo must be in 'owner/name' format, got: {repo}", - "zh": "Repo must be in 'owner/name' format, got: {repo}" - }, - "Mapped file {file} is empty. Update the content or remove from mapping.json.": { - "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." - }, - "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { - "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." + "inactive": { + "en": "inactive", + "bg": "неактивен", + "de": "inaktiv", + "ru": "неактивен", + "zh": "未激活" }, "mapping.json keys and values must be strings, got {k}={v}": { "en": "mapping.json keys and values must be strings, got {k}={v}", @@ -1000,46 +1126,25 @@ "ru": "mapping.json must be a dict of file-path -> page-title, got {type}", "zh": "mapping.json must be a dict of file-path -> page-title, got {type}" }, - "Mapped file {file} not found. Update mapping.json or create the file.": { - "en": "Mapped file {file} not found. Update mapping.json or create the file.", - "bg": "Mapped file {file} not found. Update mapping.json or create the file.", - "de": "Mapped file {file} not found. Update mapping.json or create the file.", - "ru": "Mapped file {file} not found. Update mapping.json or create the file.", - "zh": "Mapped file {file} not found. Update mapping.json or create the file." + "pending": { + "en": "pending", + "bg": "в очакване", + "de": "ausstehend", + "ru": "ожидает", + "zh": "待处理" }, - "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { - "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." + "unknown": { + "en": "unknown", + "bg": "неизвестен", + "de": "unbekannt", + "ru": "неизвестно", + "zh": "未知" }, - "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { - "en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." - }, - "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { - "en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI." - }, - "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}": { - "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}" - }, - "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}": { - "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}" + "{file} already exists. Use --force to overwrite.": { + "en": "{file} already exists. Use --force to overwrite.", + "bg": "{file} already exists. Use --force to overwrite.", + "de": "{file} already exists. Use --force to overwrite.", + "ru": "{file} already exists. Use --force to overwrite.", + "zh": "{file} already exists. Use --force to overwrite." } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f7b8dff..9dd9320 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -166,6 +166,13 @@ class TestToolsCommands: assert result.exit_code == 0 mock_run.assert_called_once_with("devx.tools.generate_badges", []) + @patch("devx.cli._run_module") + def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["tools", "generate-cliff-config"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.tools.generate_cliff_config", []) + @patch("devx.cli._run_module") def test_tools_install_checkmake(self, mock_run: MagicMock) -> None: runner = CliRunner() diff --git a/tests/unit/test_generate_cliff_config.py b/tests/unit/test_generate_cliff_config.py new file mode 100644 index 0000000..cfd1d6c --- /dev/null +++ b/tests/unit/test_generate_cliff_config.py @@ -0,0 +1,124 @@ +"""Tests for devx.tools.generate_cliff_config.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from devx.tools.generate_cliff_config import main + + +class TestGenerateCliffConfig: + """Tests for the generate_cliff_config tool.""" + + @pytest.fixture + def runner(self) -> CliRunner: + return CliRunner() + + def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None: + """Generate cliff.toml to a new file.""" + output = tmp_path / "cliff.toml" + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)]) + assert result.exit_code == 0 + assert output.exists() + content = output.read_text() + assert "git-cliff configuration for GRM" in content + assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content + + def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None: + """Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX').""" + output = tmp_path / "cliff.toml" + with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"): + result = runner.invoke(main, ["--output", str(output)]) + assert result.exit_code == 0 + content = output.read_text() + assert "git-cliff configuration for DEVX" in content + + def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None: + """Refuse to overwrite existing file without --force.""" + output = tmp_path / "cliff.toml" + output.write_text("# existing") + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)]) + assert result.exit_code != 0 + assert "already exists" in result.output + assert output.read_text() == "# existing" + + def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None: + """Overwrite existing file with --force.""" + output = tmp_path / "cliff.toml" + output.write_text("# existing") + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"]) + assert result.exit_code == 0 + content = output.read_text() + assert "git-cliff configuration for GRM" in content + assert "# existing" not in content + + def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None: + """Generated config must be valid TOML.""" + output = tmp_path / "cliff.toml" + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)]) + assert result.exit_code == 0 + with open(output, "rb") as f: + data = tomllib.load(f) + assert "changelog" in data + assert "git" in data + assert "bump" in data + assert data["bump"]["initial_tag"] == "0.1.0" + assert data["bump"]["features_always_bump_minor"] is True + + def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None: + """Preprocessor pattern must match the given prefix.""" + output = tmp_path / "cliff.toml" + result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)]) + assert result.exit_code == 0 + with open(output, "rb") as f: + data = tomllib.load(f) + preprocessors = data["git"]["commit_preprocessors"] + assert len(preprocessors) == 1 + pattern = preprocessors[0]["pattern"] + assert "INFRA" in pattern + + def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None: + """Generated config must have all standard commit parsers.""" + output = tmp_path / "cliff.toml" + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)]) + assert result.exit_code == 0 + with open(output, "rb") as f: + data = tomllib.load(f) + parsers = data["git"]["commit_parsers"] + # Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all + messages = [p["message"] for p in parsers if "message" in p] + assert "^feat" in messages + assert "^fix" in messages + assert "^perf" in messages + assert "^refactor" in messages + assert "^release:" in messages + assert "^revert" in messages + assert ".*" in messages # catch-all + + def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None: + """Default output path is cliff.toml in current directory.""" + output = tmp_path / "cliff.toml" + # Change to tmp_path so default cliff.toml is created there + import os + + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + result = runner.invoke(main, ["--prefix", "GRM"]) + assert result.exit_code == 0 + assert output.exists() + finally: + os.chdir(old_cwd) + + def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None: + """Success message includes file and prefix.""" + output = tmp_path / "cliff.toml" + result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)]) + assert result.exit_code == 0 + assert "Generated" in result.output + assert "GRM" in result.output diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index 41eff91..2088cc3 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -9,9 +9,16 @@ from click.testing import CliRunner from devx.ci.release import ( commit_release_changes, create_and_push_tag, + fetch_tags, + get_all_tags, get_bumped_version, get_changelog, + get_changelog_versions, + get_commit_version, + get_head_commit, + get_init_version, get_latest_tag, + get_tag_commit, has_unreleased_changes, main, run_cmd, @@ -19,6 +26,8 @@ from devx.ci.release import ( tag_exists, update_changelog, update_init_version, + verify_alignment, + verify_tag_consistency, ) @@ -156,6 +165,534 @@ class TestUpdateInitVersion: update_init_version("0.2.0") +class TestGetTagCommit: + @patch("devx.ci.release.run_cmd") + def test_returns_commit(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="") + assert get_tag_commit("v0.1.0") == "abc123" + + @patch("devx.ci.release.run_cmd") + def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") + assert get_tag_commit("v0.1.0") == "" + + +class TestGetHeadCommit: + @patch("devx.ci.release.run_cmd") + def test_returns_head(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="") + assert get_head_commit() == "def456" + + +class TestFetchTags: + @patch("devx.ci.release.run_cmd") + def test_success(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + fetch_tags() + + @patch("devx.ci.release.run_cmd") + def test_failure_warns(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") + # Should not raise + fetch_tags() + + +class TestGetAllTags: + @patch("devx.ci.release.run_cmd") + def test_returns_tags(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="") + tags = get_all_tags() + assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"] + + @patch("devx.ci.release.run_cmd") + def test_empty(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="") + assert get_all_tags() == [] + + @patch("devx.ci.release.run_cmd") + def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") + assert get_all_tags() == [] + + +class TestGetCommitVersion: + @patch("devx.ci.release.run_cmd") + def test_release_commit(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="") + assert get_commit_version("abc123") == "0.4.4" + + @patch("devx.ci.release.run_cmd") + def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="") + assert get_commit_version("abc123") is None + + +class TestVerifyTagConsistency: + @patch("devx.ci.release.get_commit_version") + @patch("devx.ci.release.get_all_tags") + def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: + mock_tags.return_value = ["v0.2.0", "v0.1.0"] + mock_cv.side_effect = ["0.2.0", "0.1.0"] + errors = verify_tag_consistency() + assert errors == [] + + @patch("devx.ci.release.get_commit_version") + @patch("devx.ci.release.get_all_tags") + def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: + # v0.1.0 is first (exempt), v0.2.0 is non-release (should error) + mock_tags.return_value = ["v0.2.0", "v0.1.0"] + mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok + errors = verify_tag_consistency() + assert len(errors) == 1 + assert "non-release commit" in errors[0] + + @patch("devx.ci.release.get_commit_version") + @patch("devx.ci.release.get_all_tags") + def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: + """The first (oldest) tag is allowed to point to a non-release commit.""" + mock_tags.return_value = ["v0.1.0"] + mock_cv.return_value = None # non-release commit + errors = verify_tag_consistency() + assert errors == [] # no error — first tag is exempt + + @patch("devx.ci.release.get_commit_version") + @patch("devx.ci.release.get_all_tags") + def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: + mock_tags.return_value = ["v0.2.0"] + mock_cv.return_value = "0.1.0" + errors = verify_tag_consistency() + assert len(errors) == 1 + assert "0.1.0" in errors[0] + assert "0.2.0" in errors[0] + + @patch("devx.ci.release.get_all_tags") + def test_no_tags(self, mock_tags: MagicMock) -> None: + mock_tags.return_value = [] + assert verify_tag_consistency() == [] + + +class TestGetInitVersion: + def test_returns_version(self, tmp_path, monkeypatch) -> None: + init_file = tmp_path / "__init__.py" + init_file.write_text('__version__ = "0.4.4"\n') + monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file)) + assert get_init_version() == "0.4.4" + + def test_file_not_found(self, monkeypatch) -> None: + monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py") + assert get_init_version() is None + + def test_no_version_string(self, tmp_path, monkeypatch) -> None: + init_file = tmp_path / "__init__.py" + init_file.write_text('"""module"""\n') + monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file)) + assert get_init_version() is None + + +class TestGetChangelogVersions: + def test_returns_versions(self, tmp_path, monkeypatch) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n" + "## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n" + ) + monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog)) + versions = get_changelog_versions() + assert versions == ["0.4.4", "0.4.3", "0.4.2"] + + def test_file_not_found(self, monkeypatch) -> None: + monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md") + assert get_changelog_versions() == [] + + +class TestVerifyAlignment: + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_all_aligned( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment passes when everything is consistent.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4", "v0.4.3"] + mock_vtc.return_value = [] # no tag errors + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4", "0.4.3"] + # run_cmd is called for untagged release commits check + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_misaligned_tags( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when tags are misaligned.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [" v0.1.0 → bad"] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_version_mismatch( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when __version__ != latest tag.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.3" # mismatch + mock_cv.return_value = ["0.4.4"] + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_changelog_duplicates( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when CHANGELOG has duplicate versions.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_changelog_out_of_order( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when CHANGELOG versions are not descending.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_changelog_latest_mismatch( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when CHANGELOG latest != latest tag.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.3"] # doesn't match tag + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_changelog_unreleased_section( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify passes when CHANGELOG has one unreleased section ahead of tag.""" + mock_lt.return_value = "v0.6.3" + mock_tags.return_value = ["v0.6.3", "v0.6.2"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.6.3" + mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_changelog_tag_at_wrong_position( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1).""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2 + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_duplicate_release_commits_info( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify reports duplicate release commits as info, not error.""" + mock_lt.return_value = "v0.6.1" + mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists + mock_vtc.return_value = [] + mock_iv.return_value = "0.6.1" + mock_cv.return_value = ["0.6.1"] + # git log finds 2 release commits for v0.6.1, neither has tag pointing at it + # (the tag points to a third commit) + commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n" + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout=commits, stderr=""), + MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123 + MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456 + ] + # Should return 0 — duplicates are informational, not errors + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_many_duplicate_release_commits( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify handles >5 duplicate release commits (truncation message).""" + mock_lt.return_value = "v0.6.1" + mock_tags.return_value = ["v0.6.1"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.6.1" + mock_cv.return_value = ["0.6.1"] + # Generate 7 duplicate release commits for v0.6.1 + commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7)) + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout=commits + "\n", stderr=""), + ] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)] + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_untagged_release_commits( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when there are untagged release commits.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] + # git log finds release commits, then tag --points-at finds nothing + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""), + MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123 + ] + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_no_init_version( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify alignment fails when __version__ is not found.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = None # not found + mock_cv.return_value = ["0.4.4"] + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 1 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_all_release_commits_tagged( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify passes when all release commits have tags.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] + # git log finds release commit, tag --points-at finds the tag + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""), + MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found + ] + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_no_release_commits_found( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify handles case with no release commits at all.""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="") + assert verify_alignment() == 0 + + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_many_untagged_release_commits( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """Verify handles >10 untagged release commits (truncation message).""" + mock_lt.return_value = "v0.4.4" + mock_tags.return_value = ["v0.4.4"] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] + # Generate 15 untagged release commits + commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15)) + # First call returns all commits, subsequent calls return empty (no tags) + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout=commits + "\n", stderr=""), + ] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)] + assert verify_alignment() == 1 + + class TestUpdateChangelog: def test_creates_new_file(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" @@ -250,9 +787,17 @@ class TestCreateAndPushTag: assert call.args[0][0:2] != ["git", "push"] assert call.args[0][0:2] != ["git", "tag"] + @patch("devx.ci.release.get_head_commit", return_value="abc123") + @patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.tag_exists", return_value=True) @patch("devx.ci.release.run_cmd") - def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: + def test_tag_exists_skips_creation( + self, + mock_run_cmd: MagicMock, + mock_tag_exists: MagicMock, + mock_tag_commit: MagicMock, + mock_head_commit: MagicMock, + ) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=False) assert result is False # Should not create tag, but should ensure it's pushed @@ -260,13 +805,38 @@ class TestCreateAndPushTag: assert ["git", "tag", "-a"] not in [c[:3] for c in calls] assert ["git", "push", "origin", "v0.1.0"] in calls + @patch("devx.ci.release.get_head_commit", return_value="def456") + @patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.tag_exists", return_value=True) @patch("devx.ci.release.run_cmd") - def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: + def test_tag_exists_mismatch_raises( + self, + mock_run_cmd: MagicMock, + mock_tag_exists: MagicMock, + mock_tag_commit: MagicMock, + mock_head_commit: MagicMock, + ) -> None: + """Tag exists but points to different commit than HEAD → error.""" + with pytest.raises(click.ClickException, match="misalignment"): + create_and_push_tag("0.1.0", "changelog", dry_run=False) + + @patch("devx.ci.release.get_head_commit", return_value="abc123") + @patch("devx.ci.release.get_tag_commit", return_value="abc123") + @patch("devx.ci.release.tag_exists", return_value=True) + @patch("devx.ci.release.run_cmd") + def test_tag_exists_dry_run_no_push( + self, + mock_run_cmd: MagicMock, + mock_tag_exists: MagicMock, + mock_tag_commit: MagicMock, + mock_head_commit: MagicMock, + ) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=True) assert result is False - # No git commands at all in dry-run when tag exists - mock_run_cmd.assert_not_called() + # No push in dry-run when tag exists, but alignment check still runs + for call in mock_run_cmd.call_args_list: + assert call.args[0][0:2] != ["git", "push"] + assert call.args[0][0:2] != ["git", "tag"] class TestRunTests: @@ -302,6 +872,13 @@ class TestRunTests: class TestMain: + """Tests for the main release command. + + All tests mock fetch_tags and verify_tag_consistency since these + are pre-flight checks that call git commands. Tests that need to + verify specific git call sequences mock run_cmd with side_effect. + """ + @patch.dict("os.environ", {}) @patch("devx.ci.release.run_cmd") def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None: @@ -312,9 +889,12 @@ class TestMain: assert "master" in result.output @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.run_cmd") - def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: + def test_dry_run_on_non_master_warns( + self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock + ) -> None: """Dry-run mode should not fail on non-master branches.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") runner = CliRunner() @@ -323,13 +903,22 @@ class TestMain: assert "Dry-run mode" in result.output @patch.dict("os.environ", {}) + @patch("devx.ci.release.get_head_commit", return_value="abc123") + @patch("devx.ci.release.get_tag_commit", return_value="abc123") + @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_cmd") def test_release_lock_skips_when_head_is_release_commit_and_tag_exists( - self, mock_run_cmd: MagicMock, mock_uf: MagicMock + self, + mock_run_cmd: MagicMock, + mock_uf: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, + mock_tc: MagicMock, + mock_hc: MagicMock, ) -> None: """If HEAD is a release commit and the tag exists, skip.""" - # git rev-parse, git log -1, git tag -l (tag exists) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), @@ -342,14 +931,47 @@ class TestMain: assert "Skipping" in result.output @patch.dict("os.environ", {}) + @patch("devx.ci.release.get_head_commit", return_value="def456") + @patch("devx.ci.release.get_tag_commit", return_value="abc123") + @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_cmd") + def test_release_lock_tag_points_elsewhere( + self, + mock_run_cmd: MagicMock, + mock_uf: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, + mock_tc: MagicMock, + mock_hc: MagicMock, + ) -> None: + """If HEAD is a release commit but tag points elsewhere, error.""" + mock_run_cmd.side_effect = [ + MagicMock(returncode=0, stdout="master\n", stderr=""), + MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), + MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag + ] + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code != 0 + assert "misalignment" 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.get_changelog", return_value="## changelog") @patch("devx.ci.release.create_and_push_tag", return_value=True) @patch("devx.ci.release.run_cmd") def test_release_lock_recovers_when_tag_missing( - self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock + self, + mock_run_cmd: MagicMock, + mock_create_tag: MagicMock, + mock_changelog: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """If HEAD is a release commit but the tag is missing, create the tag.""" - # git rev-parse, git log -1, git tag -l (tag NOT found) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), @@ -363,6 +985,8 @@ class TestMain: mock_create_tag.assert_called_once_with("0.5.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.has_unreleased_changes", return_value=False) @patch("devx.ci.release.get_bumped_version", return_value="0.2.0") @@ -373,6 +997,8 @@ class TestMain: mock_bumped: MagicMock, mock_has: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() @@ -381,6 +1007,7 @@ class TestMain: assert "No unreleased changes" in result.output @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.commit_release_changes") @@ -403,6 +1030,7 @@ class TestMain: mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, + mock_vtc: MagicMock, ) -> None: """Empty changelog should fail, not warn.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") @@ -412,6 +1040,7 @@ class TestMain: assert "empty changelog" in result.output.lower() @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.commit_release_changes") @@ -434,6 +1063,7 @@ class TestMain: mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, + mock_vtc: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() @@ -446,6 +1076,8 @@ class TestMain: mock_tag.assert_not_called() @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) + @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.run_cmd") @@ -454,6 +1086,8 @@ class TestMain: mock_run_cmd: MagicMock, mock_user_facing: MagicMock, mock_latest: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """Release is skipped when only workflow/infra files changed.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") @@ -464,6 +1098,8 @@ class TestMain: assert "Skipping release" 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) @@ -488,6 +1124,8 @@ class TestMain: mock_tag: MagicMock, mock_run_tests: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() @@ -501,6 +1139,8 @@ class TestMain: 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=False) @@ -525,6 +1165,8 @@ class TestMain: mock_tag: MagicMock, mock_run_tests: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """When tag already exists, still update files but report existing tag.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") @@ -535,6 +1177,8 @@ class TestMain: mock_tag.assert_called_once_with("0.1.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.create_and_push_tag", return_value=True) @patch("devx.ci.release.commit_release_changes", return_value=True) @@ -557,6 +1201,8 @@ class TestMain: mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """--skip-tests bypasses test verification.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") @@ -569,6 +1215,8 @@ class TestMain: assert make_calls == [] @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.create_and_push_tag") @patch("devx.ci.release.commit_release_changes") @@ -591,6 +1239,8 @@ class TestMain: mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """If tests fail, release aborts — no commit, no tag.""" # Calls: git rev-parse (master), git log -1 (release lock check), @@ -609,6 +1259,8 @@ class TestMain: mock_tag.assert_not_called() @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.create_and_push_tag") @patch("devx.ci.release.commit_release_changes") @@ -631,6 +1283,8 @@ class TestMain: mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, ) -> None: """If lint fails, release aborts — no commit, no tag.""" # Calls: git rev-parse (master), git log -1 (release lock check), @@ -646,3 +1300,38 @@ class TestMain: assert "Lint failed" in result.output mock_commit.assert_not_called() mock_tag.assert_not_called() + + @patch.dict("os.environ", {}) + @patch("devx.ci.release.get_changelog_versions", return_value=[]) + @patch("devx.ci.release.get_init_version", return_value="0.1.0") + @patch("devx.ci.release.get_all_tags", return_value=[]) + @patch("devx.ci.release.get_latest_tag", return_value="") + @patch("devx.ci.release.run_cmd") + def test_verify_mode_no_tags( + self, + mock_run_cmd: MagicMock, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + ) -> None: + """--verify checks alignment and exits without releasing.""" + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + runner = CliRunner() + result = runner.invoke(main, ["--verify"]) + assert result.exit_code == 0 + assert "Release Alignment Verification" in result.output + + @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"]) + @patch("devx.ci.release.fetch_tags") + @patch("devx.ci.release.run_cmd") + def test_preflight_tag_consistency_fails( + self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock + ) -> None: + """Pre-flight tag consistency check aborts if tags are misaligned.""" + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code != 0 + assert "Tag consistency check failed" in result.output