mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
4216bbb223
commit
75badf306c
27 files changed
+1833
-94
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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
@@ -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 \
|
||||
|
||||
@@ -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=""
|
||||
|
||||
Executable
+88
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
Executable
+330
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: () => ({}) },
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 /);
|
||||
});
|
||||
});
|
||||
@@ -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}"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user