Public Access
DEVX-10: feat: add tag verification, idempotency, and --verify mode to release script
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+332
-8
@@ -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=<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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->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 = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->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
|
||||
+260
-155
@@ -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 Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\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: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\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: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\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: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\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: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
+698
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user