From 2b3efc84c995ca24180d6866329bddbc953d0346 Mon Sep 17 00:00:00 2001 From: Konrad Breitsprecher Date: Tue, 27 Oct 2026 16:30:42 +0100 Subject: [PATCH] changelog: move the Update Changelogs workflow here and fix section headers The workflow and changelog_converter.py move from sil-kit-delivery (update_packaging_changelogs.yml) into sil-kit-pkg. It runs on github.com next to the files it changes, so it needs no GitHub App token and no hop from the internal GitHub Enterprise; the pull request is opened with GITHUB_TOKEN. The converter only takes the bullets of the Keep a Changelog sections (Added, Changed, Deprecated, Removed, Fixed, Security). Before, a section header was appended to the previous entry ("... ships one.# Fixed" in 5.0.8), and notes and tables of other sections could end up in the package changelogs. tests/test_changelog_converter.py covers this. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/update-changelogs.yml | 92 +++++++++++ scripts/changelog_converter.py | 203 ++++++++++++++++++++++++ tests/test_changelog_converter.py | 83 ++++++++++ 3 files changed, 378 insertions(+) create mode 100644 .github/workflows/update-changelogs.yml create mode 100644 scripts/changelog_converter.py create mode 100644 tests/test_changelog_converter.py diff --git a/.github/workflows/update-changelogs.yml b/.github/workflows/update-changelogs.yml new file mode 100644 index 0000000..08c4487 --- /dev/null +++ b/.github/workflows/update-changelogs.yml @@ -0,0 +1,92 @@ +name: "Update Changelogs" + +# Takes the newest entry of the SIL Kit changelog (docs/changelog/versions/latest.md), +# adds it to debian/changelog and rpm/libsilkit5.spec and opens a pull request for review. +# Moved here from sil-kit-delivery (update_packaging_changelogs.yml). + +on: + workflow_dispatch: + inputs: + silkit_ref: + description: "sil-kit branch, tag or commit to read the changelog from" + required: true + type: string + default: main + author: + description: "Name of the developer creating the release" + required: true + type: string + email: + description: "Email of the developer creating the release (no generic addresses)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update: + name: Update changelogs + runs-on: ubuntu-latest + steps: + - name: Check email + env: + EMAIL: ${{ inputs.email }} + run: | + case "$EMAIL" in + sil-kit@*|silkit@*|noreply@*|*@users.noreply.github.com) + echo "::error::use your personal email address, not $EMAIL" + exit 1 + ;; + esac + + - name: Checkout (sil-kit-pkg) + uses: actions/checkout@v6 + with: + ref: main + path: sil-kit-pkg + + - name: Checkout (sil-kit) + uses: actions/checkout@v6 + with: + repository: vectorgrp/sil-kit + ref: ${{ inputs.silkit_ref }} + path: sil-kit + + - name: Run tests + working-directory: sil-kit-pkg + run: python3 -m unittest discover -s tests -p "test_changelog*.py" + + - name: Update changelogs + id: convert + env: + AUTHOR: ${{ inputs.author }} + EMAIL: ${{ inputs.email }} + run: | + python3 sil-kit-pkg/scripts/changelog_converter.py "$AUTHOR" "$EMAIL" sil-kit-pkg \ + --changelog sil-kit/docs/changelog/versions/latest.md + + - name: Push and open pull request + working-directory: sil-kit-pkg + env: + GH_TOKEN: ${{ github.token }} + AUTHOR: ${{ inputs.author }} + EMAIL: ${{ inputs.email }} + VERSION: ${{ steps.convert.outputs.silkit_version }} + run: | + branch="update_to_${VERSION}" + git config user.name "$AUTHOR" + git config user.email "$EMAIL" + git switch -c "$branch" + git add debian/changelog rpm/libsilkit5.spec + git commit -s -m "changelog: Update to ${VERSION}" + git push --force origin "$branch" + + if gh pr view "$branch" --json url --jq .url 2>/dev/null; then + echo "Pull request exists, the push updated it." + else + gh pr create --base main --head "$branch" \ + --title "changelog: Update to ${VERSION}" \ + --body "Changelog entries for SIL Kit ${VERSION} from vectorgrp/sil-kit@${{ inputs.silkit_ref }} (docs/changelog/versions/latest.md). Please review before the delivery build." + fi diff --git a/scripts/changelog_converter.py b/scripts/changelog_converter.py new file mode 100644 index 0000000..546a83c --- /dev/null +++ b/scripts/changelog_converter.py @@ -0,0 +1,203 @@ +#! /bin/env python3 +# +# SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +# +# SPDX-License-Identifier: MIT +# +"""Add the newest entry of the SIL Kit changelog (docs/changelog/versions/latest.md) +to debian/changelog and rpm/libsilkit5.spec. + +Moved here from sil-kit-delivery (scripts/changelog_converter.py). Only the bullets of the +Keep a Changelog sections (Added, Changed, Deprecated, Removed, Fixed, Security) are taken +over; other sections such as notes and tables are skipped, and section headers no longer +end up in the previous entry. +""" +import argparse +import os +import re +import shutil +import sys +import textwrap + +from datetime import date, datetime +from pathlib import Path + +CHANGE_SECTIONS = {"added", "changed", "deprecated", "removed", "fixed", "security"} + + +def log(fmt, *args): + print(fmt.format(*args)) + + +def die(status, fmt, *args): + log("ERROR: " + fmt, *args) + sys.exit(status) + + +class SilKitVersion: + def __init__(self, major: int, minor: int, patch: int, suffix: str = ""): + self.major = major + self.minor = minor + self.patch = patch + self.suffix = suffix + + def pretty_for_git(self): + version_str = f"{self.major}.{self.minor}.{self.patch}" + if self.suffix and self.suffix != "": + version_str += f"-{self.suffix}" + return version_str + + def pretty(self): + version_str = f"{self.major}.{self.minor}.{self.patch}" + if self.suffix and self.suffix != "": + version_str += f"~{self.suffix}" + return version_str + + +class Changes: + def __init__(self, change_date: date, version: SilKitVersion): + self.changes = [] + self.cdate = change_date + self.version = version + + +def parse_changelog(lines, today: date = None) -> Changes: + """Parse the first (newest) version of a SIL Kit changelog in markdown.""" + + changelog = None + section = None + in_entry = False + + for line in lines: + line = line.rstrip("\n") + + if line.startswith("# "): + if changelog is not None: + log("Older version, stopping the processing") + break + + ver_reg = r"^#\s\[([0-9]+)\.([0-9]+)\.([0-9]+)\]\s+\-\s+([0-9]+\-[0-9]+\-[0-9]+|UNRELEASED)" + reg = re.match(ver_reg, line) + if reg is None: + die(64, "Malformed changelog header: {}", line) + + date_string = reg.group(4).strip() + if date_string == "UNRELEASED": + release_date = today or date.today() + else: + release_date = date(int(date_string[0:4]), int(date_string[5:7]), int(date_string[8:10])) + + changelog = Changes(release_date, SilKitVersion(major=reg.group(1), minor=reg.group(2), patch=reg.group(3))) + log("Found version {} from {}", changelog.version.pretty_for_git(), release_date) + + elif line.startswith("#"): + # "## Fixed", "### Details" ...: a header always ends the current entry + in_entry = False + if line.startswith("## "): + section = line[3:].strip().lower() + + elif line.startswith("* ") or line.startswith("- "): + if changelog is None: + die(64, "Malformed changelog, no changelog header found!") + in_entry = section in CHANGE_SECTIONS + if in_entry: + changelog.changes.append(line[2:].strip()) + + elif in_entry and line[:1].isspace() and line.strip(): + # indented continuation line (or nested bullet) of the current entry + changelog.changes[-1] += " " + line.strip() + + else: + # blank lines, paragraphs and tables + in_entry = False + + if changelog is None: + die(64, "Malformed changelog, no changelog header found!") + return changelog + + +def write_deb_log(changelog: Changes, args): + + header = f"libsilkit ({changelog.version.pretty()}-1ubuntu1) stable; urgency=medium" + footer = f"\n -- {args.name} <{args.mail}> {changelog.cdate.strftime('%a, %d %b %Y')} {datetime.now().astimezone().strftime('%H:%M:%S %z')}" + + entry = header + "\n" + for change in changelog.changes: + entry += " * " + "\n".join(textwrap.wrap(change, width=76, subsequent_indent=" ")) + "\n" + entry += footer + "\n\n" + + print(entry) + if args.dryrun: + print("Exiting due to dryrun!\n") + return + + changelog_path = args.package_dir / "debian/changelog" + with open(changelog_path, "r") as f: + old = f.read() + with open(changelog_path, "w") as f: + f.write(entry + old) + + +def write_rpm_log(changelog: Changes, args): + + header = f"* {changelog.cdate.strftime('%a %b %d %Y')} {args.name} <{args.mail}> - {changelog.version.pretty()}" + + entry = header + "\n" + for change in changelog.changes: + entry += "- " + "\n".join(textwrap.wrap(change, width=76, subsequent_indent=" ")) + "\n" + + print(entry) + if args.dryrun: + print("Exiting due to dryrun!\n") + return + + spec_path = args.package_dir / "rpm/libsilkit5.spec" + with open(spec_path, "r") as f: + lines = f.readlines() + + with open(spec_path, "w") as f: + f.write(f"%define version_major {changelog.version.major}\n") + f.write(f"%define version_minor {changelog.version.minor}\n") + f.write(f"%define version_patch {changelog.version.patch}\n") + f.write("%define version_suffix %{nil}\n") + + for line in lines: + if "define version" not in line: + f.write(line) + + if "%changelog" in line: + print("Writing new changelog entry!") + f.write("\n" + entry) + + +def main(): + + parser = argparse.ArgumentParser(prog="ChaparONE", + description="Parse and create changelogs for SIL Kit releases") + parser.add_argument("name", type=str) + parser.add_argument("mail", type=str) + parser.add_argument("package_dir", type=Path) + parser.add_argument("--changelog", type=Path, default=Path("sil-kit/docs/changelog/versions/latest.md"), + help="SIL Kit changelog in markdown") + parser.add_argument("--dryrun", action="store_true") + args = parser.parse_args() + + with open(args.changelog, "rt", encoding="utf-8") as f: + changelog = parse_changelog(f) + + if not changelog.changes: + die(64, "{} has no entries for {}", args.changelog, changelog.version.pretty()) + + if os.getenv("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"silkit_version={changelog.version.pretty_for_git()}\n") + + print(f"Changelog Version: {changelog.version.pretty()}") + + write_deb_log(changelog, args) + write_rpm_log(changelog, args) + + +if __name__ == "__main__": + + main() diff --git a/tests/test_changelog_converter.py b/tests/test_changelog_converter.py new file mode 100644 index 0000000..3db3a7d --- /dev/null +++ b/tests/test_changelog_converter.py @@ -0,0 +1,83 @@ +#! /bin/env python3 +# +# SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +# +# SPDX-License-Identifier: MIT +# +# Run with: python3 -m unittest discover -s tests -p "test_changelog*.py" +import sys +import unittest + +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from changelog_converter import parse_changelog # noqa: E402 + +# Shaped like sil-kit docs/changelog/versions/5.0.8.md +SAMPLE = """# [5.0.8] - 2026-10-13 + +## Note on the SIL Kit MSI installer + +Up to and including SIL Kit 5.0.7, the MSI installed per-user. + +### What this means when you upgrade + +- The 5.0.8 MSI can't detect an earlier per-user installation. +- Upgrading still works. The new installer: + - disables the previous service, and + - updates the CMake lookup. + +| | Up to 5.0.7 | From 5.0.8 | +|---|---|---| + +## Added + +- Add Integration Test for Timestamp Behavior +- The documentation now lists every third party dependency found in the source tree with its + version and license on the Licenses page, followed by the full license text of each component + that ships one. + +## Fixed + +- The SIL Kit Registry could hang on shutdown. + +# [5.0.7] - 2026-09-01 + +## Fixed + +- Older entry +""" + + +class ParseChangelogTest(unittest.TestCase): + + def test_version_and_date(self): + changelog = parse_changelog(SAMPLE.splitlines(True)) + self.assertEqual(changelog.version.pretty(), "5.0.8") + self.assertEqual(changelog.cdate, date(2026, 10, 13)) + + def test_only_change_sections(self): + changes = parse_changelog(SAMPLE.splitlines(True)).changes + self.assertEqual(changes, [ + "Add Integration Test for Timestamp Behavior", + "The documentation now lists every third party dependency found in the source tree with its " + "version and license on the Licenses page, followed by the full license text of each component " + "that ships one.", + "The SIL Kit Registry could hang on shutdown.", + ]) + + def test_header_is_not_appended(self): + for change in parse_changelog(SAMPLE.splitlines(True)).changes: + self.assertNotIn("#", change) + + def test_unreleased(self): + changelog = parse_changelog(["# [5.0.9] - UNRELEASED\n", "\n", "## Added\n", "\n", "- New\n"], + today=date(2026, 10, 27)) + self.assertEqual(changelog.cdate, date(2026, 10, 27)) + self.assertEqual(changelog.changes, ["New"]) + + +if __name__ == "__main__": + unittest.main()