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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 02:01:30 +00:00
1 parent 73e278d4a6
commit 964481a42e
7 files changed
+98 -46

No files matched your search

+12 -11
View File
@@ -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() { # <branch> — the tip line of refs/heads/<branch> on REMOTE_REPO, or nothing
_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" || 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
+3 -1
View File
@@ -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
+14 -15
View File
@@ -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="<install dir from Step 2>"
@@ -335,8 +336,7 @@ else
fi
```
Replace `<install dir from Step 2>` 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 <PRE_UPGRADE_COMMIT>..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.
+14 -15
View File
@@ -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="<install dir from Step 2>"
@@ -341,8 +342,7 @@ else
fi
```
Replace `<install dir from Step 2>` 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 <PRE_UPGRADE_COMMIT>..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.
+5 -3
View File
@@ -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;
+4
View File
@@ -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', () => {
+46 -1
View File
@@ -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 /);
});
});