Add UNSLOTH_UPDATE_DETAILS.json manifest system for Studio updates

Adds a centralized manifest file that controls Studio update behavior,
allowing the team to temporarily switch update sources (PyPI vs git main),
control llama.cpp source/version, and notify users of critical updates --
all without requiring a PyPI release.

Changes:
- New UNSLOTH_UPDATE_DETAILS.json manifest at repo root
- setup.sh fetches manifest on startup, applies directives for unsloth
  source, llama.cpp repo/tag overrides, writes install timestamp
- install_python_stack.py supports STUDIO_UNSLOTH_GIT_REF env var for
  installing unsloth from git instead of PyPI
- New backend utils/update_check.py for manifest fetch and cache
- New /api/update-check endpoint in main.py (unauthenticated)
- Background thread checks manifest at startup, prints terminal banner
- New use-update-check.ts React hook (module-level cache pattern)
- navbar.tsx Update button shows dynamic badge and announcement
- __root.tsx shows non-dismissable critical update banner
This commit is contained in:
Daniel Han 2026-04-03 13:25:54 +00:00
commit 7eda1ba5a8
9 changed files with 391 additions and 18 deletions

View file

@ -0,0 +1,9 @@
{
"schema_version": 1,
"unsloth_source": "pypi",
"unsloth_github_ref": "main",
"llama_cpp_source": "unslothai",
"llama_cpp_tag": "latest",
"CRITICAL_TIME": null,
"announcement": null
}

View file

@ -118,6 +118,25 @@ async def lifespan(app: FastAPI):
threading.Thread(target = _precache, daemon = True).start()
# Fetch remote update manifest in the background and print a terminal
# banner when a critical update or announcement is present.
def _check_updates():
try:
from utils.update_check import fetch_and_cache_update_status
status = fetch_and_cache_update_status()
if status.critical:
print("\n" + "=" * 60)
print("CRITICAL UPDATE AVAILABLE")
print(" Run `unsloth studio update` then restart Studio.")
print("=" * 60 + "\n")
elif status.announcement_message:
print(f"\n [Update] {status.announcement_message}\n")
except Exception:
pass # non-critical
threading.Thread(target = _check_updates, daemon = True).start()
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
@ -203,6 +222,21 @@ async def health_check():
}
@app.get("/api/update-check")
async def update_check():
"""Return cached update status from the remote manifest (unauthenticated)."""
from utils.update_check import get_update_status
s = get_update_status()
return {
"critical": s.critical,
"announcement_badge": s.announcement_badge,
"announcement_message": s.announcement_message,
"announcement_url": s.announcement_url,
"manifest_fetched": s.manifest_fetched,
}
@app.post("/api/shutdown")
async def shutdown_server(
request: Request,

View file

@ -0,0 +1,103 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Remote manifest fetch and update-status cache for Unsloth Studio.
Uses only stdlib (urllib.request, json, time, calendar) so it can run
without any third-party dependencies. The module exposes two public
functions:
fetch_and_cache_update_status() -- fetches the manifest, reads the
local UNSLOTH_STUDIO_INFO.json, compares CRITICAL_TIME, and caches
the result at module level.
get_update_status() -- returns the cached UpdateStatus (or defaults
when the fetch has not completed yet).
"""
from __future__ import annotations
import calendar
import json
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
_MANIFEST_URL = (
"https://raw.githubusercontent.com/unslothai/unsloth/main/"
"UNSLOTH_UPDATE_DETAILS.json"
)
_STUDIO_INFO_PATH = Path.home() / ".unsloth" / "studio" / "UNSLOTH_STUDIO_INFO.json"
_FETCH_TIMEOUT = 8 # seconds
@dataclass
class UpdateStatus:
critical: bool = False
announcement_badge: Optional[str] = None
announcement_message: Optional[str] = None
announcement_url: Optional[str] = None
manifest_fetched: bool = False
_cached_status: UpdateStatus = UpdateStatus()
def _parse_iso_utc(s: str) -> float:
"""Parse an ISO-8601 UTC string (ending in Z) to a Unix timestamp."""
s = s.strip().rstrip("Z")
try:
t = time.strptime(s, "%Y-%m-%dT%H:%M:%S")
except ValueError:
t = time.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
return float(calendar.timegm(t))
def fetch_and_cache_update_status() -> UpdateStatus:
"""Fetch the remote manifest, compare with local info, and cache."""
global _cached_status
try:
req = urllib.request.Request(_MANIFEST_URL, method="GET")
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
manifest = json.loads(resp.read().decode("utf-8"))
except Exception:
return _cached_status
status = UpdateStatus(manifest_fetched=True)
# -- Critical time check --
critical_time_str = manifest.get("CRITICAL_TIME")
if critical_time_str:
try:
critical_ts = _parse_iso_utc(critical_time_str)
installed_ts = 0.0
if _STUDIO_INFO_PATH.is_file():
try:
info = json.loads(
_STUDIO_INFO_PATH.read_text(encoding="utf-8")
)
installed_ts = _parse_iso_utc(info.get("installed_at_utc", ""))
except Exception:
pass
if installed_ts < critical_ts:
status.critical = True
except Exception:
pass
# -- Announcement --
announcement = manifest.get("announcement")
if isinstance(announcement, dict):
status.announcement_badge = announcement.get("badge") or None
status.announcement_message = announcement.get("message") or None
status.announcement_url = announcement.get("url") or None
_cached_status = status
return status
def get_update_status() -> UpdateStatus:
"""Return the cached update status (safe to call before fetch completes)."""
return _cached_status

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Navbar } from "@/components/navbar";
import { useUpdateCheck } from "@/hooks/use-update-check";
import { usePlatformStore } from "@/config/env";
import {
Outlet,
@ -33,12 +34,27 @@ export const Route = createRootRoute({
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
function CriticalUpdateBanner() {
const { critical } = useUpdateCheck();
if (!critical) return null;
return (
<div className="w-full bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white dark:bg-amber-600">
A critical update is available. Run{" "}
<code className="rounded bg-amber-600/50 px-1 py-0.5 text-xs dark:bg-amber-700/50">
unsloth studio update
</code>{" "}
then restart Studio.
</div>
);
}
function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
return (
<AppProvider>
{!hideNavbar && <CriticalUpdateBanner />}
{!hideNavbar && <Navbar />}
<AnimatePresence initial={false}>
<motion.div

View file

@ -36,6 +36,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { useTrainingRuntimeStore } from "@/features/training";
import { useUpdateCheck } from "@/hooks/use-update-check";
import { usePlatformStore } from "@/config/env";
import { Link, useRouterState } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@ -127,10 +128,14 @@ function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
announcementMessage,
announcementUrl,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
announcementMessage?: string | null;
announcementUrl?: string | null;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
@ -148,6 +153,17 @@ function UpdateStudioInstructions({
return (
<div className={cn("flex flex-col gap-3", className)}>
{announcementMessage ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
{announcementUrl ? (
<a href={announcementUrl} target="_blank" rel="noopener noreferrer" className="underline">
{announcementMessage}
</a>
) : (
announcementMessage
)}
</div>
) : null}
<div
className={cn(
"flex items-center gap-3",
@ -247,6 +263,11 @@ export function Navbar() {
const deviceType = usePlatformStore((s) => s.deviceType);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const defaultUpdateShell = getDefaultUpdateShell(deviceType);
const updateStatus = useUpdateCheck();
const updateBadgeText = updateStatus.critical
? "Critical Update"
: updateStatus.announcementBadge ?? null;
// Warn before closing the tab only when training is running (data loss risk).
// We store the handler in a ref so removeUnloadHandler() can clean it up
@ -434,17 +455,24 @@ export function Navbar() {
<HoverCardTrigger asChild={true}>
<button
type="button"
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className={cn(
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
updateBadgeText
? "text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"
: "text-muted-foreground hover:text-foreground",
)}
aria-label="How to update Unsloth Studio"
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update
{updateBadgeText ?? "Update"}
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-[22.5rem] p-0">
<UpdateStudioInstructions
className="p-4"
defaultShell={defaultUpdateShell}
announcementMessage={updateStatus.announcementMessage}
announcementUrl={updateStatus.announcementUrl}
/>
</HoverCardContent>
</HoverCard>
@ -555,17 +583,23 @@ export function Navbar() {
<Collapsible
open={mobileUpdateOpen}
onOpenChange={setMobileUpdateOpen}
className="rounded-md border border-border"
className={cn(
"rounded-md border",
updateBadgeText ? "border-amber-400 dark:border-amber-700" : "border-border",
)}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium transition-colors hover:bg-accent",
updateBadgeText ? "text-amber-600 dark:text-amber-400" : "text-foreground",
)}
aria-label="Toggle update instructions"
>
<span className="flex items-center gap-2">
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update Unsloth Studio
{updateBadgeText ?? "Update Unsloth Studio"}
</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
@ -580,6 +614,8 @@ export function Navbar() {
<UpdateStudioInstructions
defaultShell={defaultUpdateShell}
showTitle={false}
announcementMessage={updateStatus.announcementMessage}
announcementUrl={updateStatus.announcementUrl}
/>
</CollapsibleContent>
</Collapsible>

View file

@ -11,3 +11,4 @@ export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useInfiniteScroll } from "./use-infinite-scroll";
export { useUpdateCheck } from "./use-update-check";

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
export interface UpdateCheckInfo {
critical: boolean;
announcementBadge: string | null;
announcementMessage: string | null;
announcementUrl: string | null;
manifestFetched: boolean;
}
const DEFAULT: UpdateCheckInfo = {
critical: false,
announcementBadge: null,
announcementMessage: null,
announcementUrl: null,
manifestFetched: false,
};
// Module-level cache so multiple components share one fetch.
let cached: UpdateCheckInfo | null = null;
let fetchPromise: Promise<UpdateCheckInfo> | null = null;
async function fetchOnce(): Promise<UpdateCheckInfo> {
if (cached) return cached;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
// Unauthenticated endpoint -- use plain fetch, not authFetch.
const res = await fetch("/api/update-check");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const info: UpdateCheckInfo = {
critical: data?.critical ?? false,
announcementBadge: data?.announcement_badge ?? null,
announcementMessage: data?.announcement_message ?? null,
announcementUrl: data?.announcement_url ?? null,
manifestFetched: data?.manifest_fetched ?? false,
};
cached = info;
return info;
} catch {
fetchPromise = null;
return DEFAULT;
}
})();
return fetchPromise;
}
/**
* Fetch update-check info from `GET /api/update-check`.
*
* The result is cached at module level -- only one network request is made
* regardless of how many components call this hook.
*/
export function useUpdateCheck(): UpdateCheckInfo {
const [info, setInfo] = useState<UpdateCheckInfo>(cached ?? DEFAULT);
useEffect(() => {
if (cached) return;
let cancelled = false;
fetchOnce().then((status) => {
if (!cancelled) setInfo(status);
});
return () => {
cancelled = true;
};
}, []);
return info;
}

View file

@ -523,19 +523,38 @@ def install_python_stack() -> int:
package_name,
)
else:
# Update path: upgrade only unsloth + unsloth-zoo while preserving
# existing torch/CUDA installations. Torch is pre-installed by
# install.sh / setup.ps1; --upgrade-package targets only base pkgs.
_progress("base packages")
pip_install(
"Updating base packages",
"--no-cache-dir",
"--upgrade-package",
"unsloth",
"--upgrade-package",
"unsloth-zoo",
req = REQ_ROOT / "base.txt",
)
# Manifest override: install from git main instead of PyPI
_git_ref = os.environ.get("STUDIO_UNSLOTH_GIT_REF", "")
if _git_ref:
_progress("base packages (git)")
pip_install(
f"Installing unsloth from git@{_git_ref}",
"--no-cache-dir",
"--no-deps",
f"git+https://github.com/unslothai/unsloth.git@{_git_ref}",
constrain = False,
)
pip_install(
"Updating remaining base packages",
"--no-cache-dir",
"--upgrade-package",
"unsloth-zoo",
req = REQ_ROOT / "base.txt",
)
else:
# Update path: upgrade only unsloth + unsloth-zoo while preserving
# existing torch/CUDA installations. Torch is pre-installed by
# install.sh / setup.ps1; --upgrade-package targets only base pkgs.
_progress("base packages")
pip_install(
"Updating base packages",
"--no-cache-dir",
"--upgrade-package",
"unsloth",
"--upgrade-package",
"unsloth-zoo",
req = REQ_ROOT / "base.txt",
)
# 3. Extra dependencies
_progress("unsloth extras")

View file

@ -27,6 +27,34 @@ _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"
_DEFAULT_LLAMA_TAG="latest"
_DEFAULT_LLAMA_FORCE_COMPILE_REF="master"
# ── Fetch remote manifest ────────────────────────────────────────────
# UNSLOTH_UPDATE_DETAILS.json controls update behavior centrally.
# Fields are parsed into MANIFEST_* shell variables; all default to
# empty when the fetch fails (graceful degradation).
_MANIFEST_URL="https://raw.githubusercontent.com/unslothai/unsloth/main/UNSLOTH_UPDATE_DETAILS.json"
MANIFEST_UNSLOTH_SOURCE=""
MANIFEST_UNSLOTH_GITHUB_REF=""
MANIFEST_LLAMA_CPP_SOURCE=""
MANIFEST_LLAMA_CPP_TAG=""
MANIFEST_CRITICAL_TIME=""
MANIFEST_ANNOUNCEMENT_MESSAGE=""
MANIFEST_ANNOUNCEMENT_BADGE=""
MANIFEST_ANNOUNCEMENT_URL=""
_fetch_manifest() {
local _raw
_raw=$(curl -fsSL --max-time 8 "$_MANIFEST_URL" 2>/dev/null) || return 0
MANIFEST_UNSLOTH_SOURCE=$(printf '%s' "$_raw" | python -c "import sys,json; print(json.load(sys.stdin).get('unsloth_source',''))" 2>/dev/null || true)
MANIFEST_UNSLOTH_GITHUB_REF=$(printf '%s' "$_raw" | python -c "import sys,json; print(json.load(sys.stdin).get('unsloth_github_ref','main'))" 2>/dev/null || true)
MANIFEST_LLAMA_CPP_SOURCE=$(printf '%s' "$_raw" | python -c "import sys,json; print(json.load(sys.stdin).get('llama_cpp_source',''))" 2>/dev/null || true)
MANIFEST_LLAMA_CPP_TAG=$(printf '%s' "$_raw" | python -c "import sys,json; print(json.load(sys.stdin).get('llama_cpp_tag',''))" 2>/dev/null || true)
MANIFEST_CRITICAL_TIME=$(printf '%s' "$_raw" | python -c "import sys,json; print(json.load(sys.stdin).get('CRITICAL_TIME','') or '')" 2>/dev/null || true)
MANIFEST_ANNOUNCEMENT_MESSAGE=$(printf '%s' "$_raw" | python -c "import sys,json; a=json.load(sys.stdin).get('announcement') or {}; print(a.get('message',''))" 2>/dev/null || true)
MANIFEST_ANNOUNCEMENT_BADGE=$(printf '%s' "$_raw" | python -c "import sys,json; a=json.load(sys.stdin).get('announcement') or {}; print(a.get('badge',''))" 2>/dev/null || true)
MANIFEST_ANNOUNCEMENT_URL=$(printf '%s' "$_raw" | python -c "import sys,json; a=json.load(sys.stdin).get('announcement') or {}; print(a.get('url',''))" 2>/dev/null || true)
substep "manifest: source=${MANIFEST_UNSLOTH_SOURCE:-pypi} llama=${MANIFEST_LLAMA_CPP_SOURCE:-default}@${MANIFEST_LLAMA_CPP_TAG:-default}"
}
# ── Colors (same palette as startup_banner / install_python_stack) ──
if [ -n "${NO_COLOR:-}" ]; then
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
@ -166,6 +194,7 @@ echo ""
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
verbose_substep "verbose diagnostics enabled"
_fetch_manifest
_LLAMA_ONLY="${UNSLOTH_STUDIO_LLAMA_ONLY:-0}"
if [ "$_LLAMA_ONLY" = "1" ]; then
substep "llama.cpp only mode"
@ -500,6 +529,11 @@ print(version('$_PKG_NAME'))
fi
if [ "$_SKIP_PYTHON_DEPS" = false ]; then
# Apply manifest: install from git main instead of PyPI when directed
if [ "$MANIFEST_UNSLOTH_SOURCE" = "main" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then
export STUDIO_UNSLOTH_GIT_REF="${MANIFEST_UNSLOTH_GITHUB_REF:-main}"
substep "manifest override: installing unsloth from git@${STUDIO_UNSLOTH_GIT_REF}"
fi
install_python_stack
# ── 6b. Pre-install transformers 5.x into .venv_t5/ ──
@ -533,6 +567,22 @@ if [ "$_HOST_SYSTEM" = "Darwin" ]; then
else
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
fi
# Apply manifest overrides for llama.cpp (only if user hasn't set env vars)
if [ -n "$MANIFEST_LLAMA_CPP_SOURCE" ] && [ -z "${UNSLOTH_LLAMA_PUBLISHED_REPO:-}" ]; then
if [ "$MANIFEST_LLAMA_CPP_SOURCE" = "ggml-org" ]; then
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
elif [ "$MANIFEST_LLAMA_CPP_SOURCE" = "unslothai" ]; then
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
fi
verbose_substep "manifest override: llama.cpp repo=$_HELPER_RELEASE_REPO"
fi
if [ -n "$MANIFEST_LLAMA_CPP_TAG" ] && [ -z "${UNSLOTH_LLAMA_TAG:-}" ]; then
_DEFAULT_LLAMA_TAG="$MANIFEST_LLAMA_CPP_TAG"
_REQUESTED_LLAMA_TAG="$MANIFEST_LLAMA_CPP_TAG"
verbose_substep "manifest override: llama.cpp tag=$_REQUESTED_LLAMA_TAG"
fi
_LLAMA_PR="${UNSLOTH_LLAMA_PR:-}"
_SKIP_PREBUILT_INSTALL=false
_LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}"
@ -989,6 +1039,35 @@ else
}
fi # end _SKIP_GGUF_BUILD check
# ── Write install timestamp ──
_STUDIO_INFO_DIR="$HOME/.unsloth/studio"
mkdir -p "$_STUDIO_INFO_DIR"
python - "$_STUDIO_INFO_DIR/UNSLOTH_STUDIO_INFO.json" <<'PY' 2>/dev/null || true
import json, sys, time, calendar
from pathlib import Path
info_path = Path(sys.argv[1])
existing = {}
if info_path.is_file():
try:
existing = json.loads(info_path.read_text(encoding="utf-8"))
except Exception:
pass
import importlib.metadata as _meta
try:
_ver = _meta.version("unsloth")
except Exception:
_ver = "unknown"
import os
existing["installed_at_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
existing["unsloth_version"] = _ver
existing["unsloth_source"] = os.environ.get("STUDIO_UNSLOTH_GIT_REF", "pypi")
info_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
PY
verbose_substep "wrote install info to $_STUDIO_INFO_DIR/UNSLOTH_STUDIO_INFO.json"
# ── Footer ──
if [ "$_LLAMA_ONLY" = "1" ]; then
echo ""