mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Merge pull request #202 from BaaRai/feat/update-banner-opt-in
Opt-in update banner, off by default
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user