diff --git a/.env.example b/.env.example index 1308290..eef152c 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ ARTEX_LLM_PROXY= # output between nonce-tagged markers so the model treats it as data, not instructions. # Off by default; test it against a local target before enabling it in production. # ARTEX_UNTRUSTED_BOUNDARY=1 + +# Update banner (optional). ARTEX_UPDATE_CHECK=1 lets the backend check the public GitHub +# releases API for a newer ARTEX release at most once per 24 h and show a dismissible banner. +# Off by default: unset, no update request is made. +# ARTEX_UPDATE_CHECK=1 diff --git a/README.md b/README.md index 232b761..de8b47b 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ Daily cron example (03:20, operator crontab): > Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading. +**Update banner (opt-in, off by default)**: start ARTEX with `ARTEX_UPDATE_CHECK=1` to have the backend ask the public GitHub releases API for the latest ARTEX tag, at most once per 24 hours (cached in memory; a failed check is not retried within that window). When the release is newer than the running version, the web UI shows a small dismissible strip linking to the Releases page. The request sends only the `User-Agent: artex-update-check` header. Unset, ARTEX makes no update request and shows no banner. + ### Upgrading from a clone made before 2026-10-23 The repository history was rewritten that day to drop a personal email address from the commit metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of silently rebuilding stale code. Recover once, then upgrade normally: diff --git a/docker-compose.yml b/docker-compose.yml index d343b43..8fd5884 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: ARTEX_JWT_KEY_DIR: /app/keys ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-} ARTEX_UNTRUSTED_BOUNDARY: ${ARTEX_UNTRUSTED_BOUNDARY:-} + ARTEX_UPDATE_CHECK: ${ARTEX_UPDATE_CHECK:-} ports: - "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787" - "127.0.0.1:8788:8788" diff --git a/server/server.go b/server/server.go index d6c8159..ae00210 100644 --- a/server/server.go +++ b/server/server.go @@ -991,7 +991,12 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": true}) return } - writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion}) + out := map[string]any{"ok": true, "service": "artex", "version": BuildVersion} + // Opt-in only (ARTEX_UPDATE_CHECK); off, this returns "" without any request. + if latest := updates.newerVersion(r.Context(), time.Now(), BuildVersion); latest != "" { + out["latest_version"] = latest + } + writeJSON(w, 200, out) } func (s *Server) stats(w http.ResponseWriter, r *http.Request) { diff --git a/server/update_check.go b/server/update_check.go new file mode 100644 index 0000000..4d2a7f6 --- /dev/null +++ b/server/update_check.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// The update check is opt-in: only ARTEX_UPDATE_CHECK=1 (or true/on/yes) lets +// the backend ask the public GitHub releases API for the latest ARTEX tag. +// Unset, it makes no request at all and the UI shows no banner. +const updateCheckURL = "https://api.github.com/repos/BaaRai/ARTEX/releases/latest" +const updateCheckInterval = 24 * time.Hour + +func updateCheckEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_UPDATE_CHECK"))) { + case "1", "true", "on", "yes": + return true + } + return false +} + +// updateCheck caches the last check (time + latest tag) in memory. A failed +// check also stamps the time, so it is not retried within the same 24 h. +type updateCheck struct { + enabled bool + fetch func(context.Context) (string, error) + mu sync.Mutex + checkedAt time.Time + tag string +} + +var updates = &updateCheck{enabled: updateCheckEnabled(), fetch: fetchLatestRelease} + +// newerVersion returns the latest release tag when it is newer than current, +// fetching it at most once per updateCheckInterval; "" otherwise. +func (u *updateCheck) newerVersion(ctx context.Context, now time.Time, current string) string { + if !u.enabled { + return "" + } + u.mu.Lock() + defer u.mu.Unlock() + if u.checkedAt.IsZero() || now.Sub(u.checkedAt) >= updateCheckInterval { + u.checkedAt = now + u.tag = "" + if tag, err := u.fetch(ctx); err == nil { + u.tag = tag + } + } + if u.tag != "" && versionLess(current, u.tag) { + return u.tag + } + return "" +} + +// fetchLatestRelease sends only a generic User-Agent: no cookies, no host or +// user identifiers. +func fetchLatestRelease(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, updateCheckURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "artex-update-check") + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", errors.New("update check: " + resp.Status) + } + var body struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", err + } + return body.TagName, nil // "" shows nothing: parseVersion rejects it +} + +// versionLess reports whether a < b, comparing the leading numeric +// major.minor.patch of each ("v2.2.0-3-gabc" reads as 2.2.0). A version +// without a leading number (for example "dev") never compares as older. +func versionLess(a, b string) bool { + pa, oka := parseVersion(a) + pb, okb := parseVersion(b) + if !oka || !okb { + return false + } + for i := range pa { + if pa[i] != pb[i] { + return pa[i] < pb[i] + } + } + return false +} + +func parseVersion(s string) ([3]int, bool) { + var out [3]int + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) > 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/server/update_check_test.go b/server/update_check_test.go new file mode 100644 index 0000000..782bf92 --- /dev/null +++ b/server/update_check_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "errors" + "testing" + "time" +) + +func countingFetch(tag string, err error, calls *int) func(context.Context) (string, error) { + return func(context.Context) (string, error) { *calls++; return tag, err } +} + +func TestUpdateCheckOffByDefault(t *testing.T) { + t.Setenv("ARTEX_UPDATE_CHECK", "") + if updateCheckEnabled() { + t.Fatal("update check must be off when ARTEX_UPDATE_CHECK is unset") + } + calls := 0 + u := &updateCheck{enabled: false, fetch: countingFetch("v9.9.9", nil, &calls)} + if got := u.newerVersion(context.Background(), time.Now(), "2.2.0"); got != "" || calls != 0 { + t.Fatalf("disabled: got %q with %d fetches, want no banner and no fetch", got, calls) + } +} + +func TestUpdateCheckOncePer24h(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + calls := 0 + u := &updateCheck{enabled: true, fetch: countingFetch("v2.3.0", nil, &calls)} + for _, at := range []time.Duration{0, time.Hour, 23*time.Hour + 59*time.Minute} { + if got := u.newerVersion(context.Background(), t0.Add(at), "2.2.0"); got != "v2.3.0" { + t.Fatalf("at +%v: got %q, want v2.3.0", at, got) + } + } + if calls != 1 { + t.Fatalf("fetched %d times within 24h, want 1", calls) + } + u.newerVersion(context.Background(), t0.Add(24*time.Hour), "2.2.0") + if calls != 2 { + t.Fatalf("fetched %d times after 24h, want 2", calls) + } + if got := u.newerVersion(context.Background(), t0.Add(25*time.Hour), "2.3.0"); got != "" { + t.Fatalf("up to date: got %q, want no banner", got) + } +} + +func TestUpdateCheckFailureShowsNothingAndWaits(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + calls := 0 + u := &updateCheck{enabled: true, fetch: countingFetch("", errors.New("rate limited"), &calls)} + for _, at := range []time.Duration{0, time.Minute, 23 * time.Hour} { + if got := u.newerVersion(context.Background(), t0.Add(at), "2.2.0"); got != "" { + t.Fatalf("failed check: got %q, want no banner", got) + } + } + if calls != 1 { + t.Fatalf("failed check retried %d times within 24h, want 1 attempt", calls) + } +} + +func TestVersionLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"2.2.0", "v2.3.0", true}, + {"v2.2.0", "v2.2.0", false}, + {"2.10.0", "v2.9.0", false}, + {"2.2.0-3-gabc123-dirty", "v2.2.1", true}, + {"dev", "v9.0.0", false}, + {"2.2.0", "not-a-tag", false}, + } + for _, c := range cases { + if got := versionLess(c.a, c.b); got != c.want { + t.Errorf("versionLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index a4693f4..129f265 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -4,10 +4,13 @@ import { type ReactNode, useEffect, useState } from "react"; import { usePathname } from "next/navigation"; +import { X } from "lucide-react"; + import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { useCurrentUser } from "@/hooks/use-current-user"; import { api } from "@/lib/api"; +import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import { cn } from "@/lib/utils"; import { AccountSwitcher } from "./sidebar/account-switcher"; @@ -30,24 +33,58 @@ function isFullBleed(pathname: string) { return normalized.startsWith("/function/tasks/"); } +const RELEASES_URL = "https://github.com/BaaRai/ARTEX/releases"; +const UPDATE_DISMISSED_KEY = "artex-update-dismissed"; + export function MainContent({ children }: { children: ReactNode }) { const currentUser = useCurrentUser(); const pathname = usePathname(); const [version, setVersion] = useState(""); + // Only set when the backend has the opt-in update check on (ARTEX_UPDATE_CHECK) and found a newer release. + const [latest, setLatest] = useState(""); useEffect(() => { api .health() - .then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, ""))) + .then((h) => { + setVersion((h.version ?? "").replace(/^v(?=\d)/, "")); + const l = h.latest_version ?? ""; + setLatest(l && getLocalStorageValue(UPDATE_DISMISSED_KEY) !== l ? l : ""); + }) .catch(() => setVersion("")); }, []); + const updateBanner = latest ? ( +
+ + ARTEX {latest} is available. See the release notes. + + +
+ ) : null; + if (isFullBleed(pathname)) { - return <>{children}; + return ( + <> + {updateBanner} + {children} + + ); } return ( <> + {updateBanner}
get<{ ok: boolean; service: string; version: string }>("/health"), + health: () => get<{ ok: boolean; service: string; version: string; latest_version?: string }>("/health"), // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"),