diff --git a/pikit_api/releases.py b/pikit_api/releases.py
index af806af..2c9bdea 100644
--- a/pikit_api/releases.py
+++ b/pikit_api/releases.py
@@ -45,7 +45,11 @@ def load_update_state() -> Dict[str, Any]:
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
if UPDATE_STATE.exists():
try:
- return json.loads(UPDATE_STATE.read_text())
+ state = json.loads(UPDATE_STATE.read_text())
+ state.setdefault("changelog_url", None)
+ state.setdefault("latest_release_date", None)
+ state.setdefault("current_release_date", None)
+ return state
except Exception:
pass
return {
@@ -58,6 +62,9 @@ def load_update_state() -> Dict[str, Any]:
"in_progress": False,
"progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
+ "changelog_url": None,
+ "latest_release_date": None,
+ "current_release_date": None,
}
@@ -119,32 +126,78 @@ def fetch_manifest(url: str | None = None):
raise
-def fetch_manifest_for_channel(channel: str):
+def _try_fetch(url: Optional[str]):
+ if not url:
+ return None
+ try:
+ return fetch_manifest(url)
+ except Exception:
+ return None
+
+
+def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
"""
For stable: use normal manifest (latest non-prerelease).
For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API.
+ If a stable build is newer than the latest dev build, prefer the newer stable even on dev channel.
"""
channel = channel or "dev"
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
+ dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL")
+ stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or base_manifest_url
manifest = None
+ version_dates: Dict[str, Optional[str]] = {}
try:
- manifest = fetch_manifest(base_manifest_url)
+ manifest = fetch_manifest(stable_manifest_url)
except Exception:
manifest = None
- if manifest and channel == "stable":
- return manifest
- if manifest:
- version = manifest.get("version") or manifest.get("latest_version")
- if channel == "dev" and version and "dev" in str(version):
- return manifest
+ def _norm_ver(ver):
+ if ver is None:
+ return None
+ s = str(ver).strip()
+ if s.lower().startswith("v"):
+ s = s[1:]
+ return s
+
+ def _newer(a, b):
+ try:
+ from distutils.version import LooseVersion
+
+ return LooseVersion(a) > LooseVersion(b)
+ except Exception:
+ return a > b
+
+ def _release_version(rel: Dict[str, Any]):
+ for key in ("tag_name", "name"):
+ val = rel.get(key)
+ if val:
+ v = _norm_ver(val)
+ if v:
+ return v
+ return None
+
+ def _manifest_from_release(rel: Dict[str, Any]):
+ asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
+ if not asset or not asset.get("browser_download_url"):
+ return None
+ mf = fetch_manifest(asset["browser_download_url"])
+ if mf:
+ dt = rel.get("published_at") or rel.get("created_at")
+ if dt:
+ mf["_release_date"] = dt
+ tag = rel.get("tag_name")
+ if tag:
+ mf["_release_tag"] = tag
+ return mf
try:
parts = base_manifest_url.split("/")
if "releases" not in parts:
if manifest:
- return manifest
- return fetch_manifest(base_manifest_url)
+ return (manifest, {"version_dates": version_dates}) if with_meta else manifest
+ mf = fetch_manifest(base_manifest_url)
+ return (mf, {"version_dates": version_dates}) if with_meta else mf
idx = parts.index("releases")
owner = parts[idx - 2]
repo = parts[idx - 1]
@@ -157,25 +210,78 @@ def fetch_manifest_for_channel(channel: str):
resp = urllib.request.urlopen(req, timeout=10)
releases = json.loads(resp.read().decode())
- def pick(predicate):
- for r in releases:
- if predicate(r):
- asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None)
- if asset and asset.get("browser_download_url"):
- return fetch_manifest(asset["browser_download_url"])
- return None
+ # Map release versions to published dates so we can surface them later
+ for rel in releases:
+ v = _release_version(rel)
+ if v and v not in version_dates:
+ version_dates[v] = rel.get("published_at") or rel.get("created_at")
+
+ dev_rel = None
+ stable_rel = None
+ dev_ver = None
+ stable_ver = None
+ for rel in releases:
+ ver_str = _release_version(rel)
+ parsed = _norm_ver(ver_str) if ver_str else None
+ if parsed is None:
+ continue
+ if rel.get("prerelease") is True:
+ if dev_ver is None or _newer(parsed.replace("-", "."), dev_ver):
+ dev_rel = rel
+ dev_ver = parsed.replace("-", ".")
+ elif rel.get("prerelease") is False:
+ if stable_ver is None or _newer(parsed.replace("-", "."), stable_ver):
+ stable_rel = rel
+ stable_ver = parsed.replace("-", ".")
+
+ latest_dev = _manifest_from_release(dev_rel) if dev_rel else None
+ latest_stable = _manifest_from_release(stable_rel) if stable_rel else None
+
+ # If API didn't give us a dev manifest, try explicitly configured dev URL
+ if dev_manifest_url and latest_dev is None:
+ latest_dev = _try_fetch(dev_manifest_url)
+ if latest_dev and "_release_date" not in latest_dev:
+ latest_dev["_release_date"] = version_dates.get(
+ _norm_ver(latest_dev.get("version") or latest_dev.get("latest_version")), None
+ )
+
+ # Attach publish date to the base manifest when possible
+ if manifest:
+ mver = _norm_ver(manifest.get("version") or manifest.get("latest_version"))
+ if mver and mver in version_dates and "_release_date" not in manifest:
+ manifest["_release_date"] = version_dates[mver]
if channel == "dev":
- m = pick(lambda r: r.get("prerelease") is True)
- if m:
- return m
- m = pick(lambda r: r.get("prerelease") is False)
- if m:
- return m
+ # Choose the newest by version comparison across stable/dev/base candidates
+ candidates = [c for c in (latest_dev, latest_stable, manifest) if c]
+ best = None
+ best_ver = None
+ for c in candidates:
+ ver = _norm_ver(c.get("version") or c.get("latest_version"))
+ if not ver:
+ continue
+ ver_cmp = ver.replace("-", ".")
+ if best_ver is None or _newer(ver_cmp, best_ver):
+ best = c
+ best_ver = ver_cmp
+ manifest = best
+ else:
+ # stable channel
+ manifest = latest_stable or manifest
except Exception:
pass
+ # As a last resort for dev channel, consider explicitly configured dev manifest even without API data
+ if channel == "dev" and manifest is None and dev_manifest_url:
+ manifest = _try_fetch(dev_manifest_url)
+
+ # If still nothing and stable manifest URL is set, try that once more
+ if manifest is None and stable_manifest_url and stable_manifest_url != base_manifest_url:
+ manifest = _try_fetch(stable_manifest_url)
+
if manifest:
+ if with_meta:
+ return manifest, {"version_dates": version_dates}
return manifest
raise RuntimeError("No manifest found for channel")
@@ -303,10 +409,25 @@ def check_for_update():
state["progress"] = "Checking for updates…"
save_update_state(state)
try:
- manifest = fetch_manifest_for_channel(state.get("channel") or "dev")
+ manifest, meta = fetch_manifest_for_channel(state.get("channel") or "dev", with_meta=True)
latest = manifest.get("version") or manifest.get("latest_version")
state["latest_version"] = latest
+ state["changelog_url"] = manifest.get("changelog")
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
+ version_dates = (meta or {}).get("version_dates") or {}
+ if manifest.get("_release_date"):
+ state["latest_release_date"] = manifest.get("_release_date")
+ elif latest and latest in version_dates:
+ state["latest_release_date"] = version_dates.get(str(latest))
+ else:
+ state["latest_release_date"] = None
+ state["current_release_date"] = None
+ current_ver = state.get("current_version")
+ if current_ver and current_ver in version_dates:
+ state["current_release_date"] = version_dates.get(str(current_ver))
+ elif current_ver and current_ver == latest and state["latest_release_date"]:
+ # If current matches latest and we have a date for latest, reuse it
+ state["current_release_date"] = state["latest_release_date"]
channel = state.get("channel") or "dev"
if channel == "stable" and latest and "dev" in str(latest):
state["status"] = "up_to_date"
@@ -314,7 +435,7 @@ def check_for_update():
else:
if latest and latest != state.get("current_version"):
state["status"] = "update_available"
- state["message"] = manifest.get("changelog", "Update available")
+ state["message"] = manifest.get("notes") or manifest.get("message") or "Update available"
else:
state["status"] = "up_to_date"
state["message"] = "Up to date"
@@ -323,6 +444,7 @@ def check_for_update():
state["status"] = "up_to_date"
state["message"] = f"Could not reach update server: {e}"
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
+ state["latest_release_date"] = None
diag_log("error", "Update check failed", {"error": str(e)})
finally:
state["in_progress"] = False
@@ -371,7 +493,7 @@ def apply_update():
try:
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
- manifest = fetch_manifest_for_channel(channel)
+ manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
latest = manifest.get("version") or manifest.get("latest_version")
if not latest:
raise RuntimeError("Manifest missing version")
@@ -428,6 +550,9 @@ def apply_update():
state["current_version"] = str(latest)
state["latest_version"] = str(latest)
+ state["changelog_url"] = manifest.get("changelog")
+ state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
+ state["current_release_date"] = state.get("latest_release_date")
state["status"] = "up_to_date"
state["message"] = "Update installed"
state["progress"] = None
@@ -436,11 +561,13 @@ def apply_update():
except urllib.error.HTTPError as e:
state["status"] = "error"
state["message"] = f"No release available ({e.code})"
+ state["latest_release_date"] = None
diag_log("error", "Update apply HTTP error", {"code": e.code})
except Exception as e:
state["status"] = "error"
state["message"] = f"Update failed: {e}"
state["progress"] = None
+ state["latest_release_date"] = None
save_update_state(state)
diag_log("error", "Update apply failed", {"error": str(e)})
backup = choose_rollback_backup()