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

@@ -11,7 +11,7 @@ Key features:
import json
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any, ClassVar, Optional, Type
@@ -154,7 +154,7 @@ class GeneralSettings(SettingsBaseModel):
if v not in cls.compatible_versions:
error = (
f"Incompatible configuration version '{v}'. "
f"Expected one of: {', '.join(cls.compatible_versions)}."
f"Expected: {', '.join(cls.compatible_versions)}."
)
logger.error(error)
raise ValueError(error)
@@ -335,32 +335,44 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
file_secret_settings (pydantic_settings.PydanticBaseSettingsSource): Unused (needed for parent class interface).
Returns:
tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
Behavior:
1. Checks for the existence of a JSON configuration file in the expected location.
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
default configuration file to the location. If the copy fails, uses the default configuration file directly.
3. Creates a `pydantic_settings.JsonConfigSettingsSource` for both the configuration file and the default configuration file.
2. If the configuration file does not exist, creates the directory (if needed) and
attempts to create a default configuration file in the location. If the creation
fails, a temporary configuration directory is used.
3. Creates a `pydantic_settings.JsonConfigSettingsSource` for the configuration
file.
4. Updates class attributes `GeneralSettings._config_folder_path` and
`GeneralSettings._config_file_path` to reflect the determined paths.
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
5. Returns a tuple containing all provided and newly created settings sources in
the desired order.
Notes:
- This method logs a warning if the default configuration file cannot be copied.
- It ensures that a fallback to the default configuration file is always possible.
- This method logs an error if the default configuration file in the normal
configuration directory cannot be created.
- It ensures that a fallback to a default configuration file is always possible.
"""
# Ensure we know and have the config folder path and the config file
config_file, exists = cls._get_config_file_path()
config_dir = config_file.parent
if not exists:
config_dir.mkdir(parents=True, exist_ok=True)
# Create minimum config file
config_minimum_content = '{ "general": { "version": "' + __version__ + '" } }'
try:
shutil.copy2(cls.config_default_file_path, config_file)
config_file.write_text(config_minimum_content, encoding="utf-8")
except Exception as exc:
logger.warning(f"Could not copy default config: {exc}. Using default config...")
config_file = cls.config_default_file_path
config_dir = config_file.parent
# Create minimum config in temporary config directory as last resort
error_msg = f"Could not create minimum config file in {config_dir}: {exc}"
logger.error(error_msg)
temp_dir = Path(tempfile.mkdtemp())
info_msg = f"Using temporary config directory {temp_dir}"
logger.info(info_msg)
config_dir = temp_dir
config_file = temp_dir / config_file.name
config_file.write_text(config_minimum_content, encoding="utf-8")
# Remember config_dir and config file
GeneralSettings._config_folder_path = config_dir
GeneralSettings._config_file_path = config_file
@@ -387,19 +399,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
f"Error reading config file '{config_file}' (falling back to default config): {ex}"
)
# Append default settings to sources
default_settings = pydantic_settings.JsonConfigSettingsSource(
settings_cls, json_file=cls.config_default_file_path
)
setting_sources.append(default_settings)
return tuple(setting_sources)
@classproperty
def config_default_file_path(cls) -> Path:
"""Compute the default config file path."""
return cls.package_root_path.joinpath("data/default.config.json")
@classproperty
def package_root_path(cls) -> Path:
"""Compute the package root path."""

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

View File

@@ -1,5 +0,0 @@
{
"general": {
"version": "0.2.0+dev"
}
}