Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## [1.34.1.0] - 2026-05-13

## **`gstack-update-check` resolves remote VERSION via a SHA-pinned URL.**
## **A semver-order guard makes sure the script never proposes a downgrade.**

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.

### The numbers that matter

Source: `bun test browse/test/gstack-update-check.test.ts` — 35 existing tests + 3 new semver-guard tests, all green in 1.65s.

| Surface | Before | After |
|---|---|---|
| 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 |
| Behavior when REMOTE < LOCAL | `UPGRADE_AVAILABLE <local> <older>` (backwards downgrade prompt) | `UP_TO_DATE <local>` (silent, semver-order guard via `sort -V`) |
| `GSTACK_REMOTE_URL` override semantics | Always honored | Skipped when explicit; preserves `file://` test fixtures and private mirrors |
| `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 |
| 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 |
| Test coverage for these failure modes | 0 tests | 3 new tests: REMOTE older than LOCAL, multi-segment forward, multi-segment reverse |

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.

### What this means for builders

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.

### Itemized changes

#### Fixed

- **`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.
- **`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.
- **`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.

#### Added

- **`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.

## [1.34.0.0] - 2026-05-12

## **GStack is now consumable as a submodule.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.34.0.0
1.34.1.0
45 changes: 41 additions & 4 deletions bin/gstack-update-check
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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/<owner>/<repo>/<branch>/...) 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)
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions browse/test/gstack-update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.34.0.0",
"version": "1.34.1.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
Expand Down
Loading