chore: automate development version and release generation (#772)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled

This change introduces a GitHub Action to automate release creation, including
proper tagging and automatic addition of a development marker to the version.

A hash is also appended to development versions to make their state easier to
distinguish.

Tests and release documentation have been updated to reflect the revised
release workflow. Several files now retrieve the current version dynamically.

The test --full-run option has been rename to --finalize to make
clear it is to do commit finalization testing.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2025-11-20 00:10:19 +01:00
committed by GitHub
parent bdbb0b060d
commit 976a2c8405
28 changed files with 762 additions and 448 deletions

View File

@@ -1,5 +1,156 @@
"""Version information for akkudoktoreos."""
import hashlib
import re
from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
# For development add `+dev` to previous release
# For release omit `+dev`.
__version__ = "0.2.0+dev"
VERSION_BASE = "0.2.0+dev"
# Project hash of relevant files
HASH_EOS = ""
# ------------------------------
# Helpers for version generation
# ------------------------------
def is_excluded_dir(path: Path, excluded_dir_patterns: set[str]) -> bool:
"""Check whether a directory should be excluded based on name patterns."""
return any(fnmatch(path.name, pattern) for pattern in excluded_dir_patterns)
def hash_tree(
paths: list[Path],
allowed_suffixes: set[str],
excluded_dir_patterns: set[str],
excluded_files: Optional[set[Path]] = None,
) -> str:
"""Return SHA256 hash for files under `paths`.
Restricted by suffix, excluding excluded directory patterns and excluded_files.
"""
h = hashlib.sha256()
excluded_files = excluded_files or set()
for root in paths:
if not root.exists():
raise ValueError(f"Root path does not exist: {root}")
for p in sorted(root.rglob("*")):
# Skip excluded directories
if p.is_dir() and is_excluded_dir(p, excluded_dir_patterns):
continue
# Skip files inside excluded directories
if any(is_excluded_dir(parent, excluded_dir_patterns) for parent in p.parents):
continue
# Skip excluded files
if p.resolve() in excluded_files:
continue
# Hash only allowed file types
if p.is_file() and p.suffix.lower() in allowed_suffixes:
h.update(p.read_bytes())
digest = h.hexdigest()
return digest
def _version_hash() -> str:
"""Calculate project hash.
Only package file ins src/akkudoktoreos can be hashed to make it work also for packages.
"""
DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
# Allowed file suffixes to consider
ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"}
# Directory patterns to exclude (glob-like)
EXCLUDED_DIR_PATTERNS: set[str] = {"*_autosum", "*__pycache__", "*_generated"}
# Files to exclude
EXCLUDED_FILES: set[Path] = set()
# Directories whose changes shall be part of the project hash
watched_paths = [DIR_PACKAGE_ROOT]
hash_current = hash_tree(
watched_paths, ALLOWED_SUFFIXES, EXCLUDED_DIR_PATTERNS, excluded_files=EXCLUDED_FILES
)
return hash_current
def _version_calculate() -> str:
"""Compute version."""
global HASH_EOS
HASH_EOS = _version_hash()
if VERSION_BASE.endswith("+dev"):
return f"{VERSION_BASE}.{HASH_EOS[:6]}"
else:
return VERSION_BASE
# ---------------------------
# Project version information
# ----------------------------
# The version
__version__ = _version_calculate()
# -------------------
# Version info access
# -------------------
# Regular expression to split the version string into pieces
VERSION_RE = re.compile(
r"""
^(?P<base>\d+\.\d+\.\d+) # x.y.z
(?:\+ # +dev.hash starts here
(?:
(?P<dev>dev) # literal 'dev'
(?:\.(?P<hash>[A-Za-z0-9]+))? # optional .hash
)
)?
$
""",
re.VERBOSE,
)
def version() -> dict[str, Optional[str]]:
"""Parses the version string.
The version string shall be of the form:
x.y.z
x.y.z+dev
x.y.z+dev.HASH
Returns:
.. code-block:: python
{
"version": "0.2.0+dev.a96a65",
"base": "x.y.z",
"dev": "dev" or None,
"hash": "<hash>" or None,
}
"""
global __version__
match = VERSION_RE.match(__version__)
if not match:
raise ValueError(f"Invalid version format: {version}")
info = match.groupdict()
info["version"] = __version__
return info