mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
98092dc46c
commit
73e278d4a6
18 files changed
+550
-134
No files matched your search
@@ -7,6 +7,11 @@ name: Main backstop gate
|
|||||||
# backstop writes the freeze record gstack-ship-queue reads. Inert (no paid
|
# backstop writes the freeze record gstack-ship-queue reads. Inert (no paid
|
||||||
# work, nothing written) unless main's .gstack/ship-policy.json declares a
|
# work, nothing written) unless main's .gstack/ship-policy.json declares a
|
||||||
# `train` block; without one the per-PR gates cover main as before.
|
# `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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -25,6 +30,8 @@ jobs:
|
|||||||
if: github.actor != 'dependabot[bot]'
|
if: github.actor != 'dependabot[bot]'
|
||||||
# Settle window (60) + the full gate (270) + margin.
|
# Settle window (60) + the full gate (270) + margin.
|
||||||
timeout-minutes: 345
|
timeout-minutes: 345
|
||||||
|
outputs:
|
||||||
|
action: ${{ steps.gate.outputs.action }}
|
||||||
permissions:
|
permissions:
|
||||||
# Pushes the freeze record to refs/notes/gstack-trains.
|
# Pushes the freeze record to refs/notes/gstack-trains.
|
||||||
contents: write
|
contents: write
|
||||||
@@ -56,10 +63,38 @@ jobs:
|
|||||||
bun-version: 1.4.2
|
bun-version: 1.4.2
|
||||||
|
|
||||||
- name: Gate main unless a green train covers its tree
|
- name: Gate main unless a green train covers its tree
|
||||||
|
id: gate
|
||||||
if: steps.policy.outputs.train == 'true'
|
if: steps.policy.outputs.train == 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
|
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
|
||||||
run: |
|
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" \
|
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"
|
||||||
@@ -6,9 +6,10 @@ name: Weekly release
|
|||||||
# with dry_run to see the plan and change nothing. A pull request that touches
|
# 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,
|
# 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
|
# 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
|
# gate must already be green (free-tests and main-backstop check runs
|
||||||
# backstop gate run, no active freeze): this workflow never runs tests or
|
# succeeded, every check run finished and none red, no active freeze): this
|
||||||
# paid evals.
|
# workflow never runs tests or paid evals. Dispatch it from main: the cut job
|
||||||
|
# runs only there, with main's tooling.
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
# Monday 14:00 UTC: 07:00 PDT, 06:00 PST.
|
# Monday 14:00 UTC: 07:00 PDT, 06:00 PST.
|
||||||
@@ -20,7 +21,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
ref:
|
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
|
type: string
|
||||||
default: ''
|
default: ''
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -39,14 +40,13 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
cut:
|
cut:
|
||||||
name: Cut the release
|
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
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
# The tag, the stable branch and the release.
|
# The tag, the stable branch and the release.
|
||||||
contents: write
|
contents: write
|
||||||
# Reads check runs and backstop runs of the candidate.
|
# Reads the candidate's check runs.
|
||||||
actions: read
|
|
||||||
checks: read
|
checks: read
|
||||||
steps:
|
steps:
|
||||||
# The dispatched ref supplies the tooling; the release candidate always
|
# The dispatched ref supplies the tooling; the release candidate always
|
||||||
@@ -75,6 +75,10 @@ jobs:
|
|||||||
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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")
|
args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY")
|
||||||
[ "$DRY_RUN" = true ] && args+=(--dry-run)
|
[ "$DRY_RUN" = true ] && args+=(--dry-run)
|
||||||
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
|
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
|
||||||
@@ -88,7 +92,6 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
|
||||||
checks: read
|
checks: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
|||||||
+9
-7
@@ -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).
|
**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
|
### 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-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`.
|
- **`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.
|
- **`docs/releases.md`**: what a release is, what green means, the channels and how to switch, hotfixes, dry runs and every outcome line.
|
||||||
|
|
||||||
### Changed
|
### 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-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-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, 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.
|
- **`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`.
|
- **`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.
|
- **README** Step 2 (team mode) says team mode follows `stable` and how to follow every merge instead.
|
||||||
|
|
||||||
### Tests
|
### 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/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`, and carries the channel in its cache identity.
|
- `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.
|
- `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
|
### 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
|
## [1.91.221.0] - 2026-10-25
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,10 @@
|
|||||||
# Functions (never fail; a broken config means main, the behaviour before
|
# Functions (never fail; a broken config means main, the behaviour before
|
||||||
# channels existed):
|
# channels existed):
|
||||||
# gstack_update_channel <install dir> prints stable | main
|
# 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
|
# 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
|
# gstack_update_channel_missing <git stderr> true when a fetch failed only
|
||||||
# because origin has no such ref
|
# because origin has no such ref
|
||||||
# (no release cut yet): callers
|
# (no release cut yet): callers
|
||||||
@@ -32,7 +30,10 @@
|
|||||||
# CLI (the /gstack-upgrade skill): `bash bin/gstack-update-channel.sh [--fetch-ref] [<install dir>]`
|
# 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 channel; --fetch-ref fetches the channel ref from origin and
|
||||||
# prints the revision to fast-forward to (origin/stable or origin/main),
|
# 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() {
|
gstack_update_channel() {
|
||||||
_uc_dir=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
|
_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
|
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() {
|
gstack_update_channel_refspec() {
|
||||||
case "${1:-}" in stable) printf '+refs/heads/stable:refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac
|
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
|
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
|
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2
|
||||||
else
|
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
|
||||||
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
|
_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
|
||||||
|
|||||||
+16
-5
@@ -319,12 +319,23 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
|
|||||||
# Disable credential prompts and apply a 5-second low-speed timeout so a
|
# Disable credential prompts and apply a 5-second low-speed timeout so a
|
||||||
# flaky network or captive portal can't hang every skill preamble.
|
# flaky network or captive portal can't hang every skill preamble.
|
||||||
_REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}"
|
_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" \
|
_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 \
|
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)"
|
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}')"
|
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
|
||||||
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
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
|
if [ "$(_identity_field remote)" = "$_REMOTE_SHA" ] && _identity_ok; then
|
||||||
case "$CACHED" in
|
case "$CACHED" in
|
||||||
UP_TO_DATE*)
|
UP_TO_DATE*)
|
||||||
@@ -395,9 +406,9 @@ if [ "$LOCAL" = "$REMOTE" ]; then
|
|||||||
_write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}"
|
_write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}"
|
||||||
if [ "$FORCE" = "1" ]; then
|
if [ "$FORCE" = "1" ]; then
|
||||||
if [ "$_WHY" = "diverged" ]; 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
|
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
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+1
-1
@@ -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
|
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
|
FOLLOW=stable
|
||||||
echo "gstack-upgrade: channel stable (the weekly release; gstack-config set update_channel main follows every merge)" >&2
|
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
|
echo "gstack-upgrade: channel stable has no release on origin yet; following $DEFAULT until the first weekly release is cut" >&2
|
||||||
else
|
else
|
||||||
fetch_failed "git fetch origin stable failed"
|
fetch_failed "git fetch origin stable failed"
|
||||||
|
|||||||
+22
-11
@@ -21,15 +21,25 @@ commit on main's first-parent history, moved only by this workflow, only
|
|||||||
forward.
|
forward.
|
||||||
|
|
||||||
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
||||||
(the status main requires), no completed check run failed, the Main backstop
|
(the status main requires), the `main-backstop` check run succeeded, every
|
||||||
gate run for that push completed with conclusion success (a green train record
|
check run on it has completed and none failed, and `refs/notes/gstack-trains`
|
||||||
covers the tree, or the backstop gated it green), and `refs/notes/gstack-trains`
|
holds no active freeze. The Main backstop gate workflow publishes
|
||||||
holds no active freeze. A commit whose gate is still running is skipped and the
|
`main-backstop` on each push to main from a job of its own: success when a
|
||||||
next older one is considered, back through 7 days or 200 first-parent commits.
|
green train record covers the tree or the backstop gated it green, neutral
|
||||||
The workflow runs no tests and no paid evals of its own; it only reads what the
|
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
|
trains and the backstop already recorded. With nothing green in the window it
|
||||||
fails and lists why each commit was skipped.
|
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
|
## Channels
|
||||||
|
|
||||||
| Channel | Follows | Who |
|
| 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
|
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.
|
or Actions → **Weekly release** → Run workflow, on main (the cut job runs
|
||||||
The commit is tagged with its VERSION, the release notes say "hotfix release"
|
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
|
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
|
between ships with it), and stable fast-forwards. Team installs pick it up at
|
||||||
their next hourly check.
|
their next hourly check.
|
||||||
@@ -123,9 +134,9 @@ is exercised against real check runs before it merges.
|
|||||||
| Line | Meaning |
|
| Line | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `CANDIDATE: <sha> (<date>)` | the commit that will be (or was) released |
|
| `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 |
|
| `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 |
|
| `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: 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: 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 |
|
| `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
@@ -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.
|
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
|
### 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; }
|
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)
|
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
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=$?
|
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
|
||||||
echo "$FF"
|
echo "$FF"
|
||||||
case "$FF_RC" in
|
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;
|
checkout with `git apply -3`, never `stash apply` in the install;
|
||||||
`unmerged=1` means the rescue needs manual resolution).
|
`unmerged=1` means the rescue needs manual resolution).
|
||||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
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);
|
`ff-busy` means another gstack update is running (retry in a minute);
|
||||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||||
release adds, or another git command); `index-unreadable` means the checkout's
|
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
|
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||||
those commits. Gate it:
|
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`.
|
in `$INSTALL_DIR`.
|
||||||
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
||||||
below without asking.
|
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.
|
# 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
|
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||||
STASH_OUTPUT=$(git stash 2>&1)
|
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
|
./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."
|
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
|
### Step 4.75: Run version migrations
|
||||||
|
|
||||||
After `./setup` completes, run any migration scripts for versions between the old
|
After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent
|
||||||
and new version. Migrations handle state fixes that `./setup` alone can't cover
|
`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old
|
||||||
(stale config, orphaned files, directory structure changes).
|
version: they fix state `./setup` alone cannot (stale config, orphaned files).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations"
|
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}"
|
: "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}"
|
||||||
if [ -d "$MIGRATIONS_DIR" ]; then
|
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||||
for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do
|
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//')"
|
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
|
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..."
|
echo "Running migration $m_ver..."
|
||||||
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
|
# Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack.
|
||||||
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
|
|
||||||
# a repo-local install would silently no-op without this.
|
|
||||||
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
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)
|
### Step 4.8: Stop any stale daemon (unconditional)
|
||||||
|
|
||||||
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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; }
|
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)
|
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
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=$?
|
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
|
||||||
echo "$FF"
|
echo "$FF"
|
||||||
case "$FF_RC" in
|
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;
|
checkout with `git apply -3`, never `stash apply` in the install;
|
||||||
`unmerged=1` means the rescue needs manual resolution).
|
`unmerged=1` means the rescue needs manual resolution).
|
||||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
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);
|
`ff-busy` means another gstack update is running (retry in a minute);
|
||||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||||
release adds, or another git command); `index-unreadable` means the checkout's
|
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
|
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||||
those commits. Gate it:
|
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`.
|
in `$INSTALL_DIR`.
|
||||||
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
||||||
below without asking.
|
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.
|
# 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
|
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||||
STASH_OUTPUT=$(git stash 2>&1)
|
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
|
{{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."
|
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
|
### Step 4.75: Run version migrations
|
||||||
|
|
||||||
After `./setup` completes, run any migration scripts for versions between the old
|
After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent
|
||||||
and new version. Migrations handle state fixes that `./setup` alone can't cover
|
`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old
|
||||||
(stale config, orphaned files, directory structure changes).
|
version: they fix state `./setup` alone cannot (stale config, orphaned files).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations"
|
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}"
|
: "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}"
|
||||||
if [ -d "$MIGRATIONS_DIR" ]; then
|
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||||
for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do
|
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//')"
|
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
|
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..."
|
echo "Running migration $m_ver..."
|
||||||
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
|
# Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack.
|
||||||
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
|
|
||||||
# a repo-local install would silently no-op without this.
|
|
||||||
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
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)
|
### Step 4.8: Stop any stale daemon (unconditional)
|
||||||
|
|
||||||
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
||||||
|
|||||||
+41
-27
@@ -10,16 +10,20 @@
|
|||||||
*
|
*
|
||||||
* The checkout needs origin's full history (fetch-depth 0). Green, for a
|
* 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
|
* 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,
|
* succeeded (the status main requires), its main-backstop check run succeeded
|
||||||
* its "Main backstop gate" run completed with conclusion success (a green train
|
* (evals-main-backstop.yml publishes it: success only when a green train
|
||||||
* record covers the tree, or the backstop gated it green), and
|
* record covers the tree or the backstop gated it green; a push main moved
|
||||||
* refs/notes/gstack-trains holds no active freeze. The newest such commit in
|
* past before it was gated is neutral, never green), every check run on it
|
||||||
* the window (7 days, 200 first-parent commits) is the candidate; a commit
|
* has completed and none failed, and refs/notes/gstack-trains holds no active
|
||||||
* whose gate is still running is skipped, not waited for.
|
* 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
|
* 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
|
* (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 fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
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 Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
|
||||||
export type Git = (args: string[]) => { ok: boolean; out: 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 REQUIRED_CHECK = 'free-tests';
|
||||||
|
export const BACKSTOP_CHECK = 'main-backstop';
|
||||||
export const NOTES_REF = 'refs/notes/gstack-trains';
|
export const NOTES_REF = 'refs/notes/gstack-trains';
|
||||||
const NOTES_LOCAL = 'refs/notes/gstack-trains-release';
|
const NOTES_LOCAL = 'refs/notes/gstack-trains-release';
|
||||||
const RED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure', 'stale']);
|
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 };
|
export type Verdict = { green: true } | { green: false; why: string; pending: boolean };
|
||||||
interface CheckRun { name: string; status: string; conclusion: string | null }
|
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 {
|
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`]));
|
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 };
|
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 pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>;
|
||||||
const runs = pages.flatMap(p => p.check_runs ?? []);
|
const runs = pages.flatMap(p => p.check_runs ?? []);
|
||||||
const required = runs.filter(c => c.name === REQUIRED_CHECK);
|
for (const name of [REQUIRED_CHECK, BACKSTOP_CHECK]) {
|
||||||
if (required.length === 0) return { green: false, why: `no ${REQUIRED_CHECK} check run`, pending: false };
|
const named = runs.filter(c => c.name === name);
|
||||||
if (required.some(c => c.status !== 'completed')) return { green: false, why: `${REQUIRED_CHECK} is still running`, pending: true };
|
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
|
||||||
if (!required.some(c => c.conclusion === 'success')) return { green: false, why: `${REQUIRED_CHECK} concluded ${required.map(c => c.conclusion).join(',')}`, 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 ?? ''));
|
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 };
|
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 }> }>(
|
const running = runs.filter(c => c.status !== 'completed');
|
||||||
gh(['api', `repos/${repo}/actions/workflows/${BACKSTOP_WORKFLOW}/runs?head_sha=${sha}&per_page=10`]));
|
if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true };
|
||||||
if (!backstop) return { green: false, why: 'cannot read its backstop runs', pending: false };
|
|
||||||
const newest = (backstop.workflow_runs ?? []).sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
|
|
||||||
if (!newest) return { green: false, why: 'no Main backstop gate run', pending: false };
|
|
||||||
if (newest.status !== 'completed') return { green: false, why: 'Main backstop gate is still running', pending: true };
|
|
||||||
if (newest.conclusion !== 'success') return { green: false, why: `Main backstop gate concluded ${newest.conclusion}`, pending: false };
|
|
||||||
return { green: true };
|
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']);
|
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') : [];
|
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 (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;
|
let sha: string | null = null;
|
||||||
for (const c of candidates) {
|
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);
|
const v = gateVerdict(gh, repo, c);
|
||||||
if (v.green) { sha = c; break; }
|
if (v.green) { sha = c; break; }
|
||||||
skipped.push(c.slice(0, 12) + ': ' + v.why);
|
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 (!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 };
|
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 existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
|
||||||
const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other';
|
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 (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;
|
let prevVersion: string | null = null, prevTag: string | null = null;
|
||||||
if (prevStable) {
|
if (before) {
|
||||||
prevVersion = git(['show', prevStable + ':VERSION']).out.trim() || null;
|
prevVersion = git(['show', before + ':VERSION']).out.trim() || null;
|
||||||
if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
|
if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
|
||||||
} else {
|
} else {
|
||||||
const date = git(['show', '-s', '--format=%cI', sha]).out;
|
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 changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
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 } };
|
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 = [
|
const lines = [
|
||||||
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
|
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
|
||||||
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
|
'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',
|
'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);
|
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 });
|
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh });
|
||||||
if (!planned.ok) {
|
if (!planned.ok) {
|
||||||
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
|
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));
|
emit(describePlan(planned.plan, dryRun));
|
||||||
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
|
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
|
||||||
|
|||||||
@@ -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>
|
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>]
|
[--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.`;
|
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) {
|
if (import.meta.main) {
|
||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
@@ -208,5 +209,7 @@ if (import.meta.main) {
|
|||||||
console.log(line);
|
console.log(line);
|
||||||
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
|
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);
|
process.exit(action === 'frozen' || action === 'failed' ? 1 : 0);
|
||||||
}
|
}
|
||||||
@@ -435,6 +435,28 @@ describe('evals-main-backstop.yml', () => {
|
|||||||
expect(policy.run).not.toContain('bun');
|
expect(policy.run).not.toContain('bun');
|
||||||
const gate = job.steps.find(st => st.name === 'Gate main unless a green train covers its tree')!;
|
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.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('scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA"');
|
||||||
|
expect(gate.run).toContain('--outputs "$GITHUB_OUTPUT"');
|
||||||
|
expect(job.outputs).toEqual({ action: '${{ steps.gate.outputs.action }}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The weekly release (scripts/release-cut.ts) reads the per-commit verdict as
|
||||||
|
// the main-backstop check run: a superseded push exits 0 but is neutral there.
|
||||||
|
test('the main-backstop check run is published from its own code-free job: success for covered|gated, neutral for inert|superseded|deferred, failure otherwise', () => {
|
||||||
|
const job = backstopWf.jobs['backstop-check']!;
|
||||||
|
expect(job.needs).toBe('backstop');
|
||||||
|
expect(job.if).toBe("${{ !cancelled() && needs.backstop.outputs.action != '' }}");
|
||||||
|
expect(job.permissions).toEqual({ checks: 'write' });
|
||||||
|
expect(job.steps.map(st => st.uses).filter(Boolean)).toEqual([]);
|
||||||
|
expect(job.steps).toHaveLength(1);
|
||||||
|
const publish = job.steps[0]!;
|
||||||
|
expect(publish.env).toEqual({ GH_TOKEN: '${{ github.token }}', ACTION: '${{ needs.backstop.outputs.action }}' });
|
||||||
|
expect(publish.run).toContain('covered|gated) conclusion=success ;;');
|
||||||
|
expect(publish.run).toContain('inert|superseded|deferred) conclusion=neutral ;;');
|
||||||
|
expect(publish.run).toContain('frozen|failed) conclusion=failure ;;');
|
||||||
|
expect(publish.run).toContain('*) echo "::error::unknown backstop action');
|
||||||
|
expect(publish.run).toContain('-f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion"');
|
||||||
|
expect(publish.run).not.toContain('bun');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -183,7 +183,7 @@ describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad path
|
|||||||
const UNUSABLE: Kind[] = ['unset', 'empty', 'nonexistent', 'unreadable'];
|
const 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> }> = [
|
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: '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: '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: '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: () => ({}) },
|
{ name: 'team-mode removal', doc: 'upgrade', marker: 'git rm -r --cached', target: 'LOCAL_GSTACK', others: () => ({}) },
|
||||||
|
|||||||
+182
-20
@@ -3,15 +3,17 @@
|
|||||||
* main's first-parent history, tags it v<VERSION>, fast-forwards stable and
|
* main's first-parent history, tags it v<VERSION>, fast-forwards stable and
|
||||||
* publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a
|
* publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a
|
||||||
* named main commit; stable never moves backwards; a frozen queue, a pending
|
* 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 { 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 fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||||
import {
|
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,
|
planRelease, releaseNotes, type Gh,
|
||||||
} from '../scripts/release-cut';
|
} 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();
|
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 GREEN: CheckRun[] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }];
|
||||||
const COVERED = [{ status: 'completed', conclusion: 'success', created_at: '2026-10-25T00:00:00Z' }];
|
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 {
|
function fakeGh(state: State): Gh {
|
||||||
return (args) => {
|
return (args) => {
|
||||||
state.calls.push(args);
|
state.calls.push(args);
|
||||||
const api = args[0] === 'api' ? args[args.length - 1]! : '';
|
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: '' };
|
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] === '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(' ') };
|
return { status: 1, stdout: '', stderr: 'unexpected gh ' + args.join(' ') };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,15 +73,15 @@ function fixture() {
|
|||||||
release('1.0.3', 'Fourth trims the preamble.');
|
release('1.0.3', 'Fourth trims the preamble.');
|
||||||
execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 });
|
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 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' };
|
return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('release-cut: planning', () => {
|
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();
|
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.3']!] = backstop('in_progress', null);
|
||||||
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.2']!] = [...GREEN, { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }];
|
||||||
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||||
expect(r.ok, JSON.stringify(r)).toBe(true);
|
expect(r.ok, JSON.stringify(r)).toBe(true);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
@@ -86,7 +90,7 @@ describe('release-cut: planning', () => {
|
|||||||
expect(r.plan.tag).toBe('v1.0.1');
|
expect(r.plan.tag).toBe('v1.0.1');
|
||||||
expect(r.plan.prevStable).toBeNull();
|
expect(r.plan.prevStable).toBeNull();
|
||||||
expect(r.plan.skipped).toEqual([
|
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',
|
f.shas['1.0.2']!.slice(0, 12) + ': red check runs: windows-free-tests',
|
||||||
]);
|
]);
|
||||||
expect(r.plan.notes).toContain('First tagged release: the 2 versions, one line each');
|
expect(r.plan.notes).toContain('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');
|
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 f = fixture();
|
||||||
const head = f.shas['1.0.3']!;
|
const head = f.shas['1.0.3']!;
|
||||||
f.state.checks[head] = [{ name: 'typecheck', status: 'completed', conclusion: 'success' }];
|
f.state.checks[head] = [{ name: 'typecheck', status: 'completed', conclusion: 'success' }];
|
||||||
f.state.checks[f.shas['1.0.2']!] = [{ name: REQUIRED_CHECK, status: 'in_progress', conclusion: null }];
|
f.state.checks[f.shas['1.0.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 });
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
expect(r.plan.sha).toBe(f.shas['1.0.0']);
|
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', () => {
|
test('no green commit in the window is NO_GREEN_COMMIT with every skip named; an empty window is NOTHING_NEW', () => {
|
||||||
const f = fixture();
|
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 });
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (r.ok) return;
|
if (r.ok) return;
|
||||||
expect(r.code).toBe('NO_GREEN_COMMIT');
|
expect(r.code).toBe('NO_GREEN_COMMIT');
|
||||||
expect(r.skipped).toHaveLength(4);
|
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 });
|
const stale = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => Date.now() + 30 * 86_400_000 });
|
||||||
expect(stale.ok).toBe(false);
|
expect(stale.ok).toBe(false);
|
||||||
if (!stale.ok) expect(stale.code).toBe('NOTHING_NEW');
|
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', () => {
|
test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => {
|
||||||
const f = fixture();
|
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 };
|
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']);
|
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', () => {
|
test('stable never moves backwards, a side-branch ref is refused, and a tag naming another commit is refused', () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
|
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);
|
expect(back.ok).toBe(false);
|
||||||
if (!back.ok) { expect(back.code).toBe('REFUSED'); expect(back.message).toContain('non-fast-forward'); }
|
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']!);
|
git(f.seed, 'checkout', '-q', '-b', 'side', f.shas['1.0.3']!);
|
||||||
fs.writeFileSync(path.join(f.seed, 'side.txt'), 'x');
|
fs.writeFileSync(path.join(f.seed, 'side.txt'), 'x');
|
||||||
git(f.seed, 'add', '-A'); git(f.seed, 'commit', '-q', '-m', 'side');
|
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', () => {
|
describe('release-cut: notes', () => {
|
||||||
test('changelog headlines are the bold first line of each entry; versions order numerically', () => {
|
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');
|
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');
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
* and a scripted gh; no network, no API spend.
|
* and a scripted gh; no network, no API spend.
|
||||||
*/
|
*/
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||||
import { loadPolicyFromHead } from '../lib/ship-policy';
|
import { loadPolicyFromHead } from '../lib/ship-policy';
|
||||||
import { NOTES_REF, persist, repoIdentity } from '../lib/ship-queue-train-record';
|
import { NOTES_REF, persist, repoIdentity } from '../lib/ship-queue-train-record';
|
||||||
import type { GhRunner } from '../scripts/recover-receipts';
|
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(out.lines[0]).toContain('declares no train block');
|
||||||
expect(calls).toEqual([]);
|
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", () => {
|
describe("backstop: a train's merges are covered by the train's own green record", () => {
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import * as fs from 'node:fs';
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { runBin } from './helpers/run-bin';
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, '..');
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
const HELPER = path.join(ROOT, 'bin', 'gstack-update-channel.sh');
|
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 SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update');
|
||||||
const bases: string[] = [];
|
const bases: string[] = [];
|
||||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
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');
|
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', () => {
|
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_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: '' } });
|
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', 'refs/remotes/origin/stable', '1', 'main', '']);
|
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);
|
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). */
|
/** 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 } = {}) {
|
function world(opts: { installAt?: 'A' | 'C'; stable?: boolean } = {}) {
|
||||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-channel-world-'));
|
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', () => {
|
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 });
|
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' });
|
const onMain = run({ GSTACK_UPDATE_CHANNEL: 'main' });
|
||||||
expect(onMain.stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1');
|
expect(onMain.stdout.trim()).toBe('UPGRADE_AVAILABLE 1.0.0 1.0.1');
|
||||||
expect(fs.readFileSync(gitLog, 'utf8')).toContain('refs/heads/main');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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', () => {
|
test('the fast-forward (gstack-ff-update) runs before any reset --hard', () => {
|
||||||
const ff = tmpl.indexOf('bash bin/gstack-ff-update . "$UPSTREAM_REF"');
|
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(ff).toBeGreaterThan(-1);
|
||||||
expect(reset).toBeGreaterThan(-1);
|
expect(reset).toBeGreaterThan(-1);
|
||||||
expect(ff).toBeLessThan(reset);
|
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', () => {
|
test('the ff path carries the FF_OK success gate that skips the fallback', () => {
|
||||||
expect(tmpl).toContain('FF_OK');
|
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', () => {
|
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.
|
// 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).toContain('git rev-list "${UPSTREAM_REF:?}"..HEAD');
|
||||||
expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:-origin/main}"..HEAD')).toBeLessThan(
|
expect(tmpl.indexOf('git rev-list "${UPSTREAM_REF:?}"..HEAD')).toBeLessThan(
|
||||||
tmpl.indexOf('git reset --hard "${UPSTREAM_REF:-origin/main}"'),
|
tmpl.indexOf('git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* weekly-release.yml (free): the Monday cut's wiring (docs/releases.md). The
|
||||||
|
* cut job runs only off main, for the schedule and a dispatch, with the token
|
||||||
|
* scoped to the tag, the stable branch and the release; a pull request that
|
||||||
|
* touches the release tooling runs the dry run against main read-only; a
|
||||||
|
* dispatched hotfix ref is a full SHA and the dry_run input, true by default,
|
||||||
|
* never reaches the scheduled run. The release-cut CLI the jobs call is
|
||||||
|
* covered by test/release-cut.test.ts.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
type Step = { name?: string; uses?: string; if?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> };
|
||||||
|
type Job = { name?: string; if?: string; permissions?: Record<string, string>; 'timeout-minutes'?: number; steps: Step[] };
|
||||||
|
type Workflow = { on: Record<string, any>; concurrency: { group: string; 'cancel-in-progress': boolean }; permissions?: unknown; jobs: Record<string, Job> };
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
const wf = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/weekly-release.yml'), 'utf8')) as Workflow;
|
||||||
|
const BUN_RUN = 'bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts';
|
||||||
|
|
||||||
|
describe('weekly-release.yml', () => {
|
||||||
|
test('Monday schedule, a dispatch with dry_run (default true) and a full-SHA ref, and a pull request only for the release tooling', () => {
|
||||||
|
expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']);
|
||||||
|
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1' }]);
|
||||||
|
expect(wf.on.workflow_dispatch.inputs.dry_run).toMatchObject({ type: 'boolean', default: true });
|
||||||
|
expect(wf.on.workflow_dispatch.inputs.ref).toMatchObject({ type: 'string', default: '' });
|
||||||
|
expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts'] });
|
||||||
|
expect(wf.concurrency).toEqual({ group: "weekly-release-${{ github.event.pull_request.number || 'cut' }}", 'cancel-in-progress': false });
|
||||||
|
expect(wf.permissions).toEqual({});
|
||||||
|
expect(Object.keys(wf.jobs)).toEqual(['cut', 'dry-run']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the cut job runs only off main for a schedule or dispatch, writes contents, reads checks, and rejects a ref that is not a 40-hex SHA', () => {
|
||||||
|
const cut = wf.jobs.cut!;
|
||||||
|
expect(cut.if).toBe("github.event_name != 'pull_request' && github.ref == 'refs/heads/main'");
|
||||||
|
expect(cut.permissions).toEqual({ contents: 'write', checks: 'read' });
|
||||||
|
expect(cut.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0 });
|
||||||
|
const plan = cut.steps.find(s => s.name === 'Plan and cut')!;
|
||||||
|
expect(plan.env!.GH_TOKEN).toBe('${{ github.token }}');
|
||||||
|
expect(plan.env!.DRY_RUN).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}");
|
||||||
|
expect(plan.env!.RELEASE_REF).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}");
|
||||||
|
expect(plan.run).toContain('set -euo pipefail');
|
||||||
|
expect(plan.run).toContain('[[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]');
|
||||||
|
expect(plan.run).toContain('[ "$DRY_RUN" = true ] && args+=(--dry-run)');
|
||||||
|
expect(plan.run).toContain('[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")');
|
||||||
|
expect(plan.run).toContain(BUN_RUN + ' "${args[@]}"');
|
||||||
|
expect(cut.steps.find(s => s.name === 'Fetch stable and the tags')!.run).toContain("git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pull-request dry run is read-only, keeps no credentials, and always passes --dry-run', () => {
|
||||||
|
const dry = wf.jobs['dry-run']!;
|
||||||
|
expect(dry.if).toBe("github.event_name == 'pull_request'");
|
||||||
|
expect(dry.permissions).toEqual({ contents: 'read', checks: 'read' });
|
||||||
|
expect(dry.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0, 'persist-credentials': false });
|
||||||
|
const plan = dry.steps.find(s => s.name === 'Plan only')!;
|
||||||
|
expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run');
|
||||||
|
expect(plan.run).not.toContain('--ref');
|
||||||
|
for (const job of Object.values(wf.jobs)) {
|
||||||
|
expect(job['timeout-minutes']).toBe(20);
|
||||||
|
for (const step of job.steps) if (step.run) expect(step.run).not.toMatch(/\b(bun install|bun test|EVALS=)/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user