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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 01:39:03 +00:00
1 parent 98092dc46c
commit 73e278d4a6
18 files changed
+551 -135

No files matched your search

+36 -1
View File
@@ -7,6 +7,11 @@ name: Main backstop gate
# backstop writes the freeze record gstack-ship-queue reads. Inert (no paid
# work, nothing written) unless main's .gstack/ship-policy.json declares a
# `train` block; without one the per-PR gates cover main as before.
#
# The verdict is published on the commit as the main-backstop check run
# (success when a green train covers the tree or the backstop gated it green,
# neutral when the push was superseded or deferred, failure when it froze the
# queue or could not finish); the weekly release reads it (docs/releases.md).
on:
push:
branches: [main]
@@ -25,6 +30,8 @@ jobs:
if: github.actor != 'dependabot[bot]'
# Settle window (60) + the full gate (270) + margin.
timeout-minutes: 345
outputs:
action: ${{ steps.gate.outputs.action }}
permissions:
# Pushes the freeze record to refs/notes/gstack-trains.
contents: write
@@ -56,10 +63,38 @@ jobs:
bun-version: 1.4.2
- name: Gate main unless a green train covers its tree
id: gate
if: steps.policy.outputs.train == 'true'
env:
GH_TOKEN: ${{ github.token }}
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
run: |
bun --no-env-file --config=/dev/null --no-install run scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA" \
--summary "$GITHUB_STEP_SUMMARY"
--summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT"
# The per-commit verdict the weekly release reads (scripts/release-cut.ts).
# Its own job with checks: write and no repository code (the same split as
# evals.yml's train-check): only the action word the gate step recorded.
backstop-check:
runs-on: ubuntu-24.04
needs: backstop
if: ${{ !cancelled() && needs.backstop.outputs.action != '' }}
timeout-minutes: 5
permissions:
checks: write
steps:
- name: Publish the main-backstop check run on this commit
env:
GH_TOKEN: ${{ github.token }}
ACTION: ${{ needs.backstop.outputs.action }}
run: |
case "$ACTION" in
covered|gated) conclusion=success ;;
inert|superseded|deferred) conclusion=neutral ;;
frozen|failed) conclusion=failure ;;
*) echo "::error::unknown backstop action '$ACTION'"; exit 1 ;;
esac
gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion" \
-f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
-f "output[title]=Main backstop: ${ACTION}" -f "output[summary]=${ACTION}: see the backstop job summary of run ${GITHUB_RUN_ID}" >/dev/null
echo "- main-backstop: ${conclusion} (${ACTION}) on ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY"
+11 -8
View File
@@ -6,9 +6,10 @@ name: Weekly release
# 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, no red check run, a green Main
# backstop gate run, no active freeze): this workflow never runs tests or
# paid evals.
# 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.
@@ -20,7 +21,7 @@ on:
type: boolean
default: true
ref:
description: 'Hotfix: release this commit of main instead of the newest green one (full SHA)'
description: 'Hotfix: release this commit of main instead of the newest green one (full 40-hex SHA)'
type: string
default: ''
pull_request:
@@ -39,14 +40,13 @@ permissions: {}
jobs:
cut:
name: Cut the release
if: github.event_name != 'pull_request'
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 check runs and backstop runs of the candidate.
actions: read
# Reads the candidate's check runs.
checks: read
steps:
# The dispatched ref supplies the tooling; the release candidate always
@@ -75,6 +75,10 @@ jobs:
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")
@@ -88,7 +92,6 @@ jobs:
timeout-minutes: 20
permissions:
contents: read
actions: read
checks: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+9 -7
View File
@@ -5,25 +5,27 @@
**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` check run succeeded, with no red check run, a Main backstop gate run that concluded success and no active freeze in `refs/notes/gstack-trains`, is tagged `v<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 dry run prints the plan and the exact commands and changes nothing; a pull request that touches the workflow or the script runs that dry run against main as a read-only job. The cut job has `contents: write` only; both rulesets cover `refs/heads/main` alone, so the token can push the tag and `stable`.
- **`.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.
- **`bin/gstack-upgrade`**: fetches and fast-forwards to `stable` on that channel (`gstack-upgrade: channel stable ...` on stderr), follows the default branch with a note when origin has no `stable` yet, and treats a release whose VERSION is not higher than the installed one as current instead of diverged in a shallow clone. `--check` output lines are unchanged.
- **`gstack-upgrade/SKILL.md.tmpl`**: Step 4 resolves `UPSTREAM_REF` through the helper and uses it for the Bun-floor check, the fast-forward, the unpushed-commit gate and the reset fallback instead of a literal `origin/main`.
- **`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 backstop, red check run, missing or running `free-tests`, no backstop run); an active freeze refuses and a cleared one does not; a cut creates the annotated tag, `stable` and the release with `--verify-tag` and the headline in the notes; re-running is `NOTHING_NEW`; a hotfix `--ref` promotes a later main commit with a compare link; stable never moves backwards; a side-branch ref and a clashing tag are refused; a thousand-version span stays under the notes cap.
- `test/update-channel.test.ts`: the resolution matrix (team mode × key × env override); the session-start hook on a depth-1 team install moves to stable (B) not main (C), follows main on `update_channel=main`, falls back to main with no stable on origin, and stays put when already past the release with nothing pending; `gstack-upgrade --check` and the upgrade follow stable then main after the key flips, say when no release exists, and call a depth-1 install past the release current; `gstack-update-check` on stable stays silent while main is ahead, asks `ls-remote` for `refs/heads/stable`, and carries the channel in its cache identity.
- `test/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, backstop runs and the trains notes ref; it dispatches nothing and runs no tests or evals.
- 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.221.0] - 2026-10-25
+9 -11
View File
@@ -18,12 +18,10 @@
# Functions (never fail; a broken config means main, the behaviour before
# channels existed):
# gstack_update_channel <install dir> prints stable | main
# gstack_update_channel_ref <channel> prints the remote-tracking ref:
# refs/remotes/origin/stable, or
# "" for main (callers keep their
# @{upstream} / default-branch path)
# gstack_update_channel_refspec <channel> prints the fetch refspec for
# stable, "" for main
# 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
@@ -32,7 +30,10 @@
# 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 when origin has no stable yet.
# 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)}
@@ -48,10 +49,6 @@ gstack_update_channel() {
if [ "$_uc_team" = true ]; then printf 'stable\n'; else printf 'main\n'; fi
}
gstack_update_channel_ref() {
case "${1:-}" in stable) printf 'refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac
}
gstack_update_channel_refspec() {
case "${1:-}" in stable) printf '+refs/heads/stable:refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac
}
@@ -82,7 +79,8 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
if gstack_update_channel_missing "$_uc_err"; then
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2
else
echo "gstack-update-channel: fetching origin/stable failed ($(printf '%s' "$_uc_err" | head -1 | head -c 200)); following origin/main for this run" >&2
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
+17 -6
View File
@@ -319,12 +319,23 @@ 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/$2" 2>/dev/null' _ "$REMOTE_REPO" "$CHANNEL" || true)"
_ls_remote_branch() { # <branch> — the tip line of refs/heads/<branch> on REMOTE_REPO, or nothing
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" || true
}
_LSR_LINE="$(_ls_remote_branch "$CHANNEL")"
if [ "$CHANNEL" = stable ] && [ -z "$_LSR_LINE" ]; then
# Before the first release origin has no stable: this check follows main,
# as bin/gstack-session-update (CHANNEL_FALLBACK) and bin/gstack-upgrade do.
CHANNEL=main
REMOTE_URL="https://raw.githubusercontent.com/garrytan/gstack/main/VERSION"
UPSTREAM="${UPSTREAM%#stable}"
_LSR_LINE="$(_ls_remote_branch main)"
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*)
@@ -395,9 +406,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
+1 -1
View File
@@ -90,7 +90,7 @@ if [ "$CHANNEL" = stable ]; then
if _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin stable 2>/dev/null; then
FOLLOW=stable
echo "gstack-upgrade: channel stable (the weekly release; gstack-config set update_channel main follows every merge)" >&2
elif [ -z "$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote origin refs/heads/stable 2>/dev/null)" ]; then
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"
+22 -11
View File
@@ -21,15 +21,25 @@ commit on main's first-parent history, moved only by this workflow, only
forward.
**Green** means, on that exact commit: the `free-tests` check run succeeded
(the status main requires), no completed check run failed, the Main backstop
gate run for that push completed with conclusion success (a green train record
covers the tree, or the backstop gated it green), and `refs/notes/gstack-trains`
holds no active freeze. A commit whose gate is still running is skipped and the
next older one is considered, back through 7 days or 200 first-parent commits.
The workflow runs no tests and no paid evals of its own; it only reads what the
(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 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 |
@@ -88,8 +98,9 @@ workflow by hand with the merge commit's SHA:
gh workflow run weekly-release.yml -f ref=<full sha on main> -f dry_run=false
```
or Actions → **Weekly release** → Run workflow. The same green checks apply.
The commit is tagged with its VERSION, the release notes say "hotfix release"
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.
@@ -123,9 +134,9 @@ is exercised against real check runs before it merges.
| Line | Meaning |
|---|---|
| `CANDIDATE: <sha> (<date>)` | the commit that will be (or was) released |
| `NOTHING_NEW: origin/main <sha> is already stable` | nothing merged since the last release; exit 0 |
| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 |
| `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 |
| `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 |
+10 -16
View File
@@ -110,7 +110,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR"
```
The install type and the absolute directory printed above are used in all subsequent steps.
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
### Step 3: Save old version
@@ -143,6 +143,8 @@ echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}"
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"
# A team install past the release waits for stable; nothing moves back.
if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && git merge-base --is-ancestor origin/stable HEAD; then echo "AHEAD_OF_STABLE"; exit 0; fi
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
echo "$FF"
case "$FF_RC" in
@@ -167,6 +169,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).
`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
@@ -179,7 +182,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 "${UPSTREAM_REF:-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.
@@ -196,7 +199,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 "${UPSTREAM_REF:-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."
@@ -283,34 +286,25 @@ 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
+10 -16
View File
@@ -116,7 +116,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR"
```
The install type and the absolute directory printed above are used in all subsequent steps.
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
### Step 3: Save old version
@@ -149,6 +149,8 @@ echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}"
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"
# A team install past the release waits for stable; nothing moves back.
if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && git merge-base --is-ancestor origin/stable HEAD; then echo "AHEAD_OF_STABLE"; exit 0; fi
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
echo "$FF"
case "$FF_RC" in
@@ -173,6 +175,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).
`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
@@ -185,7 +188,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 "${UPSTREAM_REF:-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.
@@ -202,7 +205,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 "${UPSTREAM_REF:-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."
@@ -289,34 +292,25 @@ 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
+41 -27
View File
@@ -10,16 +10,20 @@
*
* The checkout needs origin's full history (fetch-depth 0). Green, for a
* commit on main's first-parent history, means: its free-tests check run
* succeeded (the status main requires), no completed check run on it failed,
* its "Main backstop gate" run completed with conclusion success (a green train
* record covers the tree, or the backstop gated it green), and
* refs/notes/gstack-trains holds no active freeze. The newest such commit in
* the window (7 days, 200 first-parent commits) is the candidate; a commit
* whose gate is still running is skipped, not waited for.
* 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. Re-running a completed cut reports it and exits 0.
* 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';
@@ -29,8 +33,8 @@ import { spawnSync } from 'node:child_process';
export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
export type Git = (args: string[]) => { ok: boolean; out: string };
export const BACKSTOP_WORKFLOW = 'evals-main-backstop.yml';
export const REQUIRED_CHECK = 'free-tests';
export const 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', 'action_required', 'startup_failure', 'stale']);
@@ -65,25 +69,22 @@ export function compareVersions(a: string, b: string): number {
export type Verdict = { green: true } | { green: false; why: string; pending: boolean };
interface CheckRun { name: string; status: string; conclusion: string | null }
/** Why a commit is not releasable, or green. Pending means a required run is still going. */
/** 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 ?? []);
const required = runs.filter(c => c.name === REQUIRED_CHECK);
if (required.length === 0) return { green: false, why: `no ${REQUIRED_CHECK} check run`, pending: false };
if (required.some(c => c.status !== 'completed')) return { green: false, why: `${REQUIRED_CHECK} is still running`, pending: true };
if (!required.some(c => c.conclusion === 'success')) return { green: false, why: `${REQUIRED_CHECK} concluded ${required.map(c => c.conclusion).join(',')}`, pending: false };
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 backstop = parseJson<{ workflow_runs?: Array<{ status: string; conclusion: string | null; created_at: string }> }>(
gh(['api', `repos/${repo}/actions/workflows/${BACKSTOP_WORKFLOW}/runs?head_sha=${sha}&per_page=10`]));
if (!backstop) return { green: false, why: 'cannot read its backstop runs', pending: false };
const newest = (backstop.workflow_runs ?? []).sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
if (!newest) return { green: false, why: 'no Main backstop gate run', pending: false };
if (newest.status !== 'completed') return { green: false, why: 'Main backstop gate is still running', pending: true };
if (newest.conclusion !== 'success') return { green: false, why: `Main backstop gate concluded ${newest.conclusion}`, pending: false };
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 };
}
@@ -188,15 +189,16 @@ export function planRelease(o: PlanOptions): PlanResult {
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
candidates = list.ok && list.out ? list.out.split('\n') : [];
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
if (prevStable && candidates[0] === prevStable) return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + prevStable.slice(0, 12) + ' is already stable' };
}
// 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) {
if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; }
const v = gateVerdict(gh, repo, c);
if (v.green) { sha = c; break; }
skipped.push(c.slice(0, 12) + ': ' + v.why);
if (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 };
@@ -212,10 +214,20 @@ export function planRelease(o: PlanOptions): PlanResult {
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 + '^']);
before = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']).out || null : null;
}
let prevVersion: string | null = null, prevTag: string | null = null;
if (prevStable) {
prevVersion = git(['show', prevStable + ':VERSION']).out.trim() || null;
if (before) {
prevVersion = git(['show', before + ':VERSION']).out.trim() || null;
if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
} else {
const date = git(['show', '-s', '--format=%cI', sha]).out;
@@ -225,7 +237,7 @@ export function planRelease(o: PlanOptions): PlanResult {
}
const changelog = git(['show', sha + ':CHANGELOG.md']);
const date = git(['show', '-s', '--format=%cs', sha]).out;
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !prevStable });
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 } };
}
@@ -271,7 +283,7 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
const lines = [
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
'STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12) + (p.stableAt ? ' (already there)' : ''),
'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);
@@ -302,7 +314,9 @@ if (import.meta.main) {
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh });
if (!planned.ok) {
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
process.exit(planned.code === 'NOTHING_NEW' ? 0 : 1);
// 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');
+5 -2
View File
@@ -192,8 +192,9 @@ function freeze(o: BackstopOptions, c: { gh: GhRunner; git: Git; repoKey: string
}
const USAGE = `usage: bun run scripts/train-backstop.ts run --repo <owner/name> --branch <main> --sha <sha>
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>]
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.`;
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>] [--outputs <file>]
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.
--outputs appends action=<action> (the workflow reads it as a step output and publishes the main-backstop check run from it).`;
if (import.meta.main) {
const [command, ...rest] = process.argv.slice(2);
@@ -208,5 +209,7 @@ if (import.meta.main) {
console.log(line);
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
}
const outputs = flag('outputs');
if (outputs) fs.appendFileSync(outputs, `action=${action}\n`);
process.exit(action === 'frozen' || action === 'failed' ? 1 : 0);
}
+22
View File
@@ -435,6 +435,28 @@ describe('evals-main-backstop.yml', () => {
expect(policy.run).not.toContain('bun');
const gate = job.steps.find(st => st.name === 'Gate main unless a green train covers its tree')!;
expect(gate.if).toBe("steps.policy.outputs.train == 'true'");
expect(gate.id).toBe('gate');
expect(gate.run).toContain('scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA"');
expect(gate.run).toContain('--outputs "$GITHUB_OUTPUT"');
expect(job.outputs).toEqual({ action: '${{ steps.gate.outputs.action }}' });
});
// The weekly release (scripts/release-cut.ts) reads the per-commit verdict as
// the main-backstop check run: a superseded push exits 0 but is neutral there.
test('the main-backstop check run is published from its own code-free job: success for covered|gated, neutral for inert|superseded|deferred, failure otherwise', () => {
const job = backstopWf.jobs['backstop-check']!;
expect(job.needs).toBe('backstop');
expect(job.if).toBe("${{ !cancelled() && needs.backstop.outputs.action != '' }}");
expect(job.permissions).toEqual({ checks: 'write' });
expect(job.steps.map(st => st.uses).filter(Boolean)).toEqual([]);
expect(job.steps).toHaveLength(1);
const publish = job.steps[0]!;
expect(publish.env).toEqual({ GH_TOKEN: '${{ github.token }}', ACTION: '${{ needs.backstop.outputs.action }}' });
expect(publish.run).toContain('covered|gated) conclusion=success ;;');
expect(publish.run).toContain('inert|superseded|deferred) conclusion=neutral ;;');
expect(publish.run).toContain('frozen|failed) conclusion=failure ;;');
expect(publish.run).toContain('*) echo "::error::unknown backstop action');
expect(publish.run).toContain('-f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion"');
expect(publish.run).not.toContain('bun');
});
});
+1 -1
View File
@@ -183,7 +183,7 @@ describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad path
const UNUSABLE: Kind[] = ['unset', 'empty', 'nonexistent', 'unreadable'];
const cases: Array<{ name: string; doc: 'upgrade' | 'spec'; marker: string; target: string; kinds?: Kind[]; others: (wd: ReturnType<typeof world>, h: HostRender) => Record<string, string> }> = [
{ name: 'ff-only upgrade', doc: 'upgrade', marker: 'bash bin/gstack-ff-update', target: 'INSTALL_DIR', others: () => ({}) },
{ name: 'reset fallback', doc: 'upgrade', marker: 'git reset --hard "${UPSTREAM_REF:-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: () => ({}) },
+182 -20
View File
@@ -3,15 +3,17 @@
* 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
* gate or a red check run keeps a commit out (docs/releases.md).
* 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 } from 'node:child_process';
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_WORKFLOW, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gitIn,
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
planRelease, releaseNotes, type Gh,
} from '../scripts/release-cut';
@@ -22,21 +24,23 @@ function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } }).trim();
}
interface State { checks: Record<string, Array<{ name: string; status: string; conclusion: string | null }>>; backstop: Record<string, Array<{ status: string; conclusion: string | null; created_at: string }>>; released: Set<string>; calls: string[][] }
type CheckRun = { name: string; status: string; conclusion: string | null };
interface State { checks: Record<string, CheckRun[]>; released: Set<string>; createFails?: string; calls: string[][] }
const GREEN = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: 'slices-report', status: 'queued', conclusion: null }];
const COVERED = [{ status: 'completed', conclusion: 'success', created_at: '2026-10-25T00:00:00Z' }];
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]! : '';
let m = /commits\/([0-9a-f]+)\/check-runs/.exec(api);
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: '' };
m = new RegExp('workflows/' + BACKSTOP_WORKFLOW.replace('.', '\\.') + '/runs\\?head_sha=([0-9a-f]+)').exec(api);
if (m) return { status: 0, stdout: JSON.stringify({ workflow_runs: state.backstop[m[1]!] ?? COVERED }), stderr: '' };
if (args[0] === 'release' && args[1] === 'view') return state.released.has(args[2]!) ? { status: 0, stdout: '{}', stderr: '' } : { status: 1, stdout: '', stderr: 'release not found' };
if (args[0] === 'release' && args[1] === 'create') { state.released.add(args[2]!); return { status: 0, stdout: 'https://github.com/o/r/releases/tag/' + args[2], stderr: '' }; }
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(' ') };
};
}
@@ -69,15 +73,15 @@ function fixture() {
release('1.0.3', 'Fourth trims the preamble.');
execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 });
const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); };
const state: State = { checks: {}, backstop: {}, released: new Set(), calls: [] };
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 gate or a red check keeps a commit out and is named', () => {
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.backstop[f.shas['1.0.3']!] = [{ status: 'in_progress', conclusion: null, created_at: '2026-10-25T01:00:00Z' }];
f.state.checks[f.shas['1.0.2']!] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }];
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;
@@ -86,7 +90,7 @@ describe('release-cut: planning', () => {
expect(r.plan.tag).toBe('v1.0.1');
expect(r.plan.prevStable).toBeNull();
expect(r.plan.skipped).toEqual([
f.shas['1.0.3']!.slice(0, 12) + ': Main backstop gate is still running',
f.shas['1.0.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');
@@ -98,33 +102,63 @@ describe('release-cut: planning', () => {
expect(lines).toContain(' git push origin ' + f.shas['1.0.1'] + ':refs/heads/stable');
});
test('a missing free-tests run, a running free-tests run and no backstop run are not green', () => {
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.backstop[f.shas['1.0.1']!] = [];
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 Main backstop gate run']);
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.backstop[sha] = [{ status: 'completed', conclusion: 'failure', created_at: '2026-10-25T00:00:00Z' }];
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('Main backstop gate concluded failure');
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 });
});
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 };
@@ -182,6 +216,66 @@ describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
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');
@@ -190,6 +284,18 @@ describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
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');
@@ -209,6 +315,62 @@ describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
});
});
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');
+17
View File
@@ -7,8 +7,10 @@
* and a scripted gh; no network, no API spend.
*/
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { loadPolicyFromHead } from '../lib/ship-policy';
import { NOTES_REF, persist, repoIdentity } from '../lib/ship-queue-train-record';
import type { GhRunner } from '../scripts/recover-receipts';
@@ -122,6 +124,21 @@ describe('backstop: inert without a train block', () => {
expect(out.lines[0]).toContain('declares no train block');
expect(calls).toEqual([]);
});
// evals-main-backstop.yml reads this step output and publishes the
// main-backstop check run the weekly release gates on (scripts/release-cut.ts).
test.skipIf(process.platform === 'win32')('the CLI appends action=<action> to --outputs and exits 0 when inert', () => {
const fx = trainFixture(BASE_POLICY);
const sha = squashMerge(fx.repo, 1);
const outputs = path.join(fx.repo.root, 'outputs.txt');
const summary = path.join(fx.repo.root, 'summary.md');
const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(import.meta.dir, '..', 'scripts', 'train-backstop.ts'), 'run', '--repo', REPO, '--branch', 'main', '--sha', sha, '--summary', summary, '--outputs', outputs],
{ cwd: fx.repo.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: path.join(fx.repo.root, 'state') } });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('BACKSTOP: inert');
expect(fs.readFileSync(outputs, 'utf8')).toBe('action=inert\n');
expect(fs.readFileSync(summary, 'utf8')).toContain('- BACKSTOP: inert');
});
});
describe("backstop: a train's merges are covered by the train's own green record", () => {
+90 -3
View File
@@ -14,8 +14,11 @@ 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 }); });
@@ -56,13 +59,39 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh: resolut
expect(f.resolve('true', 'stable', { GSTACK_UPDATE_CHANNEL: 'main' })).toBe('main');
});
test('the refspec and ref name stable only; a missing gstack-config means main', () => {
const r = spawnSync('bash', ['-c', '. "$1" && gstack_update_channel_refspec stable && gstack_update_channel_ref stable && gstack_update_channel_refspec main | wc -c && gstack_update_channel /nonexistent', '_', HELPER], { encoding: 'utf8', timeout: 10_000, env: { ...process.env, GSTACK_UPDATE_CHANNEL: '' } });
expect(r.stdout.split('\n').map(l => l.trim())).toEqual(['+refs/heads/stable:refs/remotes/origin/stable', 'refs/remotes/origin/stable', '1', 'main', '']);
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-'));
@@ -166,6 +195,52 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update on the stab
});
});
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')('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 });
@@ -238,5 +313,17 @@ describe.skipIf(process.platform === 'win32')('gstack-update-check on the stable
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 + '\n');
});
});
+5 -5
View File
@@ -15,7 +15,7 @@ describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', ()
test('the fast-forward (gstack-ff-update) runs before any reset --hard', () => {
const ff = tmpl.indexOf('bash bin/gstack-ff-update . "$UPSTREAM_REF"');
const reset = tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"');
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 "${UPSTREAM_REF:-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 "${UPSTREAM_REF:-origin/main}"..HEAD');
expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD')).toBeLessThan(
tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"'),
expect(tmpl).toContain('git rev-list "${UPSTREAM_REF:?}"..HEAD');
expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:?}"..HEAD')).toBeLessThan(
tmpl.indexOf('git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"'),
);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* weekly-release.yml (free): the Monday cut's wiring (docs/releases.md). The
* cut job runs only off main, for the schedule and a dispatch, with the token
* scoped to the tag, the stable branch and the release; a pull request that
* touches the release tooling runs the dry run against main read-only; a
* dispatched hotfix ref is a full SHA and the dry_run input, true by default,
* never reaches the scheduled run. The release-cut CLI the jobs call is
* covered by test/release-cut.test.ts.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
type Step = { name?: string; uses?: string; if?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> };
type Job = { name?: string; if?: string; permissions?: Record<string, string>; 'timeout-minutes'?: number; steps: Step[] };
type Workflow = { on: Record<string, any>; concurrency: { group: string; 'cancel-in-progress': boolean }; permissions?: unknown; jobs: Record<string, Job> };
const ROOT = path.resolve(import.meta.dir, '..');
const wf = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/weekly-release.yml'), 'utf8')) as Workflow;
const BUN_RUN = 'bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts';
describe('weekly-release.yml', () => {
test('Monday schedule, a dispatch with dry_run (default true) and a full-SHA ref, and a pull request only for the release tooling', () => {
expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']);
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1' }]);
expect(wf.on.workflow_dispatch.inputs.dry_run).toMatchObject({ type: 'boolean', default: true });
expect(wf.on.workflow_dispatch.inputs.ref).toMatchObject({ type: 'string', default: '' });
expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts'] });
expect(wf.concurrency).toEqual({ group: "weekly-release-${{ github.event.pull_request.number || 'cut' }}", 'cancel-in-progress': false });
expect(wf.permissions).toEqual({});
expect(Object.keys(wf.jobs)).toEqual(['cut', 'dry-run']);
});
test('the cut job runs only off main for a schedule or dispatch, writes contents, reads checks, and rejects a ref that is not a 40-hex SHA', () => {
const cut = wf.jobs.cut!;
expect(cut.if).toBe("github.event_name != 'pull_request' && github.ref == 'refs/heads/main'");
expect(cut.permissions).toEqual({ contents: 'write', checks: 'read' });
expect(cut.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0 });
const plan = cut.steps.find(s => s.name === 'Plan and cut')!;
expect(plan.env!.GH_TOKEN).toBe('${{ github.token }}');
expect(plan.env!.DRY_RUN).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}");
expect(plan.env!.RELEASE_REF).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}");
expect(plan.run).toContain('set -euo pipefail');
expect(plan.run).toContain('[[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]');
expect(plan.run).toContain('[ "$DRY_RUN" = true ] && args+=(--dry-run)');
expect(plan.run).toContain('[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")');
expect(plan.run).toContain(BUN_RUN + ' "${args[@]}"');
expect(cut.steps.find(s => s.name === 'Fetch stable and the tags')!.run).toContain("git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable'");
});
test('the pull-request dry run is read-only, keeps no credentials, and always passes --dry-run', () => {
const dry = wf.jobs['dry-run']!;
expect(dry.if).toBe("github.event_name == 'pull_request'");
expect(dry.permissions).toEqual({ contents: 'read', checks: 'read' });
expect(dry.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0, 'persist-credentials': false });
const plan = dry.steps.find(s => s.name === 'Plan only')!;
expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run');
expect(plan.run).not.toContain('--ref');
for (const job of Object.values(wf.jobs)) {
expect(job['timeout-minutes']).toBe(20);
for (const step of job.steps) if (step.run) expect(step.run).not.toMatch(/\b(bun install|bun test|EVALS=)/);
}
});
});