From 9cc84b14b8805f964ee8772b44721c3ec1588f4e Mon Sep 17 00:00:00 2001 From: tranch Date: Thu, 22 Oct 2026 15:13:49 +0800 Subject: feat: publish AeroCore game mode news Provide public update announcements for AeroCore OS using the upstream game mode news format. --- .github/scripts/build-announcements.py | 642 +++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 .github/scripts/build-announcements.py (limited to '.github/scripts') diff --git a/.github/scripts/build-announcements.py b/.github/scripts/build-announcements.py new file mode 100644 index 0000000..85908b5 --- /dev/null +++ b/.github/scripts/build-announcements.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 + +import html +import json +import os +import re +import urllib.request +from datetime import datetime, timezone + +MAX_ENTRIES = 20 +ROOT_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +OUTPUT_FILE = os.path.join(ROOT_DIR, "announcements.json") +DOCS_DIR = os.path.join(ROOT_DIR, "docs") +HTML_FILE = os.path.join(DOCS_DIR, "index.html") + +SOURCE_REPO = os.environ.get("SOURCE_REPO", "AeroCore-IO/AeroCore-OS") + +# branch -> (prerelease_filter, channel) +BRANCH_CONFIG = { + "stable": (False, "rel"), + "testing": (True, "beta"), + "unstable": (True, "preview"), +} + + +def branch_config(branch): + """Return (prerelease, channel) for a git branch, or raise ValueError.""" + if branch not in BRANCH_CONFIG: + raise ValueError(f"unknown branch: {branch!r} (expected one of {sorted(BRANCH_CONFIG)})") + return BRANCH_CONFIG[branch] + + +def select_releases(releases, prerelease, limit=MAX_ENTRIES): + """Filter releases by prerelease flag, drop drafts, sort newest-first, cap at limit.""" + kept = [ + r for r in releases + if bool(r.get("prerelease")) == prerelease and not r.get("draft") + ] + kept.sort(key=lambda r: r.get("published_at") or "", reverse=True) + return kept[:limit] + + +KEEP_SECTIONS = {"Major packages", "Commits"} + + +def extract_sections(body): + """Keep only allowlisted '### ' sections from a release body.""" + lines = (body or "").split("\n") + result = [] + keeping = False + for line in lines: + m = re.match(r"^#{1,6}\s+(.*)", line.strip()) + if m: + keeping = m.group(1).strip() in KEEP_SECTIONS + if keeping: + result.append(line) + return "\n".join(result).strip() + + +def normalize_release_date(published_at): + """Parse GitHub ISO-8601 UTC timestamp into (display_str, unix_ts).""" + dt = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M"), int(dt.timestamp()) + + +def release_to_entry(release, channel): + """Convert a GitHub release dict into an announcement entry.""" + body = extract_sections(release.get("body", "")) + date_str, timestamp = normalize_release_date(release["published_at"]) + return { + "title": release.get("name") or "", + "date": date_str, + "timestamp": timestamp, + "channel": channel, + "lang": "english", + "body": body, + "bbcode_body": md_to_bbcode(body), + "html_body": md_to_html(body), + } + + +def fetch_releases(repo, token, urlopen=None): + """Fetch all releases for a repo (paginated), newest first. stdlib only.""" + opener = urlopen or urllib.request.urlopen + results = [] + page = 1 + while True: + url = f"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}" + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github+json") + req.add_header("User-Agent", "aerocore-os-gamemode-news") + if token: + req.add_header("Authorization", f"Bearer {token}") + with opener(req, timeout=30) as resp: + batch = json.loads(resp.read().decode("utf-8")) + if not batch: + break + results.extend(batch) + if len(batch) < 100: + break + page += 1 + return results + + +def _is_table_row(line): + """Check if a line is a Markdown table row (| ... | ... |).""" + return bool(re.match(r"^\|.*\|$", line.strip())) + + +def _is_table_separator(line): + """Check if a line is a Markdown table separator (|---|---|).""" + return bool(re.match(r"^\|[-:\s|]+\|$", line.strip())) + + +def _parse_table_cells(line): + """Extract cell contents from a Markdown table row.""" + return [c.strip() for c in line.strip().strip("|").split("|")] + + +def md_to_bbcode(text): + """Convert Markdown to Steam BBCode.""" + lines = text.split("\n") + result = [] + in_ul = False + in_ol = False + in_quote = False + in_code_block = False + in_table = False + table_header_done = False + + for line in lines: + stripped = line.strip() + + # Fenced code block + if stripped.startswith("```"): + if in_code_block: + result.append("[/code]") + in_code_block = False + else: + in_code_block = True + result.append("[code]") + continue + if in_code_block: + result.append(line.rstrip()) + continue + + # Table + if _is_table_row(stripped): + if _is_table_separator(stripped): + continue + cells = _parse_table_cells(stripped) + if not in_table: + result.append("[table]") + in_table = True + table_header_done = False + tag = "th" if not table_header_done else "td" + row = "[tr]" + "".join(f"[{tag}]{c}[/{tag}]" for c in cells) + "[/tr]" + result.append(row) + if not table_header_done: + table_header_done = True + continue + elif in_table: + result.append("[/table]") + in_table = False + + # Unordered list + if re.match(r"^[-*]\s+", stripped): + if in_ol: + result.append("[/olist]") + in_ol = False + item = re.sub(r"^[-*]\s+", "", stripped) + if not in_ul: + result.append("[list]") + in_ul = True + result.append(f"[*] {item}") + continue + elif in_ul: + result.append("[/list]") + in_ul = False + + # Ordered list + if re.match(r"^\d+\.\s+", stripped): + if in_ul: + result.append("[/list]") + in_ul = False + item = re.sub(r"^\d+\.\s+", "", stripped) + if not in_ol: + result.append("[olist]") + in_ol = True + result.append(f"[*] {item}") + continue + elif in_ol: + result.append("[/olist]") + in_ol = False + + # Blockquote + if stripped.startswith("> "): + content = stripped[2:] + if not in_quote: + result.append("[quote]") + in_quote = True + result.append(content) + continue + elif in_quote: + result.append("[/quote]") + in_quote = False + + # Horizontal rule + if re.match(r"^[-*_]{3,}$", stripped): + result.append("[hr][/hr]") + continue + + # Heading + m = re.match(r"^(#{1,3})\s+(.*)", stripped) + if m: + level = len(m.group(1)) + tag = f"h{level}" + result.append(f"[{tag}]{m.group(2)}[/{tag}]") + continue + + result.append(stripped) + + if in_table: + result.append("[/table]") + if in_ul: + result.append("[/list]") + if in_ol: + result.append("[/olist]") + if in_quote: + result.append("[/quote]") + if in_code_block: + result.append("[/code]") + + text = "\n".join(result) + # Protect [*] list markers from being caught by italic regex + text = text.replace("[*]", "\x00LI\x00") + # Images before links (![alt](url) vs [text](url)) + text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"[img]\2[/img]", text) + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"[url=\2]\1[/url]", text) + text = re.sub(r"\*\*(.+?)\*\*", r"[b]\1[/b]", text) + text = re.sub(r"__(.+?)__", r"[u]\1[/u]", text) + text = re.sub(r"\*(.+?)\*", r"[i]\1[/i]", text) + text = re.sub(r"~~(.+?)~~", r"[strike]\1[/strike]", text) + text = re.sub(r"(?") + in_code_block = False + else: + in_code_block = True + result.append("
")
+            continue
+        if in_code_block:
+            result.append(html.escape(line.rstrip()))
+            continue
+
+        # Table
+        if _is_table_row(stripped):
+            if _is_table_separator(stripped):
+                continue
+            cells = _parse_table_cells(stripped)
+            if not in_table:
+                result.append("")
+                in_table = True
+                table_header_done = False
+            tag = "th" if not table_header_done else "td"
+            row = "" + "".join(f"<{tag}>{html.escape(c)}" for c in cells) + ""
+            result.append(row)
+            if not table_header_done:
+                table_header_done = True
+            continue
+        elif in_table:
+            result.append("
") + in_table = False + + # Unordered list + if re.match(r"^[-*]\s+", stripped): + if in_ol: + result.append("") + in_ol = False + item = re.sub(r"^[-*]\s+", "", stripped) + if not in_ul: + result.append("
    ") + in_ul = True + result.append(f"
  • {html.escape(item)}
  • ") + continue + elif in_ul: + result.append("
") + in_ul = False + + # Ordered list + if re.match(r"^\d+\.\s+", stripped): + if in_ul: + result.append("") + in_ul = False + item = re.sub(r"^\d+\.\s+", "", stripped) + if not in_ol: + result.append("
    ") + in_ol = True + result.append(f"
  1. {html.escape(item)}
  2. ") + continue + elif in_ol: + result.append("
") + in_ol = False + + # Blockquote + if stripped.startswith("> "): + content = stripped[2:] + if not in_quote: + result.append("
") + in_quote = True + result.append(html.escape(content)) + continue + elif in_quote: + result.append("
") + in_quote = False + + # Horizontal rule + if re.match(r"^[-*_]{3,}$", stripped): + result.append("
") + continue + + # Heading + m = re.match(r"^(#{1,3})\s+(.*)", stripped) + if m: + level = len(m.group(1)) + result.append(f"{html.escape(m.group(2))}") + continue + + if stripped: + result.append(f"

{html.escape(stripped)}

") + else: + result.append("") + + if in_table: + result.append("") + if in_ul: + result.append("") + if in_ol: + result.append("") + if in_quote: + result.append("") + if in_code_block: + result.append("
") + + text = "\n".join(result) + # Images before links + text = re.sub( + r"!\[([^\]]*)\]\(([^)]+)\)", + r'\1', + text, + ) + text = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + r'\1', + text, + ) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"~~(.+?)~~", r"\1", text) + text = re.sub(r"(?\1", text) + # unescape HTML tags generated by inline formatting inside escaped

/

  • // + text = re.sub( + r"<(/?(?:strong|em|del|u|a|code|img|ul|ol|li|blockquote|pre|h[1-3]|hr|table|tr|td|th)(?:\s[^>]*)?)>", + r"<\1>", + text, + ) + return text + + +def main(): + branch = os.environ.get("RELEASE_BRANCH", "").strip() + prerelease, channel = branch_config(branch) + token = os.environ.get("GITHUB_TOKEN") or None + + releases = fetch_releases(SOURCE_REPO, token) + selected = select_releases(releases, prerelease, MAX_ENTRIES) + entries = [release_to_entry(r, channel) for r in selected] + + out_file = os.path.normpath(OUTPUT_FILE) + with open(out_file, "w", encoding="utf-8") as f: + json.dump(entries, f, ensure_ascii=False, indent=2) + f.write("\n") + print(f"Generated {out_file}: {len(entries)} entries " + f"from {len(releases)} releases (branch={branch}, channel={channel})") + + generate_html(entries) + print(f"Generated {HTML_FILE}") + + +def generate_html(entries): + """Build a static HTML page from announcement entries.""" + os.makedirs(DOCS_DIR, exist_ok=True) + + # Collect available languages and channels from data + langs = sorted({e["lang"] for e in entries if e["lang"]}) + data_channels = {e["channel"] for e in entries if e["channel"]} + channels = [ch for ch in CHANNEL_LABELS if ch in data_channels] + + data_json = json.dumps(entries, ensure_ascii=False) + + with open(HTML_FILE, "w", encoding="utf-8") as f: + f.write(_html_template(data_json, langs, channels)) + + +CHANNEL_LABELS = { + "rel": {"en": "Stable", "zh": "稳定版"}, + "beta": {"en": "Beta", "zh": "测试版"}, + "preview": {"en": "Preview", "zh": "预览版"}, + "main": {"en": "Super Preview", "zh": "超级前瞻版"}, +} + +CHANNEL_COLORS = { + "rel": "#4caf50", + "beta": "#ff9800", + "preview": "#f44336", + "main": "#e40336", +} + +LANG_LABELS = { + "schinese": "简体中文", + "tchinese": "繁體中文", + "english": "English", + "japanese": "日本語", + "koreana": "한국어", +} + + +def _html_template(data_json, langs, channels): + # Build channel tab HTML + channel_tabs = "" + for ch in channels: + label_en = CHANNEL_LABELS.get(ch, {}).get("en", ch) + label_zh = CHANNEL_LABELS.get(ch, {}).get("zh", ch) + color = CHANNEL_COLORS.get(ch, "#9e9e9e") + channel_tabs += ( + f'\n' + ) + + # Build language option HTML + lang_options = "" + for lang in langs: + label = LANG_LABELS.get(lang, lang) + lang_options += ( + f'\n' + ) + + return f''' + + + + +SkorionOS Updates + + + +
    +
    +

    SkorionOS Updates

    +
    +
    +
    + + {channel_tabs} +
    +
    + +
    SkorionOS © 2026
    +
    + + +''' + + +if __name__ == "__main__": + main() -- cgit v1.2.3