diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 000000000..782679ce5 --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,71 @@ +name: Weekly release +# Tags the newest green commit on main as v, publishes the GitHub +# Release, and fast-forwards refs/heads/stable to it (docs/releases.md). Team +# installs follow stable, so this is the only thing that moves them. Runs +# every Monday morning Pacific; run it by hand with a ref for a hotfix, or +# with dry_run to see the plan and change nothing. The candidate's gate must +# already be green (free-tests, no red check run, a green Main backstop gate +# run, no active freeze): this workflow never runs tests or paid evals. +on: + schedule: + # Monday 14:00 UTC: 07:00 PDT, 06:00 PST. + - cron: '0 14 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: 'Print the plan (candidate, tag, stable move, notes) and change nothing' + type: boolean + default: true + ref: + description: 'Hotfix: release this commit of main instead of the newest green one (full SHA)' + type: string + default: '' + +# One cut at a time; a second dispatch waits for the first. +concurrency: + group: weekly-release + cancel-in-progress: false + +permissions: {} + +jobs: + cut: + name: Cut the release + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + # The tag, the stable branch and the release. + contents: write + # Reads check runs and backstop runs of the candidate. + actions: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: main + fetch-depth: 0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Fetch stable and the tags + run: | + set -euo pipefail + git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet: this cut creates it" + git fetch --quiet --tags origin + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Plan and cut + env: + GH_TOKEN: ${{ github.token }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} + RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + run: | + set -euo pipefail + args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY") + [ "$DRY_RUN" = true ] && args+=(--dry-run) + [ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF") + echo "## Weekly release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'))" >> "$GITHUB_STEP_SUMMARY" + bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ba97bc0e7..390d7fade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [Unreleased] + +**Weekly tagged releases, and a `stable` channel that team-mode auto-update follows by default.** Main kept stamping a version per merge, a dozen a day, with no tag or release anywhere, and `./setup --team` installs moved to every one of them within the hour. Now a Monday-morning workflow tags the newest green commit on main as `v`, publishes a GitHub Release from the CHANGELOG headlines and fast-forwards a `stable` branch to it; team installs follow `stable` and move once a week (plus hotfixes), contributors keep following main, and one config key switches either way. Nothing about per-merge stamping or the merge train changed (docs/releases.md). + +### Added +- **`.github/workflows/weekly-release.yml`**, **`scripts/release-cut.ts`**: every Monday at 14:00 UTC, or by `workflow_dispatch` (`dry_run`, default true for a manual run; `ref` for a hotfix), the newest first-parent commit on main whose `free-tests` check run succeeded, with no red check run, a Main backstop gate run that concluded success and no active freeze in `refs/notes/gstack-trains`, is tagged `v`, released with one headline per CHANGELOG version since the previous release (capped at 60 KB), and becomes `refs/heads/stable`. Stable only moves forward along main; a candidate behind it, a tag naming another commit, or a frozen queue is `REFUSED`. A dry run prints the plan and the exact commands and changes nothing. The job has `contents: write` only; both rulesets cover `refs/heads/main` alone, so the token can push the tag and `stable`. +- **`bin/gstack-update-channel.sh`**: resolves the channel (`gstack_update_channel`: `update_channel` `auto` is `stable` when `team_mode` is true, else `main`; `GSTACK_UPDATE_CHANNEL` overrides), and as a command (`--fetch-ref`) fetches the channel ref and prints `origin/stable` or `origin/main` for the /gstack-upgrade skill. +- **`bin/gstack-config`**: new key `update_channel` (`auto` | `stable` | `main`), validated on `set`, in the header, `list` and `defaults`. +- **`docs/releases.md`**: what a release is, what green means, the channels and how to switch, hotfixes, dry runs and every outcome line. + +### Changed +- **`bin/gstack-session-update`**: on the stable channel it fetches `+refs/heads/stable:refs/remotes/origin/stable` (team installs are single-branch clones) and fast-forwards to it, logging `CHANNEL stable target=`. Before the first release exists it follows main (`CHANNEL_FALLBACK stable->main`). An install already past the release stays put (`CHANNEL stable ahead_of_release`, then `UP_TO_DATE`): a depth-1 clone cannot relate the two commits, so the VERSIONs decide. The main channel's fetch, target and log lines are unchanged. +- **`bin/gstack-update-check`**: reads `/VERSION`, resolves the SHA with `ls-remote refs/heads/` and compares against it; the cache identity carries `#stable` so a channel switch never replays the other channel's outcome. +- **`bin/gstack-upgrade`**: fetches and fast-forwards to `stable` on that channel (`gstack-upgrade: channel stable ...` on stderr), follows the default branch with a note when origin has no `stable` yet, and treats a release whose VERSION is not higher than the installed one as current instead of diverged in a shallow clone. `--check` output lines are unchanged. +- **`gstack-upgrade/SKILL.md.tmpl`**: Step 4 resolves `UPSTREAM_REF` through the helper and uses it for the Bun-floor check, the fast-forward, the unpushed-commit gate and the reset fallback instead of a literal `origin/main`. +- **`setup --team`** prints the channel it enabled and the one-line opt-out; README's team-mode step says team mode follows `stable`. + +### Tests +- `test/release-cut.test.ts`: on a real bare origin with a fake `gh`, the newest green first-parent commit wins and every skip is named (pending backstop, red check run, missing or running `free-tests`, no backstop run); an active freeze refuses and a cleared one does not; a cut creates the annotated tag, `stable` and the release with `--verify-tag` and the headline in the notes; re-running is `NOTHING_NEW`; a hotfix `--ref` promotes a later main commit with a compare link; stable never moves backwards; a side-branch ref and a clashing tag are refused; a thousand-version span stays under the notes cap. +- `test/update-channel.test.ts`: the resolution matrix (team mode × key × env override); the session-start hook on a depth-1 team install moves to stable (B) not main (C), follows main on `update_channel=main`, falls back to main with no stable on origin, and stays put when already past the release with nothing pending; `gstack-upgrade --check` and the upgrade follow stable then main after the key flips, say when no release exists, and call a depth-1 install past the release current; `gstack-update-check` on stable stays silent while main is ahead, asks `ls-remote` for `refs/heads/stable`, and carries the channel in its cache identity. +- `test/upgrade-template-pins.test.ts`, `test/gstack-upgrade-fresh-shell.test.ts`: the pinned fast-forward, unpushed-commit gate and reset markers name `"$UPSTREAM_REF"` instead of `origin/main`; the ordering invariants are unchanged. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion changed. The release workflow reads check runs, backstop runs and the trains notes ref; it dispatches nothing and runs no tests or evals. + ## [1.91.221.0] - 2026-10-25 **The session-start updater no longer skips because of its own session's workflow lease, and the autostash tests stop failing their first pass in Free Tests.** Since #3385, `gstack-session-update` skips the fast-forward while another session holds a workflow lease, and it means to ignore the session that started it (`--exclude-ancestry-of`). The check runs in the forked background updater, though, and passed the hook's own `$$`, which has exited by then, so the ancestry walk excluded nothing. A Claude Code process that had already run a skill (and so held a lease) and then started a session again in the same process (`/clear`, resume or compaction) logged `SKIP leased pid=` and never updated. `test/session-update-autostash.test.ts` hit the same gap plus a fixture leak: its first two fixtures spawned the updater with the whole parent environment, so it read the test preload's `GSTACK_LEASE_DIR`, which every test file in the same Bun process shares. A lease another file left there gave "Received: SKIP leased pid=3196/1m" where UPDATING or PULL_FAILED was expected, in 13 Free Tests runs since #3385 merged (each passed on the serial retry). diff --git a/README.md b/README.md index 213da088e..7195c1ea0 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,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). +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 weekly tagged release (Monday mornings, plus off-cycle hotfixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-config b/bin/gstack-config index 163b1b4dd..85d843c91 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -80,6 +80,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # ─── Updates ───────────────────────────────────────────────────────── # auto_upgrade: false # true = silently upgrade on session start # update_check: true # false = suppress version check notifications +# update_channel: auto # auto = stable when team_mode is true, else main +# # stable = the weekly tagged release (refs/heads/stable) +# # main = every merge to main (docs/releases.md) # # ─── Skill naming ──────────────────────────────────────────────────── # skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship @@ -284,6 +287,7 @@ lookup_default() { telemetry) echo "off" ;; auto_upgrade) echo "false" ;; update_check) echo "true" ;; + update_channel) echo "auto" ;; # auto | stable | main — bin/gstack-update-channel.sh resolves auto (docs/releases.md) skill_prefix) echo "false" ;; explain_level) echo "default" ;; plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168) @@ -841,6 +845,11 @@ $_gstack_legacy_root" echo "Error: $KEY '$VALUE' not recognized. Valid values: local, ubicloud. Existing value left unchanged." >&2 exit 1 fi ;; + update_channel) + case "$VALUE" in auto|stable|main) ;; *) + echo "Error: update_channel '$VALUE' not recognized. Valid values: auto, stable, main. Existing value left unchanged. (https://github.com/garrytan/gstack/blob/main/docs/releases.md#channels)" >&2 + exit 1 ;; + esac ;; esac # design_detector_install_prompted records "never ask again" for the one-time # engine install offer. Rejecting a typo keeps the offer from silently coming @@ -1025,7 +1034,7 @@ $_gstack_legacy_root" echo "" echo "# ─── Active values (including defaults for unset keys) ───" report_root_disagreement - for KEY in proactive routing_declined telemetry auto_upgrade update_check \ + for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \ skill_prefix explain_level plan_mode_preamble \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ @@ -1050,7 +1059,7 @@ $_gstack_legacy_root" ;; defaults) echo "# gstack-config defaults" - for KEY in proactive routing_declined telemetry auto_upgrade update_check \ + for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \ skill_prefix explain_level plan_mode_preamble \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ diff --git a/bin/gstack-session-update b/bin/gstack-session-update index a3fa3631c..4722cf0eb 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -24,6 +24,13 @@ # the release are shelved in refs/gstack/rescue/, never left as conflict # markers, and the next session start names them (at most once a day). # +# Channel (bin/gstack-update-channel.sh, docs/releases.md): a team install +# follows refs/heads/stable, the weekly tagged release, so it moves once a +# week (plus hotfixes) instead of at every merge; update_channel=main follows +# every merge as before. Until the first release is cut, origin has no stable +# and the updater follows main (CHANNEL_FALLBACK in the log). An install +# already past the release waits for stable to catch up; nothing moves back. +# # Stages (B11): pull, setup and migrations are tracked separately. The # "just upgraded" marker is written only when every stage succeeded. A failed # stage is persisted in $STATE_DIR/session-update-pending and retried even @@ -47,6 +54,14 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" # Bun floor comparison (E1): the incoming release's floor is applied before # the live checkout moves. Missing helper fails open, like every error here. . "$(cd "$(dirname "$0")" && pwd)/gstack-bun-version.sh" 2>/dev/null +# Update channel (docs/releases.md): a team install follows refs/heads/stable, +# the weekly tagged release, unless update_channel says main. A missing +# helper means main, the behaviour before channels existed. +. "$(cd "$(dirname "$0")" && pwd)/gstack-update-channel.sh" 2>/dev/null || { + gstack_update_channel() { echo main; } + gstack_update_channel_refspec() { :; } + gstack_update_channel_missing() { return 1; } +} THROTTLE_FILE="$STATE_DIR/.last-session-update" LOCK_DIR="$STATE_DIR/.setup-lock" LOG_FILE="$STATE_DIR/analytics/session-update.log" @@ -162,6 +177,25 @@ rescue_note() { grep -q "^$1 " "$RESCUE_FILE" 2>/dev/null && return 0 printf '%s %s -\n' "$1" "$2" >> "$RESCUE_FILE" 2>/dev/null } +# channel_behind_release — true when the release commit is at +# or behind this install's head: an ancestor of it, or, when the shallow +# history cannot relate the two, a VERSION that is not higher. +channel_behind_release() { + git -C "$GSTACK_DIR" merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0 + git -C "$GSTACK_DIR" merge-base --is-ancestor "$2" "$1" 2>/dev/null && return 1 + _cb_rel=$(git -C "$GSTACK_DIR" show "$1:VERSION" 2>/dev/null | head -1) + _cb_head=$(git -C "$GSTACK_DIR" show "$2:VERSION" 2>/dev/null | head -1) + [ -n "$_cb_rel" ] && [ -n "$_cb_head" ] || return 1 + _cb_i=1 + while :; do + _cb_a=$(printf '%s' "$_cb_rel" | cut -d. -f"$_cb_i"); _cb_b=$(printf '%s' "$_cb_head" | cut -d. -f"$_cb_i") + [ -z "$_cb_a" ] && [ -z "$_cb_b" ] && return 0 + case "${_cb_a:-0}${_cb_b:-0}" in *[!0-9]*) return 1 ;; esac + [ "${_cb_a:-0}" -lt "${_cb_b:-0}" ] && return 0 + [ "${_cb_a:-0}" -gt "${_cb_b:-0}" ] && return 1 + _cb_i=$((_cb_i + 1)) + done +} pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; } # pending_save — persist the P_* stage state; nothing pending removes the file. pending_save() { @@ -392,15 +426,47 @@ CALLER_PID=$(gstack_harness_pid "$PPID") # bin/gstack-ff-update carries them over the release instead. # Capture stderr: the log must carry WHY a pull failed, never just the code. PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "") - GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ - bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" - PULL_EXIT=$? + # The channel decides which ref is fetched and followed. stable is fetched + # by refspec: team installs are single-branch clones, whose plain fetch + # brings main only. Before the first release is cut origin has no stable, + # and the updater follows main instead of standing still. + CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main + PULL_EXIT=0 + if [ "$CHANNEL" = stable ]; then + GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ + bash -c 'git -C "$1" fetch -q origin "$3" 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" "$(gstack_update_channel_refspec stable)" + PULL_EXIT=$? + if [ "$PULL_EXIT" -ne 0 ] && gstack_update_channel_missing "$(cat "${PULL_ERR_FILE:-/dev/null}" 2>/dev/null)"; then + log_entry "CHANNEL_FALLBACK stable->main reason=no-stable-on-origin" + CHANNEL=main + fi + fi + if [ "$CHANNEL" = main ]; then + GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ + bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" + PULL_EXIT=$? + fi # The fast-forward targets the exact revision whose requirement was read. # Setup runs `bun` from this PATH, so that is the binary checked; a # version it cannot read holds too (bun-version-unreadable, #3195). INCOMING="" HELD="" if [ "$PULL_EXIT" -eq 0 ]; then - INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 + if [ "$CHANNEL" = stable ]; then + INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify 'refs/remotes/origin/stable^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 + # An install ahead of the release (it followed main until now) waits + # for stable to pass it; the updater never moves a checkout backwards. + # A depth-1 clone cannot see that an older release is its ancestor, so + # when the histories look unrelated the VERSIONs decide: main stamps a + # higher version at every merge, and stable is always a main commit. + if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ] && channel_behind_release "$INCOMING" "$OLD_HEAD"; then + log_entry "CHANNEL stable ahead_of_release head=$OLD_HEAD stable=$INCOMING" + INCOMING=$OLD_HEAD + elif [ "$PULL_EXIT" -eq 0 ]; then + log_entry "CHANNEL stable target=$INCOMING" + fi + else + INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 + fi fi if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ]; then HELD=$(gstack_bun_incoming_hold "$GSTACK_DIR" "$INCOMING" 2>/dev/null) || HELD="" diff --git a/bin/gstack-update-channel.sh b/bin/gstack-update-channel.sh new file mode 100755 index 000000000..97df4ba2a --- /dev/null +++ b/bin/gstack-update-channel.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# gstack-update-channel.sh — which branch of garrytan/gstack an install follows. +# +# Sourced by bin/gstack-session-update, bin/gstack-update-check and +# bin/gstack-upgrade; runnable by the /gstack-upgrade skill. Bash 3.2 plus git. +# +# Channels (docs/releases.md): +# main every merge to main (a version per merge, a dozen a day) +# stable the weekly tagged release: refs/heads/stable on origin, moved only +# 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. +# +# Functions (never fail; a broken config means main, the behaviour before +# channels existed): +# gstack_update_channel prints stable | main +# gstack_update_channel_ref prints the remote-tracking ref: +# refs/remotes/origin/stable, or +# "" for main (callers keep their +# @{upstream} / default-branch path) +# gstack_update_channel_refspec prints the fetch refspec for +# stable, "" for main +# gstack_update_channel_missing true when a fetch failed only +# because origin has no such ref +# (no release cut yet): callers +# fall back to main +# +# CLI (the /gstack-upgrade skill): `bash bin/gstack-update-channel.sh [--fetch-ref] []` +# 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 when origin has no stable yet. + +gstack_update_channel() { + _uc_dir=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)} + _uc_value=${GSTACK_UPDATE_CHANNEL:-} + if [ -z "$_uc_value" ] && [ -x "$_uc_dir/bin/gstack-config" ]; then + _uc_value=$("$_uc_dir/bin/gstack-config" get update_channel 2>/dev/null) || _uc_value="" + fi + case "$_uc_value" in + stable|main) printf '%s\n' "$_uc_value"; return 0 ;; + esac + _uc_team="" + [ -x "$_uc_dir/bin/gstack-config" ] && { _uc_team=$("$_uc_dir/bin/gstack-config" get team_mode 2>/dev/null) || _uc_team=""; } + if [ "$_uc_team" = true ]; then printf 'stable\n'; else printf 'main\n'; fi +} + +gstack_update_channel_ref() { + case "${1:-}" in stable) printf 'refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac +} + +gstack_update_channel_refspec() { + case "${1:-}" in stable) printf '+refs/heads/stable:refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac +} + +gstack_update_channel_missing() { + printf '%s' "${1:-}" | grep -qiE "couldn't find remote ref|no such ref was fetched|invalid refspec" 2>/dev/null +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + _uc_fetch=0 _uc_arg="" + 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 ;; + *) _uc_arg=$_uc_a ;; + esac + done + _uc_root=${_uc_arg:-$(cd "$(dirname "$0")/.." && pwd)} + _uc_ch=$(gstack_update_channel "$_uc_root") + if [ "$_uc_fetch" -eq 0 ]; then printf '%s\n' "$_uc_ch"; exit 0; fi + # Egress receipts for the fetches below, fail-open like /gstack-upgrade's own. + . "$(dirname "$0")/gstack-egress-lib.sh" 2>/dev/null || _receipted_git() { shift 5; "$@"; } + _uc_host=$(git -C "$_uc_root" remote get-url origin 2>/dev/null); _uc_host=${_uc_host#*://}; _uc_host=${_uc_host#*@}; _uc_host=${_uc_host%%[/:]*} + export GIT_TERMINAL_PROMPT=0 + if [ "$_uc_ch" = stable ]; then + _uc_err=$(_receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin "$(gstack_update_channel_refspec stable)" 2>&1 >/dev/null) \ + && { printf 'origin/stable\n'; exit 0; } + if gstack_update_channel_missing "$_uc_err"; then + echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2 + else + echo "gstack-update-channel: fetching origin/stable failed ($(printf '%s' "$_uc_err" | head -1 | head -c 200)); following origin/main for this run" >&2 + fi + fi + _receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin 2>/dev/null || true + printf 'origin/main\n' +fi diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 6c0d65a69..8d1b55af2 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -5,7 +5,9 @@ # JUST_UPGRADED — marker found from recent upgrade # UPGRADE_AVAILABLE — remote VERSION differs from local # UPGRADE_AVAILABLE + -# — VERSION is equal but main moved +# — VERSION is equal but the channel branch +# (main, or stable for a team install: +# bin/gstack-update-channel.sh) moved # commits past this checkout's HEAD (the # GitHub compare API says so); the snooze # is keyed by @@ -74,7 +76,13 @@ CACHE_FILE="$STATE_DIR/last-update-check" MARKER_FILE="$STATE_DIR/just-upgraded-from" SNOOZE_FILE="$STATE_DIR/update-snoozed" VERSION_FILE="$GSTACK_DIR/VERSION" -REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}" +# Channel (bin/gstack-update-channel.sh, docs/releases.md): a stable install +# compares with refs/heads/stable, the weekly release, so it is not told every +# hour that main moved. A missing helper means main. +. "$GSTACK_DIR/bin/gstack-update-channel.sh" 2>/dev/null || gstack_update_channel() { echo main; } +CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main +case "$CHANNEL" in stable|main) ;; *) CHANNEL=main ;; esac +REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/$CHANNEL/VERSION}" REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}" REMOTE_RAW_BASE="${GSTACK_REMOTE_RAW_BASE:-https://raw.githubusercontent.com/garrytan/gstack}" COMPARE_API="${GSTACK_COMPARE_API:-https://api.github.com/repos/garrytan/gstack/compare}" @@ -99,6 +107,8 @@ if [ -e "$GSTACK_DIR/.git" ]; then if echo "$_H" | grep -qE '^[0-9a-f]{40}$'; then LOCAL_HEAD="$_H"; fi fi if [ -n "${GSTACK_REMOTE_URL:-}" ]; then UPSTREAM="$(_display_url "$REMOTE_URL")"; else UPSTREAM="$(_display_url "$REMOTE_REPO")"; fi +# A channel switch is a new upstream: the cached outcome of the other channel never replays. +[ "$CHANNEL" = stable ] && UPSTREAM="$UPSTREAM#stable" # _identity_field — a field of the cache's IDENTITY line (empty when absent). _identity_field() { @@ -311,7 +321,7 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then _REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}" _LSR_LINE="$(GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \ bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ - git ls-remote "$1" refs/heads/main 2>/dev/null' _ "$REMOTE_REPO" || true)" + git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$CHANNEL" || true)" _REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')" if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then # The same main SHA, HEAD and upstream as the cache: its outcome still holds. diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 2a5f1c514..31d579fa9 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# gstack-upgrade — move this gstack checkout to the tip of its remote default -# branch, unattended and fast-forward only. The deterministic twin of the -# /gstack-upgrade skill's git fast path, for parent agents and fix lines. +# gstack-upgrade — move this gstack checkout to the tip of its update channel +# (the remote default branch, or refs/heads/stable, the weekly release, for a +# team install: bin/gstack-update-channel.sh), unattended and fast-forward +# only. The deterministic twin of the /gstack-upgrade skill's git fast path, +# for parent agents and fix lines. # # Usage: # gstack-upgrade --check fetch (refs only) and print `gstack-upgrade: current ` @@ -76,7 +78,25 @@ BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD) \ DEFAULT=$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote --symref origin HEAD 2>/dev/null | sed -n 's|^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$|\1|p' | head -1) [ -n "$DEFAULT" ] || fetch_failed "git ls-remote origin HEAD failed" [ "$BRANCH" = "$DEFAULT" ] || refuse "on branch $BRANCH, not the remote default branch $DEFAULT (a pin)" "git -C $ROOT checkout $DEFAULT, then re-run" -_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin "$DEFAULT" 2>/dev/null || fetch_failed "git fetch origin $DEFAULT failed" +# Channel (bin/gstack-update-channel.sh, docs/releases.md): a team install +# follows refs/heads/stable, the weekly release, which is always a commit on +# the default branch's history, so the fast-forward rules below are unchanged. +# Before the first release is cut origin has no stable, and main is followed. +# shellcheck source=bin/gstack-update-channel.sh +. "$ROOT/bin/gstack-update-channel.sh" 2>/dev/null || gstack_update_channel() { echo main; } +CHANNEL=$(gstack_update_channel "$ROOT" 2>/dev/null) || CHANNEL=main +FOLLOW=$DEFAULT +if [ "$CHANNEL" = stable ]; then + if _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin stable 2>/dev/null; then + FOLLOW=stable + echo "gstack-upgrade: channel stable (the weekly release; gstack-config set update_channel main follows every merge)" >&2 + elif [ -z "$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote origin refs/heads/stable 2>/dev/null)" ]; then + echo "gstack-upgrade: channel stable has no release on origin yet; following $DEFAULT until the first weekly release is cut" >&2 + else + fetch_failed "git fetch origin stable failed" + fi +fi +[ "$FOLLOW" = stable ] || _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin "$DEFAULT" 2>/dev/null || fetch_failed "git fetch origin $DEFAULT failed" INCOMING=$(git -C "$ROOT" rev-parse FETCH_HEAD) REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending" @@ -148,7 +168,23 @@ reinstall() { # FROM — run the install step; a pending marker survives a failu echo "gstack-upgrade: the checkout is at $(head -1 "$ROOT/VERSION") but its reinstall failed (exit $_rc); fix: re-run $ROOT/bin/gstack-upgrade (it completes the pending reinstall)" >&2 exit 1 } -if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null; then +# ver_le A B — version A is not higher than B (numeric, part by part). A +# depth-1 clone cannot see that an older release is its ancestor; main stamps +# a higher VERSION at every merge and stable is always a main commit, so a +# release whose VERSION is not higher is at or behind this install. +ver_le() { + _vl_i=1 + while :; do + _vl_a=$(printf '%s' "$1" | cut -d. -f"$_vl_i"); _vl_b=$(printf '%s' "$2" | cut -d. -f"$_vl_i") + [ -z "$_vl_a" ] && [ -z "$_vl_b" ] && return 0 + case "${_vl_a:-0}${_vl_b:-0}" in *[!0-9]*) return 1 ;; esac + [ "${_vl_a:-0}" -lt "${_vl_b:-0}" ] && return 0 + [ "${_vl_a:-0}" -gt "${_vl_b:-0}" ] && return 1 + _vl_i=$((_vl_i + 1)) + done +} +if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null \ + || { [ "$FOLLOW" = stable ] && ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null && [ -n "$REMOTE_VER" ] && ver_le "$REMOTE_VER" "$INSTALLED"; }; then if [ -f "$PENDING" ]; then echo "gstack-upgrade: current $INSTALLED; reinstall pending from $(head -1 "$PENDING")" [ "$CHECK" -eq 1 ] && exit 1 @@ -166,9 +202,9 @@ fi DIRTY=$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null | cut -c4- | tr '\n' ' ' | sed 's/ $//') [ -z "$DIRTY" ] || refuse "tracked files changed: $DIRTY" "commit or move those changes (git -C $ROOT status --short), then re-run" if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then - _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" git -C "$ROOT" fetch --quiet --unshallow origin "$DEFAULT" 2>/dev/null || true + _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" git -C "$ROOT" fetch --quiet --unshallow origin "$FOLLOW" 2>/dev/null || true git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \ - || refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$DEFAULT" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run" + || refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$FOLLOW" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run" fi # Workflow leases (bin/gstack-lease): sessions sharing this installation that # are alive and not this process's own chain. Moving the checkout under them diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..19fde4514 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,126 @@ +# Releases and update channels + +gstack stamps a version at every merge to main, about a dozen a day. Since +late October 2026 there is also a weekly tagged release, and team-mode +installs follow it instead of every merge. + +## What a release is + +Every Monday at 07:00 Pacific (06:00 in winter) the **Weekly release** +workflow (`.github/workflows/weekly-release.yml`, which runs +`scripts/release-cut.ts`) takes the newest green commit on main and: + +1. tags it `v` (the VERSION file at that commit, an annotated tag); +2. publishes a GitHub Release whose notes are one line per CHANGELOG version + since the previous release, with a compare link and a link to the full + CHANGELOG at that tag; +3. fast-forwards the `stable` branch to it. + +A tag and its release never change. `stable` is a moving pointer: always a +commit on main's first-parent history, moved only by this workflow, only +forward. + +**Green** means, on that exact commit: the `free-tests` check run succeeded +(the status main requires), no completed check run failed, the Main backstop +gate run for that push completed with conclusion success (a green train record +covers the tree, or the backstop gated it green), and `refs/notes/gstack-trains` +holds no active freeze. A commit whose gate is still running is skipped and the +next older one is considered, back through 7 days or 200 first-parent commits. +The workflow runs no tests and no paid evals of its own; it only reads what the +trains and the backstop already recorded. With nothing green in the window it +fails and lists why each commit was skipped. + +## Channels + +| Channel | Follows | Who | +|---|---|---| +| `stable` | `refs/heads/stable`: the weekly release, plus any hotfix cut off-cycle | team mode (`./setup --team`) by default | +| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines | + +The config key is `update_channel` (`bin/gstack-config`): + +```bash +gstack-config get update_channel # auto (default) | stable | main +gstack-config set update_channel main # a team install that wants every merge +gstack-config set update_channel stable # a solo install that wants weekly releases +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 weekly 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, +`bin/gstack-update-channel.sh`, used by: + +- `bin/gstack-session-update`, the team-mode session-start hook: it fetches + the channel ref and fast-forwards to it. Its log + (`/analytics/session-update.log`) carries `CHANNEL stable target=` + when it follows a release. +- `bin/gstack-update-check`, the "update available" line in every skill + preamble: it reads `/VERSION` and compares with `refs/heads/`, + so a stable install is not told every hour that main moved. +- `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. + +Two edge cases are handled on purpose: + +- **Before the first release exists** origin has no `stable`. Every caller then + follows main and says so (`CHANNEL_FALLBACK stable->main` in the hook log, + `channel stable has no release on origin yet` from `gstack-upgrade`). +- **An install already past the release** (it followed main until now, or a + hotfix was cut below it) stays where it is until stable passes it. The + updater never moves a checkout backwards. A depth-1 clone cannot see that an + older release is its ancestor, so the VERSIONs decide: main stamps a higher + version at every merge and stable is always a main commit. + +The local branch stays `main` either way. A stable install is a `main` +checkout parked at the last released commit, which is why fast-forward-only +still holds and nothing about version stamping changed. + +## Cutting a release by hand (hotfixes) + +A hotfix is an ordinary PR: it merges to main through a train and gets its +version like any other change. To put it on stable before Monday, run the +workflow by hand with the merge commit's SHA: + +```bash +gh workflow run weekly-release.yml -f ref= -f dry_run=false +``` + +or Actions → **Weekly release** → Run workflow. The same green checks apply. +The commit is tagged with its VERSION, the release notes say "hotfix release" +and list every version since the previous release (everything merged in +between ships with it), and stable fast-forwards. Team installs pick it up at +their next hourly check. + +There is no release branch and no cherry-picking: stable only ever names main +commits. If main carries a change that must not ship yet, revert it on main +first, then promote. + +## Dry runs + +Run the workflow with `dry_run=true` (the default for a manual run) to see the +plan without changing anything: the candidate, its VERSION and tag, the stable +move, how many version lines the notes would carry, every skipped commit with +its reason, and the exact commands a real cut would run. The scheduled Monday +run is never a dry run. + +```bash +gh workflow run weekly-release.yml -f dry_run=true +gh run watch +``` + +The step summary carries the same plan plus the full release notes. + +## Outcomes + +| Line | Meaning | +|---|---| +| `CANDIDATE: ()` | the commit that will be (or was) released | +| `NOTHING_NEW: origin/main is already stable` | nothing merged since the last release; exit 0 | +| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 | +| `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward | +| `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) | +| `REFUSED: tag v already names ` | a version was stamped twice; needs a look before anything is cut | +| `TAG:`, `STABLE:`, `RELEASE:` | what the cut did; a re-run reports each step as already done | diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 2af64b6ce..59e72163f 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -135,12 +135,17 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin +# The update channel (docs/releases.md): origin/stable, the weekly release, for +# a team install; origin/main otherwise. The helper fetches the channel ref +# (single-branch clones do not get stable from a plain fetch) and names it. +UPSTREAM_REF=$(bash bin/gstack-update-channel.sh --fetch-ref 2>/dev/null) || UPSTREAM_REF=origin/main +echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). -HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } +HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$1"' _ "$UPSTREAM_REF" 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$? +FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in 0) if ./setup --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;; @@ -176,8 +181,8 @@ On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There --hard` DESTROYS things: a clean tree with unpushed local commits still loses those commits. Gate it: -1. Run `git status --porcelain` and `git rev-list origin/main..HEAD --oneline` - in `$INSTALL_DIR`. +1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD --oneline` + in `$INSTALL_DIR` (`UPSTREAM_REF` is the channel ref the fast-forward block printed). 2. If BOTH are empty, the reset is provably safe — run the fallback block below without asking. 3. Otherwise ask via AskUserQuestion (one-way door — destructive), listing @@ -193,7 +198,7 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p # Regenerable render-footprint dirt (setup re-renders it) never goes into the stash. git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true STASH_OUTPUT=$(git stash 2>&1) -git reset --hard origin/main +git reset --hard "${UPSTREAM_REF:-origin/main}" ./setup --refresh-registered ``` If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed (any modified generated SKILL.md/sections files were discarded first — they regenerate on setup). Run `git stash pop` in the skill directory to restore your own changes." diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 19f438045..0e6ab6a80 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -141,12 +141,17 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin +# The update channel (docs/releases.md): origin/stable, the weekly release, for +# a team install; origin/main otherwise. The helper fetches the channel ref +# (single-branch clones do not get stable from a plain fetch) and names it. +UPSTREAM_REF=$(bash bin/gstack-update-channel.sh --fetch-ref 2>/dev/null) || UPSTREAM_REF=origin/main +echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). -HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } +HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$1"' _ "$UPSTREAM_REF" 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$? +FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in 0) if {{SETUP_COMMAND}} --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;; @@ -182,8 +187,8 @@ On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There --hard` DESTROYS things: a clean tree with unpushed local commits still loses those commits. Gate it: -1. Run `git status --porcelain` and `git rev-list origin/main..HEAD --oneline` - in `$INSTALL_DIR`. +1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD --oneline` + in `$INSTALL_DIR` (`UPSTREAM_REF` is the channel ref the fast-forward block printed). 2. If BOTH are empty, the reset is provably safe — run the fallback block below without asking. 3. Otherwise ask via AskUserQuestion (one-way door — destructive), listing @@ -199,7 +204,7 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p # Regenerable render-footprint dirt (setup re-renders it) never goes into the stash. git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true STASH_OUTPUT=$(git stash 2>&1) -git reset --hard origin/main +git reset --hard "${UPSTREAM_REF:-origin/main}" {{SETUP_COMMAND}} --refresh-registered ``` If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed (any modified generated SKILL.md/sections files were discarded first — they regenerate on setup). Run `git stash pop` in the skill directory to restore your own changes." diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts new file mode 100755 index 000000000..6b5ae2286 --- /dev/null +++ b/scripts/release-cut.ts @@ -0,0 +1,314 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * release-cut: tag the newest green commit on main as v, publish a + * GitHub Release whose notes are that span's CHANGELOG headlines, and + * fast-forward refs/heads/stable to it (docs/releases.md). Run by + * .github/workflows/weekly-release.yml every Monday morning and by hand for a + * hotfix (--ref ). Dry runs print the plan and change nothing. + * + * bun scripts/release-cut.ts --repo owner/name [--ref ] [--dry-run] [--summary ] + * + * The checkout needs origin's full history (fetch-depth 0). Green, for a + * commit on main's first-parent history, means: its free-tests check run + * succeeded (the status main requires), no completed check run on it failed, + * its "Main backstop gate" run completed with conclusion success (a green train + * record covers the tree, or the backstop gated it green), and + * refs/notes/gstack-trains holds no active freeze. The newest such commit in + * the window (7 days, 200 first-parent commits) is the candidate; a commit + * whose gate is still running is skipped, not waited for. + * + * stable moves only forward: a candidate behind the current stable is refused + * (REFUSED non-fast-forward), as is a tag v that already names another + * commit. Re-running a completed cut reports it and exits 0. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string }; +export type Git = (args: string[]) => { ok: boolean; out: string }; + +export const BACKSTOP_WORKFLOW = 'evals-main-backstop.yml'; +export const REQUIRED_CHECK = 'free-tests'; +export const NOTES_REF = 'refs/notes/gstack-trains'; +const NOTES_LOCAL = 'refs/notes/gstack-trains-release'; +const RED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure', 'stale']); +export const WINDOW_DAYS = 7; +export const WINDOW_COMMITS = 200; +export const NOTES_CAP = 60_000; + +export const runGh: Gh = (args) => { + const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.error ? String(r.error) : r.stderr ?? '' }; +}; +export const gitIn = (cwd: string): Git => (args) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 }); + return r.status === 0 ? { ok: true, out: (r.stdout ?? '').replace(/\n$/, '') } : { ok: false, out: (r.stderr || String(r.error ?? '')).trim() }; +}; + +function parseJson(r: { status: number | null; stdout: string }): T | null { + if (r.status !== 0) return null; + try { return JSON.parse(r.stdout) as T; } catch { return null; } +} + +/** Four-part versions compare numerically, part by part. */ +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number), pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +export type Verdict = { green: true } | { green: false; why: string; pending: boolean }; +interface CheckRun { name: string; status: string; conclusion: string | null } + +/** Why a commit is not releasable, or green. Pending means a required run is still going. */ +export function gateVerdict(gh: Gh, repo: string, sha: string): Verdict { + const paged = parseJson(gh(['api', '--paginate', '--slurp', `repos/${repo}/commits/${sha}/check-runs?per_page=100`])); + if (!paged) return { green: false, why: 'cannot read its check runs', pending: false }; + const pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>; + const runs = pages.flatMap(p => p.check_runs ?? []); + const required = runs.filter(c => c.name === REQUIRED_CHECK); + if (required.length === 0) return { green: false, why: `no ${REQUIRED_CHECK} check run`, pending: false }; + if (required.some(c => c.status !== 'completed')) return { green: false, why: `${REQUIRED_CHECK} is still running`, pending: true }; + if (!required.some(c => c.conclusion === 'success')) return { green: false, why: `${REQUIRED_CHECK} concluded ${required.map(c => c.conclusion).join(',')}`, pending: false }; + const red = runs.filter(c => c.status === 'completed' && RED.has(c.conclusion ?? '')); + if (red.length > 0) return { green: false, why: `red check runs: ${[...new Set(red.map(c => c.name))].join(', ')}`, pending: false }; + const backstop = parseJson<{ workflow_runs?: Array<{ status: string; conclusion: string | null; created_at: string }> }>( + gh(['api', `repos/${repo}/actions/workflows/${BACKSTOP_WORKFLOW}/runs?head_sha=${sha}&per_page=10`])); + if (!backstop) return { green: false, why: 'cannot read its backstop runs', pending: false }; + const newest = (backstop.workflow_runs ?? []).sort((a, b) => b.created_at.localeCompare(a.created_at))[0]; + if (!newest) return { green: false, why: 'no Main backstop gate run', pending: false }; + if (newest.status !== 'completed') return { green: false, why: 'Main backstop gate is still running', pending: true }; + if (newest.conclusion !== 'success') return { green: false, why: `Main backstop gate concluded ${newest.conclusion}`, pending: false }; + return { green: true }; +} + +/** An active freeze record (kind freeze, cleared_at null) in the trains notes ref, after fetching it. */ +export function activeFreeze(git: Git): string | null { + const f = git(['fetch', '--no-tags', '-q', 'origin', `+${NOTES_REF}:${NOTES_LOCAL}`]); + if (!f.ok) return /couldn't find remote ref|no such ref/i.test(f.out) ? null : `cannot fetch ${NOTES_REF}: ${f.out.slice(0, 160)}`; + const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']); + if (!listed.ok || !listed.out.trim()) return null; + for (const line of listed.out.split('\n')) { + const blob = line.split(' ')[0]; + if (!blob) continue; + try { + const r = JSON.parse(git(['cat-file', 'blob', blob]).out) as { kind?: string; cleared_at?: string | null; reason?: string; train?: string }; + if (r.kind === 'freeze' && !r.cleared_at) return `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`; + } catch { /* not a record */ } + } + return null; +} + +export interface ChangelogEntry { version: string; date: string; headline: string } + +/** Every "## [X.Y.Z.W] - date" entry of a CHANGELOG with its first prose line, markdown stripped. */ +export function changelogEntries(text: string): ChangelogEntry[] { + const out: ChangelogEntry[] = []; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const m = /^## \[(\d+(?:\.\d+)+)\](?: - (\S+))?/.exec(lines[i]!); + if (!m) continue; + let headline = ''; + for (let j = i + 1; j < lines.length && !lines[j]!.startsWith('## '); j++) { + const l = lines[j]!.trim(); + if (!l || l.startsWith('#') || l.startsWith('|') || l.startsWith('-') || l.startsWith('```')) continue; + const bold = /^\*\*(.+?)\*\*/.exec(l); + headline = (bold ? bold[1]! : l.replace(/\*\*/g, '')).replace(/\s+/g, ' '); + break; + } + out.push({ version: m[1]!, date: m[2] ?? '', headline }); + } + return out; +} + +export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean } + +/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */ +export function releaseNotes(n: NotesInput): string { + const span = n.entries.filter(e => compareVersions(e.version, n.version) <= 0 && (!n.prevVersion || compareVersions(e.version, n.prevVersion) > 0)) + .sort((a, b) => compareVersions(b.version, a.version)); + const plural = span.length === 1 ? '' : 's'; + const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md'; + const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read '; + const head = [ + '## gstack v' + n.version + (n.hotfix ? ' (hotfix release)' : ' (weekly release)'), + '', + n.first || !n.prevVersion + ? '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)).', + '', + ].join('\n'); + const bullets = span.map(e => '- **' + e.version + '**' + (e.date ? ' (' + e.date + ')' : '') + ': ' + (e.headline || 'see CHANGELOG.md')); + let body = head + bullets.join('\n') + '\n'; + if (body.length > NOTES_CAP) { + const kept: string[] = []; + let size = head.length; + for (const b of bullets) { if (size + b.length + 1 > NOTES_CAP - 200) break; kept.push(b); size += b.length + 1; } + body = head + kept.join('\n') + '\n- ...and ' + (bullets.length - kept.length) + ' more versions; see CHANGELOG.md\n'; + } + return body; +} + +export interface Plan { + sha: string; version: string; tag: string; date: string; hotfix: boolean; + prevStable: string | null; prevVersion: string | null; prevTag: string | null; + tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[]; +} +export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] }; + +export interface PlanOptions { repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number } + +export function planRelease(o: PlanOptions): PlanResult { + const { git, gh, repo } = o; + const windowDays = o.windowDays ?? WINDOW_DAYS; + const skipped: string[] = []; + const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']); + if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out }; + const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']); + const prevStable = stable.ok ? stable.out : null; + const frozen = activeFreeze(git); + if (frozen) return { ok: false, code: 'REFUSED', message: frozen }; + + let candidates: string[]; + if (o.ref) { + const r = git(['rev-parse', '--verify', o.ref + '^{commit}']); + if (!r.ok) return { ok: false, code: 'REFUSED', message: '--ref ' + o.ref + ' is not a commit here: ' + r.out }; + const fp = git(['rev-list', '--first-parent', 'refs/remotes/origin/main']); + if (!fp.out.split('\n').includes(r.out)) return { ok: false, code: 'REFUSED', message: '--ref ' + r.out.slice(0, 12) + " is not on main's first-parent history; a release is always a merge commit of main" }; + candidates = [r.out]; + } else { + const since = new Date((o.now?.() ?? Date.now()) - windowDays * 86_400_000).toISOString(); + const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']); + candidates = list.ok && list.out ? list.out.split('\n') : []; + if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' }; + if (prevStable && candidates[0] === prevStable) return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + prevStable.slice(0, 12) + ' is already stable' }; + } + + let sha: string | null = null; + for (const c of candidates) { + if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; } + const v = gateVerdict(gh, repo, c); + if (v.green) { sha = c; break; } + skipped.push(c.slice(0, 12) + ': ' + v.why); + } + if (!sha) { + if (o.ref) return { ok: false, code: 'REFUSED', message: '--ref ' + candidates[0]!.slice(0, 12) + ' is not green: ' + (skipped[0] ?? '').split(': ').slice(1).join(': '), skipped }; + return { ok: false, code: 'NO_GREEN_COMMIT', message: 'no green commit among ' + candidates.length + ' on origin/main; see the skipped list', skipped }; + } + if (prevStable && sha !== prevStable && !git(['merge-base', '--is-ancestor', prevStable, sha]).ok) { + return { ok: false, code: 'REFUSED', message: 'non-fast-forward: stable ' + prevStable.slice(0, 12) + ' is not an ancestor of ' + sha.slice(0, 12) + '; stable only moves forward along main' }; + } + + const version = git(['show', sha + ':VERSION']).out.trim(); + if (!/^\d+(\.\d+)+$/.test(version)) return { ok: false, code: 'REFUSED', message: 'VERSION at ' + sha.slice(0, 12) + " is not a version: '" + version + "'" }; + const tag = 'v' + version; + const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']); + const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other'; + if (tagExists === 'other') return { ok: false, code: 'REFUSED', message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + sha.slice(0, 12) + '; a version is stamped once per merge, so this needs a look' }; + + let prevVersion: string | null = null, prevTag: string | null = null; + if (prevStable) { + prevVersion = git(['show', prevStable + ':VERSION']).out.trim() || null; + if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion; + } else { + const date = git(['show', '-s', '--format=%cI', sha]).out; + const before = new Date(new Date(date).getTime() - windowDays * 86_400_000).toISOString(); + const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]); + if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null; + } + const changelog = git(['show', sha + ':CHANGELOG.md']); + const date = git(['show', '-s', '--format=%cs', sha]).out; + const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !prevStable }); + return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } }; +} + +export interface CutResult { ok: boolean; lines: string[] } + +/** Tag, push stable and create the release for a plan; each step is skipped when already done. */ +export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDir?: string }): CutResult { + const { git, gh } = o; + const lines: string[] = []; + const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']); + if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] }; + if (!existing.ok) { + const t = git(['tag', '-a', p.tag, p.sha, '-m', 'gstack ' + p.tag + (p.hotfix ? ' (hotfix release)' : ' (weekly release)')]); + if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] }; + const push = git(['push', 'origin', 'refs/tags/' + p.tag]); + if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] }; + lines.push('TAG: ' + p.tag + ' -> ' + p.sha.slice(0, 12) + ' pushed'); + } else { + lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12)); + } + if (!p.stableAt) { + const push = git(['push', 'origin', p.sha + ':refs/heads/stable']); + if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed: ' + push.out] }; + lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12)); + } else { + lines.push('STABLE: already at ' + p.sha.slice(0, 12)); + } + const view = gh(['release', 'view', p.tag, '--repo', o.repo, '--json', 'tagName']); + if (view.status === 0) { + lines.push('RELEASE: ' + p.tag + ' already published'); + } else { + const dir = o.notesDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-')); + const file = path.join(dir, p.tag + '.md'); + fs.writeFileSync(file, p.notes); + const created = gh(['release', 'create', p.tag, '--repo', o.repo, '--verify-tag', '--title', 'gstack ' + p.tag, '--notes-file', file]); + if (created.status !== 0) return { ok: false, lines: [...lines, 'RELEASE: create failed: ' + created.stderr.trim().slice(0, 300)] }; + lines.push(('RELEASE: ' + p.tag + ' published ' + created.stdout.trim()).trim()); + } + return { ok: true, lines }; +} + +export function describePlan(p: Plan, dryRun: boolean): string[] { + const lines = [ + 'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''), + 'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''), + 'STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12) + (p.stableAt ? ' (already there)' : ''), + 'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars', + ]; + for (const s of p.skipped) lines.push('SKIPPED: ' + s); + if (dryRun) { + lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:', + ' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag, + ' git push origin ' + p.sha + ':refs/heads/stable', + ' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file '); + } + return lines; +} + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +if (import.meta.main) { + const repo = arg('--repo'); + const dryRun = process.argv.includes('--dry-run'); + const summary = arg('--summary'); + if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref ] [--dry-run] [--summary ]'); process.exit(2); } + const git = gitIn(process.cwd()); + const emit = (lines: string[]) => { + for (const l of lines) console.log(l); + if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n'); + }; + const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh }); + if (!planned.ok) { + emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]); + process.exit(planned.code === 'NOTHING_NEW' ? 0 : 1); + } + emit(describePlan(planned.plan, dryRun)); + if (summary) fs.appendFileSync(summary, '\n
Release notes\n\n' + planned.plan.notes + '\n
\n'); + else console.log('--- notes ---\n' + planned.plan.notes); + if (dryRun) process.exit(0); + const cut = cutRelease(planned.plan, { repo, git, gh: runGh }); + emit(cut.lines); + process.exit(cut.ok ? 0 : 1); +} diff --git a/setup b/setup index 10c0ae56c..7d3eb9767 100755 --- a/setup +++ b/setup @@ -4098,6 +4098,10 @@ if [ "$TEAM_MODE" -eq 1 ]; then else log "Team mode enabled (auto-update hook pending a stable install — re-run ./setup after installing globally)." fi + case "$("$GSTACK_CONFIG" get update_channel 2>/dev/null || echo auto)" in + main) log " Channel: main (every merge; gstack-config set update_channel stable follows the weekly release instead)" ;; + *) log " Channel: stable, the weekly tagged release (gstack-config set update_channel main follows every merge; docs/releases.md)" ;; + esac log " To disable: ./setup --no-team" log "" log "Bootstrap your repo:" diff --git a/test/gstack-upgrade-fresh-shell.test.ts b/test/gstack-upgrade-fresh-shell.test.ts index 608a3f6a8..c3274d44f 100644 --- a/test/gstack-upgrade-fresh-shell.test.ts +++ b/test/gstack-upgrade-fresh-shell.test.ts @@ -183,7 +183,7 @@ describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad path const UNUSABLE: Kind[] = ['unset', 'empty', 'nonexistent', 'unreadable']; const cases: Array<{ name: string; doc: 'upgrade' | 'spec'; marker: string; target: string; kinds?: Kind[]; others: (wd: ReturnType, h: HostRender) => Record }> = [ { name: 'ff-only upgrade', doc: 'upgrade', marker: 'bash bin/gstack-ff-update', target: 'INSTALL_DIR', others: () => ({}) }, - { name: 'reset fallback', doc: 'upgrade', marker: 'git reset --hard origin/main', target: 'INSTALL_DIR', others: () => ({}) }, + { name: 'reset fallback', doc: 'upgrade', marker: 'git reset --hard "${UPSTREAM_REF:-origin/main}"', target: 'INSTALL_DIR', others: () => ({}) }, { name: 'vendored swap', doc: 'upgrade', marker: 'git clone --depth 1', target: 'INSTALL_DIR', others: () => ({}) }, { name: 'local copy detection', doc: 'upgrade', marker: '_RESOLVED_PRIMARY=', target: 'INSTALL_DIR', kinds: UNUSABLE, others: () => ({}) }, { name: 'team-mode removal', doc: 'upgrade', marker: 'git rm -r --cached', target: 'LOCAL_GSTACK', others: () => ({}) }, diff --git a/test/release-cut.test.ts b/test/release-cut.test.ts new file mode 100644 index 000000000..103fcfd4c --- /dev/null +++ b/test/release-cut.test.ts @@ -0,0 +1,229 @@ +/** + * release-cut (free): the weekly release picks the newest green commit on + * main's first-parent history, tags it v, fast-forwards stable and + * publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a + * named main commit; stable never moves backwards; a frozen queue, a pending + * gate or a red check run keeps a commit out (docs/releases.md). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + BACKSTOP_WORKFLOW, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gitIn, + planRelease, releaseNotes, type Gh, +} from '../scripts/release-cut'; + +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } }).trim(); +} + +interface State { checks: Record>; backstop: Record>; released: Set; calls: string[][] } + +const GREEN = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: 'slices-report', status: 'queued', conclusion: null }]; +const COVERED = [{ status: 'completed', conclusion: 'success', created_at: '2026-10-25T00:00:00Z' }]; + +function fakeGh(state: State): Gh { + return (args) => { + state.calls.push(args); + const api = args[0] === 'api' ? args[args.length - 1]! : ''; + let m = /commits\/([0-9a-f]+)\/check-runs/.exec(api); + if (m) return { status: 0, stdout: JSON.stringify([{ check_runs: state.checks[m[1]!] ?? GREEN }]), stderr: '' }; + m = new RegExp('workflows/' + BACKSTOP_WORKFLOW.replace('.', '\\.') + '/runs\\?head_sha=([0-9a-f]+)').exec(api); + if (m) return { status: 0, stdout: JSON.stringify({ workflow_runs: state.backstop[m[1]!] ?? COVERED }), stderr: '' }; + if (args[0] === 'release' && args[1] === 'view') return state.released.has(args[2]!) ? { status: 0, stdout: '{}', stderr: '' } : { status: 1, stdout: '', stderr: 'release not found' }; + if (args[0] === 'release' && args[1] === 'create') { state.released.add(args[2]!); return { status: 0, stdout: 'https://github.com/o/r/releases/tag/' + args[2], stderr: '' }; } + return { status: 1, stdout: '', stderr: 'unexpected gh ' + args.join(' ') }; + }; +} + +function fixture() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-cut-')); + bases.push(base); + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + const work = path.join(base, 'work'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(seed); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'remote', 'add', 'origin', origin); + const shas: Record = {}; + let changelog = '# Changelog\n'; + const release = (version: string, headline: string) => { + changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n'); + fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n'); + fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog); + git(seed, 'add', '-A'); + git(seed, 'commit', '-q', '-m', 'v' + version); + git(seed, 'push', '-q', 'origin', 'main'); + shas[version] = git(seed, 'rev-parse', 'HEAD'); + return shas[version]!; + }; + release('1.0.0', 'First one.'); + release('1.0.1', 'Second: the updater no longer skips.'); + release('1.0.2', 'Third fixes Windows.'); + release('1.0.3', 'Fourth trims the preamble.'); + execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 }); + const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); }; + const state: State = { checks: {}, backstop: {}, released: new Set(), calls: [] }; + return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' }; +} + +describe('release-cut: planning', () => { + test('the newest green first-parent commit wins; a pending gate or a red check keeps a commit out and is named', () => { + const f = fixture(); + f.state.backstop[f.shas['1.0.3']!] = [{ status: 'in_progress', conclusion: null, created_at: '2026-10-25T01:00:00Z' }]; + f.state.checks[f.shas['1.0.2']!] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }]; + const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(r.ok, JSON.stringify(r)).toBe(true); + if (!r.ok) return; + expect(r.plan.sha).toBe(f.shas['1.0.1']); + expect(r.plan.version).toBe('1.0.1'); + expect(r.plan.tag).toBe('v1.0.1'); + expect(r.plan.prevStable).toBeNull(); + expect(r.plan.skipped).toEqual([ + f.shas['1.0.3']!.slice(0, 12) + ': Main backstop gate is still running', + f.shas['1.0.2']!.slice(0, 12) + ': red check runs: windows-free-tests', + ]); + expect(r.plan.notes).toContain('First tagged release: the 2 versions, one line each'); + expect(r.plan.notes).toContain('- **1.0.1** (2026-10-21): Second: the updater no longer skips.'); + expect(r.plan.notes).toContain('- **1.0.0**'); + expect(r.plan.notes).not.toContain('1.0.2'); + const lines = describePlan(r.plan, true); + expect(lines.some(l => l.startsWith('DRY_RUN:'))).toBe(true); + expect(lines).toContain(' git push origin ' + f.shas['1.0.1'] + ':refs/heads/stable'); + }); + + test('a missing free-tests run, a running free-tests run and no backstop run are not green', () => { + const f = fixture(); + const head = f.shas['1.0.3']!; + f.state.checks[head] = [{ name: 'typecheck', status: 'completed', conclusion: 'success' }]; + f.state.checks[f.shas['1.0.2']!] = [{ name: REQUIRED_CHECK, status: 'in_progress', conclusion: null }]; + f.state.backstop[f.shas['1.0.1']!] = []; + const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.plan.sha).toBe(f.shas['1.0.0']); + expect(r.plan.skipped.map(s => s.split(': ')[1])).toEqual(['no free-tests check run', 'free-tests is still running', 'no Main backstop gate run']); + }); + + test('no green commit in the window is NO_GREEN_COMMIT with every skip named; an empty window is NOTHING_NEW', () => { + const f = fixture(); + for (const sha of Object.values(f.shas)) f.state.backstop[sha] = [{ status: 'completed', conclusion: 'failure', created_at: '2026-10-25T00:00:00Z' }]; + const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.code).toBe('NO_GREEN_COMMIT'); + expect(r.skipped).toHaveLength(4); + expect(r.skipped![0]).toContain('Main backstop gate concluded failure'); + const stale = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => Date.now() + 30 * 86_400_000 }); + expect(stale.ok).toBe(false); + if (!stale.ok) expect(stale.code).toBe('NOTHING_NEW'); + }); + + test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => { + const f = fixture(); + const freeze = { schema: 1, kind: 'freeze', repo_key: 'k', train: 'abc', reason: 'backstop red on 2 cases', created_at: 'x', cleared_at: null }; + git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-m', JSON.stringify(freeze), 'HEAD'); + git(f.seed, 'push', '-q', 'origin', NOTES_REF); + const frozen = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(frozen.ok).toBe(false); + if (!frozen.ok) { expect(frozen.code).toBe('REFUSED'); expect(frozen.message).toContain('queue frozen since train abc: backstop red on 2 cases'); } + git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', JSON.stringify({ ...freeze, cleared_at: 'y' }), 'HEAD'); + git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF); + expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true); + }); +}); + +describe('release-cut: cutting, hotfixes and the forward-only stable', () => { + test('a cut tags, moves stable and publishes; re-running reports nothing new; a hotfix ref promotes a later main commit with a compare link', () => { + const f = fixture(); + const first = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.plan.sha).toBe(f.shas['1.0.3']); + const cut = cutRelease(first.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }); + expect(cut.ok, cut.lines.join('\n')).toBe(true); + expect(cut.lines).toEqual(['TAG: v1.0.3 -> ' + f.shas['1.0.3']!.slice(0, 12) + ' pushed', 'STABLE: none -> ' + f.shas['1.0.3']!.slice(0, 12), 'RELEASE: v1.0.3 published https://github.com/o/r/releases/tag/v1.0.3']); + expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.3']); + expect(git(f.origin, 'rev-parse', 'v1.0.3^{commit}')).toBe(f.shas['1.0.3']); + expect(git(f.origin, 'cat-file', '-t', 'v1.0.3')).toBe('tag'); + const create = f.state.calls.find(c => c[0] === 'release' && c[1] === 'create')!; + expect(create).toContain('--verify-tag'); + expect(fs.readFileSync(create[create.indexOf('--notes-file') + 1]!, 'utf8')).toContain('Fourth trims the preamble.'); + + f.refresh(); + const again = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(again.ok).toBe(false); + if (!again.ok) expect(again.code).toBe('NOTHING_NEW'); + + const hot = f.release('1.0.4', 'Hotfix: the lease never blocks.'); + f.refresh(); + const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh }); + expect(promo.ok, JSON.stringify(promo)).toBe(true); + if (!promo.ok) return; + expect(promo.plan.hotfix).toBe(true); + expect(promo.plan.prevStable).toBe(f.shas['1.0.3']); + expect(promo.plan.prevTag).toBe('v1.0.3'); + expect(promo.plan.notes).toContain('## gstack v1.0.4 (hotfix release)'); + expect(promo.plan.notes).toContain('1 version since v1.0.3'); + expect(promo.plan.notes).toContain('compare/v1.0.3...v1.0.4'); + expect(promo.plan.notes).toContain('- **1.0.4**'); + expect(promo.plan.notes).not.toContain('- **1.0.3**'); + const cut2 = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }); + expect(cut2.ok, cut2.lines.join('\n')).toBe(true); + expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(hot); + f.refresh(); + const redo = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }); + expect(redo.lines).toEqual(['TAG: v1.0.4 already names ' + hot.slice(0, 12), 'STABLE: ' + f.shas['1.0.3']!.slice(0, 12) + ' -> ' + hot.slice(0, 12), 'RELEASE: v1.0.4 already published']); + }); + + test('stable never moves backwards, a side-branch ref is refused, and a tag naming another commit is refused', () => { + const f = fixture(); + git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable'); + f.refresh(); + const back = planRelease({ repo: f.repo, ref: f.shas['1.0.1'], git: f.git, gh: f.gh }); + expect(back.ok).toBe(false); + if (!back.ok) { expect(back.code).toBe('REFUSED'); expect(back.message).toContain('non-fast-forward'); } + + git(f.seed, 'checkout', '-q', '-b', 'side', f.shas['1.0.3']!); + fs.writeFileSync(path.join(f.seed, 'side.txt'), 'x'); + git(f.seed, 'add', '-A'); git(f.seed, 'commit', '-q', '-m', 'side'); + const side = git(f.seed, 'rev-parse', 'HEAD'); + git(f.seed, 'push', '-q', 'origin', 'side'); + f.refresh(); + const off = planRelease({ repo: f.repo, ref: side, git: f.git, gh: f.gh }); + expect(off.ok).toBe(false); + if (!off.ok) expect(off.message).toContain("not on main's first-parent history"); + + git(f.seed, 'tag', 'v1.0.3', f.shas['1.0.0']!); + git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.3'); + f.refresh(); + const clash = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(clash.ok).toBe(false); + if (!clash.ok) expect(clash.message).toContain('tag v1.0.3 already names'); + }); +}); + +describe('release-cut: notes', () => { + test('changelog headlines are the bold first line of each entry; versions order numerically', () => { + const entries = changelogEntries('# Changelog\n\n## [1.91.221.0] - 2026-10-25\n\n**The updater no longer skips.** More.\n\n### Fixed\n- x\n\n## [1.91.99.0] - 2026-10-01\n\n| a | b |\n\n**Older.**\n'); + expect(entries).toEqual([{ version: '1.91.221.0', date: '2026-10-25', headline: 'The updater no longer skips.' }, { version: '1.91.99.0', date: '2026-10-01', headline: 'Older.' }]); + expect(changelogEntries('## [1.0.0] - 2026-01-01\n\nPlain lead with **some** bold.\n')[0]!.headline).toBe('Plain lead with some bold.'); + expect(compareVersions('1.91.221.0', '1.91.99.0')).toBeGreaterThan(0); + expect(compareVersions('1.91.9.0', '1.91.10.0')).toBeLessThan(0); + }); + + test('a span of a thousand versions stays under the notes cap and says how many were cut', () => { + const entries = Array.from({ length: 1000 }, (_, i) => ({ version: '1.91.' + (i + 1) + '.0', date: '2026-10-20', headline: 'Headline number ' + i + ' with enough words to take some room in the body.' })); + const notes = releaseNotes({ repo: 'o/r', version: '1.91.1000.0', date: '2026-10-26', prevVersion: '1.91.0.0', prevTag: null, entries, hotfix: false }); + expect(notes.length).toBeLessThanOrEqual(NOTES_CAP); + expect(notes).toMatch(/- \.\.\.and \d+ more versions; see CHANGELOG\.md/); + expect(notes).toContain('1000 versions since v1.91.0.0'); + expect(notes.split('\n')[0]).toBe('## gstack v1.91.1000.0 (weekly release)'); + }); +}); diff --git a/test/update-channel.test.ts b/test/update-channel.test.ts new file mode 100644 index 000000000..00fe9be03 --- /dev/null +++ b/test/update-channel.test.ts @@ -0,0 +1,242 @@ +/** + * Update channels (free; docs/releases.md). A team install follows + * refs/heads/stable, the weekly release, instead of every merge to main: + * bin/gstack-update-channel.sh resolves update_channel (auto | stable | main; + * auto is stable when team_mode is true), bin/gstack-session-update fetches + * and fast-forwards to the channel ref, falls back to main before the first + * release exists and never moves an install backwards, bin/gstack-upgrade + * follows the same ref, and bin/gstack-update-check compares with the channel + * so a stable install is not told that main moved. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HELPER = path.join(ROOT, 'bin', 'gstack-update-channel.sh'); +const SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update'); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +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' }; +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...GIT_ENV } }).trim(); +} +const write = (file: string, text: string, mode = 0o644) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text, { mode }); }; + +/** A gstack-config stub: team_mode and update_channel come from files the test writes. */ +const CONFIG_STUB = '#!/usr/bin/env bash\nif [ "$1" = "get" ]; then case "$2" in auto_upgrade) echo true;; skill_prefix) echo false;; team_mode) cat "$TEAM_MODE_FILE" 2>/dev/null;; update_channel) cat "$CHANNEL_FILE" 2>/dev/null;; *) echo "";; esac; fi\nexit 0\n'; + +describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh: resolution', () => { + function install() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-')); + bases.push(base); + write(path.join(base, 'bin', 'gstack-config'), CONFIG_STUB, 0o755); + const resolve = (team: string, channel: string, extra: Record = {}) => { + fs.writeFileSync(path.join(base, 'team'), team); + fs.writeFileSync(path.join(base, 'channel'), channel); + const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel "$2"', '_', HELPER, base], { encoding: 'utf8', env: { ...process.env, TEAM_MODE_FILE: path.join(base, 'team'), CHANNEL_FILE: path.join(base, 'channel'), GSTACK_UPDATE_CHANNEL: '', ...extra }, timeout: 10_000 }); + return r.stdout.trim() + (r.status === 0 ? '' : ' exit=' + r.status); + }; + return { base, resolve }; + } + + test('auto is stable for a team install and main otherwise; explicit values win; junk is auto; the env override wins over all', () => { + const f = install(); + expect(f.resolve('true', '')).toBe('stable'); + expect(f.resolve('true', 'auto')).toBe('stable'); + expect(f.resolve('false', '')).toBe('main'); + expect(f.resolve('', '')).toBe('main'); + expect(f.resolve('true', 'main')).toBe('main'); + expect(f.resolve('false', 'stable')).toBe('stable'); + expect(f.resolve('true', 'weekly')).toBe('stable'); + expect(f.resolve('false', 'weekly')).toBe('main'); + expect(f.resolve('true', 'stable', { GSTACK_UPDATE_CHANNEL: 'main' })).toBe('main'); + }); + + test('the refspec and ref name stable only; a missing gstack-config means main', () => { + const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_refspec stable && gstack_update_channel_ref stable && gstack_update_channel_refspec main | wc -c && gstack_update_channel /nonexistent', '_', HELPER], { encoding: 'utf8', timeout: 10_000, env: { ...process.env, GSTACK_UPDATE_CHANNEL: '' } }); + expect(r.stdout.split('\n').map(l => l.trim())).toEqual(['+refs/heads/stable:refs/remotes/origin/stable', 'refs/remotes/origin/stable', '1', 'main', '']); + expect(spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_missing "fatal: couldn\'t find remote ref refs/heads/stable" && ! gstack_update_channel_missing "fatal: unable to access"', '_', HELPER], { encoding: 'utf8', timeout: 10_000 }).status).toBe(0); + }); +}); + +/** Origin with main A < B < C, stable at B, a single-branch depth-1 install at A (as README's clone command makes). */ +function world(opts: { installAt?: 'A' | 'C'; stable?: boolean } = {}) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-world-')); + bases.push(base); + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + const install = path.join(base, 'install'); + const state = path.join(base, 'state'); + const home = path.join(base, 'home'); + fs.mkdirSync(state); fs.mkdirSync(home); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(seed); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'remote', 'add', 'origin', origin); + write(path.join(seed, 'bin', 'gstack-config'), CONFIG_STUB, 0o755); + write(path.join(seed, 'bin', 'gstack-patch-names'), '#!/usr/bin/env bash\nexit 0\n', 0o755); + for (const rel of ['bin/gstack-sanitize-echo.sh', 'bin/gstack-update-channel.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh', 'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts', 'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-upgrade']) { + if (!fs.existsSync(path.join(ROOT, rel))) continue; + fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); + fs.copyFileSync(path.join(ROOT, rel), path.join(seed, rel)); + fs.chmodSync(path.join(seed, rel), fs.statSync(path.join(ROOT, rel)).mode); + } + write(path.join(seed, 'setup'), '#!/usr/bin/env bash\necho "setup $*" >> "$SETUP_CALLS"\nexit 0\n', 0o755); + const shas: Record = {}; + const release = (name: string, version: string) => { + write(path.join(seed, 'VERSION'), version + '\n'); + git(seed, 'add', '-A'); + git(seed, 'commit', '-q', '-m', name); + git(seed, 'push', '-q', 'origin', 'main'); + shas[name] = git(seed, 'rev-parse', 'HEAD'); + }; + release('A', '1.0.0'); + if (opts.installAt !== 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000 }); + release('B', '1.0.1'); + release('C', '1.0.2'); + if (opts.installAt === 'C') execFileSync('git', ['clone', '-q', '--single-branch', '--depth', '1', 'file://' + origin, install], { timeout: 30_000 }); + if (opts.stable !== false) git(seed, 'push', '-q', 'origin', shas.B + ':refs/heads/stable'); + const team = path.join(base, 'team'), channel = path.join(base, 'channel'), calls = path.join(base, 'setup-calls'); + fs.writeFileSync(team, 'true'); fs.writeFileSync(channel, ''); + const env = { ...process.env, ...GIT_ENV, HOME: home, GSTACK_DIR: install, GSTACK_STATE_ROOT: state, TEAM_MODE_FILE: team, CHANNEL_FILE: channel, SETUP_CALLS: calls, TMPDIR: base, GSTACK_UPDATE_CHANNEL: '' }; + return { base, origin, seed, install, state, shas, team, channel, env, head: () => git(install, 'rev-parse', 'HEAD') }; +} +type World = ReturnType; + +async function updaterLog(w: World, pattern: RegExp, ms = 20_000): Promise { + const logFile = path.join(w.state, 'analytics', 'session-update.log'); + const deadline = Date.now() + ms; + let content = ''; + while (Date.now() < deadline) { + content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; + if (pattern.test(content) && !fs.existsSync(path.join(w.state, '.setup-lock'))) return content; + await new Promise(r => setTimeout(r, 100)); + } + throw new Error('timed out waiting for ' + pattern + '; log:\n' + content); +} +function runHook(w: World) { + return spawnSync('bash', [SESSION_UPDATE], { encoding: 'utf8', env: { ...w.env, PATH: path.dirname(process.execPath) + ':' + process.env.PATH }, timeout: 20_000 }); +} + +describe.skipIf(process.platform === 'win32')('gstack-session-update on the stable channel', () => { + test('a team install moves to origin/stable (B), not main (C), and logs the channel', async () => { + const w = world(); + expect(w.head()).toBe(w.shas.A); + expect(runHook(w).stdout).toBe(''); + const log = await updaterLog(w, /UPDATED|PULL_FAILED|UP_TO_DATE/); + expect(log).toContain('CHANNEL stable target=' + w.shas.B); + expect(log).toContain('UPDATING old=' + w.shas.A + ' new=' + w.shas.B); + expect(log).toContain('UPDATED from=1.0.0 to=1.0.1'); + expect(w.head()).toBe(w.shas.B); + expect(fs.readFileSync(path.join(w.install, 'VERSION'), 'utf8').trim()).toBe('1.0.1'); + }); + + test('update_channel=main on a team install follows every merge (C), as before channels existed', async () => { + const w = world(); + fs.writeFileSync(w.channel, 'main'); + runHook(w); + const log = await updaterLog(w, /UPDATED|PULL_FAILED|UP_TO_DATE/); + expect(log).not.toContain('CHANNEL '); + expect(log).toContain('UPDATING old=' + w.shas.A + ' new=' + w.shas.C); + expect(w.head()).toBe(w.shas.C); + }); + + test('before the first release exists (no stable on origin) the install follows main and says so', async () => { + const w = world({ stable: false }); + runHook(w); + const log = await updaterLog(w, /UPDATED|PULL_FAILED|UP_TO_DATE/); + expect(log).toContain('CHANNEL_FALLBACK stable->main reason=no-stable-on-origin'); + expect(log).toContain('UPDATING old=' + w.shas.A + ' new=' + w.shas.C); + expect(w.head()).toBe(w.shas.C); + }); + + test('an install already past the release (at C, stable at B) stays where it is: up to date, no pull failure, nothing pending', async () => { + const w = world({ installAt: 'C' }); + runHook(w); + const log = await updaterLog(w, /UPDATED|PULL_FAILED|UP_TO_DATE/); + expect(log).toContain('CHANNEL stable ahead_of_release head=' + w.shas.C + ' stable=' + w.shas.B); + expect(log).toContain('UP_TO_DATE head=' + w.shas.C); + expect(log).not.toContain('PULL_FAILED'); + expect(w.head()).toBe(w.shas.C); + expect(fs.existsSync(path.join(w.state, 'session-update-pending'))).toBe(false); + }); +}); + +describe.skipIf(process.platform === 'win32')('gstack-upgrade on the stable channel', () => { + const run = (w: World, args: string[] = []) => spawnSync('bash', [path.join(w.install, 'bin', 'gstack-upgrade'), ...args], { cwd: w.base, encoding: 'utf8', env: w.env, timeout: 60_000 }); + + test('--check and the upgrade follow stable (B) and name the channel; update_channel=main then goes on to C', () => { + const w = world(); + const check = run(w, ['--check']); + expect(check.status, check.stdout + check.stderr).toBe(1); + expect(check.stdout).toContain('gstack-upgrade: behind 1.0.0 -> 1.0.1 (1 commits)'); + expect(check.stderr).toContain('gstack-upgrade: channel stable'); + const up = run(w); + expect(up.status, up.stdout + up.stderr).toBe(0); + expect(up.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1'); + expect(w.head()).toBe(w.shas.B); + expect(run(w, ['--check']).stdout).toContain('gstack-upgrade: current 1.0.1'); + fs.writeFileSync(w.channel, 'main'); + const main = run(w, ['--check']); + expect(main.stdout).toContain('gstack-upgrade: behind 1.0.1 -> 1.0.2 (1 commits)'); + expect(main.stderr).not.toContain('channel stable'); + expect(run(w).status).toBe(0); + expect(w.head()).toBe(w.shas.C); + }); + + test('a depth-1 install already past the release (at C, stable at B) is current, not diverged', () => { + const w = world({ installAt: 'C' }); + const check = run(w, ['--check']); + expect(check.status, check.stdout + check.stderr).toBe(0); + expect(check.stdout).toContain('gstack-upgrade: current 1.0.2'); + expect(w.head()).toBe(w.shas.C); + }); + + test('with no stable on origin yet, a team install follows main and the note says so', () => { + const w = world({ stable: false }); + const check = run(w, ['--check']); + expect(check.status).toBe(1); + expect(check.stdout).toContain('gstack-upgrade: behind 1.0.0 -> 1.0.2 (2 commits)'); + expect(check.stderr).toContain('channel stable has no release on origin yet; following main'); + }); +}); + +describe.skipIf(process.platform === 'win32')('gstack-update-check on the stable channel', () => { + test('a checkout at stable stays silent while main is ahead; the cache identity carries the channel; ls-remote asks for refs/heads/stable', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-check-')); + bases.push(base); + const gstack = path.join(base, 'gstack'), origin = path.join(base, 'origin.git'), state = path.join(base, 'state'), raw = path.join(base, 'raw'), shim = path.join(base, 'shim'); + for (const d of [path.join(gstack, 'bin'), state, raw, shim]) fs.mkdirSync(d, { recursive: true }); + const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).trim(); + const gitLog = path.join(base, 'git.log'); + fs.writeFileSync(path.join(shim, 'git'), '#!/usr/bin/env bash\ncase "$1" in ls-remote) echo "git $*" >> "' + gitLog + '" ;; esac\nexec "' + realGit + '" "$@"\n', { mode: 0o755 }); + for (const name of ['gstack-config', 'gstack-egress-lib.sh', 'gstack-state-root.sh', 'gstack-egress-receipt', 'gstack-update-channel.sh']) fs.symlinkSync(path.join(ROOT, 'bin', name), path.join(gstack, 'bin', name)); + fs.writeFileSync(path.join(gstack, 'VERSION'), '1.0.0\n'); + fs.writeFileSync(path.join(gstack, '.gitignore'), 'bin/\n'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + git(gstack, 'init', '-q', '-b', 'main'); git(gstack, 'add', '-A'); git(gstack, 'commit', '-q', '-m', 'base'); + git(gstack, 'remote', 'add', 'origin', origin); git(gstack, 'push', '-q', 'origin', 'main'); git(gstack, 'push', '-q', 'origin', 'main:stable'); + const local = git(gstack, 'rev-parse', 'HEAD'); + const clone = path.join(base, 'upstream'); + execFileSync('git', ['clone', '-q', origin, clone], { timeout: 30_000 }); + fs.writeFileSync(path.join(clone, 'VERSION'), '1.0.1\n'); + git(clone, 'commit', '-qam', 'main moved'); git(clone, 'push', '-q', 'origin', 'HEAD:main'); + const main = git(clone, 'rev-parse', 'HEAD'); + for (const [sha, v] of [[local, '1.0.0'], [main, '1.0.1']]) { fs.mkdirSync(path.join(raw, sha!)); fs.writeFileSync(path.join(raw, sha!, 'VERSION'), v + '\n'); } + const env = { ...process.env, ...GIT_ENV, HOME: base, GSTACK_DIR: gstack, GSTACK_STATE_ROOT: state, GSTACK_REMOTE_REPO: origin, GSTACK_REMOTE_RAW_BASE: 'file://' + raw, GSTACK_COMPARE_API: 'file://' + path.join(base, 'compare'), PATH: shim + ':' + process.env.PATH }; + delete (env as Record).GSTACK_REMOTE_URL; + const run = (extra: Record) => spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-update-check')], { encoding: 'utf8', timeout: 30_000, env: { ...env, ...extra } }); + const stable = run({ GSTACK_UPDATE_CHANNEL: 'stable' }); + expect(stable.stdout.trim()).toBe(''); + expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toBe('UP_TO_DATE 1.0.0\nIDENTITY head=' + local + ' remote=' + local + ' upstream=' + origin + '#stable\n'); + expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/stable'); + expect(fs.readFileSync(gitLog, 'utf8')).not.toContain('refs/heads/main'); + const onMain = run({ GSTACK_UPDATE_CHANNEL: 'main' }); + expect(onMain.stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1'); + expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/main'); + }); +}); diff --git a/test/upgrade-template-pins.test.ts b/test/upgrade-template-pins.test.ts index 9d03b685f..475bb52f2 100644 --- a/test/upgrade-template-pins.test.ts +++ b/test/upgrade-template-pins.test.ts @@ -14,8 +14,8 @@ describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', () const tmpl = read('gstack-upgrade/SKILL.md.tmpl'); test('the fast-forward (gstack-ff-update) runs before any reset --hard', () => { - const ff = tmpl.indexOf('bash bin/gstack-ff-update . origin/main'); - const reset = tmpl.indexOf('git reset --hard origin/main'); + const ff = tmpl.indexOf('bash bin/gstack-ff-update . "$UPSTREAM_REF"'); + const reset = tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"'); expect(ff).toBeGreaterThan(-1); expect(reset).toBeGreaterThan(-1); expect(ff).toBeLessThan(reset); @@ -23,14 +23,14 @@ describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', () test('the ff path carries the FF_OK success gate that skips the fallback', () => { expect(tmpl).toContain('FF_OK'); - expect(tmpl.indexOf('FF_OK')).toBeLessThan(tmpl.indexOf('git reset --hard origin/main')); + expect(tmpl.indexOf('FF_OK')).toBeLessThan(tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"')); }); test('the destructive fallback is gated on unpushed commits, not just a clean tree', () => { // A clean tree with unpushed local commits is NOT safe for reset --hard. - expect(tmpl).toContain('git rev-list origin/main..HEAD'); - expect(tmpl.indexOf('git rev-list origin/main..HEAD')).toBeLessThan( - tmpl.indexOf('git reset --hard origin/main'), + expect(tmpl).toContain('git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD'); + expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD')).toBeLessThan( + tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"'), ); }); });