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

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Update VERSION_BASE in version.py after a release tag.
Behavior:
- Read VERSION_BASE from version.py
- Strip ANY existing "+dev" suffix
- Append exactly one "+dev"
- Write back the updated file
This ensures:
0.2.0 --> 0.2.0+dev
0.2.0+dev --> 0.2.0+dev
0.2.0+dev+dev -> 0.2.0+dev
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = ROOT / "src" / "akkudoktoreos" / "core" / "version.py"
def bump_dev_version_file(file: Path) -> str:
text = file.read_text(encoding="utf-8")
# Extract current version
m = re.search(r'^VERSION_BASE\s*=\s*["\']([^"\']+)["\']',
text, flags=re.MULTILINE)
if not m:
raise ValueError("VERSION_BASE not found")
base_version = m.group(1)
# Remove trailing +dev if present → ensure idempotency
cleaned = re.sub(r'(\+dev)+$', '', base_version)
# Append +dev
new_version = f"{cleaned}+dev"
# Replace inside file content
new_text = re.sub(
r'^VERSION_BASE\s*=\s*["\']([^"\']+)["\']',
f'VERSION_BASE = "{new_version}"',
text,
flags=re.MULTILINE
)
file.write_text(new_text, encoding="utf-8")
return new_version
def main():
# Use CLI argument or fallback default path
version_file = Path(sys.argv[1]) if len(sys.argv) > 1 else VERSION_FILE
try:
new_version = bump_dev_version_file(version_file)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# MUST print to stdout
print(new_version)
if __name__ == "__main__":
main()

View File

@@ -1,170 +0,0 @@
"""Update version strings in multiple project files only if the old version matches.
This script updates version information in:
- pyproject.toml
- src/akkudoktoreos/core/version.py
- src/akkudoktoreos/data/default.config.json
- Makefile
Supported version formats:
- __version__ = "<version>"
- version = "<version>"
- "version": "<version>"
- VERSION ?: <version>
It will:
- Replace VERSION → NEW_VERSION if the old version is found.
- Report which files were updated.
- Report which files contained mismatched versions.
- Report which files had no version.
Usage:
python bump_version.py VERSION NEW_VERSION
Args:
VERSION (str): Version expected before replacement.
NEW_VERSION (str): Version to write.
"""
#!/usr/bin/env python3
import argparse
import glob
import os
import re
import shutil
from pathlib import Path
from typing import List, Tuple
# Patterns to match version strings
VERSION_PATTERNS = [
re.compile(r'(__version__\s*=\s*")(?P<ver>[^"]+)(")'),
re.compile(r'(version\s*=\s*")(?P<ver>[^"]+)(")'),
re.compile(r'("version"\s*:\s*")(?P<ver>[^"]+)(")'),
re.compile(r'(VERSION\s*\?=\s*)(?P<ver>[^\s]+)'), # For Makefile: VERSION ?= 0.2.0
]
# Default files to process
DEFAULT_FILES = [
"pyproject.toml",
"src/akkudoktoreos/core/version.py",
"src/akkudoktoreos/data/default.config.json",
"Makefile",
]
def backup_file(file_path: str) -> str:
"""Create a backup of the given file with a .bak suffix.
Args:
file_path: Path to the file to backup.
Returns:
Path to the backup file.
"""
backup_path = f"{file_path}.bak"
shutil.copy2(file_path, backup_path)
return backup_path
def replace_version_in_file(
file_path: Path, old_version: str, new_version: str, dry_run: bool = False
) -> Tuple[bool, bool]:
"""
Replace old_version with new_version in the given file if it matches.
Args:
file_path: Path to the file to modify.
old_version: The old version to replace.
new_version: The new version to set.
dry_run: If True, don't actually modify files.
Returns:
Tuple[bool, bool]: (file_would_be_updated, old_version_found)
"""
content = file_path.read_text()
new_content = content
old_version_found = False
file_would_be_updated = False
for pattern in VERSION_PATTERNS:
def repl(match):
nonlocal old_version_found, file_would_be_updated
ver = match.group("ver")
if ver == old_version:
old_version_found = True
file_would_be_updated = True
# Some patterns have 3 groups (like quotes)
if len(match.groups()) == 3:
return f"{match.group(1)}{new_version}{match.group(3)}"
else:
return f"{match.group(1)}{new_version}"
return match.group(0)
new_content = pattern.sub(repl, new_content)
if file_would_be_updated:
if dry_run:
print(f"[DRY-RUN] Would update {file_path}")
else:
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
shutil.copy(file_path, backup_path)
file_path.write_text(new_content)
print(f"Updated {file_path} (backup saved to {backup_path})")
elif not old_version_found:
print(f"[SKIP] {file_path}: old version '{old_version}' not found")
return file_would_be_updated, old_version_found
def main():
parser = argparse.ArgumentParser(description="Bump version across project files.")
parser.add_argument("old_version", help="Old version to replace")
parser.add_argument("new_version", help="New version to set")
parser.add_argument(
"--dry-run", action="store_true", help="Show what would be changed without modifying files"
)
parser.add_argument(
"--glob", nargs="*", help="Optional glob patterns to include additional files"
)
args = parser.parse_args()
updated_files = []
not_found_files = []
# Determine files to update
files_to_update: List[Path] = [Path(f) for f in DEFAULT_FILES]
if args.glob:
for pattern in args.glob:
files_to_update.extend(Path(".").glob(pattern))
files_to_update = list(dict.fromkeys(files_to_update)) # remove duplicates
any_updated = False
for file_path in files_to_update:
if file_path.exists() and file_path.is_file():
updated, _ = replace_version_in_file(
file_path, args.old_version, args.new_version, args.dry_run
)
any_updated |= updated
if updated:
updated_files.append(file_path)
else:
print(f"[SKIP] {file_path}: file does not exist")
not_found_files.append(file_path)
print("\nSummary:")
if updated_files:
print(f"Updated files ({len(updated_files)}):")
for f in updated_files:
print(f" {f}")
else:
print("No files were updated.")
if not_found_files:
print(f"Files where old version was not found ({len(not_found_files)}):")
for f in not_found_files:
print(f" {f}")
if __name__ == "__main__":
main()

15
scripts/get_version.py Normal file
View File

@@ -0,0 +1,15 @@
#!.venv/bin/python
"""Get version of EOS"""
import sys
from pathlib import Path
# Add the src directory to sys.path so Sphinx can import akkudoktoreos
PROJECT_ROOT = Path(__file__).parent.parent
SRC_DIR = PROJECT_ROOT / "src"
sys.path.insert(0, str(SRC_DIR))
from akkudoktoreos.core.version import __version__
if __name__ == "__main__":
print(__version__)

113
scripts/update_version.py Normal file
View File

@@ -0,0 +1,113 @@
#!.venv/bin/python
"""General version replacement script.
Usage:
python scripts/update_version.py <version> <file1> [file2 ...]
"""
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
from typing import List
# --- Patterns to match version strings ---
VERSION_PATTERNS = [
# Python: __version__ = "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])(__version__\s*=\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# Python: version = "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])(version\s*=\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# JSON: "version": "1.2.3"
re.compile(
r'(?<![A-Za-z0-9])("version"\s*:\s*")'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(")'
),
# Makefile-style: VERSION ?= 1.2.3
re.compile(
r'(?<![A-Za-z0-9])(VERSION\s*\?=\s*)'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
),
# YAML: version: "1.2.3"
re.compile(
r'(?m)^(version\s*:\s*["\']?)'
r'(?P<ver>\d+\.\d+\.\d+(?:\+[0-9A-Za-z\.]+)?)'
r'(["\']?)\s*$'
),
]
def update_version_in_file(file_path: Path, new_version: str) -> bool:
"""
Replace version strings in a file based on VERSION_PATTERNS.
Returns True if the file was updated.
"""
content = file_path.read_text()
new_content = content
file_would_be_updated = False
for pattern in VERSION_PATTERNS:
def repl(match):
nonlocal file_would_be_updated
ver = match.group("ver")
if ver != new_version:
file_would_be_updated = True
# Three-group patterns (__version__, JSON, YAML)
if len(match.groups()) == 3:
return f"{match.group(1)}{new_version}{match.group(3)}"
# Two-group patterns (Makefile)
return f"{match.group(1)}{new_version}"
return match.group(0)
new_content = pattern.sub(repl, new_content)
if file_would_be_updated:
file_path.write_text(new_content)
return file_would_be_updated
def main(version: str, files: List[str]):
if not version:
raise ValueError("No version provided")
if not files:
raise ValueError("No files provided")
updated_files = []
for f in files:
path = Path(f)
if not path.exists():
print(f"Warning: {path} does not exist, skipping")
continue
if update_version_in_file(path, version):
updated_files.append(str(path))
if updated_files:
print(f"Updated files: {', '.join(updated_files)}")
else:
print("No files updated.")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python update_version.py <version> <file1> [file2 ...]")
sys.exit(1)
version_arg = sys.argv[1]
files_arg = sys.argv[2:]
main(version_arg, files_arg)