diff --git a/README.md b/README.md index 47e3e4ee9..a0c22ba39 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Open Claude Code and paste this. Claude does the rest. > Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing and never use mcp\_\_claude-in-chrome\_\_\* tools. Claude Code lists the installed skills itself, so the section needs no skill list. Then ask the user if they also want to add gstack to the current project so teammates get it. +**Which updates you get:** a new install follows the `stable` channel, the tagged release cut twice a week (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), so you get releases that passed the full release gate instead of every merge to main. `./setup` records this the first time it runs on a machine and prints it. A gstack contributor's checkout (marked by `bin/dev-setup`, on a branch other than `main`, or carrying local commits) follows `main` instead, and an install set up before stable became the default keeps following `main`. To switch, run `~/.claude/skills/gstack/bin/gstack-config set update_channel main` (every merge) or `~/.claude/skills/gstack/bin/gstack-config set update_channel stable` (releases only). Details: [docs/releases.md](docs/releases.md#channels). + ### Step 2: Team mode — auto-update for shared repos (recommended) From inside your repo, paste this. Switches you to team mode, bootstraps the repo so teammates get gstack automatically, and commits the change: @@ -38,7 +40,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep (cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work" ``` -No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the twice-weekly tagged release (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md). +No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel, even on an install that otherwise follows `main`: the twice-weekly tagged release, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md#channels). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 3050b63a3..d8e6ef82e 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -65,6 +65,11 @@ for a in "$@"; do prev="$a" done [ "$SKILL" = none ] && CHECK=1 +# Capy crews follow every merge (docs/releases.md#channels): main for all this +# session runs (the self-upgrade, ./setup's first-install record, the +# installer), whatever the machine's config says, unless GSTACK_UPDATE_CHANNEL +# names a channel. +case "${GSTACK_UPDATE_CHANNEL:-}" in stable|main) ;; *) export GSTACK_UPDATE_CHANNEL=main ;; esac INSTALLER="$ROOT/bin/gstack-capy-install" if [ ! -x "$INSTALLER" ]; then echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh} (re-clones or fast-forwards)" diff --git a/bin/gstack-update-channel.sh b/bin/gstack-update-channel.sh index 89bea702f..c09557bea 100755 --- a/bin/gstack-update-channel.sh +++ b/bin/gstack-update-channel.sh @@ -10,10 +10,13 @@ # forward along main's first-parent history by the release workflow # # The config key update_channel (bin/gstack-config) is auto | stable | main. -# auto, the default, is stable when team_mode is true and main otherwise, so a -# team install follows releases and a contributor install follows main with -# nothing to migrate. GSTACK_UPDATE_CHANNEL overrides the config (tests, a -# one-off command). Any other value counts as auto. +# ./setup records it once, on a fresh state root (gstack_update_channel_initial): +# stable for a user's checkout, the GSTACK_UPDATE_CHANNEL value when one is set, +# and nothing for a contributor checkout. auto, which every install set up +# before that record existed still has, is stable when team_mode is true and +# main otherwise, so an existing install keeps the branch it followed. +# GSTACK_UPDATE_CHANNEL overrides the config (tests, a one-off command, Capy +# sessions: bin/gstack-capy-session pins main). Any other value counts as auto. # # Functions (never fail; a broken config means main, the behaviour before # channels existed): @@ -27,7 +30,14 @@ # (no release cut yet): callers # fall back to main # +# gstack_update_channel_initial +# setup's first-install choice: +# prints ` ` +# (auto records nothing), or +# nothing for an existing install +# # CLI (the /gstack-upgrade skill): `bash bin/gstack-update-channel.sh [--fetch-ref] []` +# (./setup: `--initial ` prints gstack_update_channel_initial) # prints the channel; --fetch-ref fetches the channel ref from origin and # prints the revision to fast-forward to (origin/stable or origin/main), # falling back to origin/main only when origin has no stable yet. Any other @@ -57,12 +67,36 @@ gstack_update_channel_missing() { printf '%s' "${1:-}" | grep -qiE "couldn't find remote ref|no such ref was fetched|invalid refspec" 2>/dev/null } +# A state root that holds a setup marker or a config is an existing install: +# it keeps auto, so nothing changes under it. A contributor checkout keeps +# auto too: gstack.contributor (bin/dev-setup) or GSTACK_CONTRIBUTOR=1, CI, a +# checkout that is not its own git repository, a detached HEAD, a branch other +# than origin's default, no upstream, or commits the upstream does not have. +gstack_update_channel_initial() { + _uc_dir=$1 _uc_state=$2 + if [ -e "$_uc_state/.last-setup-version" ] || [ -e "$_uc_state/config.yaml" ] || [ -e "$HOME/.gstack/.last-setup-version" ]; then return 0; fi + case "${GSTACK_UPDATE_CHANNEL:-}" in stable|main) printf '%s set by GSTACK_UPDATE_CHANNEL at install\n' "$GSTACK_UPDATE_CHANNEL"; return 0 ;; esac + if [ "${GSTACK_CONTRIBUTOR:-}" = 1 ] || [ "$(git -C "$_uc_dir" config --bool --get gstack.contributor 2>/dev/null)" = true ]; then + printf 'auto contributor checkout: gstack.contributor is set\n'; return 0 + fi + [ -z "${CI:-}" ] || { printf 'auto contributor checkout: CI\n'; return 0; } + _uc_top=$(git -C "$_uc_dir" rev-parse --show-toplevel 2>/dev/null) && _uc_top=$(cd "$_uc_top" && pwd -P) || _uc_top="" + [ -n "$_uc_top" ] && [ "$_uc_top" = "$(cd "$_uc_dir" && pwd -P)" ] || { printf 'auto not a git checkout of its own\n'; return 0; } + _uc_branch=$(git -C "$_uc_dir" symbolic-ref -q --short HEAD 2>/dev/null) || { printf 'auto contributor checkout: HEAD is detached\n'; return 0; } + _uc_default=$(git -C "$_uc_dir" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null) || _uc_default=origin/main + [ "$_uc_branch" = "${_uc_default#origin/}" ] || { printf 'auto contributor checkout: on branch %s\n' "$_uc_branch"; return 0; } + _uc_up=$(git -C "$_uc_dir" rev-parse -q --verify '@{upstream}' 2>/dev/null) || { printf 'auto contributor checkout: no upstream branch\n'; return 0; } + [ "$(git -C "$_uc_dir" rev-list --count "$_uc_up..HEAD" 2>/dev/null)" = 0 ] || { printf 'auto contributor checkout: local commits\n'; return 0; } + printf 'stable new install\n' +} + if [ "${BASH_SOURCE[0]}" = "$0" ]; then _uc_fetch=0 _uc_arg="" + [ "${1:-}" = --initial ] && { gstack_update_channel_initial "${3:-$(cd "$(dirname "$0")/.." && pwd)}" "${2:?--initial needs }"; exit 0; } for _uc_a in "$@"; do case "$_uc_a" in --fetch-ref) _uc_fetch=1 ;; - -h|--help) sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) _uc_arg=$_uc_a ;; esac done diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh index f7f2eaa1b..2cb5e22b0 100755 --- a/capy/skill/gstack/scripts/install.sh +++ b/capy/skill/gstack/scripts/install.sh @@ -23,6 +23,9 @@ # else main. On stable, a fresh clone checks out origin's stable branch and # the floor fast-forward goes to stable, each only when stable exists and # meets the supported minimum; otherwise main, as before. --revision wins. +# From revision 5 only GSTACK_UPDATE_CHANNEL=stable follows stable: Capy crews +# stay on main whatever the machine's update_channel or team_mode says (the +# checkout's bin/gstack-capy-session pins main for older copies too). # Everything else (Bun, setup, the doctor, the verdict, the launch line) is # the checkout's. The last line is the `gstack:` trailer; exit 0 only on # `gstack: ok`. Contract: docs/capy-install.md; tests: @@ -34,9 +37,9 @@ # bash install.sh [--for | --require ] [--with-browser] [--revision ] [--project ] # Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO, # GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s), -# GSTACK_UPDATE_CHANNEL=main (follow every merge even in team mode) or stable. +# GSTACK_UPDATE_CHANNEL=stable (follow the tagged release; default main, every merge). set -u -GSTACK_CAPY_WRAPPER=4 +GSTACK_CAPY_WRAPPER=5 self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}" @@ -82,12 +85,11 @@ below() { } support_get() { printf '%s\n' "$1" | sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" | head -1; } # channel_stable ROOT MIN [FETCH_FLAG] — true, with origin's stable commit in $stable_sha, when -# this install follows stable and origin's stable exists and meets MIN; says why not otherwise. +# GSTACK_UPDATE_CHANNEL=stable and origin's stable exists and meets MIN; says why not otherwise. # FETCH_FLAG is --depth=1 for a fresh clone only (never --depth into an existing checkout). channel_stable() { - local ch="${GSTACK_UPDATE_CHANNEL:-}" ver - case "$ch" in stable|main) ;; *) ch=$(bash "$1/bin/gstack-update-channel.sh" "$1" 2>/dev/null | head -1) ;; esac - [ "$ch" = stable ] || return 1 + local ver + [ "${GSTACK_UPDATE_CHANNEL:-}" = stable ] || return 1 if ! bounded git -C "$1" fetch --quiet ${3:+"$3"} origin stable 2>/dev/null; then echo "gstack-capy-skill: channel stable, but origin has no stable branch yet (or the fetch failed); following main" return 1 diff --git a/changelog.d/capy-gst-1642-stable-default.md b/changelog.d/capy-gst-1642-stable-default.md new file mode 100644 index 000000000..f41b6b452 --- /dev/null +++ b/changelog.d/capy-gst-1642-stable-default.md @@ -0,0 +1 @@ +- **New installs follow the stable channel; Capy crews stay on main.** A fresh `git clone … && ./setup` now records `update_channel: stable` the first time it runs on a machine and prints `Update channel: stable (new install)` with the command that switches, so a new user gets the twice-weekly tested release instead of every merge to main. A gstack contributor's checkout (`bin/dev-setup`'s marker, another branch, local commits, CI) still follows main, and every install set up before this change keeps following main: nothing switches under anyone. Capy crew machines stay on main: `bin/gstack-capy-session` pins `GSTACK_UPDATE_CHANNEL=main` unless the variable names a channel, and the Capy skill bootstrapper (wrapper 5) no longer follows a machine's own channel. The README install section says which channel you get and how to switch. (GST-1642) diff --git a/docs/capy-install.md b/docs/capy-install.md index 7675b3c9d..cd8b02cda 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -4,7 +4,7 @@ Three layers, each owned by gstack: | Layer | Where | Job | |---|---|---| -| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env` (from wrapper 4 a fresh clone or the floor fast-forward follows `stable` instead when the update channel resolves to it and `stable` meets that minimum, see [releases.md](releases.md#channels)); in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | +| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env` (from wrapper 5 a fresh clone or the floor fast-forward follows `stable` instead only when `GSTACK_UPDATE_CHANNEL=stable` and `stable` meets that minimum; Capy crews otherwise stay on main, see [releases.md](releases.md#channels)); in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | | Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair | | Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer | @@ -613,7 +613,14 @@ other call), so the minimum is 3: every older Drive copy prints the owner action until the owner recopies `capy/skill/gstack/`. Wrapper 4 only adds the update-channel choice for the clone and the floor fast-forward (`GSTACK_UPDATE_CHANNEL=stable|main`, else the checkout's channel, else main); -the minimum stays 3, so a copy at 3 keeps working. +the minimum stays 3, so a copy at 3 keeps working. Wrapper 5 drops the +checkout's channel from that choice, so a Capy machine follows main unless +`GSTACK_UPDATE_CHANNEL=stable` (new installs elsewhere default to stable, and +Capy crews must not inherit that). The minimum still stays 3: the checkout's +`bin/gstack-capy-session` pins `GSTACK_UPDATE_CHANNEL=main` for every +bootstrapper revision, so an older copy differs only when the machine's own +config says stable, where its clone or floor fast-forward can land on the +release until the next session's self-upgrade moves it to main. ## Exit codes diff --git a/docs/install.md b/docs/install.md index 0e3006604..2e0f8105b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel From inside your repo, `(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required` switches you to team mode and bootstraps the repo so teammates get gstack automatically (the README has the full paste, which also commits the change). -No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security or regression fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md). +No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel, as every new install does: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security or regression fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md#channels). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/docs/releases.md b/docs/releases.md index fd358d6e6..394560ebc 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,8 +4,8 @@ gstack used to stamp a version at every merge to main. Under `merge: direct` PRs land unstamped, with a `changelog.d` fragment each, and the version moves only when the Captain lands a release PR (`gstack-release-stamp open`, see [ship-policy.md](ship-policy.md#release-stamp)). Since late October 2026 there -is also a tagged release, cut twice a week, and team-mode installs follow it -instead of every merge. +is also a tagged release, cut twice a week, and new installs and team-mode +installs follow it instead of every merge. Land a release PR before each cut. The cut tags the VERSION it finds, and a version is tagged once: if no release PR landed since the last cut, the next @@ -230,8 +230,8 @@ held. | Channel | Follows | Who | |---|---|---| -| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security or regression cut off-cycle | team mode (`./setup --team`) by default | -| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines | +| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security or regression cut off-cycle | new installs, team mode (`./setup --team`) | +| `main` | every merge to main | contributor checkouts, installs set up before stable became the default, Capy crew machines | The config key is `update_channel` (`bin/gstack-config`): @@ -242,10 +242,36 @@ gstack-config set update_channel stable # a solo install that wants stable rel gstack-config unset update_channel # back to auto ``` -`auto` resolves to `stable` when `team_mode` is true and to `main` otherwise, -so existing team installs moved to stable releases with nothing to migrate and -contributor installs did not move at all. `GSTACK_UPDATE_CHANNEL=stable|main` -overrides the key for one command. The resolution lives in one helper, +Which channel an install gets: + +- **A new install follows `stable`.** The first `./setup` on a machine (no + `.last-setup-version` or `config.yaml` in the state root, and no legacy + `~/.gstack/.last-setup-version`) records `update_channel: stable` and prints + `Update channel: stable (new install)` with the command that switches. When + `GSTACK_UPDATE_CHANNEL` is set during that first setup, its value is + recorded instead. +- **A contributor checkout follows `main`.** That first setup records nothing, + so the key stays `auto`, when the checkout is a contributor's: + `git config gstack.contributor true` (what `bin/dev-setup` writes) or + `GSTACK_CONTRIBUTOR=1`, `CI` set, a directory that is not its own git + repository, a detached HEAD, a branch other than origin's default, no + upstream branch, or commits the upstream does not have. Setup prints the + reason. +- **An existing install keeps what it had.** `auto` resolves to `stable` when + `team_mode` is true and to `main` otherwise, so installs set up before this + default keep following `main` (team installs keep `stable`) and nothing is + switched under them. The updater never moves a checkout backwards, so an + install that has lived at main's head and switched to stable would sit + still until the next cut passes its version; switching is one command for + anyone who wants it, never a silent change. +- **Capy crew machines follow `main`.** `bin/gstack-capy-session` sets + `GSTACK_UPDATE_CHANNEL=main` for everything a session start runs (the + self-upgrade, `./setup`, the installer) unless the variable already names a + channel, so a fresh Capy machine records `main` and an existing one follows + main whatever its own config says. `GSTACK_UPDATE_CHANNEL=stable` in the + session's environment follows the release instead. + +`GSTACK_UPDATE_CHANNEL=stable|main` overrides the key for one command. The resolution lives in one helper, `bin/gstack-update-channel.sh`, used by: - `bin/gstack-session-update`, the team-mode session-start hook: it fetches @@ -258,10 +284,11 @@ overrides the key for one command. The resolution lives in one helper, - `bin/gstack-upgrade` and `/gstack-upgrade`: they fast-forward to the channel ref (`UPSTREAM_REF=origin/stable` in the skill's output) and say which channel they followed. -- `capy/skill/gstack/scripts/install.sh`, the Capy skill's installer (wrapper - revision 4): a fresh clone or a floor upgrade follows `stable` when the channel - resolves to it and `stable` meets the support floor, and follows main - otherwise. `--revision` still pins an exact commit. +- `capy/skill/gstack/scripts/install.sh`, the Capy skill's installer: from + wrapper revision 5 a fresh clone or a floor upgrade follows `stable` only when + `GSTACK_UPDATE_CHANNEL=stable` and `stable` meets the support floor, and + follows main otherwise (revision 4 also followed the machine's own channel). + `--revision` still pins an exact commit. Two edge cases are handled on purpose: diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts index 34cd8f429..74ae43afc 100755 --- a/scripts/release-cut.ts +++ b/scripts/release-cut.ts @@ -241,7 +241,7 @@ export function releaseNotes(n: NotesInput): string { ? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.' : span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').', '', - 'Team-mode installs follow the stable branch, which now points here; gstack-config set update_channel main follows every merge instead ([docs/releases.md](https://github.com/' + n.repo + '/blob/v' + n.version + '/docs/releases.md)).', + 'New installs and team-mode installs follow the stable branch, which now points here; gstack-config set update_channel main follows every merge instead ([docs/releases.md](https://github.com/' + n.repo + '/blob/v' + n.version + '/docs/releases.md)).', '', ].join('\n'); const bullets = span.map(e => '- **' + e.version + '**' + (e.date ? ' (' + e.date + ')' : '') + ': ' + (e.headline || 'see CHANGELOG.md')); diff --git a/setup b/setup index 1ccf04e36..324abcf35 100755 --- a/setup +++ b/setup @@ -1378,6 +1378,15 @@ bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR # Priority: CLI flag > saved config > interactive prompt (or flat default for non-TTY) GSTACK_CONFIG="$SOURCE_GSTACK_DIR/bin/gstack-config" export GSTACK_SETUP_RUNNING=1 # Prevent gstack-config post-set hook from triggering relink mid-setup + +# ─── Update channel, first install only (docs/releases.md#channels) ───────── +# A fresh state root records stable for a user's checkout (or the +# GSTACK_UPDATE_CHANNEL value); a contributor checkout and every existing +# install keep auto. Read before anything below writes config.yaml. +_UC_INITIAL="$(bash "$SOURCE_GSTACK_DIR/bin/gstack-update-channel.sh" --initial "$GSTACK_STATE_ROOT" "$SOURCE_GSTACK_DIR" 2>/dev/null || true)" +case "$_UC_INITIAL" in + stable\ *|main\ *) "$GSTACK_CONFIG" set update_channel "${_UC_INITIAL%% *}" >/dev/null 2>&1 || _UC_INITIAL="" ;; +esac if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then _saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)" if [ "$_saved_prefix" = "true" ]; then @@ -5062,6 +5071,14 @@ if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then fi fi +if [ -n "$_UC_INITIAL" ]; then + _UC_NOW="$(bash "$SOURCE_GSTACK_DIR/bin/gstack-update-channel.sh" "$SOURCE_GSTACK_DIR" 2>/dev/null || echo main)" + _UC_WHY="${_UC_INITIAL#* }" + [ "${_UC_INITIAL%% *}" = auto ] && [ "$_UC_NOW" = stable ] && _UC_WHY="team mode" + log "" + log "Update channel: $_UC_NOW ($_UC_WHY). Switch: $INSTALL_GSTACK_DIR/bin/gstack-config set update_channel $([ "$_UC_NOW" = main ] && echo stable || echo main) (docs/releases.md#channels)" +fi + _setup_print_summary # ─── CSO native-helper summary ──────────────────────────────────────────────── diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index e35bf2d98..f2d85e3f8 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -42,6 +42,7 @@ const below = (a: string, b: string) => { const STUB_INSTALLER = [ '#!/usr/bin/env bash', 'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"', + 'echo "channel=${GSTACK_UPDATE_CHANNEL:-}" >> "$WRAP_LOG.channel"', 'cd "$(dirname "$0")/.."', 'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi', 'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"', @@ -62,9 +63,11 @@ const UPGRADE_DEPS = [ * cloneAt 'B': the clone sits at B, AT the minimum, and the remote moves on to * C (1.0.2, minimum unchanged): behind origin but not below the floor. * next: the remote moves on to C for a clone at A too. stable: origin's stable - * branch (the release, docs/releases.md) at that commit. + * branch (the release, docs/releases.md) at that commit. configStable: B and later + * carry a bin/gstack-config stub whose update_channel is stable (a machine whose + * own config, or team mode, would follow the release). */ -function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolean; stable?: 'A' | 'B' | 'C' } = {}) { +function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolean; stable?: 'A' | 'B' | 'C'; configStable?: boolean } = {}) { const host = capyHost({ systemBun: '1.4.2' }); hosts.push(host); const git = (args: string[], cwd: string) => { @@ -93,6 +96,7 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolea if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); } write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755); + if (opts.configStable) write(path.join(seed, 'bin/gstack-config'), '#!/usr/bin/env bash\n[ "$1 $2" = "get update_channel" ] && echo stable\nexit 0\n', 0o755); git(['add', '-A'], seed); git(['commit', '-qm', 'B'], seed); const b = git(['rev-parse', 'HEAD'], seed); @@ -115,6 +119,7 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolea return { host, root, bare, a, b, c, run, git: (args: string[]) => git(args, root), calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [], + channels: () => fs.existsSync(log + '.channel') ? fs.readFileSync(log + '.channel', 'utf8').trim().split('\n').filter(Boolean) : [], }; } @@ -145,13 +150,13 @@ describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true'); expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=1.0.0`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=5 min=1.0.1 from=1.0.0`]); const next = f.run(['--check', '--for', 'autoplan']); expect(next.status, next.stdout + next.stderr).toBe(0); expect(next.stdout).not.toContain('upgraded'); expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=4 min=1.0.1 from=`); + expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=5 min=1.0.1 from=`); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); }); @@ -268,7 +273,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(r.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=1.0.1`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=5 min=1.0.1 from=1.0.1`]); expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false); const next = f.run(['--for', 'autoplan']); @@ -276,7 +281,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(next.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m); expect(next.stdout).not.toContain('SELF_UPGRADE: done'); expect(next.last).toBe('gstack: ok 1.0.2 for=autoplan'); - expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=`); + expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=5 min=1.0.1 from=`); }); test('--check mode never moves a behind checkout: it names the gap and the fast-forwarding command, then certifies the installed revision', () => { @@ -287,7 +292,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(r.stdout).not.toContain('SELF_UPGRADE'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=4 min=1.0.1 from=`]); + expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=5 min=1.0.1 from=`]); }); test('a dirty behind checkout is skipped with gstack-upgrade\'s reason and still ends ok: above the floor, the trailer is the doctor\'s', () => { @@ -300,7 +305,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n'); expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=5 min=1.0.1 from=`]); }); test('GSTACK_CAPY_SELF_UPGRADE=off and GSTACK_CAPY_AUTO_UPGRADE=0 each disable the fast-forward by name and leave HEAD where it is', () => { @@ -367,6 +372,40 @@ describe('capy skill bootstrapper: the update channel (wrapper 4, docs/releases. }); }); +describe('capy skill bootstrapper: Capy crews stay on main (wrapper 5, docs/releases.md#channels)', () => { + // Value: protects=a fresh Capy machine clones main and runs the installer (./setup's first-install record) under GSTACK_UPDATE_CHANNEL=main even when + // the checkout's config would pick stable; fails_when=a Capy clone lands on the release, or setup on a fresh machine records stable; why_new=GST-1642 + // makes stable the default for new installs, which Capy crews must not inherit; seam=a gstack-config stub saying stable; no_claim=GitHub's stable branch + test('a fresh machine clones main and installs under the main pin, whatever the machine config says', () => { + const f = fixture({ cloneAt: 'B', stable: 'B', configStable: true }); + fs.rmSync(f.root, { recursive: true, force: true }); + const r = f.run(['--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.lines[0]).toStartWith('gstack-capy-skill: wrapper 5 '); + expect(r.stdout).not.toContain('channel stable'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + expect(f.channels()).toEqual(['channel=main']); + }); + + // Value: protects=an existing checkout behind origin self-upgrades to main's tip under the main pin although its config names stable, and + // GSTACK_UPDATE_CHANNEL=stable still keeps it on the release; fails_when=an existing Capy checkout parks at the release or the override is ignored; + // why_new=GST-1642; seam=a gstack-config stub saying stable; no_claim=a Drive copy older than wrapper 5 (the checkout half pins main for it too) + test('an existing checkout self-upgrades to main, not the release its config names; GSTACK_UPDATE_CHANNEL=stable still follows stable', () => { + const f = fixture({ cloneAt: 'B', stable: 'B', configStable: true }); + const r = f.run(['--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('SELF_UPGRADE: done 1.0.1 -> 1.0.2'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + expect(f.channels()).toEqual(['channel=main']); + + const pinned = fixture({ cloneAt: 'B', stable: 'B', configStable: true }); + const s = pinned.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' }); + expect(s.status, s.stdout + s.stderr).toBe(0); + expect(pinned.git(['rev-parse', 'HEAD'])).toBe(pinned.b); + expect(pinned.channels()).toEqual(['channel=stable']); + }); +}); + describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => { test('a fresh fetch exports origin\'s VERSION; a failed fetch exports nothing even with a stale FETCH_HEAD', () => { const f = fixture({ cloneAt: 'B' }); diff --git a/test/update-channel-setup.test.ts b/test/update-channel-setup.test.ts new file mode 100644 index 000000000..b13ae2feb --- /dev/null +++ b/test/update-channel-setup.test.ts @@ -0,0 +1,99 @@ +/** + * New installs default to the stable channel (docs/releases.md#channels, + * GST-1642): the real ./setup on a fresh state root records update_channel + * stable for a user's clone, records nothing for a contributor checkout or an + * existing install (auto, which follows main), and records an explicit + * GSTACK_UPDATE_CHANNEL as given. The checkout is a real git clone of a + * file:// origin built from the install fixture's source. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, rmSync, utimesSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, runSetup, type Fixture } from './helpers/install-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +const STUB_MTIME = new Date('2040-01-01'); +const GIT_ENV = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_CONFIG_NOSYSTEM: '1' }; + +/** A fixture whose gstack install is a git clone of a file:// origin; fresh drops the fixture's preset config.yaml (a first install). */ +function cloned(opts: { fresh: boolean; branch?: string }) { + const f = makeFixture(); + const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: { ...f.env, ...GIT_ENV } }).trim(); + const staging = makeSource(f, join(f.dir, 'staging')); + git(staging, 'init', '-q', '-b', 'main'); + git(staging, 'add', '-A', '-f'); + git(staging, 'commit', '-q', '-m', 'seed'); + const origin = join(f.dir, 'origin.git'); + git(f.dir, 'clone', '-q', '--bare', staging, origin); + const src = join(f.home, '.claude/skills/gstack'); + git(f.dir, 'clone', '-q', '--single-branch', '--depth', '1', `file://${origin}`, src); + // A clone stamps every file with the clone time; the fixture's stub binaries must stay newer than their sources, or setup rebuilds them. + for (const rel of ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']) utimesSync(join(src, rel), STUB_MTIME, STUB_MTIME); + if (opts.branch) git(src, 'checkout', '-q', '-b', opts.branch); + if (opts.fresh) rmSync(join(f.home, '.gstack/config.yaml')); + const setup = (env: Record = {}) => { + const r = runSetup(f, join(src, 'setup'), [], { env: { ...GIT_ENV, ...env } }); + return { ...r, out: r.stdout + r.stderr }; + }; + const channel = () => spawnSync('bash', [join(src, 'bin/gstack-update-channel.sh'), src], { env: f.env, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const key = () => spawnSync(join(src, 'bin/gstack-config'), ['get', 'update_channel'], { env: f.env, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + return { f, src, setup, channel, key }; +} +const onlyLine = (out: string) => out.split('\n').filter(l => l.startsWith('Update channel:')); + +describe.skipIf(process.platform === 'win32')('./setup: the update channel a first install records', () => { + // Value: protects=a fresh git clone on the default branch, set up on a fresh state root, follows stable and says so once, with the command to switch; + // fails_when=setup records nothing (new installs keep following main), records too late (after another config write marks the install existing), + // or prints no switch command; why_new=GST-1642 makes stable the default for new installs; seam=install fixture, file:// origin; no_claim=GitHub's origin + test('a fresh non-contributor clone records stable, prints the channel and the switch command once, and a rerun keeps it', () => { + const c = cloned({ fresh: true }); + const r = c.setup(); + expect(r.status, r.out).toBe(0); + expect(c.key()).toBe('stable'); + expect(c.channel()).toBe('stable'); + expect(onlyLine(r.out)).toEqual([`Update channel: stable (new install). Switch: ${c.src}/bin/gstack-config set update_channel main (docs/releases.md#channels)`]); + const again = c.setup(); + expect(again.status, again.out).toBe(0); + expect(onlyLine(again.out)).toEqual([]); + expect(c.channel()).toBe('stable'); + }, 180_000); + + // Value: protects=a contributor checkout (here: on a branch other than origin's default) keeps auto, so it follows main, and setup says why; + // fails_when=a contributor's checkout is moved to stable; why_new=new detection (GST-1642); seam=install fixture; no_claim=every signal (unit-tested) + test('a contributor checkout records nothing and follows main', () => { + const c = cloned({ fresh: true, branch: 'work' }); + const r = c.setup(); + expect(r.status, r.out).toBe(0); + expect(c.key()).toBe('auto'); + expect(c.channel()).toBe('main'); + expect(onlyLine(r.out)).toEqual([`Update channel: main (contributor checkout: on branch work). Switch: ${c.src}/bin/gstack-config set update_channel stable (docs/releases.md#channels)`]); + }, 180_000); + + // Value: protects=an existing install whose config says auto (or nothing) keeps following main with no message; fails_when=setup or an upgrade's + // setup silently moves an existing install to stable; why_new=GST-1642 must not switch existing installs; seam=the fixture's preset config.yaml; + // no_claim=installs whose state root was deleted (they read as new) + test('an existing install (config.yaml present, no update_channel) records nothing, prints nothing and follows main', () => { + const c = cloned({ fresh: false }); + const r = c.setup(); + expect(r.status, r.out).toBe(0); + expect(c.key()).toBe('auto'); + expect(c.channel()).toBe('main'); + expect(onlyLine(r.out)).toEqual([]); + }, 180_000); + + // Value: protects=GSTACK_UPDATE_CHANNEL=main at first install is recorded, so later commands without the variable still follow main (how a Capy + // session's setup leaves a fresh machine); fails_when=a fresh install made under the main pin records stable; why_new=GST-1642; seam=install fixture; + // no_claim=the Capy wrapper itself (capy-skill-wrapper.test.ts) + test('GSTACK_UPDATE_CHANNEL=main at first install records main', () => { + const c = cloned({ fresh: true }); + const r = c.setup({ GSTACK_UPDATE_CHANNEL: 'main' }); + expect(r.status, r.out).toBe(0); + expect(c.key()).toBe('main'); + expect(c.channel()).toBe('main'); + expect(existsSync(join(c.f.home, '.gstack/.last-setup-version'))).toBe(true); + expect(onlyLine(r.out)).toEqual([`Update channel: main (set by GSTACK_UPDATE_CHANNEL at install). Switch: ${c.src}/bin/gstack-config set update_channel stable (docs/releases.md#channels)`]); + }, 180_000); +}); diff --git a/test/update-channel.test.ts b/test/update-channel.test.ts index 658e681af..51e9136c6 100644 --- a/test/update-channel.test.ts +++ b/test/update-channel.test.ts @@ -372,3 +372,80 @@ describe.skipIf(process.platform === 'win32')('gstack-update-check on the stable expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toMatch(/^CHECK_FAILED 1\.0\.0 /); }); }); + +describe.skipIf(process.platform === 'win32')('gstack_update_channel_initial: the channel ./setup records at first install', () => { + // Value: protects=a fresh state root on a user's checkout (default branch, upstream, no local commits, no contributor marker) gets stable, an explicit + // GSTACK_UPDATE_CHANNEL is recorded as given, every contributor signal and every existing install (setup marker, config.yaml, legacy marker) records + // nothing so auto keeps following main; fails_when=a contributor checkout or an existing install is switched to stable, or a plain clone is not; + // why_new=the first-install record is new (GST-1642); seam=the helper's CLI, which ./setup calls; no_claim=setup's own call (update-channel-setup.test.ts) + function checkout() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-initial-')); + bases.push(base); + const origin = path.join(base, 'origin.git'), seed = path.join(base, 'seed'), home = path.join(base, 'home'), state = path.join(base, 'state'); + fs.mkdirSync(home); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(seed); + git(seed, 'init', '-q', '-b', 'main'); + write(path.join(seed, 'VERSION'), '1.0.0\n'); + git(seed, 'add', '-A'); + git(seed, 'commit', '-q', '-m', 'A'); + git(seed, 'push', '-q', origin, 'main'); + const clone = (name: string, ...flags: string[]) => { + const dir = path.join(base, name); + execFileSync('git', ['clone', '-q', ...flags, 'file://' + origin, dir], { timeout: 30_000, env: { ...process.env, ...GIT_ENV, HOME: home } }); + return dir; + }; + const initial = (dir: string, extra: Record = {}) => spawnSync('bash', [HELPER, '--initial', state, dir], { + encoding: 'utf8', timeout: 10_000, + env: { ...process.env, ...GIT_ENV, HOME: home, GSTACK_UPDATE_CHANNEL: '', GSTACK_CONTRIBUTOR: '', CI: '', ...extra }, + }).stdout.trim(); + return { base, home, state, clone, initial }; + } + + test('a plain clone (full or the README depth-1 one) on a fresh state root records stable; an explicit GSTACK_UPDATE_CHANNEL is recorded as given', () => { + const c = checkout(); + expect(c.initial(c.clone('full'))).toBe('stable new install'); + const shallow = c.clone('shallow', '--single-branch', '--depth', '1'); + expect(c.initial(shallow)).toBe('stable new install'); + expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'main' })).toBe('main set by GSTACK_UPDATE_CHANNEL at install'); + expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'stable', CI: 'true' })).toBe('stable set by GSTACK_UPDATE_CHANNEL at install'); + expect(c.initial(shallow, { GSTACK_UPDATE_CHANNEL: 'weekly' })).toBe('stable new install'); + }); + + test('an existing install records nothing: a setup marker or config.yaml in the state root, or the legacy ~/.gstack marker', () => { + const c = checkout(); + const dir = c.clone('full'); + for (const marker of [path.join(c.state, '.last-setup-version'), path.join(c.state, 'config.yaml'), path.join(c.home, '.gstack', '.last-setup-version')]) { + write(marker, '1.0.0\n'); + expect(c.initial(dir), marker).toBe(''); + expect(c.initial(dir, { GSTACK_UPDATE_CHANNEL: 'main' }), marker).toBe(''); + fs.rmSync(marker); + } + expect(c.initial(dir)).toBe('stable new install'); + }); + + test('a contributor checkout records nothing (auto): the marker, CI, another branch, local commits, a detached HEAD, no upstream, a nested directory', () => { + const c = checkout(); + const dir = c.clone('full'); + expect(c.initial(dir, { GSTACK_CONTRIBUTOR: '1' })).toBe('auto contributor checkout: gstack.contributor is set'); + expect(c.initial(dir, { CI: 'true' })).toBe('auto contributor checkout: CI'); + git(dir, 'config', 'gstack.contributor', 'true'); + expect(c.initial(dir)).toBe('auto contributor checkout: gstack.contributor is set'); + git(dir, 'config', '--unset', 'gstack.contributor'); + fs.mkdirSync(path.join(dir, 'sub')); + expect(c.initial(path.join(dir, 'sub'))).toBe('auto not a git checkout of its own'); + git(dir, 'checkout', '-q', '-b', 'work'); + expect(c.initial(dir)).toBe('auto contributor checkout: on branch work'); + git(dir, 'checkout', '-q', 'main'); + write(path.join(dir, 'VERSION'), '1.0.0-mine\n'); + git(dir, 'commit', '-q', '-am', 'mine'); + expect(c.initial(dir)).toBe('auto contributor checkout: local commits'); + git(dir, 'checkout', '-q', '--detach', 'HEAD~1'); + expect(c.initial(dir)).toBe('auto contributor checkout: HEAD is detached'); + const lone = path.join(c.base, 'lone'); + fs.mkdirSync(lone); + git(lone, 'init', '-q', '-b', 'main'); + git(lone, 'commit', '-q', '--allow-empty', '-m', 'x'); + expect(c.initial(lone)).toBe('auto contributor checkout: no upstream branch'); + }); +});