Skip to content

Commit 4545f2b

Browse files
garrytanclaude
andauthored
v1.34.1.0 fix: gstack-update-check resists stale GitHub raw CDN + adds semver-order guard (garrytan#1475)
* fix: gstack-update-check resolves remote VERSION via SHA-pinned URL Replace branch-raw fetch with git ls-remote + SHA-pinned raw URL. Add semver-order guard via sort -V so REMOTE < LOCAL stays silent instead of emitting a backwards UPGRADE_AVAILABLE line. Fence git ls-remote with GIT_TERMINAL_PROMPT=0 + 5s low-speed timeout. Honor explicit GSTACK_REMOTE_URL overrides for test fixtures and private mirrors. 3 new tests cover stale-CDN regression, multi-segment 1.9 vs 1.10 both directions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: bump version and changelog (v1.34.1.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fea0c13 commit 4545f2b

5 files changed

Lines changed: 115 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
# Changelog
22

3+
## [1.34.1.0] - 2026-05-13
4+
5+
## **`gstack-update-check` resolves remote VERSION via a SHA-pinned URL.**
6+
## **A semver-order guard makes sure the script never proposes a downgrade.**
7+
8+
The version check now runs `git ls-remote https://github.com/garrytan/gstack.git refs/heads/main` to get the live HEAD SHA, then fetches `raw.githubusercontent.com/garrytan/gstack/<SHA>/VERSION`. SHA-pinned raw URLs are immediately consistent, so a freshly-published VERSION shows up right away instead of trailing behind the branch-raw CDN by several minutes. A second guard treats `REMOTE < LOCAL` as up-to-date, so transient stale-CDN responses and dev installs running ahead of main can never produce a backwards `UPGRADE_AVAILABLE` line. The `git ls-remote` call is fenced with `GIT_TERMINAL_PROMPT=0` plus a 5-second low-speed timeout so flaky networks and captive portals cannot hang a skill preamble.
9+
10+
### The numbers that matter
11+
12+
Source: `bun test browse/test/gstack-update-check.test.ts` — 35 existing tests + 3 new semver-guard tests, all green in 1.65s.
13+
14+
| Surface | Before | After |
15+
|---|---|---|
16+
| Remote VERSION fetch | branch-raw URL (`/garrytan/gstack/main/VERSION`), can serve stale content for minutes after a push | `git ls-remote` SHA, then SHA-pinned raw URL (immediately consistent), branch-raw kept as fallback |
17+
| Behavior when REMOTE < LOCAL | `UPGRADE_AVAILABLE <local> <older>` (backwards downgrade prompt) | `UP_TO_DATE <local>` (silent, semver-order guard via `sort -V`) |
18+
| `GSTACK_REMOTE_URL` override semantics | Always honored | Skipped when explicit; preserves `file://` test fixtures and private mirrors |
19+
| `git ls-remote` hang exposure | Not used | `GIT_TERMINAL_PROMPT=0` + `GIT_HTTP_LOW_SPEED_LIMIT=1000` + `GIT_HTTP_LOW_SPEED_TIME=5` enforce a 5-second floor on hung connections |
20+
| Multi-segment version comparison | `[ "$LOCAL" = "$REMOTE" ]` only | `printf "%s\n%s\n" $LOCAL $REMOTE | sort -V | tail -1` validates ordering. `1.9.0.0 < 1.10.0.0` both directions |
21+
| Test coverage for these failure modes | 0 tests | 3 new tests: REMOTE older than LOCAL, multi-segment forward, multi-segment reverse |
22+
23+
The semver guard catches the failure shape directly. If GitHub's branch-raw CDN ever serves stale content again, the script stays silent instead of asking the user to "upgrade" to a version they already passed.
24+
25+
### What this means for builders
26+
27+
Run `/gstack-upgrade` immediately after a new release and the script finds the new VERSION via the live ref instead of waiting for the CDN to refresh. Dev installs running ahead of main also stay quiet now, no more backwards prompts every preamble. No action required, the fix is automatic on upgrade.
28+
29+
### Itemized changes
30+
31+
#### Fixed
32+
33+
- **`bin/gstack-update-check`** — replaced the unconditional `curl` of `raw.githubusercontent.com/.../main/VERSION` with a SHA-pinned fetch path that resolves the live HEAD via `git ls-remote` first, then curls `raw.githubusercontent.com/garrytan/gstack/<SHA>/VERSION`. Branch-raw fetch kept as fallback when `git ls-remote` is unavailable or `GSTACK_REMOTE_URL` is explicitly set.
34+
- **`bin/gstack-update-check`** — added a semver-order guard. After fetching REMOTE, the script runs `sort -V` to confirm REMOTE > LOCAL before emitting `UPGRADE_AVAILABLE`. When LOCAL is at or ahead of REMOTE, it writes `UP_TO_DATE` and exits silently.
35+
- **`bin/gstack-update-check`** — fenced `git ls-remote` with `GIT_TERMINAL_PROMPT=0`, `GIT_HTTP_LOW_SPEED_LIMIT=1000`, and `GIT_HTTP_LOW_SPEED_TIME=5` so a flaky network cannot hang every skill preamble.
36+
37+
#### Added
38+
39+
- **`browse/test/gstack-update-check.test.ts`** — 3 new tests covering: REMOTE older than LOCAL stays silent and caches `UP_TO_DATE`, multi-segment `1.9.0.0 < 1.10.0.0` produces `UPGRADE_AVAILABLE`, multi-segment `1.10.0.0 > 1.9.0.0` stays silent.
40+
341
## [1.34.0.0] - 2026-05-12
442

543
## **GStack is now consumable as a submodule.**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.34.0.0
1+
1.34.1.0

bin/gstack-update-check

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
#
99
# Env overrides (for testing):
1010
# GSTACK_DIR — override auto-detected gstack root
11-
# GSTACK_REMOTE_URL — override remote VERSION URL
11+
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
12+
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
1213
# GSTACK_STATE_DIR — override ~/.gstack state directory
1314
set -euo pipefail
1415

@@ -19,6 +20,7 @@ MARKER_FILE="$STATE_DIR/just-upgraded-from"
1920
SNOOZE_FILE="$STATE_DIR/update-snoozed"
2021
VERSION_FILE="$GSTACK_DIR/VERSION"
2122
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
23+
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
2224

2325
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
2426
if [ "${1:-}" = "--force" ]; then
@@ -178,9 +180,34 @@ if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off"
178180
>/dev/null 2>&1 &
179181
fi
180182

181-
# GitHub raw fetch (primary, always reliable)
183+
# Resolve VERSION via a SHA-pinned raw URL. GitHub's branch-raw CDN
184+
# (raw.githubusercontent.com/<owner>/<repo>/<branch>/...) can serve stale
185+
# content for several minutes after a push, which previously caused
186+
# /gstack-upgrade to silently report "up to date" right after a release
187+
# landed. git ls-remote always returns the live HEAD; SHA-pinned raw URLs
188+
# are immediately consistent.
189+
#
190+
# An explicit GSTACK_REMOTE_URL override (tests, mirrors) skips this path
191+
# so the override is honored verbatim.
182192
REMOTE=""
183-
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
193+
if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
194+
# Disable credential prompts and apply a 5-second low-speed timeout so a
195+
# flaky network or captive portal can't hang every skill preamble.
196+
_LSR_LINE="$(GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
197+
git ls-remote "$REMOTE_REPO" refs/heads/main 2>/dev/null || true)"
198+
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
199+
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
200+
_SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION"
201+
REMOTE="$(curl -sf --max-time 5 "$_SHA_URL" 2>/dev/null || true)"
202+
fi
203+
fi
204+
205+
# Fallback: branch-pinned URL when ls-remote is unavailable (no git, no
206+
# network, mirror without refs/heads/main) or when GSTACK_REMOTE_URL was
207+
# explicitly overridden.
208+
if [ -z "$REMOTE" ]; then
209+
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
210+
fi
184211
REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
185212

186213
# Validate: must look like a version number (reject HTML error pages)
@@ -195,7 +222,17 @@ if [ "$LOCAL" = "$REMOTE" ]; then
195222
exit 0
196223
fi
197224

198-
# Versions differ — upgrade available
225+
# Semver-order guard: only flag an upgrade when REMOTE sorts higher than
226+
# LOCAL. Protects against transient stale-CDN regressions (REMOTE < LOCAL)
227+
# and dev installs running ahead of main, both of which would otherwise
228+
# emit a backwards UPGRADE_AVAILABLE line.
229+
_HIGHER="$(printf '%s\n%s\n' "$LOCAL" "$REMOTE" | sort -V | tail -1)"
230+
if [ "$_HIGHER" != "$REMOTE" ]; then
231+
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
232+
exit 0
233+
fi
234+
235+
# REMOTE is strictly newer — upgrade available
199236
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
200237
if check_snooze "$REMOTE"; then
201238
exit 0 # snoozed — stay quiet

browse/test/gstack-update-check.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,40 @@ describe('gstack-update-check', () => {
496496

497497
// ─── Split TTL tests ─────────────────────────────────────────
498498

499+
// ─── Semver-order guard ─────────────────────────────────────
500+
// When the upstream raw CDN serves a stale (older) VERSION right after a
501+
// release, the script previously emitted a backwards UPGRADE_AVAILABLE
502+
// line. The guard treats REMOTE < LOCAL as up-to-date.
503+
504+
test('remote older than local (stale CDN) → silent, cache UP_TO_DATE', () => {
505+
writeFileSync(join(gstackDir, 'VERSION'), '1.34.0.0\n');
506+
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.33.2.0\n');
507+
508+
const { exitCode, stdout } = run();
509+
expect(exitCode).toBe(0);
510+
expect(stdout).toBe('');
511+
const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
512+
expect(cache).toContain('UP_TO_DATE 1.34.0.0');
513+
});
514+
515+
test('multi-segment sort: 1.9.0.0 < 1.10.0.0', () => {
516+
writeFileSync(join(gstackDir, 'VERSION'), '1.9.0.0\n');
517+
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.10.0.0\n');
518+
519+
const { stdout } = run();
520+
expect(stdout).toBe('UPGRADE_AVAILABLE 1.9.0.0 1.10.0.0');
521+
});
522+
523+
test('multi-segment reverse sort: 1.10.0.0 > 1.9.0.0 → no rewind', () => {
524+
writeFileSync(join(gstackDir, 'VERSION'), '1.10.0.0\n');
525+
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '1.9.0.0\n');
526+
527+
const { stdout } = run();
528+
expect(stdout).toBe('');
529+
const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
530+
expect(cache).toContain('UP_TO_DATE 1.10.0.0');
531+
});
532+
499533
test('UP_TO_DATE cache expires after 60 min (not 720)', () => {
500534
writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
501535
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gstack",
3-
"version": "1.34.0.0",
3+
"version": "1.34.1.0",
44
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
55
"license": "MIT",
66
"type": "module",

0 commit comments

Comments
 (0)