From ce8682e0eefa91600b094c467f1767b84c64c678 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 20 Feb 2026 11:22:54 +0400 Subject: [PATCH] Generate HTML changelog from text version. --- .github/scripts/generate_changelog.py | 328 ++++++++++++++++++++++++++ .github/workflows/changelog.yml | 47 ++++ .gitignore | 3 + 3 files changed, 378 insertions(+) create mode 100644 .github/scripts/generate_changelog.py create mode 100644 .github/workflows/changelog.yml diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py new file mode 100644 index 0000000000..6cdd12652d --- /dev/null +++ b/.github/scripts/generate_changelog.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Convert changelog.txt to a static HTML page for GitHub Pages.""" + +import re +import shutil +import sys +import html +from pathlib import Path + +MONTHS = [ + "", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] + +VERSION_RE = re.compile( + r"^(\d+\.\d+(?:\.\d+)?)\s*" # version number + r"(?:(alpha|beta|dev|stable)\s*)?" # optional tag + r"\((\d{2})\.(\d{2})\.(\d{2,4})\)$" # date (DD.MM.YY or DD.MM.YYYY) +) + + +def parse_date(day: str, month: str, year: str) -> tuple[str, str, str]: + """Return (sort_key, raw_display, full_display) from DD, MM, YY strings.""" + y = int(year) + if y < 100: + y += 2000 + m = int(month) + d = int(day) + sort_key = f"{y:04d}-{m:02d}-{d:02d}" + raw_display = f"{day}.{month}.{year}" + full_display = f"{d} {MONTHS[m]} {y}" + return sort_key, raw_display, full_display + + +def parse_changelog(text: str) -> list[dict]: + entries = [] + current = None + + for raw_line in text.splitlines(): + line = raw_line.rstrip() + m = VERSION_RE.match(line) + if m: + if current: + entries.append(current) + version, tag, day, month, year = m.groups() + sort_key, raw_date, full_date = parse_date(day, month, year) + current = { + "version": version, + "tag": tag or "", + "date": raw_date, + "full_date": full_date, + "sort_key": sort_key, + "lines": [], + } + elif current is not None: + # Skip blank lines at the start + if not line and not current["lines"]: + continue + # Skip stray artifact lines + if line.strip() in ("),", "),"): + continue + current["lines"].append(line) + + if current: + entries.append(current) + + # Trim trailing blank lines from each entry + for entry in entries: + while entry["lines"] and not entry["lines"][-1]: + entry["lines"].pop() + + return entries + + +def render_entry(entry: dict) -> str: + version = html.escape(entry["version"]) + tag = entry["tag"] + date = html.escape(entry["date"]) + anchor = f"v{version}" + + tag_html = "" + if tag and tag not in ("stable",): + tag_html = f' {html.escape(tag)}' + + parts = [ + f'
', + f'

' + f'{version}{tag_html}' + f'

', + ] + + in_list = False + for line in entry["lines"]: + stripped = line.lstrip() + if stripped.startswith("- ") or stripped.startswith("\u2014 "): + # Bullet point (- or em dash) + if not in_list: + parts.append(" ") + in_list = False + if stripped: + parts.append(f"

{html.escape(stripped)}

") + + if in_list: + parts.append(" ") + + parts.append("
") + return "\n".join(parts) + + +def build_html(entries: list[dict]) -> str: + count = len(entries) + first_date = entries[-1]["full_date"] if entries else "" + latest_version = entries[0]["version"] if entries else "" + + entries_html = "\n\n".join(render_entry(e) for e in entries) + + return f""" + + + + +Version history + + + + + + +
+

Version history

+

{count} releases since {first_date} · latest: {latest_version}

+
+ +
+ + +
+{entries_html} +
+
+ + + + + + +""" + + +def main(): + repo = Path(__file__).resolve().parent.parent.parent + src = repo / "changelog.txt" + if len(sys.argv) > 1: + src = Path(sys.argv[1]) + + out = repo / "docs" / "changelog" / "index.html" + if len(sys.argv) > 2: + out = Path(sys.argv[2]) + + text = src.read_text(encoding="utf-8") + entries = parse_changelog(text) + html_content = build_html(entries) + + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(html_content, encoding="utf-8") + + # Copy favicon files from resources + icons_src = repo / "Telegram" / "Resources" / "art" + for name in ("icon16.png", "icon32.png"): + icon = icons_src / name + if icon.exists(): + shutil.copy2(icon, out.parent / name) + + print(f"Generated {out} ({len(entries)} entries, {out.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000000..fc00881413 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,47 @@ +name: Changelog + +on: + push: + branches: [dev] + paths: [changelog.txt] + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Generate HTML + run: python .github/scripts/generate_changelog.py + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/changelog + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 99141c5f44..8547c2aa0b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ settings.local.json # AI work folder (session-specific, not for version control) .ai + +# Generated changelog page (built by CI, deployed to GitHub Pages) +/docs/changelog/