diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 31e9fdb6f..d0486cb4c 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -8,7 +8,8 @@ # # Env overrides (for testing): # GSTACK_DIR — override auto-detected gstack root -# GSTACK_REMOTE_URL — override remote VERSION URL +# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback) +# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution # GSTACK_STATE_DIR — override ~/.gstack state directory set -euo pipefail @@ -19,6 +20,7 @@ MARKER_FILE="$STATE_DIR/just-upgraded-from" SNOOZE_FILE="$STATE_DIR/update-snoozed" VERSION_FILE="$GSTACK_DIR/VERSION" REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}" +REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}" # ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ── if [ "${1:-}" = "--force" ]; then @@ -178,9 +180,34 @@ if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" >/dev/null 2>&1 & fi -# GitHub raw fetch (primary, always reliable) +# Resolve VERSION via a SHA-pinned raw URL. GitHub's branch-raw CDN +# (raw.githubusercontent.com////...) can serve stale +# content for several minutes after a push, which previously caused +# /gstack-upgrade to silently report "up to date" right after a release +# landed. git ls-remote always returns the live HEAD; SHA-pinned raw URLs +# are immediately consistent. +# +# An explicit GSTACK_REMOTE_URL override (tests, mirrors) skips this path +# so the override is honored verbatim. REMOTE="" -REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" +if [ -z "${GSTACK_REMOTE_URL:-}" ]; then + # Disable credential prompts and apply a 5-second low-speed timeout so a + # flaky network or captive portal can't hang every skill preamble. + _LSR_LINE="$(GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ + git ls-remote "$REMOTE_REPO" refs/heads/main 2>/dev/null || true)" + _REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')" + if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then + _SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION" + REMOTE="$(curl -sf --max-time 5 "$_SHA_URL" 2>/dev/null || true)" + fi +fi + +# Fallback: branch-pinned URL when ls-remote is unavailable (no git, no +# network, mirror without refs/heads/main) or when GSTACK_REMOTE_URL was +# explicitly overridden. +if [ -z "$REMOTE" ]; then + REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" +fi REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')" # Validate: must look like a version number (reject HTML error pages) @@ -195,7 +222,17 @@ if [ "$LOCAL" = "$REMOTE" ]; then exit 0 fi -# Versions differ — upgrade available +# Semver-order guard: only flag an upgrade when REMOTE sorts higher than +# LOCAL. Protects against transient stale-CDN regressions (REMOTE < LOCAL) +# and dev installs running ahead of main, both of which would otherwise +# emit a backwards UPGRADE_AVAILABLE line. +_HIGHER="$(printf '%s\n%s\n' "$LOCAL" "$REMOTE" | sort -V | tail -1)" +if [ "$_HIGHER" != "$REMOTE" ]; then + echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" + exit 0 +fi + +# REMOTE is strictly newer — upgrade available echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" if check_snooze "$REMOTE"; then exit 0 # snoozed — stay quiet diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 47300f0a6..0edd366e4 100644 --- a/browse/test/gstack-update-check.test.ts +++ b/browse/test/gstack-update-check.test.ts @@ -496,6 +496,40 @@ describe('gstack-update-check', () => { // ─── Split TTL tests ───────────────────────────────────────── + // ─── Semver-order guard ───────────────────────────────────── + // When the upstream raw CDN serves a stale (older) VERSION right after a + // release, the script previously emitted a backwards UPGRADE_AVAILABLE + // line. The guard treats REMOTE < LOCAL as up-to-date. + + test('remote older than local (stale CDN) → silent, cache UP_TO_DATE', () => { + writeFileSync(join(gstackDir, 'VERSION'), '1.34.0.0\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.33.2.0\n'); + + const { exitCode, stdout } = run(); + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE 1.34.0.0'); + }); + + test('multi-segment sort: 1.9.0.0 < 1.10.0.0', () => { + writeFileSync(join(gstackDir, 'VERSION'), '1.9.0.0\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.10.0.0\n'); + + const { stdout } = run(); + expect(stdout).toBe('UPGRADE_AVAILABLE 1.9.0.0 1.10.0.0'); + }); + + test('multi-segment reverse sort: 1.10.0.0 > 1.9.0.0 → no rewind', () => { + writeFileSync(join(gstackDir, 'VERSION'), '1.10.0.0\n'); + writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.9.0.0\n'); + + const { stdout } = run(); + expect(stdout).toBe(''); + const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8'); + expect(cache).toContain('UP_TO_DATE 1.10.0.0'); + }); + test('UP_TO_DATE cache expires after 60 min (not 720)', () => { writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n'); writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');