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("