Files
gstack/bin/gstack-update-check
T
Garry Tanandcapy-ai[bot] 75badf306c v1.91.234.0 feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98) (#3429)
* feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98)

Every Monday morning .github/workflows/weekly-release.yml tags the newest green
commit on main as v<VERSION>, publishes a GitHub Release from the CHANGELOG
headlines and fast-forwards refs/heads/stable to it (scripts/release-cut.ts;
workflow_dispatch with dry_run, and ref for a hotfix). Team-mode installs
follow stable through the new update_channel key (auto: stable when
team_mode, else main) in bin/gstack-session-update, bin/gstack-update-check,
bin/gstack-upgrade and /gstack-upgrade. Per-merge stamping and the merge
train are unchanged. docs/releases.md explains it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): check out the dispatched ref so a dry run exercises that branch's script

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): a PR touching the release tooling runs the dry run against main (read-only); the cut job checks out the dispatched ref

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gstack-upgrade): no $1 in the rendered skill (Claude Code rewrites $N), trim Step 4 under the size budget, pin the new floor-check literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: leave setup untouched (its channel note moves to README and docs/releases.md); the release workflow and script are free-only PR files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: record the pr-profile free-only classification as non-behavioral (HARNESS_VERSION 6)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): gate the cut on a main-backstop check run, finish half-done cuts, keep team installs on their channel

/ship review fixes (GSTA-98, PR #3429):

- The weekly release read the Main backstop gate run's conclusion, but
  scripts/train-backstop.ts exits 0 when main moved past the push before it
  was gated (superseded), so a never-gated tree read as green. The backstop
  workflow now publishes its verdict on the commit as the `main-backstop`
  check run from a code-free checks:write job (success for covered|gated,
  neutral for inert|superseded|deferred, failure for frozen|failed), fed by
  the new `--outputs` flag; release-cut.ts requires that check run and
  free-tests to succeed and holds a commit whose check runs are still going.
- A cut that pushed the tag and stable but failed to create the release can
  be finished: stable itself stays a candidate until its release exists, the
  notes compare against the release tag behind it, and a dry run with nothing
  green exits 0 with the skips named (the real cut still fails).
- weekly-release.yml: the cut job runs only from refs/heads/main, `ref` must
  be a full 40-hex SHA, and actions:read is dropped (no workflow-runs call).
- /gstack-upgrade: the destructive fallback requires the carried UPSTREAM_REF
  instead of defaulting to origin/main in a fresh shell; a team install past
  the release stops with AHEAD_OF_STABLE instead of FF_REFUSED and a reset
  backwards. gstack-update-channel.sh --fetch-ref keeps origin/stable on any
  fetch failure other than a missing release; dead gstack_update_channel_ref
  removed. gstack-update-check follows main before the first release instead
  of caching CHECK_FAILED; gstack-upgrade treats a failed ls-remote as a
  fetch failure, not an empty answer.
- Tests: release-cut (check-run fixture, partial cut, neutral/pending),
  weekly-release-workflow (new), evals-train-workflow and train-backstop
  (backstop-check job, --outputs), update-channel (no-release fallback,
  offline --fetch-ref), template pins.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): the upgrade skill's ahead-of-stable exit works in depth-1 clones; update-check keeps its cache identity on the pre-release fallback

/ship second-pass review of 73e278d4 (GSTA-98, PR #3429):

- gstack-upgrade/SKILL.md.tmpl: README installs are depth-1 clones, where
  origin/stable never connects to HEAD, so the AHEAD_OF_STABLE shortcut now
  also fires when HEAD is not behind stable and stable's VERSION is not
  higher than the installed one (the order bin/gstack-upgrade uses). The
  fence prints the channel helper's warnings instead of discarding them, and
  a 'fetching origin/stable failed' line is a STOP. Prose trimmed to stay
  under the skill size budget. New behavioral test runs the Step 4 fence on
  a depth-1 install past the release (AHEAD_OF_STABLE, nothing moved) and on
  one behind it (goes on to the fast-forward).
- bin/gstack-update-check: the no-stable fallback keeps #stable in the
  cache identity (so the outcome replays within its TTL instead of two
  ls-remote calls per skill start), triggers only when ls-remote answered
  with no such branch (a failed ls-remote stays CHECK_FAILED on stable), and
  the branch-pinned URL derives from REMOTE_RAW_BASE so tests stay offline.
- scripts/release-cut.ts: a cancelled check run is red; the previous-release
  lookups check .ok before using git output. docs/releases.md says which
  pushes get no main-backstop check run at all.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 07:22:44 +00:00

443 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-update-check — periodic version check for all skills.
#
# Output (one line, or nothing):
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
# UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>
# — VERSION is equal but the channel branch
# (main, or stable for a team install:
# bin/gstack-update-channel.sh) moved <n>
# commits past this checkout's HEAD (the
# GitHub compare API says so); the snooze
# is keyed by <sha>
# CHECK_FAILED <reason> — the check crashed, or (--force only) the
# remote version could not be read
# (nothing) — up to date, snoozed, disabled, or check skipped
#
# A remote version that cannot be read is cached as CHECK_FAILED (short TTL),
# never as UP_TO_DATE (#2786). Skill preambles replay it silently; only
# --force (/gstack-upgrade) prints it with the failed URL. A diverged or
# unreadable compare (equal VERSION, different main) is CHECK_FAILED too.
#
# Cache: line 1 is the outcome; line 2 is
# IDENTITY head=<local HEAD|none> remote=<compared main SHA|none> upstream=<url>
# A cached outcome is replayed only while this checkout's HEAD and upstream
# match line 2. A git checkout ignores a cache without line 2 (written before
# the equal-version compare existed); a non-git install has no HEAD, so its
# cache keeps the version-only meaning. Never fetches: ls-remote and one
# compare request are the only network reads.
#
# Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
# GSTACK_REMOTE_RAW_BASE — override the SHA-pinned raw VERSION base URL
# GSTACK_COMPARE_API — override the GitHub compare API base URL
# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -euo pipefail
# A crash must not read as "up to date" (#1974). With set -e, any unguarded
# failure used to exit silently — and silence IS the up-to-date signal, so a
# broken check was indistinguishable from a current install (observed live as
# a 45-release silent-staleness incident). -E propagates the trap into
# functions/subshells; exit 0 keeps callers' `|| true` from eating the line.
set -E
trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
# Egress receipt helpers (_receipted_curl / _receipted_git). Update checks
# are fail-OPEN: a receipt hiccup warns but never blocks the version check.
. "$GSTACK_DIR/bin/gstack-egress-lib.sh"
# _receipted_version_fetch <url>
# Receipted bodyless GET for a VERSION file (fail-open). Local reads
# (file://, or a URL with no network host) never leave the machine — they
# are not egress, so they get no receipt.
_receipted_version_fetch() {
local url="$1" host scheme
scheme="${url%%://*}"
host="${url#*://}"; host="${host%%/*}"
case "$scheme" in
http|https|git|ssh|ftp|ftps)
GSTACK_HOME="$STATE_DIR" _receipted_curl open update-check "$host" version-fetch "update_check!=false" --no-payload \
curl -sf --max-time 5 "$url" || true
;;
*)
curl -sf --max-time 5 "$url" 2>/dev/null || true
;;
esac
}
CACHE_FILE="$STATE_DIR/last-update-check"
MARKER_FILE="$STATE_DIR/just-upgraded-from"
SNOOZE_FILE="$STATE_DIR/update-snoozed"
VERSION_FILE="$GSTACK_DIR/VERSION"
# Channel (bin/gstack-update-channel.sh, docs/releases.md): a stable install
# compares with refs/heads/stable, the weekly release, so it is not told every
# hour that main moved. A missing helper means main.
. "$GSTACK_DIR/bin/gstack-update-channel.sh" 2>/dev/null || gstack_update_channel() { echo main; }
CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main
case "$CHANNEL" in stable|main) ;; *) CHANNEL=main ;; esac
REMOTE_RAW_BASE="${GSTACK_REMOTE_RAW_BASE:-https://raw.githubusercontent.com/garrytan/gstack}"
REMOTE_URL="${GSTACK_REMOTE_URL:-$REMOTE_RAW_BASE/$CHANNEL/VERSION}"
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
COMPARE_API="${GSTACK_COMPARE_API:-https://api.github.com/repos/garrytan/gstack/compare}"
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
FORCE=0
if [ "${1:-}" = "--force" ]; then
FORCE=1
rm -f "$CACHE_FILE"
rm -f "$SNOOZE_FILE"
fi
# _display_url <url> — strip userinfo (user:token@) before a URL is printed or cached.
_display_url() {
printf '%s' "$1" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/@]*@#\1#'
}
# ─── Cache identity: this checkout's HEAD and the upstream it compares with ──
LOCAL_HEAD="none"
if [ -e "$GSTACK_DIR/.git" ]; then
_H="$(git -C "$GSTACK_DIR" rev-parse --verify --quiet HEAD 2>/dev/null || true)"
if echo "$_H" | grep -qE '^[0-9a-f]{40}$'; then LOCAL_HEAD="$_H"; fi
fi
if [ -n "${GSTACK_REMOTE_URL:-}" ]; then UPSTREAM="$(_display_url "$REMOTE_URL")"; else UPSTREAM="$(_display_url "$REMOTE_REPO")"; fi
# A channel switch is a new upstream: the cached outcome of the other channel never replays.
[ "$CHANNEL" = stable ] && UPSTREAM="$UPSTREAM#stable"
# _identity_field <name> — a field of the cache's IDENTITY line (empty when absent).
_identity_field() {
printf '%s\n' "${CACHED_ID:-}" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -1
}
# _identity_ok — the cached outcome belongs to this HEAD and upstream.
_identity_ok() {
if [ -z "${CACHED_ID:-}" ]; then
[ "$LOCAL_HEAD" = "none" ]
return
fi
[ "$(_identity_field head)" = "$LOCAL_HEAD" ] && [ "$(_identity_field upstream)" = "$UPSTREAM" ]
}
# _write_cache <outcome line> — the outcome plus the identity it was computed for
# (a non-git install keeps the one-line, version-only cache).
_write_cache() {
if [ "$LOCAL_HEAD" = "none" ]; then
printf '%s\n' "$1" > "$CACHE_FILE"
return
fi
printf '%s\nIDENTITY head=%s remote=%s upstream=%s\n' "$1" "$LOCAL_HEAD" "${_REMOTE_SHA:-none}" "$UPSTREAM" > "$CACHE_FILE"
}
# _snooze_key <UPGRADE_AVAILABLE line> — the SHA for the equal-version form, else the new version.
_snooze_key() {
printf '%s\n' "$1" | awk '{ print (NF >= 4 ? $4 : $3) }'
}
# ─── Step 0: Check if updates are disabled ────────────────────
_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true)
if [ "$_UC" = "false" ]; then
exit 0
fi
# ─── Migration: fix stale Codex descriptions (one-time) ───────
# Existing installs may have .agents/skills/gstack/SKILL.md with oversized
# descriptions (>1024 chars) that Codex rejects. We can't regenerate from
# the runtime root (no bun/scripts), so delete oversized files — the next
# ./setup or /gstack-upgrade will regenerate them properly.
# Marker file ensures this runs at most once per install.
if [ ! -f "$STATE_DIR/.codex-desc-healed" ]; then
for _AGENTS_SKILL in "$GSTACK_DIR"/.agents/skills/*/SKILL.md; do
[ -f "$_AGENTS_SKILL" ] || continue
_DESC=$(awk '/^---$/{n++;next}n==1&&/^description:/{d=1;sub(/^description:\s*/,"");if(length>0)print;next}d&&/^ /{sub(/^ /,"");print;next}d{d=0}' "$_AGENTS_SKILL" | wc -c | tr -d ' ')
if [ "${_DESC:-0}" -gt 1024 ]; then
rm -f "$_AGENTS_SKILL"
fi
done
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/.codex-desc-healed"
fi
# ─── Snooze helper ──────────────────────────────────────────
# check_snooze <remote_version>
# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output).
#
# Snooze file format: <version> <level> <epoch>
# Level durations: 1=24h, 2=48h, 3+=7d
# New version (version mismatch) resets snooze.
check_snooze() {
local remote_ver="$1"
if [ ! -f "$SNOOZE_FILE" ]; then
return 1 # no snooze file → not snoozed
fi
local snoozed_ver snoozed_level snoozed_epoch
snoozed_ver="$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_level="$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null || true)"
snoozed_epoch="$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null || true)"
# Validate: all three fields must be non-empty
if [ -z "$snoozed_ver" ] || [ -z "$snoozed_level" ] || [ -z "$snoozed_epoch" ]; then
return 1 # corrupt file → not snoozed
fi
# Validate: level and epoch must be integers
case "$snoozed_level" in *[!0-9]*) return 1 ;; esac
case "$snoozed_epoch" in *[!0-9]*) return 1 ;; esac
# New version dropped? Ignore snooze.
if [ "$snoozed_ver" != "$remote_ver" ]; then
return 1
fi
# Compute snooze duration based on level
local duration
case "$snoozed_level" in
1) duration=86400 ;; # 24 hours
2) duration=172800 ;; # 48 hours
*) duration=604800 ;; # 7 days (level 3+)
esac
local now
now="$(date +%s)"
local expires=$(( snoozed_epoch + duration ))
if [ "$now" -lt "$expires" ]; then
return 0 # still snoozed
fi
return 1 # snooze expired
}
# ─── Step 1: Read local version ──────────────────────────────
LOCAL=""
if [ -f "$VERSION_FILE" ]; then
LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')"
fi
if [ -z "$LOCAL" ]; then
exit 0 # No VERSION file → skip check
fi
# ─── Step 2: Check "just upgraded" marker ─────────────────────
if [ -f "$MARKER_FILE" ]; then
OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')"
rm -f "$MARKER_FILE"
rm -f "$SNOOZE_FILE"
if [ -n "$OLD" ]; then
echo "JUST_UPGRADED $OLD $LOCAL"
fi
# Don't exit — fall through to remote check in case
# more updates landed since the upgrade
fi
# ─── Step 3: Check cache freshness ──────────────────────────
# UP_TO_DATE: 60 min TTL (detect new releases quickly)
# UPGRADE_AVAILABLE: 720 min TTL (keep nagging)
# CHECK_FAILED: 10 min TTL (retry soon, without re-fetching on every skill start)
CACHED=""
CACHED_ID=""
if [ -f "$CACHE_FILE" ]; then
CACHED="$(head -1 "$CACHE_FILE" 2>/dev/null || true)"
CACHED_ID="$(sed -n '2{/^IDENTITY /p;}' "$CACHE_FILE" 2>/dev/null || true)"
case "$CACHED" in
UP_TO_DATE*) CACHE_TTL=60 ;;
UPGRADE_AVAILABLE*) CACHE_TTL=720 ;;
CHECK_FAILED*) CACHE_TTL=10 ;;
*) CACHE_TTL=0 ;; # corrupt → force re-fetch
esac
_identity_ok || CACHE_TTL=0
STALE=$(find "$CACHE_FILE" -mmin +$CACHE_TTL 2>/dev/null || true)
if [ -z "$STALE" ] && [ "$CACHE_TTL" -gt 0 ]; then
case "$CACHED" in
UP_TO_DATE*|CHECK_FAILED*)
# A cached CHECK_FAILED replays silently: status is unknown, and a
# preamble line every skill start for a transient outage is noise.
CACHED_VER="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_VER" = "$LOCAL" ]; then
exit 0
fi
;;
UPGRADE_AVAILABLE*)
CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')"
if [ "$CACHED_OLD" = "$LOCAL" ]; then
if check_snooze "$(_snooze_key "$CACHED")"; then
exit 0 # snoozed — stay quiet
fi
echo "$CACHED"
exit 0
fi
;;
esac
fi
fi
# ─── Step 4: Slow path — fetch remote version ────────────────
mkdir -p "$STATE_DIR"
# Fire Supabase install ping in background (parallel, non-blocking)
# This logs an update check event for community health metrics via edge function.
# If Supabase is not configured or telemetry is off, this is a no-op.
if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
. "$GSTACK_DIR/supabase/config.sh"
fi
_SUPA_URL="${GSTACK_SUPABASE_URL:-}"
_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off
_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)"
if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then
_OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
# Receipted (fail-open) and fully backgrounded: the receipt write happens
# inside the background subshell, so it can never block this script's exit.
_PING_FILE="$(mktemp "${TMPDIR:-/tmp}/gstack-update-ping-XXXXXX" 2>/dev/null)" || _PING_FILE=""
if [ -n "$_PING_FILE" ]; then
printf '{"version":"%s","os":"%s"}' "$LOCAL" "$_OS" > "$_PING_FILE"
_SUPA_HOST="${_SUPA_URL#*://}"; _SUPA_HOST="${_SUPA_HOST%%/*}"
GSTACK_HOME="$STATE_DIR" _receipted_curl open update-check "$_SUPA_HOST" update-check-ping "telemetry=$_TEL_TIER" "$_PING_FILE" \
curl -sf --max-time 5 \
-X POST "${_SUPA_URL}/functions/v1/update-check" \
-H "Content-Type: application/json" \
-H "apikey: ${_SUPA_KEY}" \
>/dev/null 2>&1 &
fi
fi
# 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=""
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.
_REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}"
_ls_remote_branch() { # <branch> — the tip line of refs/heads/<branch> on REMOTE_REPO; exit 0 and nothing when origin has no such branch
GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \
bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$1"
}
_LSR_LINE="$(_ls_remote_branch "$CHANNEL")" || _LSR_LINE=""
if [ "$CHANNEL" = stable ] && [ -z "$_LSR_LINE" ] && _ls_remote_branch stable >/dev/null 2>&1; then
# Before the first release origin has no stable (ls-remote answered, empty):
# this check follows main, as bin/gstack-session-update (CHANNEL_FALLBACK)
# and bin/gstack-upgrade do. The cache identity keeps #stable, so the
# outcome replays within its TTL like any other.
CHANNEL=main
REMOTE_URL="$REMOTE_RAW_BASE/main/VERSION"
_LSR_LINE="$(_ls_remote_branch main)" || _LSR_LINE=""
fi
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
# The same channel SHA, HEAD and upstream as the cache: its outcome still holds.
if [ "$(_identity_field remote)" = "$_REMOTE_SHA" ] && _identity_ok; then
case "$CACHED" in
UP_TO_DATE*)
[ "$(echo "$CACHED" | awk '{print $2}')" = "$LOCAL" ] && { touch "$CACHE_FILE"; exit 0; } ;;
UPGRADE_AVAILABLE*)
if [ "$(echo "$CACHED" | awk '{print $2}')" = "$LOCAL" ]; then
touch "$CACHE_FILE"
check_snooze "$(_snooze_key "$CACHED")" || echo "$CACHED"
exit 0
fi ;;
esac
fi
_SHA_URL="$REMOTE_RAW_BASE/${_REMOTE_SHA}/VERSION"
REMOTE="$(_receipted_version_fetch "$_SHA_URL")"
else
_REMOTE_SHA=""
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="$(_receipted_version_fetch "$REMOTE_URL")"
fi
REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
# Validate: must look like a version number (reject HTML error pages)
if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then
# Invalid or empty response: the remote version is UNKNOWN, not equal (#2786).
# Both fetches were tried when ls-remote resolved a SHA: name both.
_FAILED_URL="$(_display_url "$REMOTE_URL")"
if [ -n "${_SHA_URL:-}" ]; then
_FAILED_URL="$(_display_url "$_SHA_URL") (then $_FAILED_URL)"
fi
_write_cache "CHECK_FAILED $LOCAL $_FAILED_URL"
if [ "$FORCE" = "1" ]; then
echo "CHECK_FAILED could not read the remote gstack version from $_FAILED_URL — update status UNKNOWN, not up-to-date"
fi
exit 0
fi
NEW="$REMOTE"
if [ "$LOCAL" = "$REMOTE" ]; then
# Equal VERSION: main may still have moved past this checkout without a
# release. Ask GitHub how the two commits relate (status, ahead_by); page 2
# of one commit per page carries the counts without the file list.
if [ -z "${_REMOTE_SHA:-}" ] || [ "$LOCAL_HEAD" = "none" ] || [ "$_REMOTE_SHA" = "$LOCAL_HEAD" ]; then
_write_cache "UP_TO_DATE $LOCAL"
exit 0
fi
_CMP_URL="$COMPARE_API/${LOCAL_HEAD}...${_REMOTE_SHA}?per_page=1&page=2"
_CMP="$(_receipted_version_fetch "$_CMP_URL" | tr -s '\n\r\t ' ' ')"
_CMP_STATUS="$(printf '%s' "$_CMP" | sed -nE 's/.*"status" ?: ?"([a-z]+)" ?, ?"ahead_by" ?: ?([0-9]+).*/\1 \2/p')"
_AHEAD="${_CMP_STATUS#* }"
case "${_CMP_STATUS% *}" in
identical|behind)
_write_cache "UP_TO_DATE $LOCAL"
exit 0 ;;
ahead)
if [ "${_AHEAD:-0}" -gt 0 ] 2>/dev/null; then
NEW="$LOCAL+$_AHEAD $(printf '%s' "$_REMOTE_SHA" | cut -c1-7)"
fi ;;
esac
if [ "$NEW" = "$REMOTE" ]; then
_WHY="${_CMP_STATUS% *}"
_CMP_SHOWN="$(_display_url "$COMPARE_API")/${LOCAL_HEAD:0:12}...${_REMOTE_SHA:0:12}"
_write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}"
if [ "$FORCE" = "1" ]; then
if [ "$_WHY" = "diverged" ]; then
echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from $CHANNEL (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date"
else
echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with $CHANNEL (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date"
fi
fi
exit 0
fi
else
# 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
_write_cache "UP_TO_DATE $LOCAL"
exit 0
fi
fi
# REMOTE is strictly newer, or main moved past HEAD at the same VERSION
_write_cache "UPGRADE_AVAILABLE $LOCAL $NEW"
if check_snooze "$(_snooze_key "UPGRADE_AVAILABLE $LOCAL $NEW")"; then
exit 0 # snoozed — stay quiet
fi
# Log upgrade_prompted event (only on slow-path fetch, not cached replays)
TEL_CMD="$GSTACK_DIR/bin/gstack-telemetry-log"
if [ -x "$TEL_CMD" ]; then
"$TEL_CMD" --event-type upgrade_prompted --skill "" --duration 0 \
--outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null &
fi
echo "UPGRADE_AVAILABLE $LOCAL $NEW"