mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Adds a lint-packages target/workflow that parses the generated packaging metadata with each ecosystem's native parser (dpkg-parsechangelog, rpmspec, bash -n for the PKGBUILD), failing on any parser stderr so warnings cannot slip through. Wired into the make lint fan-out and ci-orchestrator's lint gate; an install group provisions the parsers (best-effort, cross-distro). Hardens package_mirror so the generated changelogs parse without warnings: every RPM body line gets a '- ' continuation prefix (a column-0 '*' was read as a new %changelog entry -> 'bad date'), and the archived-releases notice is folded into the oldest kept entry instead of trailing after the final entry (free text there made dpkg-parsechangelog warn). Regenerates packaging/debian/changelog and the Fedora spec accordingly. Adds a test-lint check (test_package_changelog_completeness) asserting both package changelogs list every CHANGELOG.md + docs/changelog release and that their inline window matches CHANGELOG.md, plus unit regressions for the RPM continuation prefix and the Debian footer fold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
"""Mirror the kept ``CHANGELOG.md`` entries into the package changelogs.
|
|
|
|
Both targets (Debian changelog and the RPM ``%changelog`` section) are
|
|
fully regenerated on every run from the kept entries plus an
|
|
archived-releases notice that points at the documentation site and lists
|
|
the archived versions and dates as plain text. The notice is folded into
|
|
the body of the oldest kept entry (not appended after the last entry),
|
|
because free text after the final entry is not valid changelog data and
|
|
makes the native parsers warn. Because the package files are
|
|
regenerated, they cannot drift from the CHANGELOG.md SPOT.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import date
|
|
from typing import TYPE_CHECKING
|
|
|
|
from cli.contributing.changelog.archive.changelog_md import md_body_after_header
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
DOCS_URL = "https://docs.infinito.nexus/"
|
|
FOOTER_REFERENCE = f"For older releases, see {DOCS_URL}"
|
|
FOOTER_LIST_HEADER = "Earlier releases:"
|
|
DEFAULT_MAINTAINER = "Kevin Veen-Birkenbach <kevin@veen.world>"
|
|
DEBIAN_SIG_TIME = "00:00:00 +0000"
|
|
|
|
_DEBIAN_DISTRIBUTION = "unstable"
|
|
_DEBIAN_URGENCY = "medium"
|
|
_RPM_CHANGELOG_RE = re.compile(r"^%changelog\s*$", re.MULTILINE)
|
|
|
|
|
|
def build_package_footer(archived: list[tuple[str, str]]) -> str:
|
|
"""Footer block listing every archived release as plain text and
|
|
pointing at :data:`DOCS_URL` for the full history. No links.
|
|
"""
|
|
lines = ["", FOOTER_REFERENCE, "", FOOTER_LIST_HEADER]
|
|
lines.extend(f" {version} ({date})" for version, date in archived)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def format_debian_entry(
|
|
version: str,
|
|
date_iso: str,
|
|
body_md: str,
|
|
maintainer: str,
|
|
footer_md: str = "",
|
|
) -> str:
|
|
"""Render one CHANGELOG.md entry as a Debian changelog stanza.
|
|
|
|
Every non-empty line of the markdown body gets a two-space indent so
|
|
Debian's parser accepts it: each change line MUST start with at least
|
|
two spaces, otherwise dpkg treats it as a badly formatted line, loses
|
|
the entry boundary and reports an empty maintainer. Blank lines stay
|
|
blank.
|
|
|
|
*footer_md* (the archived-releases notice) is folded into this
|
|
entry's change body — indented like the rest — rather than appended
|
|
after the trailer, because free text after the final ``--`` line is
|
|
not valid changelog data and makes ``dpkg-parsechangelog`` warn. The
|
|
signature time is a placeholder because CHANGELOG.md only carries
|
|
calendar dates.
|
|
"""
|
|
sig_date = date.fromisoformat(date_iso).strftime("%a, %d %b %Y")
|
|
body = md_body_after_header(f"## [{version}] - {date_iso}\n{body_md}")
|
|
lines = body.rstrip().split("\n")
|
|
if footer_md:
|
|
lines.extend(footer_md.rstrip("\n").split("\n"))
|
|
lines = [" " + ln if ln.strip() else ln for ln in lines]
|
|
body_text = "\n".join(lines)
|
|
return (
|
|
f"infinito-nexus ({version}-1) {_DEBIAN_DISTRIBUTION}; "
|
|
f"urgency={_DEBIAN_URGENCY}\n"
|
|
f"\n"
|
|
f"{body_text}\n"
|
|
f"\n"
|
|
f" -- {maintainer} {sig_date} {DEBIAN_SIG_TIME}\n"
|
|
)
|
|
|
|
|
|
def format_rpm_entry(
|
|
version: str,
|
|
date_iso: str,
|
|
body_md: str,
|
|
maintainer: str,
|
|
footer_md: str = "",
|
|
) -> str:
|
|
"""Render one CHANGELOG.md entry as an RPM ``%changelog`` stanza.
|
|
|
|
Every non-empty body line gets a ``- `` continuation prefix so rpm
|
|
does not read a markdown ``*`` bullet at column 0 as a new changelog
|
|
entry header (which makes ``rpmspec`` report ``bad date``). Blank
|
|
lines stay blank. *footer_md* (the archived-releases notice) is
|
|
folded into this entry's body for the same reason it is on the Debian
|
|
side: trailing free text confuses the parser.
|
|
"""
|
|
rpm_date = date.fromisoformat(date_iso).strftime("%a %b %d %Y")
|
|
body = md_body_after_header(f"## [{version}] - {date_iso}\n{body_md}")
|
|
lines = body.rstrip().split("\n")
|
|
if footer_md:
|
|
lines.extend(footer_md.rstrip("\n").split("\n"))
|
|
lines = ["- " + ln if ln.strip() else ln for ln in lines]
|
|
body_text = "\n".join(lines)
|
|
return f"* {rpm_date} {maintainer} - {version}-1\n{body_text}\n"
|
|
|
|
|
|
def mirror_to_debian_changelog(
|
|
path: Path,
|
|
entries: list[tuple[str, str, str]],
|
|
archived: list[tuple[str, str]],
|
|
*,
|
|
maintainer: str = DEFAULT_MAINTAINER,
|
|
dry_run: bool = False,
|
|
) -> bool:
|
|
"""Regenerate the Debian changelog at *path* from CHANGELOG.md.
|
|
|
|
*entries* is the list of ``(version, date, body_markdown)`` tuples
|
|
for the kept releases. *archived* is the list of
|
|
``(version, date)`` tuples for older releases that go into the
|
|
trailing notice.
|
|
|
|
Returns ``True`` when the file was rewritten, ``False`` when
|
|
nothing happened (no entries or dry run).
|
|
"""
|
|
if not entries:
|
|
return False
|
|
footer = build_package_footer(archived) if archived else ""
|
|
last = len(entries) - 1
|
|
blocks = [
|
|
format_debian_entry(v, d, b, maintainer, footer if i == last else "")
|
|
for i, (v, d, b) in enumerate(entries)
|
|
]
|
|
new_content = "\n".join(blocks).rstrip() + "\n"
|
|
if dry_run:
|
|
return True
|
|
if path.is_file():
|
|
# nocheck below: idempotency check immediately before write; cache would mask non-idempotency
|
|
current = path.read_text(encoding="utf-8") # nocheck: cache-read
|
|
if current == new_content:
|
|
return True
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(new_content, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def mirror_to_rpm_spec_changelog(
|
|
path: Path,
|
|
entries: list[tuple[str, str, str]],
|
|
archived: list[tuple[str, str]],
|
|
*,
|
|
maintainer: str = DEFAULT_MAINTAINER,
|
|
dry_run: bool = False,
|
|
) -> bool:
|
|
"""Regenerate the ``%changelog`` section of an RPM spec file at
|
|
*path* from CHANGELOG.md.
|
|
|
|
The file's pre-``%changelog`` head is preserved verbatim. The
|
|
``%changelog`` section is replaced with one stanza per entry plus
|
|
the trailing notice. Returns ``True`` when the file was rewritten,
|
|
``False`` when nothing happened (missing file, no ``%changelog``
|
|
section, no entries, or dry run).
|
|
"""
|
|
if not entries or not path.is_file():
|
|
return False
|
|
content = path.read_text(
|
|
encoding="utf-8"
|
|
) # nocheck: cache-read — function reads spec file then rewrites it; cached value would go stale on subsequent calls
|
|
section = _RPM_CHANGELOG_RE.search(content)
|
|
if section is None:
|
|
return False
|
|
head = content[: section.end()] + "\n"
|
|
footer = build_package_footer(archived) if archived else ""
|
|
last = len(entries) - 1
|
|
blocks = [
|
|
format_rpm_entry(v, d, b, maintainer, footer if i == last else "")
|
|
for i, (v, d, b) in enumerate(entries)
|
|
]
|
|
new_changelog = "\n".join(blocks).rstrip() + "\n"
|
|
new_content = head + new_changelog
|
|
if dry_run:
|
|
return True
|
|
if new_content == content:
|
|
return True
|
|
path.write_text(new_content, encoding="utf-8")
|
|
return True
|