diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 84e0b42f6..986519978 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -82,9 +82,9 @@ VERSION_FILE="$GSTACK_DIR/VERSION" . "$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_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/$CHANNEL/VERSION}" -REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}" 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) ── @@ -319,19 +319,20 @@ 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() { # — the tip line of refs/heads/ on REMOTE_REPO, or nothing + _ls_remote_branch() { # — the tip line of refs/heads/ 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" || true + git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$1" } - _LSR_LINE="$(_ls_remote_branch "$CHANNEL")" - if [ "$CHANNEL" = stable ] && [ -z "$_LSR_LINE" ]; then - # Before the first release origin has no stable: this check follows main, - # as bin/gstack-session-update (CHANNEL_FALLBACK) and bin/gstack-upgrade do. + _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="https://raw.githubusercontent.com/garrytan/gstack/main/VERSION" - UPSTREAM="${UPSTREAM%#stable}" - _LSR_LINE="$(_ls_remote_branch 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 diff --git a/docs/releases.md b/docs/releases.md index 47a194c95..cff2a89e3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,7 +29,9 @@ green train record covers the tree or the backstop gated it green, neutral when main moved past the push before it was gated (superseded) or a freeze's own backstop run owns it (deferred), failure when it froze the queue or could not finish. Neutral is never green: a commit whose own tree was never gated is -not released. A commit with a check run still going is skipped and the next +not released. A push that gets no backstop run at all (a dependabot push, a +policy without a train block) has no `main-backstop` check run and is not +green either; a cancelled check run counts as red. A commit with a check run still going is skipped and the next older one is considered, back through 7 days or 200 first-parent commits. The workflow runs no tests and no paid evals of its own; it only reads what the trains and the backstop already recorded. With nothing green in the window it diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 8888d1253..31328f3e1 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -135,16 +135,18 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin -# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main. -UPSTREAM_REF=$(bash bin/gstack-update-channel.sh --fetch-ref 2>/dev/null) || UPSTREAM_REF=origin/main +# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main +_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main +echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1) echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -# A team install past the release waits for stable; nothing moves back. -if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && git merge-base --is-ancestor origin/stable HEAD; then echo "AHEAD_OF_STABLE"; exit 0; fi +# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back. +if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \ + && { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in @@ -169,7 +171,7 @@ their conflicting local edits were saved there and relay the `Inspect`, checkout with `git apply -3`, never `stash apply` in the install; `unmerged=1` means the rescue needs manual resolution). On `FF_STOPPED`, STOP and relay the reason: nothing was changed. -On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). +On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed. `ff-busy` means another gstack update is running (retry in a minute); `autostash-unrecoverable` names what is in the way (an untracked file the release adds, or another git command); `index-unreadable` means the checkout's @@ -307,10 +309,9 @@ fi ### Step 4.8: Stop any stale daemon (unconditional) -A browse daemon started before the upgrade keeps serving the OLD binary's code -until it is stopped — it survives `git reset --hard` and `./setup` because the -running process holds the old executable. Always run this step, using -the install directory detected in Step 2. +A browse daemon started before the upgrade keeps running the old binary (the +reset and `./setup` do not stop it), so always run this step with the install +directory from Step 2. ```bash INSTALL_DIR_PLACEHOLDER="" @@ -335,8 +336,7 @@ else fi ``` -Replace `` with the actual install directory before -running. Interpret the `DAEMON_CHECK` result: +Substitute the install directory from Step 2, then act on `DAEMON_CHECK`: 1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command @@ -347,7 +347,7 @@ running. Interpret the `DAEMON_CHECK` result: busy right now. When it finishes, stop it with `browse stop` — or force it immediately with `browse --force-restart stop` (loses that session's tabs/cookies/logins)." -3. **`none` / `dead` / `current`:** Nothing to do — say nothing. +3. **`none` / `dead` / `current`:** say nothing. ### Step 5: Write marker + clear cache @@ -361,7 +361,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed ### Step 6: Show What's New -Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant. +Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first. Four fields: summarize `git -C "$INSTALL_DIR" log --oneline ..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}". @@ -379,7 +379,7 @@ Happy shipping! ### Step 7: Continue -After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed. +Continue with the skill the user originally invoked; the upgrade is done. --- @@ -392,7 +392,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble): ~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \ .claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true ``` -Use the output to determine if an upgrade is available. 2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above. diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 437347842..7298daa15 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -141,16 +141,18 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin -# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main. -UPSTREAM_REF=$(bash bin/gstack-update-channel.sh --fetch-ref 2>/dev/null) || UPSTREAM_REF=origin/main +# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main +_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main +echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1) echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -# A team install past the release waits for stable; nothing moves back. -if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && git merge-base --is-ancestor origin/stable HEAD; then echo "AHEAD_OF_STABLE"; exit 0; fi +# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back. +if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \ + && { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in @@ -175,7 +177,7 @@ their conflicting local edits were saved there and relay the `Inspect`, checkout with `git apply -3`, never `stash apply` in the install; `unmerged=1` means the rescue needs manual resolution). On `FF_STOPPED`, STOP and relay the reason: nothing was changed. -On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). +On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed. `ff-busy` means another gstack update is running (retry in a minute); `autostash-unrecoverable` names what is in the way (an untracked file the release adds, or another git command); `index-unreadable` means the checkout's @@ -313,10 +315,9 @@ fi ### Step 4.8: Stop any stale daemon (unconditional) -A browse daemon started before the upgrade keeps serving the OLD binary's code -until it is stopped — it survives `git reset --hard` and `./setup` because the -running process holds the old executable. Always run this step, using -the install directory detected in Step 2. +A browse daemon started before the upgrade keeps running the old binary (the +reset and `./setup` do not stop it), so always run this step with the install +directory from Step 2. ```bash INSTALL_DIR_PLACEHOLDER="" @@ -341,8 +342,7 @@ else fi ``` -Replace `` with the actual install directory before -running. Interpret the `DAEMON_CHECK` result: +Substitute the install directory from Step 2, then act on `DAEMON_CHECK`: 1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command @@ -353,7 +353,7 @@ running. Interpret the `DAEMON_CHECK` result: busy right now. When it finishes, stop it with `browse stop` — or force it immediately with `browse --force-restart stop` (loses that session's tabs/cookies/logins)." -3. **`none` / `dead` / `current`:** Nothing to do — say nothing. +3. **`none` / `dead` / `current`:** say nothing. ### Step 5: Write marker + clear cache @@ -367,7 +367,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed ### Step 6: Show What's New -Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant. +Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first. Four fields: summarize `git -C "$INSTALL_DIR" log --oneline ..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}". @@ -385,7 +385,7 @@ Happy shipping! ### Step 7: Continue -After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed. +Continue with the skill the user originally invoked; the upgrade is done. --- @@ -398,7 +398,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble): ~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \ .claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true ``` -Use the output to determine if an upgrade is available. 2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above. diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts index cf321bedc..9bac6b18c 100755 --- a/scripts/release-cut.ts +++ b/scripts/release-cut.ts @@ -37,7 +37,7 @@ export const REQUIRED_CHECK = 'free-tests'; export const BACKSTOP_CHECK = 'main-backstop'; export const NOTES_REF = 'refs/notes/gstack-trains'; const NOTES_LOCAL = 'refs/notes/gstack-trains-release'; -const RED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure', 'stale']); +const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale']); export const WINDOW_DAYS = 7; export const WINDOW_COMMITS = 200; export const NOTES_CAP = 60_000; @@ -223,11 +223,13 @@ export function planRelease(o: PlanOptions): PlanResult { let before = prevStable; if (prevStable === sha) { const prior = git(['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', '--first-parent', sha + '^']); - before = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']).out || null : null; + const resolved = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']) : null; + before = resolved?.ok ? resolved.out : null; } let prevVersion: string | null = null, prevTag: string | null = null; if (before) { - prevVersion = git(['show', before + ':VERSION']).out.trim() || null; + const shown = git(['show', before + ':VERSION']); + prevVersion = shown.ok ? shown.out.trim() || null : null; if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion; } else { const date = git(['show', '-s', '--format=%cI', sha]).out; diff --git a/test/release-cut.test.ts b/test/release-cut.test.ts index 44b374407..2698df41a 100644 --- a/test/release-cut.test.ts +++ b/test/release-cut.test.ts @@ -157,6 +157,10 @@ describe('release-cut: planning', () => { expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true }); const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]); expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false }); + const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]); + expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false }); + const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]); + expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true }); }); test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => { diff --git a/test/update-channel.test.ts b/test/update-channel.test.ts index 740c52540..9af92d882 100644 --- a/test/update-channel.test.ts +++ b/test/update-channel.test.ts @@ -241,6 +241,40 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh as the / }); }); +describe.skipIf(process.platform === 'win32')('the /gstack-upgrade Step 4 fence on the stable channel', () => { + const fence = (() => { + const tmpl = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'), 'utf8'); + const hits = [...tmpl.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]!).filter(f => f.includes('bash bin/gstack-ff-update . "$UPSTREAM_REF"')); + if (hits.length !== 1) throw new Error('expected one Step 4 fence, found ' + hits.length); + return hits[0]!.replace(/\{\{SETUP_COMMAND\}\}/g, './setup'); + })(); + const run = (w: World) => spawnSync('bash', ['-c', fence], { cwd: w.base, encoding: 'utf8', timeout: 60_000, env: { ...w.env, INSTALL_DIR: w.install } }); + + // Value: protects=a team install past the release stops with AHEAD_OF_STABLE and moves nothing, including the README's depth-1 clone where stable's history + // never connects to HEAD (ancestry says nothing; the VERSION order decides), and an install behind the release does not take that exit; fails_when=the shortcut + // tests ancestry alone (depth-1 installs made since the last cut get FF_REFUSED and an offer to reset backwards) or fires for an install stable is ahead of; + // why_new=the template is covered by string pins only; seam=none; no_claim=the FF_OK arm (bin/gstack-ff-update and setup are not part of this world) + test('a depth-1 team install past the release prints AHEAD_OF_STABLE and exits 0 untouched; one behind the release goes on to the fast-forward', () => { + const ahead = world({ installAt: 'C' }); + const r = run(ahead); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('UPSTREAM_REF=origin/stable'); + expect(r.stdout.trim().split('\n').at(-1)).toBe('AHEAD_OF_STABLE'); + expect(ahead.head()).toBe(ahead.shas.C); + expect(git(ahead.install, 'rev-parse', 'refs/remotes/origin/stable')).toBe(ahead.shas.B); + // The depth-1 histories never connect, so ancestry said nothing and the VERSION order decided. + expect(spawnSync('git', ['-C', ahead.install, 'merge-base', '--is-ancestor', 'origin/stable', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).status).toBe(1); + expect(fs.existsSync(path.join(ahead.base, 'setup-calls'))).toBe(false); + + const behind = world({ installAt: 'A' }); + const b = run(behind); + expect(b.stdout).toContain('UPSTREAM_REF=origin/stable'); + expect(b.stdout).not.toContain('AHEAD_OF_STABLE'); + expect(b.status).not.toBe(0); + expect(behind.head()).toBe(behind.shas.A); + }); +}); + describe.skipIf(process.platform === 'win32')('gstack-upgrade on the stable channel', () => { const run = (w: World, args: string[] = []) => spawnSync('bash', [path.join(w.install, 'bin', 'gstack-upgrade'), ...args], { cwd: w.base, encoding: 'utf8', env: w.env, timeout: 60_000 }); @@ -324,6 +358,17 @@ describe.skipIf(process.platform === 'win32')('gstack-update-check on the stable expect(noRelease.stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1'); expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/stable'); expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/main'); - expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1\nIDENTITY head=' + local + ' remote=' + main + ' upstream=' + origin + '\n'); + expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1\nIDENTITY head=' + local + ' remote=' + main + ' upstream=' + origin + '#stable\n'); + // The outcome replays from the cache within its TTL: no second pair of ls-remote calls per skill start. + fs.writeFileSync(gitLog, ''); + expect(run({ GSTACK_UPDATE_CHANNEL: 'stable' }).stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1'); + expect(fs.readFileSync(gitLog, 'utf8')).toBe(''); + // A failed ls-remote (origin unreachable) is not "no stable yet": the check does not flip to main. + fs.writeFileSync(gitLog, ''); + fs.rmSync(path.join(state, 'last-update-check'), { force: true }); + const unreachable = run({ GSTACK_UPDATE_CHANNEL: 'stable', GSTACK_REMOTE_REPO: path.join(base, 'gone.git') }); + expect(unreachable.stdout.trim()).toBe(''); + expect(fs.readFileSync(gitLog, 'utf8')).not.toContain('refs/heads/main'); + expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toMatch(/^CHECK_FAILED 1\.0\.0 /); }); });