diff --git a/.github/workflows/evals-main-backstop.yml b/.github/workflows/evals-main-backstop.yml index b802e1db6..da91abc02 100644 --- a/.github/workflows/evals-main-backstop.yml +++ b/.github/workflows/evals-main-backstop.yml @@ -7,6 +7,11 @@ name: Main backstop gate # backstop writes the freeze record gstack-ship-queue reads. Inert (no paid # work, nothing written) unless main's .gstack/ship-policy.json declares a # `train` block; without one the per-PR gates cover main as before. +# +# The verdict is published on the commit as the main-backstop check run +# (success when a green train covers the tree or the backstop gated it green, +# neutral when the push was superseded or deferred, failure when it froze the +# queue or could not finish); the weekly release reads it (docs/releases.md). on: push: branches: [main] @@ -25,6 +30,8 @@ jobs: if: github.actor != 'dependabot[bot]' # Settle window (60) + the full gate (270) + margin. timeout-minutes: 345 + outputs: + action: ${{ steps.gate.outputs.action }} permissions: # Pushes the freeze record to refs/notes/gstack-trains. contents: write @@ -56,10 +63,38 @@ jobs: bun-version: 1.4.2 - name: Gate main unless a green train covers its tree + id: gate if: steps.policy.outputs.train == 'true' env: GH_TOKEN: ${{ github.token }} GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state run: | bun --no-env-file --config=/dev/null --no-install run scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA" \ - --summary "$GITHUB_STEP_SUMMARY" + --summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT" + + # The per-commit verdict the weekly release reads (scripts/release-cut.ts). + # Its own job with checks: write and no repository code (the same split as + # evals.yml's train-check): only the action word the gate step recorded. + backstop-check: + runs-on: ubuntu-24.04 + needs: backstop + if: ${{ !cancelled() && needs.backstop.outputs.action != '' }} + timeout-minutes: 5 + permissions: + checks: write + steps: + - name: Publish the main-backstop check run on this commit + env: + GH_TOKEN: ${{ github.token }} + ACTION: ${{ needs.backstop.outputs.action }} + run: | + case "$ACTION" in + covered|gated) conclusion=success ;; + inert|superseded|deferred) conclusion=neutral ;; + frozen|failed) conclusion=failure ;; + *) echo "::error::unknown backstop action '$ACTION'"; exit 1 ;; + esac + gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion" \ + -f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + -f "output[title]=Main backstop: ${ACTION}" -f "output[summary]=${ACTION}: see the backstop job summary of run ${GITHUB_RUN_ID}" >/dev/null + echo "- main-backstop: ${conclusion} (${ACTION}) on ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 000000000..bb4566aef --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,114 @@ +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. A pull request that touches +# the release tooling runs the dry run against main with a read-only token, +# which is the only pre-merge exercise of the real workflow. The candidate's +# gate must already be green (free-tests and main-backstop check runs +# succeeded, every check run finished and none red, no active freeze): this +# workflow never runs tests or paid evals. Dispatch it from main: the cut job +# runs only there, with main's tooling. +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 40-hex SHA)' + type: string + default: '' + pull_request: + paths: + - '.github/workflows/weekly-release.yml' + - 'scripts/release-cut.ts' + +# One cut at a time; a second dispatch waits for the first. A pull request's +# dry run is its own group. +concurrency: + group: weekly-release-${{ github.event.pull_request.number || 'cut' }} + cancel-in-progress: false + +permissions: {} + +jobs: + cut: + name: Cut the release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + # The tag, the stable branch and the release. + contents: write + # Reads the candidate's check runs. + checks: read + steps: + # The dispatched ref supplies the tooling; the release candidate always + # comes from origin/main, which fetch-depth 0 brings along with every + # branch. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + 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 + if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::ref must be the full 40-hex SHA of a commit on main, not '$RELEASE_REF'" + exit 1 + fi + 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[@]}" + + dry-run: + name: Dry run against main + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Plan only + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet" + git fetch --quiet --tags origin + echo "## Weekly release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY" + bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 6680d44df..c4a3a64d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.91.234.0] - 2026-10-26 + +**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` and `main-backstop` check runs succeeded, with every check run finished and none red 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 cut that stopped half-way (tag and `stable` pushed, release not created) is finished by the next run. A dry run prints the plan and the exact commands and changes nothing (nothing green is exit 0 for a dry run, with every skip named); a pull request that touches the workflow or the script runs that dry run against main as a read-only job. The cut job runs only from `refs/heads/main`, takes `ref` as a full 40-hex SHA, and has `contents: write` and `checks: read` only; both rulesets cover `refs/heads/main` alone, so the token can push the tag and `stable`. +- **`.github/workflows/evals-main-backstop.yml`**, **`scripts/train-backstop.ts`**: the backstop's verdict is published on the commit as the `main-backstop` check run from a code-free `checks: write` job (`--outputs` records the action): success for `covered` and `gated`, neutral for `superseded`, `deferred` and `inert`, failure for `frozen` and `failed`. The run's own conclusion is exit 0 for a superseded push (main moved past it before it was gated), so reading the run would have called a never-gated tree green; the release reads the check run instead. +- **`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. With no `stable` on origin yet it follows main for that check, as the session updater and `gstack-upgrade` do, instead of caching `CHECK_FAILED` from a 404 until the first cut. +- **`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 (an `ls-remote` that fails is a fetch failure, not an empty answer), 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`; the fallback block requires the carried `UPSTREAM_REF` (`:?`) instead of defaulting to `origin/main` in a fresh shell, and a team install already past the release stops with `AHEAD_OF_STABLE` instead of `FF_REFUSED` and an offer to reset backwards. The helper's `--fetch-ref` keeps `origin/stable` when the fetch fails for any reason other than a missing release, so a network blink never moves a team install to main; its unused `gstack_update_channel_ref` is gone. +- **README** Step 2 (team mode) says team mode follows `stable` and how to follow every merge instead. + +### 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 or neutral `main-backstop`, red check run, missing or running `free-tests`, no `main-backstop` check run, an unrelated check run still going); 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 cut whose `gh release create` failed is finished by the next run with notes against the release before it; the CLI exits 0 on a dry run with nothing green and 1 on the real cut; 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`, carries the channel in its cache identity, and follows main when origin has no stable yet; `--fetch-ref` keeps `origin/stable` when origin is unreachable. +- `test/weekly-release-workflow.test.ts`: the workflow's triggers, concurrency, the cut job's main-only guard, permissions and 40-hex `ref` check, and the read-only pull-request dry run. `test/evals-train-workflow.test.ts`, `test/train-backstop.test.ts`: the `backstop-check` job's action-to-conclusion map, its code-free `checks: write` split, and the CLI's `--outputs` line. +- `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 and the trains notes ref; it dispatches nothing and runs no tests or evals. The backstop gates exactly as before; it additionally publishes its verdict as a check run. + ## [1.91.233.0] - 2026-10-26 **/plan-tune's inferred profile no longer pins at 1.0 after a few reviews, and it now records how many answers moved each dimension.** This closes the last two parts of #2623. One /plan-eng-review produces about five answers, and every answer added its full delta, so eight sessions of "fix now" took `architecture_care` to 0.998, as uninformative as the 0.5 it replaced. Now each UTC day moves a dimension at most as far as one strong answer, and eight days of the same answers read 0.808. The profile JSON (`--profile`) also records `contributing_events` per dimension, so a dimension with no evidence reads 0 there instead of a bare 0.5; /plan-tune's display and `--check-mismatch` don't read it yet (#3436). diff --git a/README.md b/README.md index 055a992ee..9ef2ef590 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/VERSION b/VERSION index 26e431ac8..75224693e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.233.0 +1.91.234.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3b7189a1f..f63a3b9ef 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.233.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.234.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, 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..033040d6b --- /dev/null +++ b/bin/gstack-update-channel.sh @@ -0,0 +1,88 @@ +#!/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_refspec prints the fetch refspec for +# stable, "" for main (callers +# keep their @{upstream} / +# default-branch path) +# 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 only when origin has no stable yet. Any other +# fetch failure keeps origin/stable (the fast-forward then stops or finds +# nothing new); a team install never moves to main because the network +# blinked. + +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_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)); staying on origin/stable as last fetched" >&2 + printf 'origin/stable\n'; exit 0 + 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..986519978 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,9 +76,15 @@ 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}" -REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}" +# 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_RAW_BASE="${GSTACK_REMOTE_RAW_BASE:-https://raw.githubusercontent.com/garrytan/gstack}" +REMOTE_URL="${GSTACK_REMOTE_URL:-$REMOTE_RAW_BASE/$CHANNEL/VERSION}" +REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}" COMPARE_API="${GSTACK_COMPARE_API:-https://api.github.com/repos/garrytan/gstack/compare}" # ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ── @@ -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() { @@ -309,12 +319,24 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then # Disable credential prompts and apply a 5-second low-speed timeout so a # flaky network or captive portal can't hang every skill preamble. _REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}" - _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)" + _ls_remote_branch() { # — the tip line of refs/heads/ on REMOTE_REPO; exit 0 and nothing when origin has no such branch + GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \ + bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ + git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$1" + } + _LSR_LINE="$(_ls_remote_branch "$CHANNEL")" || _LSR_LINE="" + if [ "$CHANNEL" = stable ] && [ -z "$_LSR_LINE" ] && _ls_remote_branch stable >/dev/null 2>&1; then + # Before the first release origin has no stable (ls-remote answered, empty): + # this check follows main, as bin/gstack-session-update (CHANNEL_FALLBACK) + # and bin/gstack-upgrade do. The cache identity keeps #stable, so the + # outcome replays within its TTL like any other. + CHANNEL=main + REMOTE_URL="$REMOTE_RAW_BASE/main/VERSION" + _LSR_LINE="$(_ls_remote_branch main)" || _LSR_LINE="" + fi _REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')" if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then - # The same main SHA, HEAD and upstream as the cache: its outcome still holds. + # The same channel SHA, HEAD and upstream as the cache: its outcome still holds. if [ "$(_identity_field remote)" = "$_REMOTE_SHA" ] && _identity_ok; then case "$CACHED" in UP_TO_DATE*) @@ -385,9 +407,9 @@ if [ "$LOCAL" = "$REMOTE" ]; then _write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}" if [ "$FORCE" = "1" ]; then if [ "$_WHY" = "diverged" ]; then - echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from main (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date" + echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from $CHANNEL (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date" else - echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with main (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date" + echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with $CHANNEL (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date" fi fi exit 0 diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 2a5f1c514..0fde3bf72 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 _LSR=$(_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) && [ -z "$_LSR" ]; 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..cff2a89e3 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,144 @@ +# 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), the `main-backstop` check run succeeded, every +check run on it has completed and none failed, and `refs/notes/gstack-trains` +holds no active freeze. The Main backstop gate workflow publishes +`main-backstop` on each push to main from a job of its own: success when a +green train record covers the tree or the backstop gated it green, neutral +when main moved past the push before it was gated (superseded) or a freeze's +own backstop run owns it (deferred), failure when it froze the queue or could +not finish. Neutral is never green: a commit whose own tree was never gated is +not released. A push that gets no backstop run at all (a dependabot push, a +policy without a train block) has no `main-backstop` check run and is not +green either; a cancelled check run counts as red. A commit with a check run still going is skipped and the next +older one is considered, back through 7 days or 200 first-parent commits. The +workflow runs no tests and no paid evals of its own; it only reads what the +trains and the backstop already recorded. With nothing green in the window it +fails and lists why each commit was skipped. + +A cut that stopped half-way (tag and `stable` pushed, release not created) is +finished by the next run, by cron or by hand: `stable` itself stays a +candidate until its release exists, and its notes compare against the release +tag before it. + +## 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, on main (the cut job runs +only from `refs/heads/main`, with main's tooling; `ref` must be the full +40-hex SHA). 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. + +A pull request that touches `.github/workflows/weekly-release.yml` or +`scripts/release-cut.ts` runs the same dry run against main as its own job +(`Dry run against main`, read-only token), so a change to the release tooling +is exercised against real check runs before it merges. + +## Outcomes + +| Line | Meaning | +|---|---| +| `CANDIDATE: ()` | the commit that will be (or was) released | +| `NOTHING_NEW: origin/main is already released as v` | nothing merged since the last release; exit 0 | +| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) | +| `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 and finishes a step an earlier run did not reach | diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 2af64b6ce..31328f3e1 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -110,7 +110,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR" ``` The install type and the absolute directory printed above are used in all subsequent steps. -Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout. +Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout. ### Step 3: Save old version @@ -135,12 +135,19 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin +# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main +_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main +echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1) +echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). -HOLD=$(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=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$? +# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back. +if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \ + && { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi +FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in 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 ;; @@ -164,6 +171,7 @@ their conflicting local edits were saved there and relay the `Inspect`, checkout with `git apply -3`, never `stash apply` in the install; `unmerged=1` means the rescue needs manual resolution). On `FF_STOPPED`, STOP and relay the reason: nothing was changed. +On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed. `ff-busy` means another gstack update is running (retry in a minute); `autostash-unrecoverable` names what is in the way (an untracked file the release adds, or another git command); `index-unreadable` means the checkout's @@ -176,7 +184,7 @@ 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` +1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:?}"..HEAD --oneline` in `$INSTALL_DIR`. 2. If BOTH are empty, the reset is provably safe — run the fallback block below without asking. @@ -193,7 +201,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:?UPSTREAM_REF is not set: substitute the value Step 4 printed}" ./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." @@ -280,40 +288,30 @@ Only on `LOCAL_SYNC_OK`, tell user: "Also updated vendored copy at `$LOCAL_GSTAC ### Step 4.75: Run version migrations -After `./setup` completes, run any migration scripts for versions between the old -and new version. Migrations handle state fixes that `./setup` alone can't cover -(stale config, orphaned files, directory structure changes). +After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent +`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old +version: they fix state `./setup` alone cannot (stale config, orphaned files). ```bash MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations" : "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}" if [ -d "$MIGRATIONS_DIR" ]; then for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do - # Extract version from filename: v0.15.2.0.sh → 0.15.2.0 m_ver="$(basename "$migration" .sh | sed 's/^v//')" - # Run if this migration version is newer than old version - # (simple string compare works for dotted versions with same segment count) if [ "$OLD_VERSION" != "unknown" ] && [ "$(printf '%s\n%s' "$OLD_VERSION" "$m_ver" | sort -V | head -1)" = "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$m_ver" ]; then echo "Running migration $m_ver..." - # GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just - # ~/.gstack state) default to ~/.claude/skills/gstack when unset — - # a repo-local install would silently no-op without this. + # Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack. GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)" fi done fi ``` -Migrations are idempotent bash scripts in `gstack-upgrade/migrations/`. Each is named -`v{VERSION}.sh` and runs only when upgrading from an older version. See CONTRIBUTING.md -for how to add new migrations. - ### Step 4.8: Stop any stale daemon (unconditional) -A browse daemon started before the upgrade keeps serving the OLD binary's code -until it is stopped — it survives `git reset --hard` and `./setup` because the -running process holds the old executable. Always run this step, using -the install directory detected in Step 2. +A browse daemon started before the upgrade keeps running the old binary (the +reset and `./setup` do not stop it), so always run this step with the install +directory from Step 2. ```bash INSTALL_DIR_PLACEHOLDER="" @@ -338,8 +336,7 @@ else fi ``` -Replace `` with the actual install directory before -running. Interpret the `DAEMON_CHECK` result: +Substitute the install directory from Step 2, then act on `DAEMON_CHECK`: 1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command @@ -350,7 +347,7 @@ running. Interpret the `DAEMON_CHECK` result: busy right now. When it finishes, stop it with `browse stop` — or force it immediately with `browse --force-restart stop` (loses that session's tabs/cookies/logins)." -3. **`none` / `dead` / `current`:** Nothing to do — say nothing. +3. **`none` / `dead` / `current`:** say nothing. ### Step 5: Write marker + clear cache @@ -364,7 +361,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed ### Step 6: Show What's New -Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant. +Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first. Four fields: summarize `git -C "$INSTALL_DIR" log --oneline ..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}". @@ -382,7 +379,7 @@ Happy shipping! ### Step 7: Continue -After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed. +Continue with the skill the user originally invoked; the upgrade is done. --- @@ -395,7 +392,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble): ~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \ .claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true ``` -Use the output to determine if an upgrade is available. 2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above. diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 19f438045..7298daa15 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -116,7 +116,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR" ``` The install type and the absolute directory printed above are used in all subsequent steps. -Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout. +Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout. ### Step 3: Save old version @@ -141,12 +141,19 @@ is saved in a rescue ref instead of being left as conflict markers: cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1 { [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; } git fetch origin +# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main +_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main +echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1) +echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}" # Same pre-advance check as the auto-updater: the incoming release's Bun floor, # judged by bash whatever this tool shell is (zsh does not word-split). -HOLD=$(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=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; } PRE_UPGRADE_COMMIT=$(git rev-parse HEAD) echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT" -FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$? +# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back. +if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \ + && { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi +FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$? echo "$FF" case "$FF_RC" in 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 ;; @@ -170,6 +177,7 @@ their conflicting local edits were saved there and relay the `Inspect`, checkout with `git apply -3`, never `stash apply` in the install; `unmerged=1` means the rescue needs manual resolution). On `FF_STOPPED`, STOP and relay the reason: nothing was changed. +On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed. `ff-busy` means another gstack update is running (retry in a minute); `autostash-unrecoverable` names what is in the way (an untracked file the release adds, or another git command); `index-unreadable` means the checkout's @@ -182,7 +190,7 @@ 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` +1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:?}"..HEAD --oneline` in `$INSTALL_DIR`. 2. If BOTH are empty, the reset is provably safe — run the fallback block below without asking. @@ -199,7 +207,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:?UPSTREAM_REF is not set: substitute the value Step 4 printed}" {{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." @@ -286,40 +294,30 @@ Only on `LOCAL_SYNC_OK`, tell user: "Also updated vendored copy at `$LOCAL_GSTAC ### Step 4.75: Run version migrations -After `./setup` completes, run any migration scripts for versions between the old -and new version. Migrations handle state fixes that `./setup` alone can't cover -(stale config, orphaned files, directory structure changes). +After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent +`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old +version: they fix state `./setup` alone cannot (stale config, orphaned files). ```bash MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations" : "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}" if [ -d "$MIGRATIONS_DIR" ]; then for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do - # Extract version from filename: v0.15.2.0.sh → 0.15.2.0 m_ver="$(basename "$migration" .sh | sed 's/^v//')" - # Run if this migration version is newer than old version - # (simple string compare works for dotted versions with same segment count) if [ "$OLD_VERSION" != "unknown" ] && [ "$(printf '%s\n%s' "$OLD_VERSION" "$m_ver" | sort -V | head -1)" = "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$m_ver" ]; then echo "Running migration $m_ver..." - # GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just - # ~/.gstack state) default to ~/.claude/skills/gstack when unset — - # a repo-local install would silently no-op without this. + # Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack. GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)" fi done fi ``` -Migrations are idempotent bash scripts in `gstack-upgrade/migrations/`. Each is named -`v{VERSION}.sh` and runs only when upgrading from an older version. See CONTRIBUTING.md -for how to add new migrations. - ### Step 4.8: Stop any stale daemon (unconditional) -A browse daemon started before the upgrade keeps serving the OLD binary's code -until it is stopped — it survives `git reset --hard` and `./setup` because the -running process holds the old executable. Always run this step, using -the install directory detected in Step 2. +A browse daemon started before the upgrade keeps running the old binary (the +reset and `./setup` do not stop it), so always run this step with the install +directory from Step 2. ```bash INSTALL_DIR_PLACEHOLDER="" @@ -344,8 +342,7 @@ else fi ``` -Replace `` with the actual install directory before -running. Interpret the `DAEMON_CHECK` result: +Substitute the install directory from Step 2, then act on `DAEMON_CHECK`: 1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command @@ -356,7 +353,7 @@ running. Interpret the `DAEMON_CHECK` result: busy right now. When it finishes, stop it with `browse stop` — or force it immediately with `browse --force-restart stop` (loses that session's tabs/cookies/logins)." -3. **`none` / `dead` / `current`:** Nothing to do — say nothing. +3. **`none` / `dead` / `current`:** say nothing. ### Step 5: Write marker + clear cache @@ -370,7 +367,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed ### Step 6: Show What's New -Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant. +Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first. Four fields: summarize `git -C "$INSTALL_DIR" log --oneline ..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}". @@ -388,7 +385,7 @@ Happy shipping! ### Step 7: Continue -After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed. +Continue with the skill the user originally invoked; the upgrade is done. --- @@ -401,7 +398,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble): ~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \ .claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true ``` -Use the output to determine if an upgrade is available. 2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above. diff --git a/package.json b/package.json index b4a7ae0e4..1dd562f81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.233", + "version": "1.91.234", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index 0cb61ce66..a85ce9cc5 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -17,7 +17,7 @@ "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}, "scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":6}, "scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, - "scripts/test-pr-profile.ts": {"blob":"d334195b0d0393622e2c380dc3e2feebe099b6e6","since":6,"nonBehavioral":"classify the new evals-main-backstop.yml workflow as free-lane only: it dispatches evals.yml on main and runs inside no case, so no case's selection on a PR, input or verdict changes"}, + "scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"}, "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}, "test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6}, "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6}, diff --git a/scripts/release-cut.ts b/scripts/release-cut.ts new file mode 100755 index 000000000..9bac6b18c --- /dev/null +++ b/scripts/release-cut.ts @@ -0,0 +1,330 @@ +#!/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), its main-backstop check run succeeded + * (evals-main-backstop.yml publishes it: success only when a green train + * record covers the tree or the backstop gated it green; a push main moved + * past before it was gated is neutral, never green), every check run on it + * has completed and none failed, 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 with a check run still going 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. A cut that stopped half-way (tag and stable pushed, release not + * created) is finished by the next run; a completed cut reports NOTHING_NEW + * 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 REQUIRED_CHECK = 'free-tests'; +export const BACKSTOP_CHECK = 'main-backstop'; +export const NOTES_REF = 'refs/notes/gstack-trains'; +const NOTES_LOCAL = 'refs/notes/gstack-trains-release'; +const RED = new Set(['failure', 'timed_out', 'cancelled', '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 check run on it 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 ?? []); + for (const name of [REQUIRED_CHECK, BACKSTOP_CHECK]) { + const named = runs.filter(c => c.name === name); + if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false }; + if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true }; + if (!named.some(c => c.conclusion === 'success')) return { green: false, why: `${name} concluded ${named.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 running = runs.filter(c => c.status !== 'completed'); + if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true }; + 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' }; + } + + // stable itself stays a candidate so a cut that stopped half-way (tag and + // stable pushed, release missing) is finished; nothing older is looked at. + let sha: string | null = null; + for (const c of candidates) { + const v = gateVerdict(gh, repo, c); + if (v.green) { sha = c; break; } + skipped.push(c.slice(0, 12) + ': ' + v.why); + if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; } + } + 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' }; + if (prevStable === sha && tagExists === 'same' && gh(['release', 'view', tag, '--repo', repo, '--json', 'tagName']).status === 0) { + return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + sha.slice(0, 12) + ' is already released as ' + tag + '; nothing newer is green' }; + } + + // The release this one follows: stable, or when stable already sits here + // (a cut that stopped half-way), the newest release tag behind it. + let before = prevStable; + if (prevStable === sha) { + const prior = git(['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', '--first-parent', sha + '^']); + const resolved = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']) : null; + before = resolved?.ok ? resolved.out : null; + } + let prevVersion: string | null = null, prevTag: string | null = null; + if (before) { + const shown = git(['show', before + ':VERSION']); + prevVersion = shown.ok ? shown.out.trim() || null : null; + if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion; + } else { + const date = git(['show', '-s', '--format=%cI', sha]).out; + 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: !before }); + 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.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)), + '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)]); + // A dry run that finds nothing green has done its job (the skips say why); + // the real cut goes red so someone looks. + process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') ? 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/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index d334195b0..a80de69f1 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -275,6 +275,8 @@ export const FREE_ONLY_PR_FILES = [ '.github/workflows/test-health.yml', '.github/workflows/eval-sweep.yml', // The weekly off-ship sweep on main; no PR-gate case reads it, and it never gates a PR. '.github/workflows/evals-main-backstop.yml', // The merge-train backstop on main: it dispatches evals.yml after a push to main; no PR-gate case reads it, and it never gates a PR. + '.github/workflows/weekly-release.yml', // The Monday release cut (tag, stable, GitHub Release): reads check runs on main; no paid case reads it, and it never gates a PR. + 'scripts/release-cut.ts', // The release cut's logic, run only by weekly-release.yml; covered by the free test/release-cut.test.ts. '.github/actionlint.yaml', // Runner labels for actionlint only. 'scripts/ship-measure-sweep.ts', // The weekly sweep CLI; it launches paid cases, never runs inside one. '.github/workflows/version-gate.yml', diff --git a/scripts/train-backstop.ts b/scripts/train-backstop.ts index e2c6d35f4..935353550 100644 --- a/scripts/train-backstop.ts +++ b/scripts/train-backstop.ts @@ -192,8 +192,9 @@ function freeze(o: BackstopOptions, c: { gh: GhRunner; git: Git; repoKey: string } const USAGE = `usage: bun run scripts/train-backstop.ts run --repo --branch
--sha - [--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary ] -Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.`; + [--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary ] [--outputs ] +Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish. +--outputs appends action= (the workflow reads it as a step output and publishes the main-backstop check run from it).`; if (import.meta.main) { const [command, ...rest] = process.argv.slice(2); @@ -208,5 +209,7 @@ if (import.meta.main) { console.log(line); if (summary) fs.appendFileSync(summary, `- ${line}\n`); } + const outputs = flag('outputs'); + if (outputs) fs.appendFileSync(outputs, `action=${action}\n`); process.exit(action === 'frozen' || action === 'failed' ? 1 : 0); } diff --git a/test/bun-version-shells.test.ts b/test/bun-version-shells.test.ts index 5d1e880f2..6cd918206 100644 --- a/test/bun-version-shells.test.ts +++ b/test/bun-version-shells.test.ts @@ -107,7 +107,7 @@ describe.skipIf(process.platform === 'win32')('Bun floor check in every shell (# test('/gstack-upgrade Step 4 runs the floor check through bash, whatever the tool shell is', () => { const tmpl = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'), 'utf8'); - expect(tmpl).toContain(`HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null)`); + expect(tmpl).toContain(`HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null)`); expect(tmpl).not.toMatch(/HOLD=\$\(\. bin\/gstack-bun-version\.sh/); expect(tmpl).toContain('BUN_VERSION_UNREADABLE'); }); diff --git a/test/evals-train-workflow.test.ts b/test/evals-train-workflow.test.ts index e52fd1986..891063a13 100644 --- a/test/evals-train-workflow.test.ts +++ b/test/evals-train-workflow.test.ts @@ -435,6 +435,28 @@ describe('evals-main-backstop.yml', () => { expect(policy.run).not.toContain('bun'); const gate = job.steps.find(st => st.name === 'Gate main unless a green train covers its tree')!; expect(gate.if).toBe("steps.policy.outputs.train == 'true'"); + expect(gate.id).toBe('gate'); expect(gate.run).toContain('scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA"'); + expect(gate.run).toContain('--outputs "$GITHUB_OUTPUT"'); + expect(job.outputs).toEqual({ action: '${{ steps.gate.outputs.action }}' }); + }); + + // The weekly release (scripts/release-cut.ts) reads the per-commit verdict as + // the main-backstop check run: a superseded push exits 0 but is neutral there. + test('the main-backstop check run is published from its own code-free job: success for covered|gated, neutral for inert|superseded|deferred, failure otherwise', () => { + const job = backstopWf.jobs['backstop-check']!; + expect(job.needs).toBe('backstop'); + expect(job.if).toBe("${{ !cancelled() && needs.backstop.outputs.action != '' }}"); + expect(job.permissions).toEqual({ checks: 'write' }); + expect(job.steps.map(st => st.uses).filter(Boolean)).toEqual([]); + expect(job.steps).toHaveLength(1); + const publish = job.steps[0]!; + expect(publish.env).toEqual({ GH_TOKEN: '${{ github.token }}', ACTION: '${{ needs.backstop.outputs.action }}' }); + expect(publish.run).toContain('covered|gated) conclusion=success ;;'); + expect(publish.run).toContain('inert|superseded|deferred) conclusion=neutral ;;'); + expect(publish.run).toContain('frozen|failed) conclusion=failure ;;'); + expect(publish.run).toContain('*) echo "::error::unknown backstop action'); + expect(publish.run).toContain('-f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion"'); + expect(publish.run).not.toContain('bun'); }); }); diff --git a/test/gstack-upgrade-fresh-shell.test.ts b/test/gstack-upgrade-fresh-shell.test.ts index 608a3f6a8..08fe04521 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:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"', 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..2698df41a --- /dev/null +++ b/test/release-cut.test.ts @@ -0,0 +1,395 @@ +/** + * 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 + * check run or a red one keeps a commit out; a cut that stopped half-way is + * finished by the next run (docs/releases.md). + */ +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'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { + BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, 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(); +} + +type CheckRun = { name: string; status: string; conclusion: string | null }; +interface State { checks: Record; released: Set; createFails?: string; calls: string[][] } + +const GREEN: CheckRun[] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }]; +const backstop = (status: string, conclusion: string | null): CheckRun[] => [...GREEN.filter(c => c.name !== BACKSTOP_CHECK), { name: BACKSTOP_CHECK, status, conclusion }]; + +function fakeGh(state: State): Gh { + return (args) => { + state.calls.push(args); + const api = args[0] === 'api' ? args[args.length - 1]! : ''; + const 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: '' }; + 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') { + if (state.createFails === args[2]) return { status: 1, stdout: '', stderr: 'HTTP 502 Bad Gateway' }; + 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: {}, 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 backstop or a red check keeps a commit out and is named', () => { + const f = fixture(); + f.state.checks[f.shas['1.0.3']!] = backstop('in_progress', null); + f.state.checks[f.shas['1.0.2']!] = [...GREEN, { 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) + ': ' + BACKSTOP_CHECK + ' 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, no backstop check run and a superseded (neutral) backstop 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.checks[f.shas['1.0.1']!] = GREEN.filter(c => c.name !== BACKSTOP_CHECK); + 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 ' + BACKSTOP_CHECK + ' check run']); + // A push main moved past before the backstop gated it (train-backstop.ts + // 'superseded', exit 0) publishes a neutral check run: never green. + f.state.checks[f.shas['1.0.1']!] = backstop('completed', 'neutral'); + const neutral = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(neutral.ok).toBe(true); + if (neutral.ok) { expect(neutral.plan.sha).toBe(f.shas['1.0.0']); expect(neutral.plan.skipped[2]).toBe(f.shas['1.0.1']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral'); } + }); + + 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.checks[sha] = backstop('completed', 'failure'); + 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(BACKSTOP_CHECK + ' 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'); + }); + + // Value: protects=gateVerdict names a failed free-tests run and an unreadable check-runs API as not green, holds a commit with any check run still going as + // pending, and merges paginated check pages; fails_when=a required-check failure is treated as red-list noise, a gh error reads as green or pending, a queued + // unrelated check run is read as green, or only the first page of check runs is read; why_new=the planning tests cover missing, running and red runs only, + // never a failed required run, a gh error, an unrelated running check or a second page; seam=none; no_claim=GitHub's real check-runs payload shape (a fake gh answers; unit only) + test('gateVerdict: a failed free-tests run, an unreadable check-runs API, an unrelated running check and a paginated check list', () => { + const checks = (runs: unknown[]): Gh => (args) => { + const api = args[args.length - 1]!; + if (/check-runs/.test(api)) return { status: 0, stdout: JSON.stringify(runs), stderr: '' }; + return { status: 1, stdout: '', stderr: 'unexpected gh ' + args.join(' ') }; + }; + const failed = checks([{ check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'failure' }] }]); + expect(gateVerdict(failed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: REQUIRED_CHECK + ' concluded failure', pending: false }); + const unreadable: Gh = () => ({ status: 1, stdout: '', stderr: 'HTTP 403' }); + expect(gateVerdict(unreadable, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'cannot read its check runs', pending: false }); + const garbled: Gh = () => ({ status: 0, stdout: 'not json', stderr: '' }); + expect(gateVerdict(garbled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'cannot read its check runs', pending: false }); + const queued = checks([{ check_runs: [...GREEN, { name: 'slices-report', status: 'queued', conclusion: null }] }]); + expect(gateVerdict(queued, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'check runs still going: slices-report', pending: true }); + const paged = checks([{ check_runs: [{ name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }] }]); + expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true }); + const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]); + expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false }); + const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]); + expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false }); + const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]); + expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true }); + }); + + test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => { + 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']); + }); + + // Value: protects=a cut that pushed the tag and stable but failed to create the release is finished by the next run (same commit, notes against the release + // before, release created) instead of being refused as 'already stable' forever; fails_when=planRelease drops stable itself from the candidates, the notes + // compare the commit with itself, or a finished cut is cut again; why_new=the cut test re-runs only a completed cut (NOTHING_NEW); seam=none; no_claim=none beyond protects + test('a cut that stopped after pushing the tag and stable is finished by the next run, by window or by --ref', () => { + const f = fixture(); + f.state.createFails = 'v1.0.3'; + const first = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const half = cutRelease(first.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }); + expect(half.ok).toBe(false); + expect(half.lines.at(-1)).toContain('RELEASE: create failed: HTTP 502'); + expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.3']); + delete f.state.createFails; + + f.refresh(); + let resumed = first.plan; + for (const ref of [undefined, f.shas['1.0.3']]) { + const again = planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh }); + expect(again.ok, JSON.stringify(again)).toBe(true); + if (!again.ok) return; + resumed = again.plan; + expect(again.plan.sha).toBe(f.shas['1.0.3']); + expect(again.plan.stableAt).toBe(true); + expect(again.plan.tagExists).toBe('same'); + expect(again.plan.prevVersion).toBeNull(); + expect(again.plan.notes).toContain('First tagged release: the 4 versions, one line each'); + expect(describePlan(again.plan, true)[2]).toBe('STABLE: already at ' + f.shas['1.0.3']!.slice(0, 12) + ' (finishing an earlier cut)'); + } + const done = cutRelease(resumed, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }); + expect(done.ok, done.lines.join('\n')).toBe(true); + expect(done.lines).toEqual(['TAG: v1.0.3 already names ' + f.shas['1.0.3']!.slice(0, 12), 'STABLE: already at ' + f.shas['1.0.3']!.slice(0, 12), 'RELEASE: v1.0.3 published https://github.com/o/r/releases/tag/v1.0.3']); + + // A later half-done cut reads its notes against the release before it. + f.state.createFails = 'v1.0.4'; + 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).toBe(true); + if (!promo.ok) return; + expect(cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false); + delete f.state.createFails; + f.refresh(); + const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(finish.ok, JSON.stringify(finish)).toBe(true); + if (!finish.ok) return; + expect(finish.plan.sha).toBe(hot); + expect(finish.plan.prevTag).toBe('v1.0.3'); + expect(finish.plan.notes).toContain('1 version since v1.0.3'); + expect(finish.plan.notes).toContain('compare/v1.0.3...v1.0.4'); + expect(finish.plan.notes).not.toContain('- **1.0.3**'); + const once = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(cutRelease(finish.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true); + expect(once.ok).toBe(true); + f.refresh(); + const nothing = planRelease({ repo: f.repo, git: f.git, gh: f.gh }); + expect(nothing.ok).toBe(false); + if (!nothing.ok) expect(nothing.message).toBe('origin/main ' + hot.slice(0, 12) + ' is already released as v1.0.4; nothing newer is green'); + }); + + 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'); } + + // Value: protects=a hotfix --ref that is not green or not a commit is REFUSED (exit 1 in the workflow), never promoted; fails_when=--ref skips the gate or an unknown + // ref falls through to the window; why_new=the hotfix test promotes a green ref only and the side-branch case covers first-parent, not the gate; seam=none; + // no_claim=none beyond protects + f.state.checks[f.shas['1.0.3']!] = backstop('completed', 'failure'); + const red = planRelease({ repo: f.repo, ref: f.shas['1.0.3'], git: f.git, gh: f.gh }); + expect(red.ok).toBe(false); + if (!red.ok) { expect(red.code).toBe('REFUSED'); expect(red.message).toBe('--ref ' + f.shas['1.0.3']!.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded failure'); } + const unknown = planRelease({ repo: f.repo, ref: 'f'.repeat(40), git: f.git, gh: f.gh }); + expect(unknown.ok).toBe(false); + if (!unknown.ok) { expect(unknown.code).toBe('REFUSED'); expect(unknown.message).toContain('is not a commit here'); } + delete f.state.checks[f.shas['1.0.3']!]; + + 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.skipIf(process.platform === 'win32')('release-cut: the workflow entry point', () => { + // Value: protects=the CLI weekly-release.yml runs: --dry-run plans, prints CANDIDATE/DRY_RUN lines, appends the plan and notes to --summary and pushes nothing, + // exits 0 on NOTHING_NEW, 1 on REFUSED and 2 without --repo; fails_when=an argument is misread, an exit code flips so the workflow goes red on a quiet week or + // green on a refusal, or a dry run pushes; why_new=every other test calls the exported functions, never the argv/exit/summary boundary the workflow uses; seam=none; + // no_claim=GitHub's API or a real gh (a gh shim answers from PATH; the git side is a real bare origin) + test('--dry-run plans and pushes nothing, the summary carries the plan and notes, and the exit codes are the workflow contract', () => { + const f = fixture(); + const shim = path.join(f.base, 'shim'); + fs.mkdirSync(shim); + fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\necho "gh $*" >> "' + path.join(f.base, 'gh.log') + '"\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN }]) + '\' ;;\n "release view v1.0.3 "*) echo \'{\"tagName\":\"v1.0.3\"}\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 }); + const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), ...args], + { cwd: f.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, PATH: shim + path.delimiter + process.env.PATH } }); + + const summary = path.join(f.base, 'summary.md'); + const dry = run('--repo', f.repo, '--dry-run', '--summary', summary); + expect(dry.status, dry.stdout + dry.stderr).toBe(0); + expect(dry.stdout).toContain('CANDIDATE: ' + f.shas['1.0.3'] + ' ('); + expect(dry.stdout).toContain('VERSION: 1.0.3 -> tag v1.0.3'); + expect(dry.stdout).toContain('DRY_RUN: nothing pushed, nothing created.'); + expect(dry.stdout).not.toContain('--- notes ---'); + const written = fs.readFileSync(summary, 'utf8'); + expect(written).toContain('- CANDIDATE: ' + f.shas['1.0.3']); + expect(written).toContain('
Release notes'); + expect(written).toContain('- **1.0.3** (2026-10-23): Fourth trims the preamble.'); + expect(execFileSync('git', ['ls-remote', f.origin, 'refs/heads/stable', 'refs/tags/v1.0.3'], { encoding: 'utf8', timeout: 30_000 }).trim()).toBe(''); + expect(fs.readFileSync(path.join(f.base, 'gh.log'), 'utf8')).not.toContain('gh release'); + + const usage = run('--dry-run'); + expect(usage.status).toBe(2); + expect(usage.stderr).toContain('usage: release-cut.ts --repo owner/name'); + + git(f.seed, 'tag', 'v1.0.3', f.shas['1.0.3']!); + git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.3', f.shas['1.0.3'] + ':refs/heads/stable'); + f.refresh(); + const quiet = run('--repo', f.repo, '--dry-run'); + expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0); + expect(quiet.stdout.split('\n')[0]).toBe('NOTHING_NEW: origin/main ' + f.shas['1.0.3']!.slice(0, 12) + ' is already released as v1.0.3; nothing newer is green'); + + const refused = run('--repo', f.repo, '--dry-run', '--ref', f.shas['1.0.1']!); + expect(refused.status).toBe(1); + expect(refused.stdout).toContain('REFUSED: non-fast-forward'); + + // Nothing green: the real cut goes red (exit 1), a dry run has answered (exit 0) and names every skip. + fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN.filter(c => c.name !== BACKSTOP_CHECK) }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 }); + git(f.seed, 'push', '-q', '--delete', 'origin', 'stable', 'refs/tags/v1.0.3'); + f.refresh(); git(f.work, 'tag', '-d', 'v1.0.3'); + const noneDry = run('--repo', f.repo, '--dry-run'); + expect(noneDry.status, noneDry.stdout + noneDry.stderr).toBe(0); + expect(noneDry.stdout.split('\n')[0]).toContain('NO_GREEN_COMMIT: no green commit among 4 on origin/main'); + expect(noneDry.stdout).toContain('SKIPPED: ' + f.shas['1.0.3']!.slice(0, 12) + ': no ' + BACKSTOP_CHECK + ' check run'); + const noneCut = run('--repo', f.repo); + expect(noneCut.status).toBe(1); + expect(execFileSync('git', ['ls-remote', f.origin, 'refs/heads/stable', 'refs/tags/*'], { encoding: 'utf8', timeout: 30_000 }).trim()).toBe(''); + }); +}); + +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/train-backstop.test.ts b/test/train-backstop.test.ts index 44a13d188..b71244faf 100644 --- a/test/train-backstop.test.ts +++ b/test/train-backstop.test.ts @@ -7,8 +7,10 @@ * and a scripted gh; no network, no API spend. */ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; import { loadPolicyFromHead } from '../lib/ship-policy'; import { NOTES_REF, persist, repoIdentity } from '../lib/ship-queue-train-record'; import type { GhRunner } from '../scripts/recover-receipts'; @@ -122,6 +124,21 @@ describe('backstop: inert without a train block', () => { expect(out.lines[0]).toContain('declares no train block'); expect(calls).toEqual([]); }); + + // evals-main-backstop.yml reads this step output and publishes the + // main-backstop check run the weekly release gates on (scripts/release-cut.ts). + test.skipIf(process.platform === 'win32')('the CLI appends action= to --outputs and exits 0 when inert', () => { + const fx = trainFixture(BASE_POLICY); + const sha = squashMerge(fx.repo, 1); + const outputs = path.join(fx.repo.root, 'outputs.txt'); + const summary = path.join(fx.repo.root, 'summary.md'); + const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(import.meta.dir, '..', 'scripts', 'train-backstop.ts'), 'run', '--repo', REPO, '--branch', 'main', '--sha', sha, '--summary', summary, '--outputs', outputs], + { cwd: fx.repo.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: path.join(fx.repo.root, 'state') } }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('BACKSTOP: inert'); + expect(fs.readFileSync(outputs, 'utf8')).toBe('action=inert\n'); + expect(fs.readFileSync(summary, 'utf8')).toContain('- BACKSTOP: inert'); + }); }); describe("backstop: a train's merges are covered by the train's own green record", () => { diff --git a/test/update-channel.test.ts b/test/update-channel.test.ts new file mode 100644 index 000000000..9af92d882 --- /dev/null +++ b/test/update-channel.test.ts @@ -0,0 +1,374 @@ +/** + * 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'; + +import { runBin } from './helpers/run-bin'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HELPER = path.join(ROOT, 'bin', 'gstack-update-channel.sh'); +const CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config'); +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 names stable only; a missing gstack-config means main', () => { + const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_refspec 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', '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); + }); +}); + +describe('gstack-config update_channel', () => { + // Value: protects=update_channel defaults to auto in get, list and defaults, accepts exactly auto|stable|main, and rejects anything else with exit 1 and the + // stored value unchanged; fails_when=the default arm, the closed-domain guard or the list/defaults rows are dropped, or a typo is stored and silently read as auto; + // why_new=the resolution tests stub gstack-config, so the real key's contract (the one docs/releases.md documents) has no test; seam=none; + // no_claim=none beyond protects + test('defaults to auto, accepts auto|stable|main, and refuses anything else without changing the stored value', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-config-')); + bases.push(home); + const run = (args: string[]) => runBin(CONFIG_BIN, args, { gstackHome: home, cwd: ROOT, trim: true }); + expect(run(['get', 'update_channel']).stdout).toBe('auto'); + expect(run(['defaults']).stdout).toMatch(/update_channel:\s+auto$/m); + expect(run(['list']).stdout).toMatch(/update_channel:\s+auto \(default\)$/m); + for (const v of ['stable', 'main', 'auto']) { + expect(run(['set', 'update_channel', v]).status).toBe(0); + expect(run(['get', 'update_channel']).stdout).toBe(v); + } + expect(run(['set', 'update_channel', 'stable']).status).toBe(0); + const junk = run(['set', 'update_channel', 'weekly']); + expect(junk.status).toBe(1); + expect(junk.stderr).toContain("update_channel 'weekly' not recognized. Valid values: auto, stable, main. Existing value left unchanged."); + expect(run(['get', 'update_channel']).stdout).toBe('stable'); + expect(run(['list']).stdout).toMatch(/update_channel:\s+stable \(set\)$/m); + expect(fs.readFileSync(path.join(home, 'config.yaml'), 'utf8')).toContain('# update_channel: auto # auto = stable when team_mode is true, else main'); + }); +}); + +/** 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-update-channel.sh as the /gstack-upgrade skill runs it', () => { + const cli = (w: World, ...args: string[]) => spawnSync('bash', [path.join(w.install, 'bin', 'gstack-update-channel.sh'), ...args, w.install], { cwd: w.base, encoding: 'utf8', env: w.env, timeout: 30_000 }); + + // Value: protects=the CLI the upgrade skill substitutes into UPSTREAM_REF: it prints the channel, --fetch-ref fetches and names origin/stable for a team install, + // and names origin/main (with the note) when origin has no stable or the channel is main; fails_when=argument parsing or the fallback breaks so the skill + // resets a team install to origin/main, or --fetch-ref prints a ref it never fetched; why_new=the function-level tests source the helper and never run its + // __main__ block; seam=none; no_claim=the skill's own fence (the template is pinned by upgrade-template-pins.test.ts; this runs the helper alone) + test('prints the channel; --fetch-ref names origin/stable after fetching it, and origin/main when there is no release yet or the channel is main', () => { + const w = world(); + expect(cli(w).stdout.trim()).toBe('stable'); + const stable = cli(w, '--fetch-ref'); + expect(stable.status, stable.stdout + stable.stderr).toBe(0); + expect(stable.stdout.trim()).toBe('origin/stable'); + expect(stable.stderr).toBe(''); + expect(git(w.install, 'rev-parse', 'refs/remotes/origin/stable')).toBe(w.shas.B); + expect(w.head()).toBe(w.shas.A); + + fs.writeFileSync(w.channel, 'main'); + expect(cli(w).stdout.trim()).toBe('main'); + const onMain = cli(w, '--fetch-ref'); + expect(onMain.status).toBe(0); + expect(onMain.stdout.trim()).toBe('origin/main'); + expect(onMain.stderr).toBe(''); + expect(git(w.install, 'rev-parse', 'refs/remotes/origin/main')).toBe(w.shas.C); + + const early = world({ stable: false }); + const fallback = cli(early, '--fetch-ref'); + expect(fallback.status).toBe(0); + expect(fallback.stdout.trim()).toBe('origin/main'); + expect(fallback.stderr).toContain('origin has no stable release yet; following origin/main until the first weekly release is cut'); + expect(git(early.install, 'rev-parse', 'refs/remotes/origin/main')).toBe(early.shas.C); + expect(early.head()).toBe(early.shas.A); + + // A fetch that fails for any other reason (origin unreachable) keeps the + // team install on origin/stable; the fast-forward then stops or finds + // nothing new, and nobody is moved to main by a network blink. + const offline = world(); + git(offline.install, 'remote', 'set-url', 'origin', path.join(offline.base, 'gone.git')); + const kept = cli(offline, '--fetch-ref'); + expect(kept.status).toBe(0); + expect(kept.stdout.trim()).toBe('origin/stable'); + expect(kept.stderr).toContain('fetching origin/stable failed'); + expect(kept.stderr).toContain('staying on origin/stable as last fetched'); + }); +}); + +describe.skipIf(process.platform === 'win32')('the /gstack-upgrade Step 4 fence on the stable channel', () => { + const fence = (() => { + const tmpl = fs.readFileSync(path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'), 'utf8'); + const hits = [...tmpl.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]!).filter(f => f.includes('bash bin/gstack-ff-update . "$UPSTREAM_REF"')); + if (hits.length !== 1) throw new Error('expected one Step 4 fence, found ' + hits.length); + return hits[0]!.replace(/\{\{SETUP_COMMAND\}\}/g, './setup'); + })(); + const run = (w: World) => spawnSync('bash', ['-c', fence], { cwd: w.base, encoding: 'utf8', timeout: 60_000, env: { ...w.env, INSTALL_DIR: w.install } }); + + // Value: protects=a team install past the release stops with AHEAD_OF_STABLE and moves nothing, including the README's depth-1 clone where stable's history + // never connects to HEAD (ancestry says nothing; the VERSION order decides), and an install behind the release does not take that exit; fails_when=the shortcut + // tests ancestry alone (depth-1 installs made since the last cut get FF_REFUSED and an offer to reset backwards) or fires for an install stable is ahead of; + // why_new=the template is covered by string pins only; seam=none; no_claim=the FF_OK arm (bin/gstack-ff-update and setup are not part of this world) + test('a depth-1 team install past the release prints AHEAD_OF_STABLE and exits 0 untouched; one behind the release goes on to the fast-forward', () => { + const ahead = world({ installAt: 'C' }); + const r = run(ahead); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('UPSTREAM_REF=origin/stable'); + expect(r.stdout.trim().split('\n').at(-1)).toBe('AHEAD_OF_STABLE'); + expect(ahead.head()).toBe(ahead.shas.C); + expect(git(ahead.install, 'rev-parse', 'refs/remotes/origin/stable')).toBe(ahead.shas.B); + // The depth-1 histories never connect, so ancestry said nothing and the VERSION order decided. + expect(spawnSync('git', ['-C', ahead.install, 'merge-base', '--is-ancestor', 'origin/stable', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).status).toBe(1); + expect(fs.existsSync(path.join(ahead.base, 'setup-calls'))).toBe(false); + + const behind = world({ installAt: 'A' }); + const b = run(behind); + expect(b.stdout).toContain('UPSTREAM_REF=origin/stable'); + expect(b.stdout).not.toContain('AHEAD_OF_STABLE'); + expect(b.status).not.toBe(0); + expect(behind.head()).toBe(behind.shas.A); + }); +}); + +describe.skipIf(process.platform === 'win32')('gstack-upgrade on the stable channel', () => { + const run = (w: World, args: string[] = []) => spawnSync('bash', [path.join(w.install, 'bin', 'gstack-upgrade'), ...args], { cwd: w.base, encoding: 'utf8', env: w.env, timeout: 60_000 }); + + 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'); + + // Before the first release origin has no stable: the check follows main for + // that run (as session-update and gstack-upgrade do) instead of caching + // CHECK_FAILED from a 404 on stable/VERSION until Monday. + git(clone, 'push', '-q', 'origin', ':stable'); + fs.rmSync(path.join(state, 'last-update-check'), { force: true }); + fs.writeFileSync(gitLog, ''); + const noRelease = run({ GSTACK_UPDATE_CHANNEL: 'stable' }); + expect(noRelease.stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1'); + expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/stable'); + expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/main'); + expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1\nIDENTITY head=' + local + ' remote=' + main + ' upstream=' + origin + '#stable\n'); + // The outcome replays from the cache within its TTL: no second pair of ls-remote calls per skill start. + fs.writeFileSync(gitLog, ''); + expect(run({ GSTACK_UPDATE_CHANNEL: 'stable' }).stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1'); + expect(fs.readFileSync(gitLog, 'utf8')).toBe(''); + // A failed ls-remote (origin unreachable) is not "no stable yet": the check does not flip to main. + fs.writeFileSync(gitLog, ''); + fs.rmSync(path.join(state, 'last-update-check'), { force: true }); + const unreachable = run({ GSTACK_UPDATE_CHANNEL: 'stable', GSTACK_REMOTE_REPO: path.join(base, 'gone.git') }); + expect(unreachable.stdout.trim()).toBe(''); + expect(fs.readFileSync(gitLog, 'utf8')).not.toContain('refs/heads/main'); + expect(fs.readFileSync(path.join(state, 'last-update-check'), 'utf8')).toMatch(/^CHECK_FAILED 1\.0\.0 /); + }); +}); diff --git a/test/upgrade-template-pins.test.ts b/test/upgrade-template-pins.test.ts index 9d03b685f..31cbfbd83 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:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"'); 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:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"')); }); 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:?}"..HEAD'); + expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:?}"..HEAD')).toBeLessThan( + tmpl.indexOf('git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"'), ); }); }); diff --git a/test/weekly-release-workflow.test.ts b/test/weekly-release-workflow.test.ts new file mode 100644 index 000000000..eddef038a --- /dev/null +++ b/test/weekly-release-workflow.test.ts @@ -0,0 +1,63 @@ +/** + * weekly-release.yml (free): the Monday cut's wiring (docs/releases.md). The + * cut job runs only off main, for the schedule and a dispatch, with the token + * scoped to the tag, the stable branch and the release; a pull request that + * touches the release tooling runs the dry run against main read-only; a + * dispatched hotfix ref is a full SHA and the dry_run input, true by default, + * never reaches the scheduled run. The release-cut CLI the jobs call is + * covered by test/release-cut.test.ts. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type Step = { name?: string; uses?: string; if?: string; run?: string; env?: Record; with?: Record }; +type Job = { name?: string; if?: string; permissions?: Record; 'timeout-minutes'?: number; steps: Step[] }; +type Workflow = { on: Record; concurrency: { group: string; 'cancel-in-progress': boolean }; permissions?: unknown; jobs: Record }; +const ROOT = path.resolve(import.meta.dir, '..'); +const wf = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/weekly-release.yml'), 'utf8')) as Workflow; +const BUN_RUN = 'bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts'; + +describe('weekly-release.yml', () => { + test('Monday schedule, a dispatch with dry_run (default true) and a full-SHA ref, and a pull request only for the release tooling', () => { + expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']); + expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1' }]); + expect(wf.on.workflow_dispatch.inputs.dry_run).toMatchObject({ type: 'boolean', default: true }); + expect(wf.on.workflow_dispatch.inputs.ref).toMatchObject({ type: 'string', default: '' }); + expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts'] }); + expect(wf.concurrency).toEqual({ group: "weekly-release-${{ github.event.pull_request.number || 'cut' }}", 'cancel-in-progress': false }); + expect(wf.permissions).toEqual({}); + expect(Object.keys(wf.jobs)).toEqual(['cut', 'dry-run']); + }); + + test('the cut job runs only off main for a schedule or dispatch, writes contents, reads checks, and rejects a ref that is not a 40-hex SHA', () => { + const cut = wf.jobs.cut!; + expect(cut.if).toBe("github.event_name != 'pull_request' && github.ref == 'refs/heads/main'"); + expect(cut.permissions).toEqual({ contents: 'write', checks: 'read' }); + expect(cut.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0 }); + const plan = cut.steps.find(s => s.name === 'Plan and cut')!; + expect(plan.env!.GH_TOKEN).toBe('${{ github.token }}'); + expect(plan.env!.DRY_RUN).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}"); + expect(plan.env!.RELEASE_REF).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}"); + expect(plan.run).toContain('set -euo pipefail'); + expect(plan.run).toContain('[[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]'); + expect(plan.run).toContain('[ "$DRY_RUN" = true ] && args+=(--dry-run)'); + expect(plan.run).toContain('[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")'); + expect(plan.run).toContain(BUN_RUN + ' "${args[@]}"'); + expect(cut.steps.find(s => s.name === 'Fetch stable and the tags')!.run).toContain("git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable'"); + }); + + test('the pull-request dry run is read-only, keeps no credentials, and always passes --dry-run', () => { + const dry = wf.jobs['dry-run']!; + expect(dry.if).toBe("github.event_name == 'pull_request'"); + expect(dry.permissions).toEqual({ contents: 'read', checks: 'read' }); + expect(dry.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0, 'persist-credentials': false }); + const plan = dry.steps.find(s => s.name === 'Plan only')!; + expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run'); + expect(plan.run).not.toContain('--ref'); + for (const job of Object.values(wf.jobs)) { + expect(job['timeout-minutes']).toBe(20); + for (const step of job.steps) if (step.run) expect(step.run).not.toMatch(/\b(bun install|bun test|EVALS=)/); + } + }); +});