diff --git a/README.md b/README.md index 01fd989b5..af8d2d013 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 (Monday and Thursday at most once every 72 hours, an important fix within about a day, never more than a week stale, plus verified security and regression fixes), instead of every merge to main. `./setup` records this the first time it runs on a machine and prints it. The clone itself starts at main's latest commit, and the updater never moves a checkout backwards, so it moves onto releases at the next cut that passes it (within a few days). A gstack contributor's checkout (marked by `bin/dev-setup`, on a branch other than origin's default `main`, or carrying local commits) and a Capy crew machine (its installer records `main`) follow `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 tagged release (Monday and Thursday at most once every 72 hours, an important fix within about a day, never more than a week stale, 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 while `update_channel` is `auto` (so also on an install from before stable became the default): the tagged release (Monday and Thursday at most once every 72 hours, an important fix within about a day, never more than a week stale, 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#channels). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 2929043b4..2a9b29a3e 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -625,6 +625,11 @@ EOF_SHIM # keeps its ok trailer, whose notes then read # behind= (latest: ); upgrade skipped: (); fix: # (below the minimum the bootstrapper fails revision before anything runs). +# A checkout more than 24 hours behind (bin/gstack-capy-session exports +# GSTACK_CAPY_STALE) adds `stale=h` after `behind=`, or `stale=h (pinned: )` +# for a pin with a recorded reason; a failed fetch with no successful one in 24 +# hours adds `stale=no fetch from origin in h` on its own. Unlike a bare +# `behind=`, a `stale=` with no pin means: run the fix before the first workflow. # A project floor the doctor could not meet (`WARN floor`, PROJECT_FLOOR_UNMET) # adds `floor= ()` and a `fix:` note the same way; when no release # meets the floor (the row says `no release meets this floor yet`), the note is @@ -656,6 +661,7 @@ if [ -n "$BLOCKED" ]; then fi if [ -n "${GSTACK_CAPY_BEHIND:-}" ]; then note "behind=$GSTACK_CAPY_BEHIND (latest: ${GSTACK_CAPY_BEHIND_TO:-${REMOTE_VERSION:-unknown}})" + [ -n "${GSTACK_CAPY_STALE:-}" ] && note "stale=$GSTACK_CAPY_STALE" [ -n "$BLOCKED" ] && note "upgrade skipped: $BLOCKED_REASON ($BLOCKED_CODE)" # The trailer's fix is always one runnable command (the session's: the rerun # or bin/gstack-upgrade, which prints its own refusal); the refusal's own @@ -665,6 +671,10 @@ elif [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \ && [ "$(printf '%s\n%s\n' "$INSTALLED" "$REMOTE_VERSION" | sort -V | head -1)" = "$INSTALLED" ]; then note "latest: $REMOTE_VERSION available" fi +if [ -n "${GSTACK_CAPY_STALE:-}" ] && [ -z "${GSTACK_CAPY_BEHIND:-}" ]; then + note "stale=$GSTACK_CAPY_STALE" + FIX_NOTE="check network access to origin, then ${GSTACK_CAPY_WRAPPER_PATH:+bash $GSTACK_CAPY_WRAPPER_PATH${FOR:+ --for $FOR}}" +fi # The project floor (the doctor's WARN floor row): the floor and its file ride # the trailer, and the fix is the install when it fast-forwards, else the row's. FLOOR_ROW=$(printf '%s\n' "$OUT" | grep '^WARN floor — ' | head -1) diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 3050b63a3..1e07f1afb 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -35,6 +35,19 @@ # GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` # | `none (current)` | `disabled ()`. +# Daily (R75): a checkout more than 24 hours behind origin's default branch +# (the oldest commit it lacks is over a day old) cannot hide behind a pin with +# no recorded reason. A pin is GSTACK_CAPY_SELF_UPGRADE=off, +# GSTACK_CAPY_AUTO_UPGRADE=0, a detached HEAD (--revision) or another branch; +# its reason is GSTACK_PIN_REASON or `git -C config gstack.pinReason`. +# In install mode an unreasoned pin on a clean, undiverged checkout is +# overridden (`SELF_UPGRADE: pin overridden (...) (PIN_UNREASONED)`, the +# default branch checked out) and the checkout fast-forwards; --check points +# its fix at the install. Either way GSTACK_CAPY_STALE= hands the +# installer the trailer's `stale=` note, which also covers a failed fetch +# with no successful one in 24 hours. Workflow boundary only: a live workflow +# lease (above) or a live eval run (gstack-detach's ~/.gstack/locks/gstack-evals.lock) +# skips every self-upgrade (UPGRADE_BUSY). # --check never moves the checkout; behind origin it prints # `gstack-capy-session: behind origin/: -> ( commits); fix: ...`. # Whenever the session ends behind origin (--check, or a self-upgrade that was @@ -56,15 +69,20 @@ # The last line is the `gstack:` trailer; the exit status is the installer's. set -u ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -CHECK=0 SKILL="" +CHECK=0 SKILL="" REVISION_ARG="" prev="" for a in "$@"; do [ "$prev" = --root ] && ROOT="$a" - case "$prev" in --for|--only) SKILL="$a" ;; esac + case "$prev" in --for|--only) SKILL="$a" ;; --revision) REVISION_ARG="$a" ;; esac [ "$a" = --check ] && CHECK=1 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)" @@ -109,11 +127,72 @@ if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then esac fi RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh}${SKILL:+ --for $SKILL}" +unset GSTACK_CAPY_STALE +NOW=$(date +%s) STALE_H="" PIN_WHAT="" UNREASONED="" SWITCH="" +GIT_DIR_ABS=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null) +PIN_REASON="${GSTACK_PIN_REASON:-$(git -C "$ROOT" config --local --get gstack.pinReason 2>/dev/null)}" +[ -n "$PIN_REASON" ] || [ -z "$REVISION_ARG" ] || PIN_REASON="--revision $REVISION_ARG" +[ -n "$PIN_REASON" ] || [ "$(git -C "$ROOT" config --local --bool --get gstack.contributor 2>/dev/null)" != true ] || PIN_REASON="contributor checkout (gstack.contributor)" +PIN_REASON=$(printf '%s' "$PIN_REASON" | tr ';\r\n' ' ' | tr -s ' ' | cut -c1-80) +DEFAULT_BRANCH=$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$GIT_DIR_ABS/FETCH_HEAD" 2>/dev/null) +# A failed fetch empties FETCH_HEAD and resets its mtime, so a successful one leaves its own stamp. +if [ -n "${GSTACK_CAPY_REMOTE_MIN:-}" ] && [ "$GSTACK_CAPY_REMOTE_MIN" != unknown ] && [ -n "$GIT_DIR_ABS" ]; then + : > "$GIT_DIR_ABS/gstack-last-fetch" 2>/dev/null || true +fi +if [ "${GSTACK_UPDATE_CHANNEL:-}" = stable ]; then : +elif behind; then + _t=$(git -C "$ROOT" log -1 --format=%ct "$(git -C "$ROOT" rev-list --first-parent --reverse HEAD..FETCH_HEAD 2>/dev/null | head -1)" 2>/dev/null) + case "$_t" in ''|*[!0-9]*) ;; *) [ $((NOW - _t)) -gt 86400 ] && STALE_H=$(((NOW - _t) / 3600)) ;; esac +elif [ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && [ "$SKILL" != none ]; then + _ft="" + if [ -f "$GIT_DIR_ABS/gstack-last-fetch" ]; then _ft=$(date -r "$GIT_DIR_ABS/gstack-last-fetch" +%s 2>/dev/null) + elif [ -s "$GIT_DIR_ABS/FETCH_HEAD" ]; then _ft=$(date -r "$GIT_DIR_ABS/FETCH_HEAD" +%s 2>/dev/null) + fi + if [ -z "$_ft" ]; then export GSTACK_CAPY_STALE="no fetch from origin recorded" + elif [ $((NOW - _ft)) -gt 86400 ]; then export GSTACK_CAPY_STALE="no fetch from origin in $(((NOW - _ft) / 3600))h" + fi +fi +if [ -n "$STALE_H" ]; then + [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ] && PIN_WHAT="GSTACK_CAPY_SELF_UPGRADE=off" + [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ] && PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }GSTACK_CAPY_AUTO_UPGRADE=0" + _branch=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null) + if [ -z "$_branch" ]; then PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }HEAD detached at $(git -C "$ROOT" rev-parse --short HEAD)" + elif [ -n "$DEFAULT_BRANCH" ] && [ "$_branch" != "$DEFAULT_BRANCH" ]; then PIN_WHAT="${PIN_WHAT:+$PIN_WHAT, }on branch $_branch" + fi + if gstack_doctor_install_profile "$SKILL"; then export GSTACK_CAPY_STALE="${STALE_H}h (shared checkout: the lane's install moves it)" + elif [ -n "$PIN_REASON" ]; then export GSTACK_CAPY_STALE="${STALE_H}h (pinned: $PIN_REASON)" + else + export GSTACK_CAPY_STALE="${STALE_H}h" + if [ -n "$PIN_WHAT" ] && [ -n "$DEFAULT_BRANCH" ] \ + && [ -z "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \ + && git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then + # Off the default branch, switch only to a local default that is absent or + # fast-forwardable, so the switch never lands on diverged work. + if [ "$_branch" = "$DEFAULT_BRANCH" ]; then UNREASONED=1 + elif ! git -C "$ROOT" show-ref --verify -q "refs/heads/$DEFAULT_BRANCH" \ + || git -C "$ROOT" merge-base --is-ancestor "refs/heads/$DEFAULT_BRANCH" FETCH_HEAD 2>/dev/null; then UNREASONED=1 SWITCH=1 + fi + fi + fi +fi +# foreign_busy — what bin/gstack-upgrade refuses on: a live lease outside this +# process chain (or a shared harness's under 120 minutes). Checked before the +# override switches branches, so nothing changes under another session. +foreign_busy() { + [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \ + && "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ --count-shared-harness >/dev/null 2>&1 +} +# eval_live — a live eval run holds gstack-detach's machine-wide eval lock. +eval_live() { + local lock="$HOME/.gstack/locks/gstack-evals.lock" + [ -f "$lock" ] && command -v flock >/dev/null 2>&1 && ! flock -n "$lock" true 2>/dev/null +} if [ "$CHECK" -eq 1 ]; then if behind; then # The rerun fast-forwards only what gstack-upgrade would move; otherwise # gstack-upgrade itself names the refusal and its fix. if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade" + elif [ -n "$UNREASONED" ]; then report_behind "$RERUN" elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN" elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN" elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \ @@ -123,10 +202,11 @@ if [ "$CHECK" -eq 1 ]; then fi BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD) echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $GSTACK_CAPY_BEHIND_TO ($GSTACK_CAPY_BEHIND commits); fix: $GSTACK_CAPY_BEHIND_FIX$([ "$GSTACK_CAPY_BEHIND_FIX" = "$RERUN" ] && echo ' (fast-forwards)')" + [ -n "$STALE_H" ] && [ -z "$PIN_REASON" ] && ! gstack_doctor_install_profile "$SKILL" && echo "gstack-capy-session: stale: the oldest commit this checkout lacks is ${STALE_H}h old (over 24h); run the fix before the first workflow" fi elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then - if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE - elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE + if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ] && [ -z "$UNREASONED" ]; then disabled GSTACK_CAPY_SELF_UPGRADE + elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ] && [ -z "$UNREASONED" ]; then disabled GSTACK_CAPY_AUTO_UPGRADE elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)" elif ! behind; then echo "SELF_UPGRADE: none (current)" elif gstack_doctor_install_profile "$SKILL"; then @@ -148,11 +228,35 @@ EOF_OWN echo "SELF_UPGRADE: skipped ($REASON)" report_behind "$ROOT/bin/gstack-upgrade" export GSTACK_CAPY_BEHIND_BLOCKED="$REASON" + elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && eval_live; then + REASON="a live eval run holds $HOME/.gstack/locks/gstack-evals.lock, so the upgrade waits for it to finish; fix: $RERUN once the eval run ends (UPGRADE_BUSY)" + echo "SELF_UPGRADE: skipped ($REASON)" + report_behind "$RERUN" + export GSTACK_CAPY_BEHIND_BLOCKED="$REASON" + elif [ -n "$SWITCH" ] && foreign_busy; then + REASON="live gstack sessions hold this installation, so the unreasoned pin ($PIN_WHAT) is not switched under them; fix: $RERUN once they finish (UPGRADE_BUSY)" + echo "SELF_UPGRADE: skipped ($REASON)" + report_behind "$RERUN" + export GSTACK_CAPY_BEHIND_BLOCKED="$REASON" else + PRE_HEAD="" PRE_BRANCH="" NEW_BRANCH="" + if [ -n "$SWITCH" ]; then + PRE_HEAD=$(git -C "$ROOT" rev-parse HEAD) PRE_BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null) + if git -C "$ROOT" show-ref --verify -q "refs/heads/$DEFAULT_BRANCH"; then git -C "$ROOT" checkout -q "$DEFAULT_BRANCH" 2>/dev/null || PRE_HEAD="" + elif git -C "$ROOT" checkout -q -b "$DEFAULT_BRANCH" 2>/dev/null; then NEW_BRANCH=1 + else PRE_HEAD="" + fi + fi OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null) UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$? [ -n "$UP_OUT" ] && printf '%s\n' "$UP_OUT" + if [ "$UP_RC" -ne 0 ] && [ -n "$PRE_HEAD" ]; then + if [ -n "$PRE_BRANCH" ]; then git -C "$ROOT" checkout -q "$PRE_BRANCH"; else git -C "$ROOT" checkout -q --detach "$PRE_HEAD"; fi + [ -n "$NEW_BRANCH" ] && git -C "$ROOT" branch -q -D "$DEFAULT_BRANCH" + echo "SELF_UPGRADE: pin kept (the fast-forward was refused, so HEAD is back at ${PRE_BRANCH:-$(git -C "$ROOT" rev-parse --short HEAD)})" + fi if [ "$UP_RC" -eq 0 ]; then + [ -n "$UNREASONED" ] && echo "SELF_UPGRADE: pin overridden ($PIN_WHAT, with no recorded reason, and the oldest commit this checkout lacks is ${STALE_H}h old; keep a pin with GSTACK_PIN_REASON= or git -C $ROOT config gstack.pinReason ) (PIN_UNREASONED)" echo "SELF_UPGRADE: done $OLD -> $(head -1 "$ROOT/VERSION" 2>/dev/null)" export GSTACK_CAPY_UPGRADED_FROM="$OLD" GSTACK_CAPY_SELF_UPGRADED=1 exec "$ROOT/bin/gstack-capy-session" "$@" diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 4c5c49a74..81677601c 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -381,6 +381,7 @@ gstack_result_anchor() { LAUNCH_FILE_UNWRITABLE) printf '%s' 'capy-install-launch-file-unwritable' ;; PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;; PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;; + PIN_UNREASONED) printf '%s' 'pin-unreasoned' ;; UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; diff --git a/bin/gstack-update-channel.sh b/bin/gstack-update-channel.sh index 89bea702f..8e09abe0f 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,44 @@ 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 (or the legacy ~/.gstack) that holds a setup marker or a config +# is an existing install: it keeps auto, so nothing changes under it. A Capy +# machine (CAPY_MACHINE_CONFIG or ~/.capy/system, as bin/gstack-capy-install +# detects it) records main, even for a ./setup run outside a Capy session. 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 + for _uc_m in "$_uc_state/.last-setup-version" "$_uc_state/config.yaml" "$HOME/.gstack/.last-setup-version" "$HOME/.gstack/config.yaml"; do + [ -e "$_uc_m" ] && return 0 + done + case "${GSTACK_UPDATE_CHANNEL:-}" in stable|main) printf '%s set by GSTACK_UPDATE_CHANNEL at install\n' "$GSTACK_UPDATE_CHANNEL"; return 0 ;; esac + if [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ]; then printf 'main Capy machine: crews follow every merge\n'; return 0; fi + 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="" + if [ "${1:-}" = --initial ]; then + [ -n "${2:-}" ] || { echo "usage: gstack-update-channel.sh --initial []" >&2; exit 2; } + gstack_update_channel_initial "${3:-$(cd "$(dirname "$0")/.." && pwd)}" "$2"; exit 0 + fi 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/SKILL.md b/capy/skill/gstack/SKILL.md index b7c9e1582..03e7fae1e 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta **Crew lanes:** after this check passes, read [lane-card.md](lane-card.md), not the rest of this file; it says what to read before /ship. Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper ()` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). 1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. Then, whatever the trailer says, once per session read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) when there is one, otherwise the `factory.md` beside this SKILL.md: how crews work, with pointers to the rules. If neither exists, report `factory.md: none` on its own line after the verbatim trailer. With no checkout, or after a `for=none` trailer (no launch file names the preference then), also read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data: a family such as `opus` means the newest model of that family in your model list, `provider/model` means that model, and `host`, no file or anything else (an unparseable value, two `agent_model` lines) means keep your own model and say so in your report. Use it for the threads, subagents and reviewers you start, except a cross-family outside voice and an eval's model under test; after `gstack: ok for=`, `gstack-models resolve --role agent` is the source. -2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor= ()` or `behind= (latest: )` with a `fix: ` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix once before the first workflow; a note saying `no release meets this floor yet` has no fix: report it. `behind=` alone is informational ( commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped ()`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. +2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor= ()`, `behind= (latest: )` or `stale=` with a `fix: ` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row), and `stale=h` that the checkout has lacked an origin commit for over 24 hours: run the fix once before the first workflow; a `floor=` note saying `no release meets this floor yet` has no fix: report it. `behind=` alone is informational; `stale=h (pinned: )` is a pin with a recorded reason and stays. A thread that lives past a day reruns the check before a workflow once its last check is over 24 hours old. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Install mode fast-forwards a clean checkout behind origin, reinstalls and rechecks in one locked step. A dirty or diverged checkout, or one a live session's lease or a live eval run holds (`UPGRADE_BUSY`), is never moved; a pin (`--revision`, detached, another branch, `GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`) holds past 24 hours only with a recorded reason (`GSTACK_PIN_REASON=` or `git -C config gstack.pinReason `), else the install overrides it (`PIN_UNREASONED`). A refused run stays ok with `WARN behind` after `SELF_UPGRADE: skipped ()` and names the exact commands. Details: `docs/capy-install.md` in the checkout. 3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for ` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests. diff --git a/capy/skill/gstack/lane-card.md b/capy/skill/gstack/lane-card.md index 42bc2a4cd..d04632ea8 100644 --- a/capy/skill/gstack/lane-card.md +++ b/capy/skill/gstack/lane-card.md @@ -4,7 +4,7 @@ For a crew lane (a thread working for a Captain). Read this once `gstack: ok `. Never re-type the assignments as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export`: they print API keys into the transcript. Hand a child agent the file path, never the values. - Every subagent runs its own `--check` and puts its trailer in its first report; a read-only reviewer uses `--for review-voice`. One machine's install never covers another. diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh index f7f2eaa1b..2d951bdb0 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: @@ -33,10 +36,11 @@ # bash install.sh --check --for none this machine runs no workflow: ok not-needed, nothing installed # 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_CAPY_AUTO_UPGRADE=0 (no fast-forward; above the floor the checkout's session +# overrides it past 24 hours behind unless GSTACK_PIN_REASON is set), GSTACK_CAPY_FETCH_TIMEOUT (s), +# 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 +86,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..653754677 --- /dev/null +++ b/changelog.d/capy-gst-1642-stable-default.md @@ -0,0 +1,2 @@ +- **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 (the clone starts at main's tip and joins releases at the next cut that passes it). 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, a fresh Capy machine records main, 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) +- **Capy threads run gstack's latest main at least daily.** When the oldest origin commit a Capy checkout lacks is more than 24 hours old, the session check's trailer says `stale=h` and the skill runs the install before the next workflow. A pin (`GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`, a detached HEAD or another branch) holds past that only with a recorded reason (`GSTACK_PIN_REASON`, `git config gstack.pinReason`, an explicit `--revision`); an unreasoned one is overridden at the workflow boundary and reported (`PIN_UNREASONED`). The upgrade never runs under a live workflow lease or eval run, never switches a checkout another session holds or onto diverged work, and a failed fetch with none successful in 24 hours is reported. (R75, GST-1642) diff --git a/docs/capy-install.md b/docs/capy-install.md index a0bec718e..9283084cc 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 | @@ -136,9 +136,28 @@ there is refused with the usual `--for ` fix. disabled), the ok trailer also says so, because lanes relay only the trailer: `behind= (latest: ); fix: ` (see the trailer grammar below). + **Daily (R75).** When the oldest origin commit the checkout lacks is more + than 24 hours old, the trailer adds `stale=h` after `behind=`, and + unlike a bare `behind=` the agent runs the fix before the first workflow. + A pin (`GSTACK_CAPY_SELF_UPGRADE=off`, `GSTACK_CAPY_AUTO_UPGRADE=0`, a + detached HEAD or another branch) then holds only with a recorded reason: + `GSTACK_PIN_REASON=`, `git -C config gstack.pinReason `, + an explicit `--revision`, or `gstack.contributor=true` + (`stale=h (pinned: )`). Without one, an install-mode run on a clean, + undiverged checkout overrides it (`SELF_UPGRADE: pin overridden (...) + (PIN_UNREASONED)`), switching to the default branch only when that local + branch is absent or fast-forwardable and no other session holds a lease, + and switching back if `bin/gstack-upgrade` refuses. A workflow lease of the + current run or a live eval run (gstack-detach's + `~/.gstack/locks/gstack-evals.lock`) defers every self-upgrade + (`UPGRADE_BUSY`). A `--for review-voice` reader gets + `stale=h (shared checkout: the lane's install moves it)` and only reports + it. With `GSTACK_UPDATE_CHANNEL=stable` nothing is measured against main. 4. When the fetch fails (offline), the bootstrapper says `remote minimum unknown (fetch failed)` and checks the local install only; it never claims - the minimum, and nothing is fast-forwarded. + the minimum, and nothing is fast-forwarded. With no successful fetch in 24 + hours (the session's `/gstack-last-fetch` stamp) the trailer adds + `stale=no fetch from origin in h` with a network fix. 5. The project's floor. A consuming repository names the oldest gstack it wants its crew on as `min_gstack_version` in its `.gstack/ship-policy.json` (the key `gstack-ship-queue` already validates; a file holding only that key is @@ -425,7 +444,8 @@ with every bootstrapper revision), optionally followed by `upgrade skipped: ()` when an install-mode fast-forward was refused, or `latest: available` (origin's default branch has a newer VERSION; the bootstrapper exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded -in this run); then `floor= ()` when the project's floor is unmet +in this run); `stale=` right after `behind=` (or alone, for no fetch in 24 +hours); then `floor= ()` when the project's floor is unmet (`floor= (; no release meets this floor yet)` when no stable release meets it either, with no fix of its own); and last exactly one `fix: ` whenever `behind=` or a fixable `floor=` is present. @@ -621,7 +641,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 52432a0c1..96cc1c0b9 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 an important change (within about a day) or a security or regression fix, and never more than a week stale, 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 an important change (within about a day) or a security or regression fix, and never more than a week stale, 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/recommended-harness.md b/docs/recommended-harness.md index 4fa52ce95..93abb8d36 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -240,8 +240,8 @@ security or regression fix (`--security` or `--regression`, with `--ref`): it ta (its required check succeeded, the backstop check succeeded, every check run completed and none failed, no active freeze), tags it `v`, publishes a GitHub Release whose notes are one line per CHANGELOG version since the -previous release, and fast-forwards `stable`. Team-mode installs follow -`stable`; `gstack-config set update_channel main` follows every merge. A +previous release, and fast-forwards `stable`. New installs and team-mode installs +follow `stable`; `gstack-config set update_channel main` follows every merge. A hotfix is `--ref `, held by the same floor. Details, the floor's refusal codes and the "nothing green in the window" failure mode: [docs/releases.md](releases.md). A repository adopting this diff --git a/docs/releases.md b/docs/releases.md index dc6b0a871..c48028c7b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,10 +4,11 @@ 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, and team-mode installs follow it instead of every -merge. It is cut twice a week, and since November 2026 a daily check also cuts -it within about a day of an important change and never lets it go more than a -week stale while main has gated changes ([the daily check](#the-daily-check)). +is also a tagged release, and new installs and team-mode installs follow it +instead of every merge. It is cut twice a week, and since November 2026 a daily +check also cuts it within about a day of an important change and never lets it +go more than a week stale while main has gated changes ([the daily +check](#the-daily-check)). 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 @@ -321,8 +322,8 @@ held. | Channel | Follows | Who | |---|---|---| -| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), an important change's cut within about a day (24-hour floor), a forced cut when it is a week old, 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), an important change's cut within about a day (24-hour floor), a forced cut when it is a week old, 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`): @@ -333,10 +334,39 @@ 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` or `~/.gstack/config.yaml`) 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, and a Capy machine (`CAPY_MACHINE_CONFIG` or + `~/.capy/system`) records `main`. The clone starts at main's latest + commit; since nothing moves a checkout backwards, a new stable install runs + that build until the next cut passes it, then follows releases. +- **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 @@ -349,10 +379,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/docs/troubleshooting.md b/docs/troubleshooting.md index 8d56a4b66..767d6f71f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1759,6 +1759,25 @@ after the refusal is resolved, or the Capy skill installer again once the lease holder finishes. Below the supported minimum the bootstrapper fails `revision` instead (`REVISION_BELOW_MINIMUM`). + +### `SELF_UPGRADE: pin overridden (, with no recorded reason, and the oldest commit this checkout lacks is h old; ...) (PIN_UNREASONED)` / trailer note `stale=h` + +**Meaning.** Capy threads run gstack's latest main at least daily (R75). A Capy +session start in install mode (`--for `, a workflow boundary) found the +checkout more than 24 hours behind origin's default branch, by the commit time +of the oldest commit it lacks, and pinned by `GSTACK_CAPY_SELF_UPGRADE=off`, +`GSTACK_CAPY_AUTO_UPGRADE=0`, a detached HEAD (`--revision`) or another branch, +with no recorded reason. It checked out the default branch where needed and +fast-forwarded. A dirty or diverged checkout is still never moved, and a live +workflow lease or eval run still defers the upgrade (`UPGRADE_BUSY`). On +`--check` the trailer carries `stale=h` with the install as its fix; unlike a +bare `behind=`, run that fix before the first workflow. + +**Fix.** Nothing, when the pin was not deliberate. To keep a pin, record why: +`GSTACK_PIN_REASON=` in the session's environment, or +`git -C config gstack.pinReason ""`; the trailer then reads +`stale=h (pinned: )` and the checkout stays where it is. + ### `gstack-upgrade: ; nothing was changed; fix: (UPGRADE_REFUSED)` diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts index fbd4301a3..6519a4778 100644 --- a/lib/result-codes-capy.ts +++ b/lib/result-codes-capy.ts @@ -79,6 +79,11 @@ export const CAPY_RESULT_CODES = { summary: "the Node on PATH does not satisfy the project's Node pin while every Bun pin is met; the pins row is a WARN and the trailer stays ok, because gstack runs on Bun and the project's own tests may select their Node (nvm, CI)", fix: "put a Node that satisfies the pin first on PATH (the Capy skill installer installs an exact pin into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in the project", }, + PIN_UNREASONED: { + anchor: 'pin-unreasoned', + summary: 'a Capy session start found the checkout more than 24 hours behind origin\'s default branch and pinned (GSTACK_CAPY_SELF_UPGRADE=off, GSTACK_CAPY_AUTO_UPGRADE=0, a detached HEAD or another branch) with no recorded reason, so it overrode the pin and fast-forwarded (R75)', + fix: 'nothing when the pin was not deliberate; to keep a pin, record why: GSTACK_PIN_REASON= for the session, or git -C config gstack.pinReason ""', + }, UPGRADE_SKIPPED: { anchor: 'upgrade-skipped', summary: 'the install-mode session was behind origin and its session-start fast-forward was skipped (dirty, diverged or pinned checkout, a live lease, or gstack-upgrade missing); the row is a WARN with the reason and the trailer stays ok with a behind= note, because a gap above the supported minimum is never a hard fail', diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 8bb6f4089..79296bab8 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -603,7 +603,7 @@ export const RESULT_CODES = { }, UPGRADE_BUSY: { anchor: 'upgrade-busy', - summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, or a shared harness (capy-server) whose lease may be a sibling session\'s is under 120 minutes old, so moving the checkout would change gstack under it', + summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, or a shared harness (capy-server) whose lease may be a sibling session\'s is under 120 minutes old, or a live eval run holds gstack-detach\'s ~/.gstack/locks/gstack-evals.lock, so moving the checkout would change gstack under it', fix: 'wait for those sessions to finish, or for a shared-harness lease to turn 120 minutes old (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know no other session uses the checkout', }, UPGRADE_FETCH_FAILED: { diff --git a/scripts/free-test-durations.json b/scripts/free-test-durations.json index 8e8879ff8..63d1cc68a 100644 --- a/scripts/free-test-durations.json +++ b/scripts/free-test-durations.json @@ -1439,6 +1439,7 @@ "test/unattended-grant.test.ts": 9, "test/uninstall-windows-copies.test.ts": 1131, "test/uninstall.test.ts": 4140, + "test/update-channel-setup.test.ts": 24000, "test/update-channel.test.ts": 5290, "test/update-check-crash-sentinel.test.ts": 409, "test/update-check-main-moved.test.ts": 2176, diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts index 12ce46f88..9c8176a0e 100755 --- a/scripts/release-cut.ts +++ b/scripts/release-cut.ts @@ -244,7 +244,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..44a5e3740 100755 --- a/setup +++ b/setup @@ -1378,6 +1378,19 @@ 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 || { + echo "Update channel: could not record ${_UC_INITIAL%% *} (gstack-config failed); fix: $INSTALL_GSTACK_DIR/bin/gstack-config set update_channel ${_UC_INITIAL%% *}" >&2 + _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 +5075,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..6d9f3712b 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,13 +63,16 @@ 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). nextAgeHours: C's commit + * date that many hours ago (a checkout at B is that stale). */ -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; nextAgeHours?: number } = {}) { const host = capyHost({ systemBun: '1.4.2' }); hosts.push(host); - const git = (args: string[], cwd: string) => { - const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 }); + const git = (args: string[], cwd: string, extra: Record = {}) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...host.env, ...extra }, timeout: 30_000 }); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); return r.stdout.trim(); }; @@ -93,6 +97,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); @@ -101,7 +106,8 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolea if (opts.cloneAt === 'B' || opts.next) { if (opts.cloneAt === 'B') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base); write(path.join(seed, 'VERSION'), '1.0.2\n'); - git(['commit', '-qam', 'C'], seed); + const at = opts.nextAgeHours ? `@${Math.floor(Date.now() / 1000) - opts.nextAgeHours * 3600 - 60} +0000` : ''; + git(['commit', '-qam', 'C'], seed, at ? { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at } : {}); c = git(['rev-parse', 'HEAD'], seed); git(['push', '-q', bare, 'main'], seed); } @@ -115,6 +121,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 +152,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 +275,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 +283,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 +294,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 +307,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', () => { @@ -321,7 +328,7 @@ describe('capy skill: always current when online (the minimum is the floor, not // bootstrapper read in this run; a FETCH_HEAD left by an earlier run never // reaches the installer. describe('capy skill bootstrapper: the update channel (wrapper 4, docs/releases.md#channels)', () => { - // Value: protects=an install that follows stable (GSTACK_UPDATE_CHANNEL=stable, or team mode through bin/gstack-update-channel.sh) is cloned at + // Value: protects=an install that follows stable (GSTACK_UPDATE_CHANNEL=stable; from wrapper 5 the machine's own channel no longer counts) is cloned at // origin's stable branch, not main, and stays there through the checkout half's self-upgrade; GSTACK_UPDATE_CHANNEL=main and a remote with no stable // branch (before the first release) clone main; fails_when=a fresh team install lands on unreleased main, the override cannot pick main, or a missing // stable branch fails the install; why_new=the clone test covers the default branch only; seam=GSTACK_UPDATE_CHANNEL instead of gstack-config's @@ -367,6 +374,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' }); @@ -730,3 +771,221 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => { }); }); }); + +// R75 (Garry, 2026-11-06): every Capy thread runs gstack's latest main at least daily. +describe('capy skill: a checkout more than 24 hours behind origin (R75)', () => { + const STALE_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} stale=${GSTACK_CAPY_STALE:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n'; + const rerun = `bash ${BOOT} --for autoplan`; + /** Clone at B; origin's C is ageHours old. */ + const behindBy = (ageHours: number) => { + const f = fixture({ cloneAt: 'B', nextAgeHours: ageHours }); + write(path.join(f.root, 'bin/gstack-capy-install'), STALE_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + return f; + }; + + // Value: protects=a checkout whose oldest missing commit is over 24h old is flagged stale on --check with the fast-forwarding install as its fix (no longer + // a bare informational behind=), and the install at the workflow boundary fast-forwards it; an hour behind stays plain behind=; fails_when=--check + // leaves a day-old checkout informational, or the boundary install does not move it; why_new=R75; seam=C's commit date; no_claim=GitHub's main + test('25 hours behind: --check notes stale=25h with the install as its fix and moves nothing; the install fast-forwards; an hour behind is not stale', () => { + const f = behindBy(25); + const check = f.run(['--check', '--for', 'autoplan']); + expect(check.status, check.stdout + check.stderr).toBe(0); + expect(check.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`); + expect(check.stdout).toContain('gstack-capy-session: stale: the oldest commit this checkout lacks is 25h old (over 24h); run the fix before the first workflow'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + const install = f.run(['--for', 'autoplan']); + expect(install.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + + const fresh = behindBy(1); + expect(fresh.run(['--check', '--for', 'autoplan']).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=none fix=${rerun}`); + }); + + // Value: protects=the daily upgrade happens only at a workflow boundary: a run holding a fresh workflow lease, or a live eval run (gstack-detach's eval + // lock), skips it even for a stale checkout with an unreasoned pin; fails_when=a /ship, review or eval run has gstack moved under it; why_new=R75 adds + // a forced path that must not bypass the in-run markers; seam=gstack-lease and flock on the eval lock; no_claim=leases of other machines + test('mid-run: a fresh workflow lease of this run, or a live eval run, defers the upgrade of a stale checkout', () => { + const f = behindBy(25); + const leaseDir = path.join(f.host.base, 'leases'); + const script = ['bash "$1" touch --pid $$ --root "$2" || exit 9', 'bash "$3" --for autoplan; echo "exit=$?"'].join('\n'); + const inRun = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir, GSTACK_CAPY_SELF_UPGRADE: 'off' }, timeout: 120_000 }); + expect(inRun.stdout, inRun.stdout + inRun.stderr).toContain('exit=0'); + expect(inRun.stdout).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)\)$/m); + expect(inRun.stdout).not.toContain('pin overridden'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + + if (spawnSync('sh', ['-c', 'command -v flock'], { timeout: 5_000 }).status !== 0) return; + const lock = path.join(f.host.home, '.gstack/locks/gstack-evals.lock'); + fs.mkdirSync(path.dirname(lock), { recursive: true }); + const holder = spawn('bash', ['-c', 'exec 9>"$1" && flock 9 && exec sleep 300', '_', lock], { stdio: 'ignore' }); + try { + for (let i = 0; i < 50 && spawnSync('flock', ['-n', lock, 'true'], { timeout: 5_000 }).status === 0; i++) spawnSync('sleep', ['0.1']); + const evalRun = f.run(['--for', 'autoplan']); + expect(evalRun.status, evalRun.stdout + evalRun.stderr).toBe(0); + expect(evalRun.stdout).toContain(`SELF_UPGRADE: skipped (a live eval run holds ${lock}, so the upgrade waits for it to finish; fix: ${rerun} once the eval run ends (UPGRADE_BUSY))`); + expect(evalRun.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + } finally { + holder.kill('SIGKILL'); + } + const after = f.run(['--for', 'autoplan']); + expect(after.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + }); + + // Value: protects=a pin with a recorded reason (GSTACK_PIN_REASON, or gstack.pinReason in the checkout's git config) holds a stale checkout and the + // trailer names the reason; fails_when=a deliberate, explained pin is moved; why_new=R75; seam=both reason sources; no_claim=pins below the floor + test('a pin with a recorded reason holds, and the trailer carries the reason', () => { + const f = behindBy(25); + const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off', GSTACK_PIN_REASON: 'bisecting GST-1 at 1.0.1' }); + expect(off.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_SELF_UPGRADE\)$/m); + expect(off.last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h (pinned: bisecting GST-1 at 1.0.1) fix=unset GSTACK_CAPY_SELF_UPGRADE, then ${rerun}`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + f.git(['checkout', '-q', '--detach']); + f.git(['config', 'gstack.pinReason', 'repro for GST-2']); + const detached = f.run(['--for', 'autoplan']); + expect(detached.stdout).toMatch(/^SELF_UPGRADE: skipped \(HEAD is detached .+\)$/m); + expect(detached.last).toContain('stale=25h (pinned: repro for GST-2)'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + }); + + // Value: protects=a pin with no recorded reason does not hold a stale checkout: the install overrides it, says so with PIN_UNREASONED, puts a detached + // checkout back on the default branch and fast-forwards; --check points its fix at that install; fails_when=an unexplained pin keeps a thread on a + // day-old gstack; why_new=R75; seam=GSTACK_CAPY_SELF_UPGRADE=off and a detached HEAD; no_claim=dirty or diverged checkouts (still refused) + test('a pin with no recorded reason is overridden at the boundary and reported; --check names the install as the fix', () => { + const f = behindBy(25); + expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' }).last).toBe(`gstack: ok 1.0.1 for=autoplan behind=1 stale=25h fix=${rerun}`); + const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' }); + expect(off.status, off.stdout + off.stderr).toBe(0); + expect(off.stdout).toContain(`SELF_UPGRADE: pin overridden (GSTACK_CAPY_SELF_UPGRADE=off, with no recorded reason, and the oldest commit this checkout lacks is 25h old; keep a pin with GSTACK_PIN_REASON= or git -C ${f.root} config gstack.pinReason ) (PIN_UNREASONED)`); + expect(off.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + + const d = behindBy(25); + d.git(['checkout', '-q', '--detach']); + const detached = d.run(['--for', 'autoplan']); + expect(detached.status, detached.stdout + detached.stderr).toBe(0); + expect(detached.stdout).toContain(`SELF_UPGRADE: pin overridden (HEAD detached at ${d.b.slice(0, 7)}, with no recorded reason`); + expect(detached.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + expect(d.git(['rev-parse', 'HEAD'])).toBe(d.c); + expect(d.git(['symbolic-ref', '--short', 'HEAD'])).toBe('main'); + }); +}); + +describe('capy skill: R75 edges (review)', () => { + const STALE_INSTALLER = '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan behind=${GSTACK_CAPY_BEHIND:-none} stale=${GSTACK_CAPY_STALE:-none} fix=${GSTACK_CAPY_BEHIND_FIX:-none}"\n'; + const behindBy = (ageHours: number) => { + const f = fixture({ cloneAt: 'B', nextAgeHours: ageHours }); + write(path.join(f.root, 'bin/gstack-capy-install'), STALE_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + return f; + }; + + // Value: protects=an unreasoned branch pin and GSTACK_CAPY_AUTO_UPGRADE=0 are overridden like SELF_UPGRADE=off; fails_when=either pin kind is missed; + // why_new=R75 review F7; seam=fixture; no_claim=none + test('another branch and GSTACK_CAPY_AUTO_UPGRADE=0 are overridden too', () => { + const b = behindBy(25); + b.git(['checkout', '-q', '-b', 'topic']); + const r = b.run(['--for', 'autoplan']); + expect(r.stdout).toContain('SELF_UPGRADE: pin overridden (on branch topic, with no recorded reason'); + expect(b.git(['symbolic-ref', '--short', 'HEAD'])).toBe('main'); + expect(b.git(['rev-parse', 'HEAD'])).toBe(b.c); + const a = behindBy(25); + const s = a.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' }); + expect(s.stdout).toContain('SELF_UPGRADE: pin overridden (GSTACK_CAPY_AUTO_UPGRADE=0, with no recorded reason'); + expect(a.git(['rev-parse', 'HEAD'])).toBe(a.c); + }); + + // Value: protects=the override never switches a checkout under another live session and never onto a diverged local default branch, and HEAD stays + // where it was; fails_when=a refusal leaves gstack switched (R75 review F1, High); why_new=review F1; seam=gstack-lease, a local commit on main; + // no_claim=Bun-hold and setup-lock refusals (they restore through the same path) + test('a foreign lease or a diverged local main: the detached pin is not switched and HEAD stays', () => { + const f = behindBy(25); + f.git(['checkout', '-q', '--detach']); + const leaseDir = path.join(f.host.base, 'leases'); + const holder = spawn('sleep', ['300'], { stdio: 'ignore' }); + try { + expect(spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--pid', String(holder.pid), '--root', f.root], { encoding: 'utf8', env: { ...f.host.env, GSTACK_LEASE_DIR: leaseDir }, timeout: 30_000 }).status).toBe(0); + const r = f.run(['--for', 'autoplan'], { GSTACK_LEASE_DIR: leaseDir }); + expect(r.stdout).toContain('SELF_UPGRADE: skipped (live gstack sessions hold this installation, so the unreasoned pin (HEAD detached at'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + expect(spawnSync('git', ['-C', f.root, 'symbolic-ref', '-q', 'HEAD'], { timeout: 10_000 }).status).not.toBe(0); + } finally { + holder.kill('SIGKILL'); + } + + const d = behindBy(25); + write(path.join(d.root, 'README.md'), 'local work\n'); + d.git(['commit', '-qam', 'local work']); + const local = d.git(['rev-parse', 'HEAD']); + d.git(['checkout', '-q', '--detach', d.b]); + const r = d.run(['--for', 'autoplan']); + expect(r.stdout).not.toContain('pin overridden'); + expect(d.git(['rev-parse', 'HEAD'])).toBe(d.b); + expect(d.git(['rev-parse', 'refs/heads/main'])).toBe(local); + }); + + // Value: protects=a fast-forward refused after the override switched a pin puts the pin back: a detached HEAD returns to its commit with no stray + // default branch, another branch returns to that branch; fails_when=a refusal leaves the checkout switched or keeps a created main; why_new=/ship + // review specialist-batched#1; seam=a held $GSTACK_STATE_ROOT/.setup-lock; no_claim=the trailer's fix target after the rollback + test('a fast-forward refused after the override switch restores the pin', () => { + const held = (f: ReturnType) => { + const stateRoot = spawnSync('bash', [path.join(f.root, 'bin/gstack-paths'), '--get', 'GSTACK_STATE_ROOT'], { encoding: 'utf8', env: f.host.env, timeout: 10_000 }).stdout.trim(); + expect(path.resolve(stateRoot).startsWith(path.resolve(f.host.base) + path.sep)).toBe(true); + fs.mkdirSync(path.join(stateRoot, '.setup-lock'), { recursive: true }); + }; + const d = behindBy(25); + d.git(['checkout', '-q', '--detach']); + d.git(['branch', '-q', '-D', 'main']); + held(d); + const detached = d.run(['--for', 'autoplan']); + expect(detached.stdout).toContain('SELF_UPGRADE: pin kept'); + expect(detached.stdout).not.toContain('pin overridden'); + expect(d.git(['rev-parse', 'HEAD'])).toBe(d.b); + expect(spawnSync('git', ['-C', d.root, 'symbolic-ref', '-q', 'HEAD'], { timeout: 10_000 }).status).not.toBe(0); + expect(spawnSync('git', ['-C', d.root, 'show-ref', '--verify', '-q', 'refs/heads/main'], { timeout: 10_000 }).status).not.toBe(0); + + const t = behindBy(25); + t.git(['checkout', '-q', '-b', 'topic']); + held(t); + const topic = t.run(['--for', 'autoplan']); + expect(topic.stdout).toContain('SELF_UPGRADE: pin kept (the fast-forward was refused, so HEAD is back at topic)'); + expect(t.git(['symbolic-ref', '--short', 'HEAD'])).toBe('topic'); + expect(t.git(['rev-parse', 'HEAD'])).toBe(t.b); + }); + + // Value: protects=an explicit --revision counts as a recorded reason, a reason cannot break the trailer grammar, a review-voice reader is told the + // lane's install moves the shared checkout, and the stable channel is not measured against main; fails_when=any of those regress; why_new=review + // F2/F4/F5/F6; seam=fixture; no_claim=none + test('--revision is a reason; a reason is sanitized; review-voice and the stable channel get no fix-before-workflow stale note', () => { + const f = behindBy(25); + f.git(['checkout', '-q', '--detach']); + const rev = f.run(['--for', 'autoplan', '--revision', f.b]); + expect(rev.stdout).not.toContain('pin overridden'); + expect(rev.last).toContain(`stale=25h (pinned: --revision ${f.b})`); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + const evil = f.run(['--check', '--for', 'autoplan'], { GSTACK_PIN_REASON: 'x; fix: rm -rf /\nmore' }); + expect(evil.last).toContain('stale=25h (pinned: x fix: rm -rf / more)'); + expect(evil.last).not.toContain('; fix: rm'); + f.git(['checkout', '-q', 'main']); + const voice = f.run(['--check', '--for', 'review-voice']); + expect(voice.last).toContain("stale=25h (shared checkout: the lane's install moves it)"); + expect(voice.stdout).not.toContain('run the fix before the first workflow'); + expect(f.run(['--check', '--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' }).last).toContain('stale=none'); + }); + + // Value: protects=a failed fetch with no successful one in 24 hours is reported from gstack's own success stamp (git empties FETCH_HEAD and resets its + // mtime on failure); fails_when=a dead network hides a day-old checkout; why_new=review F3; seam=the stamp's mtime and a broken origin; no_claim=none + test('no successful fetch in 24 hours: stale=no fetch from origin in h', () => { + const f = behindBy(1); + expect(f.run(['--check', '--for', 'autoplan']).last).toContain('stale=none'); + const stamp = path.join(f.root, '.git/gstack-last-fetch'); + expect(fs.existsSync(stamp)).toBe(true); + const t = Date.now() / 1000 - 25 * 3600 - 60; + fs.utimesSync(stamp, t, t); + f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/gone.git`]); + const r = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REPO: `file://${f.host.base}/gone.git` }); + expect(r.stdout).toContain('remote minimum unknown (fetch failed)'); + expect(r.last).toContain('stale=no fetch from origin in 25h'); + }); +}); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 69ea5ac58..6d1702654 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -307,6 +307,17 @@ describe('gstack-capy-install', () => { expect(lastLine(run(f, [], { ...behind, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex'); }); + // Value: protects=the real installer renders GSTACK_CAPY_STALE as stale= right after behind=, and on its own (no fetch) with a network fix and no + // "; " inside the fix; fails_when=the note is dropped, misplaced, or breaks the trailer grammar; why_new=R75 (GST-1642) review F7; seam=env; + // no_claim=the session's computation (capy-skill-wrapper.test.ts) + test('trailer grammar: stale= follows behind=, or stands alone with a network fix when no fetch succeeded in 24 hours', () => { + const f = fixture(); + const behind = { GSTACK_CAPY_BEHIND: '5', GSTACK_CAPY_BEHIND_TO: '1.2.8', GSTACK_CAPY_BEHIND_FIX: 'bash /s/install.sh --for autoplan', GSTACK_CAPY_STALE: '30h' }; + expect(lastLine(run(f, [], behind).stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=5 (latest: 1.2.8); stale=30h; fix: bash /s/install.sh --for autoplan)'); + const nofetch = run(f, ['--for', 'autoplan'], { GSTACK_CAPY_STALE: 'no fetch from origin in 26h', GSTACK_CAPY_WRAPPER_PATH: '/s/install.sh' }); + expect(lastLine(nofetch.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (stale=no fetch from origin in 26h; fix: check network access to origin, then bash /s/install.sh --for autoplan)'); + }); + test('a skipped session-start upgrade above the minimum is a WARN, never a fail: the trailer stays ok with behind=, the skip reason and the fix, exit 0, and a WARN behind row names the code', () => { const f = fixture(); const busy = { GSTACK_CAPY_BEHIND: '3', GSTACK_CAPY_BEHIND_TO: '1.2.6', GSTACK_CAPY_BEHIND_FIX: `${f.root}/bin/gstack-upgrade`, GSTACK_CAPY_BEHIND_BLOCKED: 'live gstack sessions hold this installation: pid 379 (124m dbus-daemon); fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)' }; 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..6929fa7a9 100644 --- a/test/update-channel.test.ts +++ b/test/update-channel.test.ts @@ -372,3 +372,86 @@ 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: '', CAPY_MACHINE_CONFIG: '', ...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'); + expect(c.initial(shallow, { CAPY_MACHINE_CONFIG: '/etc/capy.json' })).toBe('main Capy machine: crews follow every merge'); + fs.mkdirSync(path.join(c.home, '.capy', 'system'), { recursive: true }); + expect(c.initial(shallow)).toBe('main Capy machine: crews follow every merge'); + fs.rmSync(path.join(c.home, '.capy'), { recursive: true }); + const usage = spawnSync('bash', [HELPER, '--initial'], { encoding: 'utf8', timeout: 10_000 }); + expect([usage.status, usage.stderr.trim()]).toEqual([2, 'usage: gstack-update-channel.sh --initial []']); + }); + + 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'), path.join(c.home, '.gstack', 'config.yaml')]) { + 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'); + }); +});