Source nightly from Clangtron and discover PR builds via PR comments

Signed-off-by: Mythrax <mythrax@mythrax-rs.org>
This commit is contained in:
Mythrax
2026-05-14 16:03:57 +10:00
parent 9e6bd05d2a
commit e398fe0e0b
3 changed files with 523 additions and 64 deletions
+23 -9
View File
@@ -5,18 +5,26 @@ Official external updater utility for **Citron Neo** (Windows only), built with
It can pull builds from any of three official channels:
- **Stable** — <https://github.com/citron-neo/emulator/releases>
- **Nightly CI (MSVC)** — <https://github.com/citron-neo/CI/releases>
- **PR Builds** — <https://github.com/citron-neo/PR/tags> (release assets are pulled from `citron-neo/PR/releases`)
- **Nightly CI (Clangtron)** — <https://github.com/citron-neo/CI/releases>
- **PR Builds** — open pull requests on <https://github.com/citron-neo/emulator/pulls>; per-PR Windows artifacts are pulled from the `**Build Artifacts for PR #N**` comment (powered by [nightly.link](https://nightly.link/))
The Clangtron (MinGW-w64) toolchain is no longer produced upstream, so the
nightly CI channel only ships MSVC artifacts.
Upstream no longer ships the MSVC or MinGW-w64 Windows toolchains. The only
Windows artifact produced is the Clangtron build (Clang LTO cross-compiled
from Linux), e.g. `Citron-windows-nightly-<sha>-x64-clangtron.zip`.
For the **PR Builds** channel, the updater scans the most recently updated
open PRs on `citron-neo/emulator`. For each PR it reads the build-artifact
comment posted to the PR, extracts the Windows Clangtron download URL (only
URLs hosted on `nightly.link` are accepted), and presents the PRs in a
dropdown together with their build status (`ready`, `building`, or
`no Windows build`).
## Features
- Automatic update check on startup
- Manual **Check for Updates** and **Update Now**
- Current version vs latest version display
- Switchable release channel: Stable / Nightly CI / PR Builds (Nightly CI is the default)
- Switchable release channel: Stable / Nightly CI (Clangtron) / PR Builds (Nightly CI is the default)
- Modern dark-mode UI (CustomTkinter)
- Download + extraction progress bar
- Detailed log panel
@@ -64,14 +72,20 @@ nightly CI channel only ships MSVC artifacts.
1. Launch updater.
2. On first run, choose the install/update folder in setup popup.
3. Optional: import data from an older portable install by selecting the folder containing `user`.
4. Choose a release channel (`Stable`, `Nightly CI - MSVC`, or `PR Builds`).
4. Choose a release channel (`Stable`, `Nightly CI - Clangtron`, or `PR Builds`).
5. Click **Check for Updates**.
6. Click **Update Now** if an update is available.
7. Click **Launch Citron Neo** after success.
6. For **PR Builds**, pick an open PR from the dropdown that became visible.
The summary line tells you whether its Windows build is `ready`, still
`building`, or has no usable Windows artifact. Use **Open PR on GitHub**
to open the PR thread in your browser.
7. Click **Update Now** if an update is available.
8. Click **Launch Citron Neo** after success.
Switching the channel triggers a fresh check against the new repository.
Builds applied from different channels are tracked independently — moving from
nightly to stable (or vice versa) will be detected as an update.
nightly to stable (or to a specific PR build) will be detected as an update.
Installed PR builds are recorded as `pr-<number>-<short-sha>` so re-checking a
PR after CI rebuilds the same commit will not trigger a redundant download.
The updater stores config in:
+205 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import queue
import threading
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox
from typing import Callable, Optional
@@ -12,7 +13,11 @@ from updater import (
CHANNEL_NIGHTLY,
CHANNEL_PR,
CHANNEL_STABLE,
PR_BUILD_STATUS_BUILDING,
PR_BUILD_STATUS_MISSING,
PR_BUILD_STATUS_READY,
CheckResult,
PullRequestBuild,
ReleaseInfo,
UpdaterError,
UpdaterService,
@@ -20,13 +25,20 @@ from updater import (
CHANNEL_LABELS = {
CHANNEL_STABLE: "Stable (citron-neo/emulator)",
CHANNEL_NIGHTLY: "Nightly CI - MSVC (citron-neo/CI)",
CHANNEL_PR: "PR Builds (citron-neo/PR)",
CHANNEL_NIGHTLY: "Nightly CI - Clangtron (citron-neo/CI)",
CHANNEL_PR: "PR Builds (citron-neo/emulator)",
}
# Order matters for the dropdown: stable, nightly, PR.
CHANNEL_ORDER = (CHANNEL_STABLE, CHANNEL_NIGHTLY, CHANNEL_PR)
LABEL_TO_CHANNEL = {label: key for key, label in CHANNEL_LABELS.items()}
PR_PLACEHOLDER_LABEL = "(no PRs loaded)"
PR_STATUS_TEXT = {
PR_BUILD_STATUS_READY: "Ready - Clangtron Windows build available",
PR_BUILD_STATUS_BUILDING: "Building - check back once CI finishes",
PR_BUILD_STATUS_MISSING: "No usable Windows build for this PR",
}
class UpdaterApp:
def __init__(self) -> None:
@@ -38,6 +50,9 @@ class UpdaterApp:
self.busy = False
self._startup_check_done = False
self.ui_queue: queue.Queue[Callable[[], None]] = queue.Queue()
self.pr_builds: list[PullRequestBuild] = []
self.pr_label_lookup: dict[str, PullRequestBuild] = {}
self.selected_pr: Optional[PullRequestBuild] = None
self.root = ctk.CTk()
self.root.title("Citron Neo Updater")
@@ -54,7 +69,7 @@ class UpdaterApp:
def _build_ui(self) -> None:
self.root.grid_columnconfigure(0, weight=1)
self.root.grid_rowconfigure(3, weight=1)
self.root.grid_rowconfigure(4, weight=1)
title = ctk.CTkLabel(
self.root,
@@ -94,8 +109,51 @@ class UpdaterApp:
)
self.channel_menu.grid(row=0, column=2, padx=12, pady=10, sticky="e")
self.pr_frame = ctk.CTkFrame(self.root)
self.pr_frame.grid(row=2, column=0, padx=20, pady=8, sticky="ew")
self.pr_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
self.pr_frame,
text="PR Build:",
font=ctk.CTkFont(size=14, weight="bold"),
).grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")
self.pr_var = ctk.StringVar(value=PR_PLACEHOLDER_LABEL)
self.pr_menu = ctk.CTkOptionMenu(
self.pr_frame,
variable=self.pr_var,
values=[PR_PLACEHOLDER_LABEL],
command=self._on_pr_selected,
)
self.pr_menu.grid(row=0, column=1, padx=8, pady=(10, 4), sticky="ew")
self.pr_menu.configure(state="disabled")
self.pr_open_btn = ctk.CTkButton(
self.pr_frame,
text="Open PR on GitHub",
width=160,
command=self._open_selected_pr,
)
self.pr_open_btn.grid(row=0, column=2, padx=(8, 12), pady=(10, 4), sticky="e")
self.pr_open_btn.configure(state="disabled")
self.pr_status_var = ctk.StringVar(value="Switch to PR Builds and check for updates to populate this list.")
ctk.CTkLabel(
self.pr_frame,
textvariable=self.pr_status_var,
text_color="#bdbdbd",
font=ctk.CTkFont(size=13),
anchor="w",
justify="left",
wraplength=820,
).grid(row=1, column=0, columnspan=3, padx=12, pady=(0, 10), sticky="ew")
# Hidden by default; toggled when the PR channel is selected.
self.pr_frame.grid_remove()
controls_frame = ctk.CTkFrame(self.root)
controls_frame.grid(row=2, column=0, padx=20, pady=8, sticky="ew")
controls_frame.grid(row=3, column=0, padx=20, pady=8, sticky="ew")
controls_frame.grid_columnconfigure((0, 1, 2, 3, 4), weight=1)
self.check_btn = ctk.CTkButton(
@@ -135,7 +193,7 @@ class UpdaterApp:
self.import_btn.grid(row=0, column=4, padx=8, pady=12, sticky="ew")
progress_frame = ctk.CTkFrame(self.root)
progress_frame.grid(row=3, column=0, padx=20, pady=(8, 6), sticky="nsew")
progress_frame.grid(row=4, column=0, padx=20, pady=(8, 6), sticky="nsew")
progress_frame.grid_columnconfigure(0, weight=1)
progress_frame.grid_rowconfigure(2, weight=1)
@@ -177,6 +235,9 @@ class UpdaterApp:
self.install_path_var.set(str(install_path))
preferred = self.service.get_preferred_channel()
self.channel_var.set(CHANNEL_LABELS.get(preferred, CHANNEL_LABELS[CHANNEL_NIGHTLY]))
self._set_pr_panel_visible(preferred == CHANNEL_PR)
if preferred == CHANNEL_PR:
self.pr_status_var.set("Loading open PRs from citron-neo/emulator...")
self.log("Updater started.")
def _maybe_show_first_run_setup(self) -> None:
@@ -295,6 +356,9 @@ class UpdaterApp:
self.import_btn.configure(state=button_state)
self.channel_menu.configure(state=button_state)
self.update_btn.configure(state=button_state if self.current_release else "disabled")
pr_menu_state = button_state if self.pr_builds and not busy else "disabled"
self.pr_menu.configure(state=pr_menu_state)
self.pr_open_btn.configure(state=button_state if self.selected_pr else "disabled")
def _progress_cb(self, value: float, status: str) -> None:
self.ui_queue.put(lambda: self.progress_bar.set(max(0.0, min(1.0, value))))
@@ -308,10 +372,13 @@ class UpdaterApp:
def check_updates(self) -> None:
def task() -> None:
self.ui_queue.put(lambda: self.status_var.set("Status: Checking for updates..."))
channel = self.service.get_preferred_channel().upper()
self.ui_queue.put(
lambda c=channel: self.log(f"Checking GitHub release (channel: {c})...")
)
channel = self.service.get_preferred_channel()
channel_upper = channel.upper()
if channel == CHANNEL_PR:
msg = "Scanning open PRs on citron-neo/emulator for Windows Clangtron builds..."
else:
msg = f"Checking GitHub release (channel: {channel_upper})..."
self.ui_queue.put(lambda m=msg: self.log(m))
self.ui_queue.put(lambda: self.progress_bar.set(0))
try:
result = self.service.check_for_updates()
@@ -324,10 +391,17 @@ class UpdaterApp:
self._run_background(task)
def _apply_check_result(self, result: CheckResult) -> None:
self.current_release = result.release
self.current_version_var.set(f"Current: {result.current_version}")
self.latest_version_var.set(f"Latest: {result.latest_version}")
channel = self.service.get_preferred_channel()
if channel == CHANNEL_PR:
self.current_release = None
self._populate_pr_dropdown(result.pull_requests)
return
self.current_release = result.release
if result.update_available and result.release:
self.status_var.set("Status: Update available")
self.update_btn.configure(state="normal")
@@ -340,6 +414,122 @@ class UpdaterApp:
self.update_btn.configure(state="disabled")
self.log("No update needed.")
def _populate_pr_dropdown(self, prs: list[PullRequestBuild]) -> None:
self.pr_builds = list(prs or [])
self.selected_pr = None
self.current_release = None
self.pr_label_lookup = {}
labels: list[str] = []
ready_count = 0
building_count = 0
for pr in self.pr_builds:
label = pr.display_label
# Disambiguate duplicates that share the same display string.
base = label
n = 2
while label in self.pr_label_lookup:
label = f"{base} #{n}"
n += 1
self.pr_label_lookup[label] = pr
labels.append(label)
if pr.status == PR_BUILD_STATUS_READY:
ready_count += 1
elif pr.status == PR_BUILD_STATUS_BUILDING:
building_count += 1
if not labels:
labels = [PR_PLACEHOLDER_LABEL]
self.pr_var.set(PR_PLACEHOLDER_LABEL)
self.pr_menu.configure(values=labels, state="disabled")
self.pr_status_var.set(
"No open PRs were returned by GitHub. Check your network or try again later."
)
self.pr_open_btn.configure(state="disabled")
self.update_btn.configure(state="disabled")
self.status_var.set("Status: No PR builds available")
self.log("No open PRs returned for the PR channel.")
return
self.pr_menu.configure(values=labels, state="normal")
first_ready_label = next(
(lbl for lbl, pr in self.pr_label_lookup.items() if pr.status == PR_BUILD_STATUS_READY),
None,
)
default_label = first_ready_label or labels[0]
self.pr_var.set(default_label)
self._on_pr_selected(default_label)
summary = (
f"Loaded {len(self.pr_builds)} open PR(s) - "
f"{ready_count} ready, {building_count} building, "
f"{len(self.pr_builds) - ready_count - building_count} without Windows builds."
)
self.log(summary)
self.status_var.set(
f"Status: {ready_count} PR build(s) ready"
if ready_count
else "Status: No PR Windows builds ready yet"
)
def _on_pr_selected(self, selected_label: str) -> None:
pr = self.pr_label_lookup.get(selected_label)
self.selected_pr = pr
if not pr:
self.current_release = None
self.update_btn.configure(state="disabled")
self.pr_open_btn.configure(state="disabled")
self.pr_status_var.set("Select a PR to see its build status.")
return
self.pr_open_btn.configure(state="disabled" if self.busy else "normal")
commit_part = f"@ {pr.short_sha}" if pr.short_sha else ""
author_part = f"by {pr.author}" if pr.author else ""
meta = " ".join(part for part in (commit_part, author_part) if part)
status_text = PR_STATUS_TEXT.get(pr.status, pr.status)
self.pr_status_var.set(f"PR #{pr.number} {meta} - {status_text}")
if pr.status != PR_BUILD_STATUS_READY:
self.current_release = None
self.update_btn.configure(state="disabled")
return
try:
release = self.service.pr_build_to_release_info(pr)
except UpdaterError as exc:
self.current_release = None
self.update_btn.configure(state="disabled")
self.pr_status_var.set(f"PR #{pr.number}: {exc}")
return
self.current_release = release
self.latest_version_var.set(f"Latest: PR #{pr.number} ({pr.short_sha or 'unknown'})")
self.update_btn.configure(state="disabled" if self.busy else "normal")
def _open_selected_pr(self) -> None:
if not self.selected_pr:
return
url = self.selected_pr.pr_url
if not url:
return
try:
webbrowser.open(url, new=2)
except Exception as exc:
self._handle_error("Could not open PR in browser", exc)
def _set_pr_panel_visible(self, visible: bool) -> None:
if visible:
self.pr_frame.grid()
else:
self.pr_frame.grid_remove()
self.pr_builds = []
self.pr_label_lookup = {}
self.selected_pr = None
self.pr_var.set(PR_PLACEHOLDER_LABEL)
self.pr_menu.configure(values=[PR_PLACEHOLDER_LABEL], state="disabled")
self.pr_open_btn.configure(state="disabled")
def _on_channel_changed(self, selected_label: str) -> None:
channel = LABEL_TO_CHANNEL.get(selected_label, CHANNEL_NIGHTLY)
try:
@@ -347,6 +537,11 @@ class UpdaterApp:
except Exception as exc:
self._handle_error("Channel setting failed", exc)
return
self._set_pr_panel_visible(channel == CHANNEL_PR)
if channel == CHANNEL_PR:
self.pr_status_var.set("Loading open PRs from citron-neo/emulator...")
else:
self.pr_status_var.set("Switch to PR Builds and check for updates to populate this list.")
self.log(f"Release channel set to {channel.upper()}.")
self.status_var.set(f"Status: Release channel: {channel.upper()}")
# Refresh release lookup to switch source repository immediately.
+295 -45
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlparse
from zipfile import BadZipFile, ZipFile
import requests
@@ -18,8 +20,12 @@ VERSION_MARKER_NAME = ".citron_updater_version.json"
KNOWN_PROCESS_NAMES = ("citron-neo.exe", "citron.exe", "yuzu.exe")
# Release channels.
# Clangtron / MinGW is no longer produced upstream — only MSVC builds remain
# on the CI channel, so toolchain selection has been removed.
# Upstream no longer ships MSVC or MinGW Windows builds — the only Windows
# toolchain produced is Clangtron (Clang LTO cross-compiled from Linux),
# served from the citron-neo/CI nightly-windows release. PR builds are now
# discovered by scanning open PRs on citron-neo/emulator and reading the
# "Build Artifacts for PR #N" comment that links direct downloads via
# nightly.link.
CHANNEL_STABLE = "stable"
CHANNEL_NIGHTLY = "nightly"
CHANNEL_PR = "pr"
@@ -28,16 +34,46 @@ DEFAULT_CHANNEL = CHANNEL_NIGHTLY
CHANNEL_RELEASE_API = {
CHANNEL_STABLE: "https://api.github.com/repos/citron-neo/emulator/releases",
CHANNEL_NIGHTLY: "https://api.github.com/repos/citron-neo/CI/releases",
CHANNEL_PR: "https://api.github.com/repos/citron-neo/PR/releases",
}
# Fallback for PR builds: upstream surfaces them at /tags rather than /releases.
# We still need release assets to install binaries, but if the releases endpoint
# is empty we can hit /tags to verify the channel exists before erroring out.
CHANNEL_TAGS_API = {
CHANNEL_PR: "https://api.github.com/repos/citron-neo/PR/tags",
# Channels the user may select. PR is sourced from open PRs on
# citron-neo/emulator instead of a releases endpoint, so it isn't part of
# CHANNEL_RELEASE_API but is still a valid channel.
SUPPORTED_CHANNELS = frozenset({CHANNEL_STABLE, CHANNEL_NIGHTLY, CHANNEL_PR})
EMULATOR_PRS_API = "https://api.github.com/repos/citron-neo/emulator/pulls"
EMULATOR_PR_COMMENTS_API = (
"https://api.github.com/repos/citron-neo/emulator/issues/{number}/comments"
)
EMULATOR_PR_HTML_URL = "https://github.com/citron-neo/emulator/pull/{number}"
GITHUB_API_HEADERS = {
"Accept": "application/vnd.github+json",
"User-Agent": "CitronNeoUpdater",
}
# Cap how many PRs we look up so we don't hammer the GitHub API for every open
# PR (and run into anonymous rate limits).
PR_FETCH_LIMIT = 25
# Restrict PR artifact URLs to nightly.link to avoid being tricked into
# downloading from arbitrary hosts via crafted PR comments.
ALLOWED_PR_ARTIFACT_HOST = "nightly.link"
PR_ARTIFACT_HEADER_RE = re.compile(r"\*\*Build Artifacts for PR\b", re.IGNORECASE)
PR_WINDOWS_ROW_RE = re.compile(
r"\|\s*\*\*Windows\*\*\s*\|"
r"(?P<commit>[^|]*)\|"
r"(?P<artifacts>[^|]*)\|"
r"(?P<logs>[^|]*)\|",
re.IGNORECASE,
)
PR_MD_LINK_RE = re.compile(r"\[(?P<label>[^\]]+)\]\((?P<url>[^)]+)\)")
PR_BUILD_STATUS_READY = "ready"
PR_BUILD_STATUS_BUILDING = "building"
PR_BUILD_STATUS_MISSING = "missing"
CONFIG_DIR = Path(os.getenv("APPDATA", str(Path.home()))) / "CitronNeoUpdater"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_INSTALL_PATH = Path(os.getenv("APPDATA", str(Path.home()))) / "citron"
@@ -68,12 +104,40 @@ class ReleaseInfo:
channel: str
@dataclass
class PullRequestBuild:
number: int
title: str
author: str
head_sha: str
short_sha: str
pr_url: str
updated_at: str
status: str # one of PR_BUILD_STATUS_*
artifact_label: Optional[str] = None
artifact_url: Optional[str] = None
run_url: Optional[str] = None
@property
def display_label(self) -> str:
title = " ".join(self.title.split()) or "Untitled PR"
if len(title) > 70:
title = title[:67] + "..."
suffix = {
PR_BUILD_STATUS_READY: "ready",
PR_BUILD_STATUS_BUILDING: "building",
PR_BUILD_STATUS_MISSING: "no Windows build",
}.get(self.status, self.status)
return f"#{self.number} ({suffix}) - {title}"
@dataclass
class CheckResult:
current_version: str
latest_version: str
update_available: bool
release: Optional[ReleaseInfo]
pull_requests: list[PullRequestBuild] = field(default_factory=list)
def _default_config() -> dict:
@@ -87,11 +151,12 @@ def _default_config() -> dict:
def _normalize_channel(value: object) -> str:
text = str(value or "").lower().strip()
if text in CHANNEL_RELEASE_API:
if text in SUPPORTED_CHANNELS:
return text
# Migrate legacy toolchain values from older configs — both toolchains map
# to the nightly CI channel, which is now MSVC-only.
if text in {"msvc", "mingw"}:
# Migrate legacy toolchain values from older configs. The MSVC and MinGW
# toolchains were retired upstream; both now resolve to the nightly CI
# channel (Clangtron-only).
if text in {"msvc", "mingw", "clang", "clangtron"}:
return CHANNEL_NIGHTLY
return DEFAULT_CHANNEL
@@ -149,7 +214,7 @@ class UpdaterService:
def set_preferred_channel(self, channel: str) -> None:
normalized = str(channel).lower().strip()
if normalized not in CHANNEL_RELEASE_API:
if normalized not in SUPPORTED_CHANNELS:
raise UpdaterError(f"Unsupported release channel: {channel}")
cfg = self.config_store.load()
cfg["preferred_channel"] = normalized
@@ -201,12 +266,33 @@ class UpdaterService:
def check_for_updates(self, install_path: Optional[Path] = None) -> CheckResult:
install_path = install_path or self.get_install_path()
current = self.get_current_version(install_path)
release = self._fetch_latest_windows_release()
channel = self.get_preferred_channel()
if channel == CHANNEL_PR:
prs = self.fetch_open_pull_requests()
ready_count = sum(1 for pr in prs if pr.status == PR_BUILD_STATUS_READY)
if not prs:
latest = "No open PRs found"
elif ready_count:
latest = f"{ready_count} PR build(s) ready"
else:
latest = "No PR Windows builds ready yet"
return CheckResult(
current_version=current,
latest_version=latest,
update_available=False,
release=None,
pull_requests=prs,
)
release = self._fetch_latest_windows_release(channel=channel)
latest = (
f"{release.tag_name or release.name or 'Unknown'} "
f"({release.asset_name}, {release.channel.upper()})"
)
update_available = self._is_update_available(install_path=install_path, latest_release=release)
update_available = self._is_update_available(
install_path=install_path, latest_release=release
)
return CheckResult(
current_version=current,
latest_version=latest,
@@ -214,11 +300,18 @@ class UpdaterService:
release=release,
)
def _fetch_latest_windows_release(self) -> ReleaseInfo:
channel = self.get_preferred_channel()
api_url = CHANNEL_RELEASE_API[channel]
def _fetch_latest_windows_release(self, channel: str) -> ReleaseInfo:
api_url = CHANNEL_RELEASE_API.get(channel)
if not api_url:
raise UpdaterError(
f"Channel {channel!r} does not expose GitHub releases."
)
try:
resp = requests.get(api_url, timeout=DEFAULT_TIMEOUT)
resp = requests.get(
api_url,
timeout=DEFAULT_TIMEOUT,
headers=GITHUB_API_HEADERS,
)
resp.raise_for_status()
releases = resp.json()
except requests.RequestException as exc:
@@ -229,10 +322,7 @@ class UpdaterService:
raise NetworkError("GitHub API returned invalid JSON.") from exc
if not isinstance(releases, list) or not releases:
tag_hint = self._tag_hint_for_channel(channel)
raise NetworkError(
f"No releases found on the {channel} channel.{tag_hint}"
)
raise NetworkError(f"No releases found on the {channel} channel.")
for rel in releases:
if rel.get("draft"):
@@ -257,27 +347,179 @@ class UpdaterService:
)
raise NetworkError(
f"No suitable Windows zip artifact was found on the {channel} channel."
f"No suitable Windows Clangtron zip artifact was found on the "
f"{channel} channel."
)
def _tag_hint_for_channel(self, channel: str) -> str:
tags_url = CHANNEL_TAGS_API.get(channel)
if not tags_url:
return ""
def fetch_open_pull_requests(self) -> list[PullRequestBuild]:
try:
resp = requests.get(tags_url, timeout=DEFAULT_TIMEOUT)
resp = requests.get(
EMULATOR_PRS_API,
params={
"state": "open",
"per_page": PR_FETCH_LIMIT,
"sort": "updated",
"direction": "desc",
},
timeout=DEFAULT_TIMEOUT,
headers=GITHUB_API_HEADERS,
)
resp.raise_for_status()
tags = resp.json()
except (requests.RequestException, ValueError):
return ""
if isinstance(tags, list) and tags:
sample = str(tags[0].get("name", "")).strip()
if sample:
return (
f" Tags exist (e.g. {sample}) but no release assets are "
f"published — install requires built binaries."
payload = resp.json()
except requests.RequestException as exc:
raise NetworkError(f"Unable to fetch open pull requests: {exc}") from exc
except ValueError as exc:
raise NetworkError("GitHub API returned invalid JSON for PR list.") from exc
if not isinstance(payload, list):
raise NetworkError("Unexpected PR list payload from GitHub.")
results: list[PullRequestBuild] = []
for pr in payload:
if not isinstance(pr, dict):
continue
number = int(pr.get("number", 0) or 0)
if not number:
continue
head = pr.get("head") or {}
head_sha = str(head.get("sha", "") or "")
user = pr.get("user") or {}
artifact = self._fetch_pr_windows_artifact(number)
results.append(
PullRequestBuild(
number=number,
title=str(pr.get("title", "") or "Untitled PR"),
author=str(user.get("login", "") or "unknown"),
head_sha=head_sha,
short_sha=head_sha[:7],
pr_url=str(
pr.get("html_url", "")
or EMULATOR_PR_HTML_URL.format(number=number)
),
updated_at=str(pr.get("updated_at", "") or ""),
status=artifact.get("status", PR_BUILD_STATUS_MISSING),
artifact_label=artifact.get("label"),
artifact_url=artifact.get("url"),
run_url=artifact.get("run"),
)
return ""
)
return results
def _fetch_pr_windows_artifact(self, pr_number: int) -> dict:
try:
resp = requests.get(
EMULATOR_PR_COMMENTS_API.format(number=pr_number),
timeout=DEFAULT_TIMEOUT,
headers=GITHUB_API_HEADERS,
params={"per_page": 100},
)
resp.raise_for_status()
comments = resp.json()
except (requests.RequestException, ValueError):
return {"status": PR_BUILD_STATUS_MISSING}
if not isinstance(comments, list):
return {"status": PR_BUILD_STATUS_MISSING}
# Walk newest -> oldest so the freshest build comment wins, but fall
# back to "building" if older comments only show in-progress state.
building_seen = False
for comment in reversed(comments):
if not isinstance(comment, dict):
continue
body = str(comment.get("body", "") or "")
if not PR_ARTIFACT_HEADER_RE.search(body):
continue
parsed = self._parse_pr_artifact_comment(body)
if not parsed:
continue
if parsed["status"] == PR_BUILD_STATUS_READY:
return parsed
if parsed["status"] == PR_BUILD_STATUS_BUILDING:
building_seen = True
if building_seen:
return {"status": PR_BUILD_STATUS_BUILDING}
return {"status": PR_BUILD_STATUS_MISSING}
def _parse_pr_artifact_comment(self, body: str) -> Optional[dict]:
match = PR_WINDOWS_ROW_RE.search(body)
if not match:
return None
artifacts_cell = match.group("artifacts").strip()
logs_cell = match.group("logs").strip()
if not artifacts_cell or "building" in artifacts_cell.lower():
return {"status": PR_BUILD_STATUS_BUILDING}
links = PR_MD_LINK_RE.findall(artifacts_cell)
chosen_label: Optional[str] = None
chosen_url: Optional[str] = None
# Prefer Clangtron — the only Windows toolchain produced upstream.
for label, url in links:
if not self._is_acceptable_pr_artifact_url(url):
continue
blob = (label + " " + url).lower()
if "clangtron" in blob:
chosen_label, chosen_url = label, url
break
# Fall back to any other Windows artifact that isn't a discontinued
# MSVC/MinGW toolchain build.
if not chosen_url:
for label, url in links:
if not self._is_acceptable_pr_artifact_url(url):
continue
blob = (label + " " + url).lower()
if "msvc" in blob or "mingw" in blob:
continue
chosen_label, chosen_url = label, url
break
if not chosen_url:
# Build is up but only MSVC/MinGW artifacts are present, which we
# treat as no usable Windows build.
return {"status": PR_BUILD_STATUS_MISSING}
run_match = PR_MD_LINK_RE.search(logs_cell)
run_url = run_match.group("url") if run_match else None
return {
"status": PR_BUILD_STATUS_READY,
"label": chosen_label or "Windows",
"url": chosen_url,
"run": run_url,
}
def _is_acceptable_pr_artifact_url(self, url: str) -> bool:
if not url.lower().endswith(".zip"):
return False
try:
host = urlparse(url).hostname or ""
except ValueError:
return False
return host.lower() == ALLOWED_PR_ARTIFACT_HOST
def pr_build_to_release_info(self, pr: PullRequestBuild) -> ReleaseInfo:
if pr.status != PR_BUILD_STATUS_READY or not pr.artifact_url:
raise UpdaterError(
f"PR #{pr.number} does not have a ready Windows Clangtron build yet."
)
label_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", pr.artifact_label or "Windows").strip("-") or "Windows"
asset_name = f"Citron-PR-{pr.number}-{pr.short_sha or 'unknown'}-{label_slug}.zip"
return ReleaseInfo(
name=f"PR #{pr.number}: {pr.title}",
tag_name=f"pr-{pr.number}-{pr.short_sha}" if pr.short_sha else f"pr-{pr.number}",
published_at=pr.updated_at,
release_id=pr.number,
asset_name=asset_name,
asset_url=pr.artifact_url,
asset_size=0,
asset_updated_at=pr.updated_at,
channel=CHANNEL_PR,
)
def _pick_windows_asset(self, assets: list[dict]) -> Optional[dict]:
scored: list[tuple[int, dict]] = []
@@ -286,14 +528,25 @@ class UpdaterService:
if not name.endswith(".zip"):
continue
# Hard reject discontinued toolchains. Upstream stopped producing
# MSVC and MinGW Windows builds; only Clangtron (Clang LTO) is
# shipped, so we never select these even if older releases still
# have the artifacts attached.
if "msvc" in name or "mingw" in name:
continue
score = 0
if "windows" in name or "win64" in name or "win-" in name:
score += 6
elif "win" in name:
score += 3
if "msvc" in name:
score += 8
# Clangtron is the only Windows toolchain produced upstream now.
if "clangtron" in name:
score += 12
elif "clang" in name:
score += 6
if "x86_64" in name or "x64" in name or "amd64" in name:
score += 3
if "citron" in name:
@@ -303,9 +556,6 @@ class UpdaterService:
if "nightly" in name:
score += 2
# Removed/undesired variants.
if "mingw" in name or "clang" in name or "clangtron" in name:
score -= 50
if "debug" in name or "symbols" in name or "pdb" in name:
score -= 4
if "source" in name or name.endswith(("-src.zip", "_src.zip")):