v1.91.234.0 feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98) (#3429)

* feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98)

Every Monday morning .github/workflows/weekly-release.yml tags the newest green
commit on main as v<VERSION>, publishes a GitHub Release from the CHANGELOG
headlines and fast-forwards refs/heads/stable to it (scripts/release-cut.ts;
workflow_dispatch with dry_run, and ref for a hotfix). Team-mode installs
follow stable through the new update_channel key (auto: stable when
team_mode, else main) in bin/gstack-session-update, bin/gstack-update-check,
bin/gstack-upgrade and /gstack-upgrade. Per-merge stamping and the merge
train are unchanged. docs/releases.md explains it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): check out the dispatched ref so a dry run exercises that branch's script

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(weekly-release): a PR touching the release tooling runs the dry run against main (read-only); the cut job checks out the dispatched ref

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gstack-upgrade): no $1 in the rendered skill (Claude Code rewrites $N), trim Step 4 under the size budget, pin the new floor-check literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: leave setup untouched (its channel note moves to README and docs/releases.md); the release workflow and script are free-only PR files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: record the pr-profile free-only classification as non-behavioral (HARNESS_VERSION 6)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): gate the cut on a main-backstop check run, finish half-done cuts, keep team installs on their channel

/ship review fixes (GSTA-98, PR #3429):

- The weekly release read the Main backstop gate run's conclusion, but
  scripts/train-backstop.ts exits 0 when main moved past the push before it
  was gated (superseded), so a never-gated tree read as green. The backstop
  workflow now publishes its verdict on the commit as the `main-backstop`
  check run from a code-free checks:write job (success for covered|gated,
  neutral for inert|superseded|deferred, failure for frozen|failed), fed by
  the new `--outputs` flag; release-cut.ts requires that check run and
  free-tests to succeed and holds a commit whose check runs are still going.
- A cut that pushed the tag and stable but failed to create the release can
  be finished: stable itself stays a candidate until its release exists, the
  notes compare against the release tag behind it, and a dry run with nothing
  green exits 0 with the skips named (the real cut still fails).
- weekly-release.yml: the cut job runs only from refs/heads/main, `ref` must
  be a full 40-hex SHA, and actions:read is dropped (no workflow-runs call).
- /gstack-upgrade: the destructive fallback requires the carried UPSTREAM_REF
  instead of defaulting to origin/main in a fresh shell; a team install past
  the release stops with AHEAD_OF_STABLE instead of FF_REFUSED and a reset
  backwards. gstack-update-channel.sh --fetch-ref keeps origin/stable on any
  fetch failure other than a missing release; dead gstack_update_channel_ref
  removed. gstack-update-check follows main before the first release instead
  of caching CHECK_FAILED; gstack-upgrade treats a failed ls-remote as a
  fetch failure, not an empty answer.
- Tests: release-cut (check-run fixture, partial cut, neutral/pending),
  weekly-release-workflow (new), evals-train-workflow and train-backstop
  (backstop-check job, --outputs), update-channel (no-release fallback,
  offline --fetch-ref), template pins.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(releases): the upgrade skill's ahead-of-stable exit works in depth-1 clones; update-check keeps its cache identity on the pre-release fallback

/ship second-pass review of 73e278d4 (GSTA-98, PR #3429):

- gstack-upgrade/SKILL.md.tmpl: README installs are depth-1 clones, where
  origin/stable never connects to HEAD, so the AHEAD_OF_STABLE shortcut now
  also fires when HEAD is not behind stable and stable's VERSION is not
  higher than the installed one (the order bin/gstack-upgrade uses). The
  fence prints the channel helper's warnings instead of discarding them, and
  a 'fetching origin/stable failed' line is a STOP. Prose trimmed to stay
  under the skill size budget. New behavioral test runs the Step 4 fence on
  a depth-1 install past the release (AHEAD_OF_STABLE, nothing moved) and on
  one behind it (goes on to the fast-forward).
- bin/gstack-update-check: the no-stable fallback keeps #stable in the
  cache identity (so the outcome replays within its TTL instead of two
  ls-remote calls per skill start), triggers only when ls-remote answered
  with no such branch (a failed ls-remote stays CHECK_FAILED on stable), and
  the branch-pinned URL derives from REMOTE_RAW_BASE so tests stay offline.
- scripts/release-cut.ts: a cancelled check run is red; the previous-release
  lookups check .ok before using git output. docs/releases.md says which
  pushes get no main-backstop check run at all.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 07:22:44 +00:00
1 parent 4216bbb223
commit 75badf306c
27 files changed
+1833 -94

No files matched your search

+36 -1
View File
@@ -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"
+114
View File
@@ -0,0 +1,114 @@
name: Weekly release
# Tags the newest green commit on main as v<VERSION>, 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
+27
View File
@@ -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<VERSION>`, 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<VERSION>`, 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=<sha>`. 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 `<channel>/VERSION`, resolves the SHA with `ls-remote refs/heads/<channel>` 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).
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.91.233.0
1.91.234.0
+1 -1
View File
@@ -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,
+11 -2
View File
@@ -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 \
+70 -4
View File
@@ -24,6 +24,13 @@
# the release are shelved in refs/gstack/rescue/<oid>, 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 <release> <head> — 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=""
+88
View File
@@ -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 <install dir> prints stable | main
# gstack_update_channel_refspec <channel> prints the fetch refspec for
# stable, "" for main (callers
# keep their @{upstream} /
# default-branch path)
# gstack_update_channel_missing <git stderr> 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] [<install dir>]`
# 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
+31 -9
View File
@@ -5,7 +5,9 @@
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
# UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>
# — VERSION is equal but main moved <n>
# — VERSION is equal but the channel branch
# (main, or stable for a team install:
# bin/gstack-update-channel.sh) moved <n>
# commits past this checkout's HEAD (the
# GitHub compare API says so); the snooze
# is keyed by <sha>
@@ -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 <name> — 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() { # <branch> — the tip line of refs/heads/<branch> on REMOTE_REPO; exit 0 and nothing when origin has no such branch
GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \
bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$1"
}
_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
+43 -7
View File
@@ -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 <ver>`
@@ -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
+144
View File
@@ -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<VERSION>` (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
(`<state root>/analytics/session-update.log`) carries `CHANNEL stable target=<sha>`
when it follows a release.
- `bin/gstack-update-check`, the "update available" line in every skill
preamble: it reads `<channel>/VERSION` and compares with `refs/heads/<channel>`,
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=<full sha on main> -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: <sha> (<date>)` | the commit that will be (or was) released |
| `NOTHING_NEW: origin/main <sha> is already released as v<X>` | 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<X> already names <other sha>` | 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 |
+24 -28
View File
@@ -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="<install dir from Step 2>"
@@ -338,8 +336,7 @@ else
fi
```
Replace `<install dir from Step 2>` with the actual install directory before
running. Interpret the `DAEMON_CHECK` result:
Substitute the install directory from Step 2, then act on `DAEMON_CHECK`:
1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the
old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command
@@ -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 <PRE_UPGRADE_COMMIT>..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.
+24 -28
View File
@@ -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="<install dir from Step 2>"
@@ -344,8 +342,7 @@ else
fi
```
Replace `<install dir from Step 2>` with the actual install directory before
running. Interpret the `DAEMON_CHECK` result:
Substitute the install directory from Step 2, then act on `DAEMON_CHECK`:
1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the
old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command
@@ -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 <PRE_UPGRADE_COMMIT>..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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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},
+330
View File
@@ -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<VERSION>, 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 <sha on main>). Dry runs print the plan and change nothing.
*
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
*
* 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<VERSION> 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<T>(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<unknown>(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 <notes>');
}
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 <sha>] [--dry-run] [--summary <file>]'); 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<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\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);
}
+2
View File
@@ -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',
+5 -2
View File
@@ -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 <owner/name> --branch <main> --sha <sha>
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>]
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 <file>] [--outputs <file>]
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=<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);
}
+1 -1
View File
@@ -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');
});
+22
View File
@@ -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');
});
});
+1 -1
View File
@@ -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<typeof world>, h: HostRender) => Record<string, string> }> = [
{ 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: () => ({}) },
+395
View File
@@ -0,0 +1,395 @@
/**
* release-cut (free): the weekly release picks the newest green commit on
* main's first-parent history, tags it v<VERSION>, 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<string, CheckRun[]>; released: Set<string>; 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<string, string> = {};
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('<details><summary>Release notes</summary>');
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)');
});
});
+17
View File
@@ -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=<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", () => {
+374
View File
@@ -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<string, string> = {}) => {
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<string, string> = {};
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<typeof world>;
async function updaterLog(w: World, pattern: RegExp, ms = 20_000): Promise<string> {
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<string, string | undefined>).GSTACK_REMOTE_URL;
const run = (extra: Record<string, string>) => 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 /);
});
});
+6 -6
View File
@@ -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}"'),
);
});
});
+63
View File
@@ -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<string, string>; with?: Record<string, unknown> };
type Job = { name?: string; if?: string; permissions?: Record<string, string>; 'timeout-minutes'?: number; steps: Step[] };
type Workflow = { on: Record<string, any>; concurrency: { group: string; 'cancel-in-progress': boolean }; permissions?: unknown; jobs: Record<string, Job> };
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=)/);
}
});
});