Merge pull request #202 from BaaRai/feat/update-banner-opt-in

Opt-in update banner, off by default
This commit is contained in:
BaaRai
2026-11-12 13:38:14 +01:00
committed by GitHub
8 changed files with 255 additions and 4 deletions
+5
View File
@@ -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
+2
View File
@@ -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:
+1
View File
@@ -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"
+6 -1
View File
@@ -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) {
+123
View File
@@ -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
}
+78
View File
@@ -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)
}
}
}
@@ -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 ? (
<div className="flex shrink-0 items-center justify-center gap-2 border-b bg-muted/50 px-4 py-1 text-xs">
<a href={RELEASES_URL} target="_blank" rel="noreferrer" className="underline-offset-2 hover:underline">
ARTEX {latest} is available. See the release notes.
</a>
<button
type="button"
aria-label="Dismiss update notice"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setLocalStorageValue(UPDATE_DISMISSED_KEY, latest);
setLatest("");
}}
>
<X className="size-3.5" />
</button>
</div>
) : null;
if (isFullBleed(pathname)) {
return <>{children}</>;
return (
<>
{updateBanner}
{children}
</>
);
}
return (
<>
{updateBanner}
<header
className={cn(
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
+1 -1
View File
@@ -237,7 +237,7 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
export const api = {
// Backend application version (injected via ldflags at release, default "dev").
health: () => 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"),