- Add XBPS build and sign steps to CI workflow - Add XBPS publication as independent gate (requires manual trigger) - Track format availability in release notes - Update release-footer.md with XBPS install instructions - Add --available/--withheld arguments to extract_changelog.py - Attach XBPS artifacts to Gitea release - Clean up XBPS signing key material after use Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
130 lines
4.5 KiB
Python
Executable File
130 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract one validated Keep a Changelog version section."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import date
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
|
|
class ChangelogError(ValueError):
|
|
"""A release cannot safely use the supplied changelog."""
|
|
|
|
|
|
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
|
_VERSION_HEADING = re.compile(
|
|
rf"^## \[(?P<version>{_SEMVER})\] - (?P<date>.+)$", re.MULTILINE
|
|
)
|
|
|
|
|
|
def extract_version_section(changelog: str, version: str) -> str:
|
|
"""Return *version*'s changelog section without altering its bytes.
|
|
|
|
The section ends immediately before the next level-two heading. A release
|
|
cannot use an absent, empty, or malformed version section.
|
|
"""
|
|
if not re.fullmatch(_SEMVER, version):
|
|
raise ChangelogError(f"requested version is not bare semver: {version!r}")
|
|
|
|
heading = next(
|
|
(match for match in _VERSION_HEADING.finditer(changelog)
|
|
if match.group("version") == version),
|
|
None,
|
|
)
|
|
if heading is None:
|
|
if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE):
|
|
raise ChangelogError(f"version {version} has a malformed heading or date")
|
|
raise ChangelogError(f"version {version} is missing from the changelog")
|
|
|
|
heading_date = heading.group("date")
|
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date):
|
|
raise ChangelogError(f"version {version} has a malformed release date")
|
|
try:
|
|
date.fromisoformat(heading_date)
|
|
except ValueError as error:
|
|
raise ChangelogError(f"version {version} has a malformed release date") from error
|
|
|
|
next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE)
|
|
section_end = heading.end() + next_heading.start() if next_heading else len(changelog)
|
|
section = changelog[heading.start():section_end]
|
|
if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE):
|
|
raise ChangelogError(f"version {version} has an empty changelog section")
|
|
return section
|
|
|
|
|
|
def extract_changelog(path: Path, version: str) -> str:
|
|
"""Read and extract a requested version from a changelog file."""
|
|
try:
|
|
return extract_version_section(path.read_text(encoding="utf-8"), version)
|
|
except OSError as error:
|
|
raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error
|
|
|
|
|
|
def assemble_release_body(section: str, footer: str) -> str:
|
|
"""Append standing guidance while preserving the extracted section verbatim."""
|
|
separator = "\n" if section.endswith("\n") else "\n\n"
|
|
return f"{section}{separator}{footer}"
|
|
|
|
|
|
def format_availability_section(
|
|
available: list[str], withheld: list[str]
|
|
) -> str:
|
|
"""Generate a package formats section for release notes."""
|
|
if not available and not withheld:
|
|
return ""
|
|
lines = ["\n## Package formats\n"]
|
|
if available:
|
|
lines.append(f"Available: {', '.join(available)}")
|
|
if withheld:
|
|
lines.append(f"Withheld: {', '.join(withheld)}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("changelog", type=Path)
|
|
parser.add_argument("version")
|
|
parser.add_argument(
|
|
"--footer",
|
|
type=Path,
|
|
help="append this standing release guidance after the extracted section",
|
|
)
|
|
parser.add_argument(
|
|
"--available",
|
|
action="append",
|
|
default=[],
|
|
help="format available for this release (can be repeated)",
|
|
)
|
|
parser.add_argument(
|
|
"--withheld",
|
|
action="append",
|
|
default=[],
|
|
help="format withheld from this release (can be repeated)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
section = extract_changelog(args.changelog, args.version)
|
|
if args.footer:
|
|
try:
|
|
footer = args.footer.read_text(encoding="utf-8")
|
|
except OSError as error:
|
|
raise ChangelogError(
|
|
f"cannot read release footer {args.footer}: {error.strerror}"
|
|
) from error
|
|
section = assemble_release_body(section, footer)
|
|
if args.available or args.withheld:
|
|
formats = format_availability_section(args.available, args.withheld)
|
|
if formats:
|
|
section = f"{section}\n{formats}"
|
|
sys.stdout.write(section)
|
|
except ChangelogError as error:
|
|
print(f"::error::{error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|