v1.91.292.0 Stable channel: a regression exception beside the security one, and the stale weekly wording (#3533)

* feat(release): regression cuts pass the 72-hour floor like security cuts; weekly wording follows the twice-weekly cadence

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

* fix: a regression anchor needs a published release; an unmoved exception tag reruns only with its recorded reason; a version may end the sentence

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

* RESTAMP: 1.91.291.0 -> 1.91.292.0 (stamped; queue_mode=stamp-at-merge base=origin/main@6f27f0c269cb policy=base origin/main@6f27f0c269cb:.gstack/ship-policy.json)
STAMP_VERSION: 1.91.292.0
stage version: done (wrote 1.91.292.0)
stage release_tool: done (bun scripts/gen-agents-digest.ts)
stage changelog: done (Unreleased -> 1.91.292.0)
stage stamp_paths: skipped (no tentative mention newer than the fork version 1.91.291.0 and none of base 1.91.291.0 on a line this branch added)
stage mirror: skipped (no mirror in policy)
stage apply: done (4 file(s))
FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=40a8df86de0bda12cedea7dbdc744b90f4b1d5eb
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-28 09:59:40 +00:00
1 parent 6f27f0c269
commit 219f7488bb
16 files changed
+347 -103

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ name: Main backstop gate
# 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).
# queue or could not finish); the stable release reads it (docs/releases.md).
on:
push:
branches: [main]
@@ -72,7 +72,7 @@ jobs:
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" --outputs "$GITHUB_OUTPUT"
# The per-commit verdict the weekly release reads (scripts/release-cut.ts).
# The per-commit verdict the stable 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:
+17 -4
View File
@@ -6,9 +6,12 @@ name: Stable release
# weekly-release.yml name so its run history and every `gh workflow run`
# reference stay intact). stable moves at most once per 72 hours: a cut inside
# that floor is refused with REFUSED_FLOOR and NEXT_ALLOWED; a scheduled run
# gets 3 hours of grace for cron start jitter, a hand-run cut none. The only
# exception is a security cut: the security input with a ref, naming a GHSA,
# CVE or #<n> the script ties to the released commits. Run it by hand with a
# gets 3 hours of grace for cron start jitter, a hand-run cut none. Two
# exceptions pass it, one per cut and each with a ref: a security cut (the
# security input, naming a GHSA, CVE or #<n> the script ties to the released
# commits) and a regression cut (the regression input, naming a
# regression-labelled #<n> and the earlier stable release v<X> the behavior
# worked in). Run it by hand with a
# ref for a hotfix, or with dry_run to see the plan and change nothing. A pull request that touches
# the release tooling runs the dry run against main with a read-only token,
# which is the only pre-merge exercise of the real workflow. The candidate's
@@ -35,6 +38,10 @@ on:
description: 'Security cut (needs ref): the reason, naming the GHSA-…, CVE-… or #<n> it fixes; passes the 72-hour floor once verified and lands in the release notes'
type: string
default: ''
regression:
description: 'Regression cut (needs ref): the reason, naming the regression-labelled #<n> it fixes and the earlier stable release v<X> it worked in; passes the 72-hour floor once verified and lands in the release notes'
type: string
default: ''
pull_request:
paths:
- '.github/workflows/weekly-release.yml'
@@ -59,7 +66,7 @@ jobs:
contents: write
# Reads the candidate's check runs.
checks: read
# A security cut reads the security label on the #<n> its reason names.
# A security or regression cut reads the label on the #<n> its reason names.
issues: read
pull-requests: read
steps:
@@ -88,6 +95,7 @@ jobs:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }}
REGRESSION_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }}
run: |
set -euo pipefail
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
@@ -98,10 +106,15 @@ jobs:
echo "::error::a security cut needs ref: the full SHA of the fix commit on main"
exit 1
fi
if [ -n "$REGRESSION_REASON" ] && [ -z "$RELEASE_REF" ]; then
echo "::error::a regression cut needs ref: the full SHA of the fix commit on main"
exit 1
fi
args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY")
[ "$DRY_RUN" = true ] && args+=(--dry-run)
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON")
[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")
echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY"
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}"
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [1.91.292.0] - 2026-10-28
**A fix for a regression on stable can skip the 72-hour wait, like a security fix.** Garry, asked whether non-security breakage on stable may bypass the twice-weekly cadence and the floor, answered on 2026-10-28: "Regression". Until now a fix for something the last stable release did right, and the current one breaks, waited up to 72 hours on every team install. Now `--regression "#n: worked in v<X>: why"` with `--ref` passes the floor once the tool ties it to the release, and the claim is public in the tag and the notes.
### Added
- **Regression cuts in `scripts/release-cut.ts`: `--regression "<reason>"` with `--ref <fix sha>`** (workflow input `regression`). The reason must name exactly one earlier stable release `v<X>` and at least one `#<n>`. The cut is refused with `REFUSED_REGRESSION`, red on every trigger, unless `v<X>` is an annotated release tag strictly behind the current stable with a published GitHub Release (the current stable itself never counts, nor a tag a half-done cut left behind), `#<n>` carries the `regression` label (a maintainer's declaration; a `security` label does not count, nor the reverse) and a released commit references `#<n>`. That the behavior really worked in `v<X>` stays attested. The record matches a security cut. The sanitized reason and evidence go into the tag (`Regression:` / `Verified:`), the notes (`(regression release)`, `**Regression release:** …`) and the plan (`REGRESSION:`, `RIDING_ALONG:`); a half-done cut finishes from its tag; an unmoved exception tag must verify again with the reason it recorded (this now holds for security cuts too).
### Changed
- **One exception per cut.** `--security` and `--regression` together are `REFUSED_REGRESSION`. A regression cut passes a dated floor only; `FLOOR_UNKNOWN` (an undatable or unpublished stable) still yields to a security cut alone. The floor counts from any exception release, so the next scheduled cut may be held. `REFUSED_FLOOR` names both ways past it.
- **"Weekly" wording follows the twice-weekly cadence** in README.md, `bin/gstack-update-channel.sh` (its comment and the no-stable-yet message, now "until the first stable release is cut"), the /gstack-upgrade skill's `AHEAD_OF_STABLE` line and the main-backstop workflow comments. docs/releases.md and docs/recommended-harness.md describe the regression exception.
## [1.91.291.0] - 2026-10-28
**/ship and /autoplan size their review to the diff.** A /ship review used to send the same specialist army, Red Team, adversarial pass and outside voice at every diff and repeat the whole army after any fix, up to three passes. Now `gstack-review-plan` classifies the diff (docs, tests, code or gate) and plans the next step through one transition table, so a README edit gets the core checklist and a gate change still gets everything. Measured on two captured diffs across five loop scenarios with measured reviewer output (`scripts/review-dispatch-compare.ts`), estimated review tokens fall to a median 0.26x of before on docs-only diffs and 0.85x on code diffs (0.52x over all ten runs, range 0.23–0.99). Every reviewer that can block a ship stays at full effort on the lane's model; a paid replay showed a medium-effort fix-check miss a fail-open that the full-effort check caught.
+2 -2
View File
@@ -38,7 +38,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
```
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the weekly tagged release (Monday mornings, plus off-cycle hotfixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the twice-weekly tagged release (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
Swap `required` for `optional` if you'd rather nudge teammates than block them.
@@ -241,7 +241,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
| Doc | What it covers |
|-----|---------------|
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the twice-weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers, agent harness registration |
| [Other agents](docs/hosts.md) | Agent harnesses (OpenClaw, Hermes, dots, QM, Muse Code, Grok, any other), OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
+1 -1
View File
@@ -1 +1 @@
1.91.291.0
1.91.292.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.291.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.292.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+2 -2
View File
@@ -6,7 +6,7 @@
#
# Channels (docs/releases.md):
# main every merge to main (a version per merge, a dozen a day)
# stable the weekly tagged release: refs/heads/stable on origin, moved only
# stable the twice-weekly tagged release: refs/heads/stable on origin, moved only
# forward along main's first-parent history by the release workflow
#
# The config key update_channel (bin/gstack-config) is auto | stable | main.
@@ -77,7 +77,7 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
_uc_err=$(_receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin "$(gstack_update_channel_refspec stable)" 2>&1 >/dev/null) \
&& { printf 'origin/stable\n'; exit 0; }
if gstack_update_channel_missing "$_uc_err"; then
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first stable release is cut" >&2
else
echo "gstack-update-channel: fetching origin/stable failed ($(printf '%s' "$_uc_err" | head -1 | head -c 200)); staying on origin/stable as last fetched" >&2
printf 'origin/stable\n'; exit 0
+2 -2
View File
@@ -225,8 +225,8 @@ one place, and run it once by hand before enabling it.
`.github/workflows/weekly-release.yml` ("Stable release") runs
`scripts/release-cut.ts` every Monday and Thursday at 14:00 UTC, and never moves
`stable` more than once in 72 hours except for a verified security fix
(`--security` with `--ref`): it takes the newest green commit on main's first-parent history
`stable` more than once in 72 hours except for a verified security or regression
fix (`--security` or `--regression`, with `--ref`): it takes the newest green commit on main's first-parent history
(its required check succeeded, the backstop check succeeded, every check run
completed and none failed, no active freeze), tags it `v<VERSION>`, publishes a
GitHub Release whose notes are one line per CHANGELOG version since the
+65 -16
View File
@@ -20,7 +20,8 @@ commit on main and:
A tag and its release never change. `stable` is a moving pointer: always a
commit on main's first-parent history, moved only by this workflow, only
forward, and at most once every 72 hours except for a security fix (below).
forward, and at most once every 72 hours except for a security or regression
fix (below).
**Green** means, on that exact commit: the `free-tests` check run succeeded
(the status main requires), the commit was gated (below), every check run on
@@ -74,7 +75,7 @@ extra tag can neither date the move nor stall later cuts.
A cut inside the floor changes nothing and prints, as its first two lines:
```
REFUSED_FLOOR: stable moved to v1.91.300.0 at 2026-11-02T14:04:51Z, 47.2 h ago; a hand-run cut needs 72 h; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>
REFUSED_FLOOR: stable moved to v1.91.300.0 at 2026-11-02T14:04:51Z, 47.2 h ago; a hand-run cut needs 72 h; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>
NEXT_ALLOWED: 2026-11-05T14:04:51Z for a hand-run cut; 2026-11-05T11:04:51Z for a scheduled run
```
@@ -87,8 +88,8 @@ always needs the full 72. Thursday to Monday is 96 hours.
A refused hand cut exits 1 with an error annotation. A refused scheduled run
exits 0 with a notice annotation and the same lines in the step summary: that
is the policy working (a security cut on Wednesday holds Thursday, and
Monday's cut carries everything), not a fault. A dry run prints the plan and
is the policy working (a security or regression cut on Wednesday restarts the
floor and holds Thursday, and Monday's cut carries everything), not a fault. A dry run prints the plan and
the floor verdict (`FLOOR:` line) and exits 0.
Two cases are not held. The first cut, before `stable` exists, has nothing to
@@ -98,7 +99,7 @@ it considers anything newer.
The floor fails closed when it cannot date the move. Three cases are
`FLOOR_UNKNOWN`, red on every trigger, and only a verified security cut gets
past them, as it gets past the floor. They are a `stable` that no annotated `v<VERSION>` tag dates
past them (a regression cut passes a dated floor only). They are a `stable` that no annotated `v<VERSION>` tag dates
(only this tool moves `stable` and it tags first, so that state means someone
moved it by hand), a release that cannot be read (a GitHub API error is not
"no release"), and a `ref` cut while `stable`'s own release is still
@@ -107,14 +108,15 @@ future holds the floor from now, not from that date. The floor binds the
release tool, not a writer pushing `stable` directly; that would take a branch
ruleset limiting `stable` to the Actions bot.
The floor applies to non-security breakage too. If a cut breaks team
installs, the fix waits for the floor; an affected install can follow main
meanwhile (`gstack-config set update_channel main`) and the next scheduled
cut brings it back.
Breakage that is not a regression of an earlier stable release (a new
feature that misbehaves, say) waits for the floor; an affected install can
follow main meanwhile (`gstack-config set update_channel main`) and the next
scheduled cut brings it back.
## Security releases
A security fix is the one exception to the floor. Run the workflow by hand
A security fix is one of the two exceptions to the floor (the other is a
regression fix, below); a cut carries one exception, never both. Run the workflow by hand
with `ref` set to the fix commit on main and `security` set to a reason that
names what it fixes:
@@ -157,14 +159,60 @@ A security cut that stopped half-way after moving `stable` is finished by
the next run with the reason and evidence its tag recorded, without checking
them again. A tag that records a security cut but never moved `stable` gets
no such pass: the cut is refused (`REFUSED_SECURITY`) until it runs again
with `security` and `ref` and verifies. The floor then counts from the security
with `security`, `ref` and the reason its tag recorded, and verifies. The floor then counts from the security
release, and the next regular cut's notes list only the versions after it.
## Regression releases
A regression fix is the other exception (Garry, 2026-10-28: "Regression"). A
regression is a fix for behavior that worked in an earlier stable release and
is broken on the current one. A maintainer declares it by putting the
`regression` label on the issue or pull request (labelling needs triage
access; create the label once if the repository lacks it), and the person
running the cut names it in the `regression` input, with `ref` set to the
fix commit on main:
```bash
gh workflow run weekly-release.yml -f ref=<full sha of the fix> -f regression="#3601: worked in v1.91.300.0: the update check stopped running" -f dry_run=false
```
The reason must name exactly one release `v<X>` and at least one `#<n>`.
All of these must hold, or the cut is `REFUSED_REGRESSION` with what it
checked:
1. `v<X>` is an annotated release tag strictly behind the current `stable`
whose GitHub Release is published (not a draft): an earlier stable release
team installs actually ran, which a tag left behind by a half-done cut is
not. The current stable
itself does not count, since a fix for something it still does right needs
no exception.
2. `#<n>` carries the `regression` label (a `security` label does not count,
nor the reverse) and a commit in `stable`..`ref` references it (the squash
subject `(#<n>)` does).
**Verified:** the release tag, its place behind `stable` and its published
release, the label, and
that the labelled issue or PR is in the released span. **Attested, not
verified:** that the behavior really worked in `v<X>` and that this change
restores it; the label and the public reason carry that claim.
Everything else matches a security cut. `ref` is required, `regression` and
`security` together are refused, every version between `stable` and the fix
rides along (`RIDING_ALONG:`), and the sanitized reason and evidence go into
the tag message (`Regression:` / `Verified:`) and the notes
(``**Regression release:** `<reason>` (verified: <evidence>)``). A half-done
regression cut finishes from its tag's record, and a regression tag that never
moved `stable` is refused until it runs again with `regression` and the reason
its tag recorded, and verifies.
Unlike a security cut, a regression cut does not pass `FLOOR_UNKNOWN`. The
floor counts from the regression release, so the next scheduled cut may be
held.
## Channels
| Channel | Follows | Who |
|---|---|---|
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security cut off-cycle | team mode (`./setup --team`) by default |
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security or regression cut off-cycle | team mode (`./setup --team`) by default |
| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines |
The config key is `update_channel` (`bin/gstack-config`):
@@ -213,7 +261,7 @@ still holds and nothing about version stamping changed.
A hotfix is an ordinary PR: it merges to main through a train and gets its
version like any other change. To put it on stable before the next scheduled
cut, run the workflow by hand with the merge commit's SHA, once the 72-hour
floor allows it (a security fix uses the security input instead):
floor allows it (a security or regression fix uses its input instead):
```bash
gh workflow run weekly-release.yml -f ref=<full sha on main> -f dry_run=false
@@ -263,7 +311,8 @@ is exercised against real check runs before it merges.
| `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; ...` | inside the 72-hour floor; nothing changed. Exit 1 by hand, 0 on the schedule or a dry run |
| `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run` | follows every `REFUSED_FLOOR` (and `FLOOR_UNKNOWN`, where it says why there is no time) |
| `FLOOR_UNKNOWN: ...` | stable's last move cannot be dated (no annotated `v<VERSION>` tag, an unreadable release, or an unpublished release under a `ref` cut); exit 1 on every trigger |
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, or a security tag that never moved stable run without `security`; exit 1 on every trigger |
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security exception (...)` | the floor verdict, in every plan |
| ``SECURITY: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | a security cut's plan |
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, a security tag that never moved stable run without `security`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `security` without `ref` before the script runs) |
| `REFUSED_REGRESSION: ...` | `regression` without `ref` or beside `security`, a reason without one earlier stable `v<X>` and a regression-labelled `#<n>` in the span, a regression tag that never moved stable run without `regression`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `regression` without `ref` before the script runs) |
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security|regression exception (...)` | the floor verdict, in every plan |
| ``SECURITY: `<reason>` (verified: <evidence>)`` or ``REGRESSION: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | an exception cut's plan |
| `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 |
+1 -1
View File
@@ -171,7 +171,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
checkout with `git apply -3`, never `stash apply` in the install;
`unmerged=1` means the rescue needs manual resolution).
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the latest stable release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
`ff-busy` means another gstack update is running (retry in a minute);
`autostash-unrecoverable` names what is in the way (an untracked file the
release adds, or another git command); `index-unreadable` means the checkout's
+1 -1
View File
@@ -177,7 +177,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
checkout with `git apply -3`, never `stash apply` in the install;
`unmerged=1` means the rescue needs manual resolution).
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the latest stable release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
`ff-busy` means another gstack update is running (retry in a minute);
`autostash-unrecoverable` names what is in the way (an untracked file the
release adds, or another git command); `index-unreadable` means the checkout's
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.291",
"version": "1.91.292",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+114 -55
View File
@@ -7,7 +7,7 @@
* Thursday at 14:00 UTC and by hand (--ref <sha on main> for a hotfix). Dry
* runs print the plan and change nothing.
*
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason>] [--dry-run]
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--dry-run]
* [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
*
* The checkout needs origin's full history (fetch-depth 0). Green, for a
@@ -42,10 +42,14 @@
* with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
* scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
* slack for cron start jitter; nothing else does. Finishing a half-done cut is
* not a new move. The one exception is a security cut: --security <reason>
* with --ref, where the reason names a GHSA, CVE or #<n> that verifySecurity
* ties to the commits being released; the reason and its evidence go into the
* tag message and the notes, and a retry recovers them from the tag.
* not a new move. Two exceptions pass the floor, each with --ref and one per
* cut: a security cut (--security <reason>, where the reason names a GHSA, CVE
* or #<n> that verifySecurity ties to the commits being released) and a
* regression cut (--regression <reason>, where the reason names a
* regression-labelled #<n> a released commit references and the earlier stable
* release v<X> it worked in; see verifyRegression). Only a security cut passes
* FLOOR_UNKNOWN. The reason and its evidence go into the tag message and the
* notes, and a retry recovers them from the tag.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -183,13 +187,15 @@ export function changelogEntries(text: string): ChangelogEntry[] {
return out;
}
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; security?: Security | null }
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; exception?: Exception | null }
/** A verified security cut: the sanitized reason and what tied it to the released commits. */
export interface Security { reason: string; evidence: string }
/** A verified floor exception (a security or a regression cut): the sanitized reason and what tied it to the released commits. */
export interface Exception { kind: 'security' | 'regression'; reason: string; evidence: string }
export function releaseKind(hotfix: boolean, security: Security | null | undefined): 'security' | 'hotfix' | 'regular' {
return security ? 'security' : hotfix ? 'hotfix' : 'regular';
const KIND_LABEL = { security: 'Security', regression: 'Regression' } as const;
export function releaseKind(hotfix: boolean, exception: Exception | null | undefined): 'security' | 'regression' | 'hotfix' | 'regular' {
return exception ? exception.kind : hotfix ? 'hotfix' : 'regular';
}
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
@@ -200,9 +206,9 @@ export function releaseNotes(n: NotesInput): string {
const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
const head = [
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.security) + ' release)',
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.exception) + ' release)',
'',
...(n.security ? ['**Security release:** `' + n.security.reason + '` (verified: ' + n.security.evidence + '). Every version since the previous release ships with it.', ''] : []),
...(n.exception ? ['**' + KIND_LABEL[n.exception.kind] + ' release:** `' + n.exception.reason + '` (verified: ' + n.exception.evidence + '). Every version since the previous release ships with it.', ''] : []),
n.first || !n.prevVersion
? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
: span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
@@ -248,17 +254,18 @@ export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): {
}
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'regression' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; security: boolean; scheduled: boolean; now: number }): Floor {
/** A regression cut passes only a dated floor; an undatable or unpublished stable stays security-only. */
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number }): Floor {
if (!o.prevStable) return { verdict: 'first', line: 'FLOOR: none (no stable yet; this cut creates it)' };
if (o.prevStable === o.sha) return { verdict: 'finishing', line: 'FLOOR: not applied (finishing the cut that already moved stable)' };
const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
if (moved.at === null) {
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
if (o.exception === 'security') return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v<VERSION> tag dates the release stable points at' };
}
if (!moved.published && !o.security) {
if (!moved.published && o.exception !== 'security') {
return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
}
// A tag dated in the future holds the floor from now, not from that date.
@@ -266,10 +273,10 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + since + ')' };
if (o.exception) return { verdict: o.exception, line: 'FLOOR: bypassed by the ' + o.exception + ' exception (' + since + ')' };
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
if (o.now - moved.at >= need * HOUR) return { verdict: 'clear', line: 'FLOOR: clear (' + since + '; ' + kind + ')' };
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>', next };
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>', next };
}
/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
@@ -288,11 +295,9 @@ export function sanitizeReason(reason: string): string {
export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string }): { ok: true; evidence: string } | { ok: false; why: string } {
const advisories = [...new Set([...o.reason.matchAll(/\bGHSA(?:-[2-9cfghjmpqrvwx]{4}){3}\b/gi)].map(m => 'GHSA' + m[0].slice(4).toLowerCase())
.concat([...o.reason.matchAll(/\bCVE-\d{4}-\d{4,}\b/gi)].map(m => m[0].toUpperCase())))];
const issues = [...new Set([...o.reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
const issues = issueNumbers(o.reason);
if (advisories.length === 0 && issues.length === 0) return { ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' };
const range = o.from ? [o.from + '..' + o.to] : ['--max-count=' + WINDOW_COMMITS, o.to];
const log = o.git(['log', '--format=%H%x1f%B%x1e', ...range]);
const commits = (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
const commits = spanCommits(o.git, o.from, o.to);
// Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
.flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
@@ -307,31 +312,76 @@ export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: stri
if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
}
for (const n of issues) {
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(o.gh(['api', 'repos/' + o.repo + '/issues/' + n]));
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === 'security'));
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the security label and commit ' + ref.sha + ' references it' };
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no security label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
}
return { ok: false, why: 'nothing in ' + (o.from ? o.from.slice(0, 12) + '..' : 'up to ') + o.to.slice(0, 12) + ' ties the reason to the release: ' + why.join('; ') };
const labelled = labelledReference(o.gh, o.repo, commits, issues, 'security');
if (labelled.ok) return labelled;
return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + [...why, ...labelled.why].join('; ') };
}
/** The security reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
export function securityFromTag(git: Git, tag: string): Security | null {
/**
* Ties a regression reason to the commits in (from, to]. A regression is a fix
* for behavior that worked in an earlier stable release. The reason names
* exactly one release v<X> and at least one #<n>; it is verified when v<X> is
* an annotated release tag strictly behind the current stable with a published
* GitHub Release (so team installs ran it and have since moved past it; a tag a
* half-done cut left behind does not count), and issue or PR #<n> carries the
* regression label (a maintainer's declaration: labelling needs triage rights)
* and a commit in the span references #<n>. That the behavior really worked in
* v<X> stays attested, in public, by the label and the reason.
*/
export function verifyRegression(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string; stable: string | null }): { ok: true; evidence: string } | { ok: false; why: string } {
const releases = [...new Set([...o.reason.matchAll(/(?<![\w.])v(\d+(?:\.\d+)+)(?!\w|\.\d)/g)].map(m => 'v' + m[1]!))];
const issues = issueNumbers(o.reason);
if (releases.length !== 1 || issues.length === 0) return { ok: false, why: 'the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named ' + releases.length + ' release' + (releases.length === 1 ? '' : 's') + ' and ' + issues.length + ' #<n>)' };
if (!o.stable) return { ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' };
const tag = releases[0]!;
const type = o.git(['for-each-ref', '--format=%(objecttype)', 'refs/tags/' + tag]);
const worked = o.git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
if (!type.ok || type.out !== 'tag' || !worked.ok) return { ok: false, why: tag + ' is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in' };
if (worked.out === o.stable || !o.git(['merge-base', '--is-ancestor', worked.out, o.stable]).ok) return { ok: false, why: tag + ' is not an earlier stable release: it must sit strictly behind the current stable ' + o.stable.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception' };
const release = o.gh(['release', 'view', tag, '--repo', o.repo, '--json', 'isDraft']);
const published = release.status === 0 ? parseJson<{ isDraft?: boolean }>(release) : null;
if (!published || published.isDraft === true) return { ok: false, why: tag + ' has no published release, so team installs never ran it as stable' };
const labelled = labelledReference(o.gh, o.repo, spanCommits(o.git, o.from, o.to), issues, 'regression');
if (!labelled.ok) return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + labelled.why.join('; ') };
return { ok: true, evidence: labelled.evidence + '; ' + tag + ' (' + worked.out.slice(0, 12) + ') is an earlier stable release' };
}
const issueNumbers = (reason: string) => [...new Set([...reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
const spanName = (from: string | null, to: string) => (from ? from.slice(0, 12) + '..' : 'up to ') + to.slice(0, 12);
function spanCommits(git: Git, from: string | null, to: string): Array<{ sha: string; body: string }> {
const log = git(['log', '--format=%H%x1f%B%x1e', ...(from ? [from + '..' + to] : ['--max-count=' + WINDOW_COMMITS, to])]);
return (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
}
/** The first #<n> carrying the label that a span commit references; each miss says why. */
function labelledReference(gh: Gh, repo: string, commits: Array<{ sha: string; body: string }>, issues: string[], label: string): { ok: true; evidence: string } | { ok: false; why: string[] } {
const why: string[] = [];
for (const n of issues) {
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(gh(['api', 'repos/' + repo + '/issues/' + n]));
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === label));
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the ' + label + ' label and commit ' + ref.sha + ' references it' };
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no ' + label + ' label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
}
return { ok: false, why };
}
/** The exception reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
export function exceptionFromTag(git: Git, tag: string): Exception | null {
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
const reason = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
const reason = body.ok ? /^(Security|Regression): (.+)$/m.exec(body.out) : null;
if (!reason) return null;
return { reason: reason[1]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
return { kind: reason[1] === 'Security' ? 'security' : 'regression', reason: reason[2]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
}
export interface Plan {
sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
security: Security | null; floor: Floor; ridingAlong: number;
exception: Exception | null; floor: Floor; ridingAlong: number;
}
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY';
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
export interface PlanOptions {
@@ -341,13 +391,19 @@ export interface PlanOptions {
scheduled?: boolean;
/** --security <reason>: bypass the floor for a verified security fix; needs ref. */
security?: string;
/** --regression <reason>: bypass a dated floor for a verified regression fix; needs ref. */
regression?: string;
}
const refusalFor = (kind: Exception['kind']): RefusalCode => (kind === 'security' ? 'REFUSED_SECURITY' : 'REFUSED_REGRESSION');
export function planRelease(o: PlanOptions): PlanResult {
const { git, gh, repo } = o;
const windowDays = o.windowDays ?? WINDOW_DAYS;
const skipped: string[] = [];
if (o.security !== undefined && !o.ref) return { ok: false, code: 'REFUSED_SECURITY', message: '--security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest' };
if (o.security !== undefined && o.regression !== undefined) return { ok: false, code: 'REFUSED_REGRESSION', message: '--security and --regression together: a cut carries one exception, so its notes make one claim' };
const asked: Exception['kind'] | null = o.security !== undefined ? 'security' : o.regression !== undefined ? 'regression' : null;
if (asked && !o.ref) return { ok: false, code: refusalFor(asked), message: '--' + asked + ' needs --ref <the fix commit>: a ' + asked + ' cut releases up to the fix, never whatever is newest' };
const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
@@ -424,23 +480,24 @@ export function planRelease(o: PlanOptions): PlanResult {
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
}
// Finishing a security cut keeps the reason and evidence its tag recorded; a tag
// that recorded one but never moved stable needs --security verified again.
const recorded = tagExists === 'same' ? securityFromTag(git, tag) : null;
let security: Security | null = recorded && prevStable === sha ? recorded : null;
if (recorded && !security && o.security === undefined) return { ok: false, code: 'REFUSED_SECURITY', message: 'tag ' + tag + ' records a security cut that has not moved stable; run it again with --security and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
if (!security && o.security !== undefined) {
const reason = sanitizeReason(o.security);
const verified = verifySecurity({ reason, git, gh, repo, from: before, to: sha });
if (!verified.ok) return { ok: false, code: 'REFUSED_SECURITY', message: verified.why };
security = { reason, evidence: verified.evidence };
// Finishing an exception cut keeps the reason and evidence its tag recorded; a
// tag that recorded one but never moved stable needs that same kind verified again.
const recorded = tagExists === 'same' ? exceptionFromTag(git, tag) : null;
let exception: Exception | null = recorded && prevStable === sha ? recorded : null;
if (recorded && !exception && asked !== recorded.kind) return { ok: false, code: refusalFor(recorded.kind), message: 'tag ' + tag + ' records a ' + recorded.kind + ' cut that has not moved stable; run it again with --' + recorded.kind + ' and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
if (!exception && asked) {
const reason = sanitizeReason((asked === 'security' ? o.security : o.regression)!);
if (recorded && recorded.reason !== reason) return { ok: false, code: refusalFor(asked), message: 'tag ' + tag + ' records the ' + asked + ' reason "' + recorded.reason + '"; run it again with that same reason so the tag and the notes agree' };
const verified = asked === 'security' ? verifySecurity({ reason, git, gh, repo, from: before, to: sha }) : verifyRegression({ reason, git, gh, repo, from: before, to: sha, stable: prevStable });
if (!verified.ok) return { ok: false, code: refusalFor(asked), message: verified.why };
exception = { kind: asked, reason, evidence: verified.evidence };
}
const changelog = git(['show', sha + ':CHANGELOG.md']);
const date = git(['show', '-s', '--format=%cs', sha]).out;
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, security });
const floor = floorStatus({ git, gh, repo, prevStable, sha, security: Boolean(security), scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, exception });
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, security, floor, ridingAlong };
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, exception, floor, ridingAlong };
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
return { ok: true, plan };
}
@@ -454,7 +511,7 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
if (!existing.ok) {
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.security) + ' release)' + (p.security ? '\n\nSecurity: ' + p.security.reason + '\nVerified: ' + p.security.evidence : '');
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.exception) + ' release)' + (p.exception ? '\n\n' + KIND_LABEL[p.exception.kind] + ': ' + p.exception.reason + '\nVerified: ' + p.exception.evidence : '');
const t = git(['tag', '-a', p.tag, p.sha, '-m', message]);
if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
@@ -494,7 +551,7 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
p.floor.line,
];
if (p.security) lines.push('SECURITY: `' + p.security.reason + '` (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
if (p.exception) lines.push(p.exception.kind.toUpperCase() + ': `' + p.exception.reason + '` (verified: ' + p.exception.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
if (dryRun) {
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
@@ -515,8 +572,10 @@ if (import.meta.main) {
const dryRun = process.argv.includes('--dry-run');
const summary = arg('--summary');
const security = arg('--security');
if (!repo || (process.argv.includes('--security') && (!security || security.startsWith('--')))) {
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
const regression = arg('--regression');
const empty = (flag: string, value: string | undefined) => process.argv.includes(flag) && (!value || value.startsWith('--'));
if (!repo || empty('--security', security) || empty('--regression', regression)) {
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
process.exit(2);
}
const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
@@ -525,7 +584,7 @@ if (import.meta.main) {
for (const l of lines) console.log(l);
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
};
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security });
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression });
if (!planned.ok) {
emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
// A dry run that finds nothing green has done its job (the skips say why),
+118 -11
View File
@@ -1,5 +1,5 @@
/**
* release-cut (free): the weekly release picks the newest green commit on
* release-cut (free): the stable release picks the newest green commit on
* main's first-parent history, tags it v<VERSION>, fast-forwards stable and
* publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a
* named main commit; stable never moves backwards; a frozen queue, a pending
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import {
BACKSTOP_CHECK, FLOOR_HOURS, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, SCHEDULED_GRACE_HOURS, changelogEntries, compareVersions, cutRelease, describePlan, floorStatus,
gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifySecurity, type Gh, type Git,
gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifyRegression, verifySecurity, type Gh, type Git,
} from '../scripts/release-cut';
const bases: string[] = [];
@@ -624,7 +624,7 @@ describe('release-cut: the 72-hour floor', () => {
// Value: protects=FLOOR_UNKNOWN's promise that a verified --security cut still moves an undatable stable; fails_when=the unknown branch returns before
// the security check, stranding a security fix until someone hand-tags stable; why_new=the security test always starts from a dated stable; seam=none;
// no_claim=verifySecurity (floorStatus takes the verified flag)
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, security: true, scheduled: false, now: pastFloor() }))
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, exception: 'security', scheduled: false, now: pastFloor() }))
.toEqual({ verdict: 'security', line: 'FLOOR: bypassed by the security exception (no annotated v1.0.2 tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated)' });
git(f.seed, 'push', '-q', '--delete', 'origin', 'refs/tags/v1.0.2');
@@ -640,7 +640,7 @@ describe('release-cut: the 72-hour floor', () => {
expect(held.ok).toBe(false);
if (!held.ok) expect(held.next).toStartWith('2026-10-24T09:30:00Z for a hand-run cut');
const base = { git: f.git, gh: f.gh, repo: f.repo, security: false, scheduled: false, now: Date.now() };
const base = { git: f.git, gh: f.gh, repo: f.repo, exception: null, scheduled: false, now: Date.now() };
expect(floorStatus({ ...base, prevStable: null, sha: f.shas['1.0.3']! }).verdict).toBe('first');
expect(floorStatus({ ...base, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.2']! }).verdict).toBe('finishing');
@@ -715,7 +715,7 @@ describe('release-cut: the 72-hour floor', () => {
expect(past.ok).toBe(false);
if (!past.ok) { expect(past.code).toBe('FLOOR_UNKNOWN'); expect(past.message).toBe('stable moved to v1.0.4 but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)'); }
const future = floorStatus({ git: f.git, gh: (args) => args[0] === 'release' ? { status: 0, stdout: JSON.stringify({ publishedAt: '2099-01-01T00:00:00Z' }), stderr: '' } : f.gh(args), repo: f.repo, prevStable: hot, sha: newer, security: false, scheduled: false, now: at });
const future = floorStatus({ git: f.git, gh: (args) => args[0] === 'release' ? { status: 0, stdout: JSON.stringify({ publishedAt: '2099-01-01T00:00:00Z' }), stderr: '' } : f.gh(args), repo: f.repo, prevStable: hot, sha: newer, exception: null, scheduled: false, now: at });
expect(future.verdict).toBe('refused');
expect(future.next).toStartWith(isoZ(at + 72 * H) + ' for a hand-run cut');
});
@@ -788,20 +788,20 @@ describe('release-cut: the security exception', () => {
f.state.api['repos/o/r/issues/77'] = { labels: [{ name: 'Security' }] };
const byLabel = sec(labelled, '#77: summary injection');
expect(byLabel.ok, JSON.stringify(byLabel)).toBe(true);
if (byLabel.ok) { expect(byLabel.plan.security!.evidence).toBe('#77 carries the security label and commit ' + labelled.slice(0, 12) + ' references it'); expect(byLabel.plan.ridingAlong).toBe(1); }
if (byLabel.ok) { expect(byLabel.plan.exception!.evidence).toBe('#77 carries the security label and commit ' + labelled.slice(0, 12) + ' references it'); expect(byLabel.plan.ridingAlong).toBe(1); }
const labelOnly = sec(labelled, '#79');
if (!labelOnly.ok) expect(labelOnly.message).toEndWith('#79: no commit in the span references it');
for (const id of ['GHSA-9999-xxxx-wwww', 'CVE-2026-55555']) {
const byAdvisory = sec(bump, id);
expect(byAdvisory.ok, JSON.stringify(byAdvisory)).toBe(true);
if (byAdvisory.ok) expect(byAdvisory.plan.security!.evidence).toBe('advisory ' + id + ' lists left-pad, which changes in package.json/bun.lock');
if (byAdvisory.ok) expect(byAdvisory.plan.exception!.evidence).toBe('advisory ' + id + ' lists left-pad, which changes in package.json/bun.lock');
}
const verified = sec(fix, 'ghsa-2345-CFGH-jmpq: tar\npath traversal \`x\`');
expect(verified.ok, JSON.stringify(verified)).toBe(true);
if (!verified.ok) return;
const reason = 'ghsa-2345-CFGH-jmpq: tar path traversal ' + "'x'";
expect(verified.plan.security).toEqual({ reason, evidence: 'commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq' });
expect(verified.plan.exception).toEqual({ kind: 'security', reason, evidence: 'commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq' });
expect(verified.plan.floor.verdict).toBe('security');
expect(verified.plan.ridingAlong).toBe(0);
expect(verified.plan.notes).toContain('## gstack v1.0.4 (security release)\n\n**Security release:** \`' + reason + '\` (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq).');
@@ -817,7 +817,7 @@ describe('release-cut: the security exception', () => {
expect(retry.ok, JSON.stringify(retry)).toBe(true);
if (!retry.ok) return;
expect(retry.plan.stableAt).toBe(true);
expect(retry.plan.security!.reason).toBe(reason);
expect(retry.plan.exception!.reason).toBe(reason);
expect(retry.plan.notes).toBe(verified.plan.notes);
expect(cutRelease(retry.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
f.refresh();
@@ -832,7 +832,7 @@ describe('release-cut: the security exception', () => {
const next = planRelease({ repo: f.repo, git: f.git, gh: f.gh, scheduled: true, now: () => movedAt + 69 * H });
expect(next.ok, JSON.stringify(next)).toBe(true);
if (!next.ok) return;
expect(next.plan.security).toBeNull();
expect(next.plan.exception).toBeNull();
expect(next.plan.prevTag).toBe('v1.0.4');
expect(next.plan.notes).toContain('## gstack v1.0.6 (regular release)');
expect(next.plan.notes).toContain('2 versions since v1.0.4');
@@ -860,7 +860,7 @@ describe('release-cut: the security exception', () => {
f.state.api = {};
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => at + 2 * H, scheduled: true });
expect(finish.ok, JSON.stringify(finish)).toBe(true);
if (finish.ok) expect(finish.plan.security).toEqual(sec.plan.security);
if (finish.ok) expect(finish.plan.exception).toEqual(sec.plan.exception);
// A security-recorded tag at a commit stable never reached.
const later = f.release('1.0.5', 'Later.', { message: 'v1.0.5 (#78)' });
@@ -889,6 +889,108 @@ describe('release-cut: the security exception', () => {
});
});
describe('release-cut: the regression exception', () => {
// Value: protects=Garry's "Regression" (2026-10-28): a fix for behavior that worked in an earlier stable release passes the floor only with --ref, a
// regression-labelled #n a released commit references, and exactly one annotated v<X> strictly behind the current stable; it is recorded in the tag and
// notes like a security cut, a retry recovers it, labels do not cross kinds, and the floor restarts from it; fails_when=a free-text claim, a main-only
// break (v<X> == stable), an unknown or lightweight tag, a security label or an unreferenced #n bypasses the floor, or the record is lost; why_new=no
// regression exception existed; seam=gh issues are faked; no_claim=that the behavior really worked in v<X> (attested by the label and the reason)
test('verified regression reasons bypass a dated floor; every unverifiable claim is REFUSED_REGRESSION; the record survives a retry and restarts the floor', () => {
const f = fixture();
const first = cutFirst(f);
f.release('1.0.4', 'Regular work.');
f.refresh();
const second = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => first + 73 * H });
expect(second.ok, JSON.stringify(second)).toBe(true);
if (!second.ok) return;
expect(cutRelease(second.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
f.refresh();
const moved = stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.4']!);
if (moved.at === null) throw new Error(moved.why);
const fix = f.release('1.0.5', 'Restores the update check.', { message: 'v1.0.5: restore the update check (#91)' });
git(f.seed, 'tag', 'v1.0.1', f.shas['1.0.1']!);
execFileSync('git', ['tag', '-a', 'v1.0.2', f.shas['1.0.2']!, '-m', 'gstack v1.0.2 (regular release)'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.1', 'refs/tags/v1.0.2');
f.refresh();
const now = () => moved.at + H;
const reg = (ref: string | undefined, reason: string, extra: { security?: string } = {}) => planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh, now, regression: reason, ...extra });
const reason = '#91: the update check stopped running; it worked in v1.0.3.';
const refused = (r: ReturnType<typeof reg>) => { expect(r.ok).toBe(false); return r.ok ? '' : (expect(r.code).toBe('REFUSED_REGRESSION'), r.message); };
expect(refused(reg(undefined, reason))).toBe('--regression needs --ref <the fix commit>: a regression cut releases up to the fix, never whatever is newest');
expect(refused(reg(fix, reason, { security: 'CVE-2026-1: x' }))).toBe('--security and --regression together: a cut carries one exception, so its notes make one claim');
expect(refused(reg(fix, '#91: the update check broke'))).toBe('the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named 0 releases and 1 #<n>)');
expect(refused(reg(fix, '#91: worked in v1.0.3 and v1.0.2'))).toContain('(named 2 releases and 1 #<n>)');
expect(refused(reg(fix, 'worked in v1.0.3'))).toContain('(named 1 release and 0 #<n>)');
expect(refused(reg(fix, '#91: worked in v1.0.4'))).toBe('v1.0.4 is not an earlier stable release: it must sit strictly behind the current stable ' + f.shas['1.0.4']!.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception');
expect(refused(reg(fix, '#91: worked in v9.9.9'))).toBe('v9.9.9 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
expect(refused(reg(fix, '#91: worked in v1.0.1'))).toBe('v1.0.1 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
// An annotated tag a half-done cut left behind, with no published release, never ran as stable.
expect(refused(reg(fix, '#91: worked in v1.0.2'))).toBe('v1.0.2 has no published release, so team installs never ran it as stable');
f.state.api = { 'repos/o/r/issues/91': { labels: [{ name: 'security' }] }, 'repos/o/r/issues/92': { labels: [{ name: 'regression' }] } };
expect(refused(reg(fix, reason))).toEndWith('#91: no regression label');
expect(refused(reg(fix, '#92: worked in v1.0.3'))).toEndWith('#92: no commit in the span references it');
f.state.api['repos/o/r/issues/91'] = { labels: [{ name: 'Regression' }] };
const crossed = planRelease({ repo: f.repo, ref: fix, git: f.git, gh: f.gh, now, security: '#91: x' });
expect(crossed.ok).toBe(false);
if (!crossed.ok) { expect(crossed.code).toBe('REFUSED_SECURITY'); expect(crossed.message).toEndWith('#91: no security label'); }
const verified = reg(fix, reason);
expect(verified.ok, JSON.stringify(verified)).toBe(true);
if (!verified.ok) return;
const evidence = '#91 carries the regression label and commit ' + fix.slice(0, 12) + ' references it; v1.0.3 (' + f.shas['1.0.3']!.slice(0, 12) + ') is an earlier stable release';
expect(verified.plan.exception).toEqual({ kind: 'regression', reason, evidence });
expect(verified.plan.floor).toEqual({ verdict: 'regression', line: 'FLOOR: bypassed by the regression exception (stable moved to v1.0.4 at ' + isoZ(moved.at) + ', 1.0 h ago)' });
expect(verified.plan.notes).toContain('## gstack v1.0.5 (regression release)\n\n**Regression release:** \`' + reason + '\` (verified: ' + evidence + ').');
expect(describePlan(verified.plan, false)).toContain('REGRESSION: \`' + reason + '\` (verified: ' + evidence + ')');
// The release step fails; a scheduled retry with no flag finishes it with the record read back from the tag.
f.state.createFails = 'v1.0.5';
expect(cutRelease(verified.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
delete f.state.createFails;
expect(git(f.origin, 'cat-file', '-p', 'v1.0.5')).toContain('gstack v1.0.5 (regression release)\n\nRegression: ' + reason + '\nVerified: ' + evidence);
f.refresh();
f.state.api = {};
const retry = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => moved.at + 2 * H, scheduled: true });
expect(retry.ok, JSON.stringify(retry)).toBe(true);
if (!retry.ok) return;
expect(retry.plan.exception).toEqual(verified.plan.exception);
expect(cutRelease(retry.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
f.refresh();
// The floor restarts from the regression release: the next scheduled cut 30 h later is held.
const later = f.release('1.0.6', 'Later.', { message: 'v1.0.6 (#93)' });
f.refresh();
const held = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => moved.at + 32 * H, scheduled: true });
expect(held.ok).toBe(false);
if (!held.ok) { expect(held.code).toBe('REFUSED_FLOOR'); expect(held.message).toStartWith('stable moved to v1.0.5 at '); }
// A regression-recorded tag stable never reached cannot pass with the other flag.
execFileSync('git', ['tag', '-a', 'v1.0.6', later, '-m', 'gstack v1.0.6 (regression release)\n\nRegression: #93: forged\nVerified: nothing'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.6');
f.refresh();
const forged = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, security: 'CVE-2026-2: x' });
expect(forged.ok).toBe(false);
if (!forged.ok) expect(forged).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records a regression cut that has not moved stable; run it again with --regression and --ref ' + later.slice(0, 12) + ' so the reason is verified' });
// A different reason than the tag recorded would publish notes the tag (and a later retry) contradicts.
const other = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, regression: '#93: worked in v1.0.4: other' });
expect(other.ok).toBe(false);
if (!other.ok) expect(other).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records the regression reason "#93: forged"; run it again with that same reason so the tag and the notes agree' });
});
// Value: protects=an undatable stable and the very first cut stay closed to a regression claim (FLOOR_UNKNOWN is security-only; nothing earlier worked
// before stable existed); fails_when=a regression cut moves a stable whose last move cannot be dated, or anchors to no release; why_new=the security
// exception alone passed FLOOR_UNKNOWN; seam=none; no_claim=none beyond protects
test('a regression cut does not pass FLOOR_UNKNOWN, and with no stable yet nothing earlier can have worked', () => {
const f = fixture();
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
f.refresh();
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, exception: 'regression', scheduled: false, now: pastFloor() }).verdict).toBe('unknown');
expect(verifyRegression({ reason: '#1: worked in v1.0.0', git: f.git, gh: f.gh, repo: f.repo, from: null, to: f.shas['1.0.3']!, stable: null }))
.toEqual({ ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' });
});
});
describe.skipIf(process.platform === 'win32')('release-cut: refusals at the workflow entry point', () => {
// Value: protects=the CLI contract for refusals: REFUSED_FLOOR then NEXT_ALLOWED on the first two lines, the plan after them, nothing pushed, exit 1 for a
// hand cut and an ::error:: annotation, exit 0 with a ::notice:: on the schedule or a dry run, REFUSED_SECURITY and FLOOR_UNKNOWN red on every trigger,
@@ -937,6 +1039,11 @@ describe.skipIf(process.platform === 'win32')('release-cut: refusals at the work
expect(escaped.stdout.split('\n').find(l => l.startsWith('::error'))).toStartWith('::error title=release-cut REFUSED::REFUSED: --ref no%25such%0D%0Aref is not a commit here: ');
expect(run({}, '--security').status).toBe(2);
expect(run({}, '--security', '--dry-run').status).toBe(2);
expect(run({}, '--regression').status).toBe(2);
const regNoRef = run({ GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'schedule' }, '--regression', '#1: worked in v1.0.2');
expect(regNoRef.status).toBe(1);
expect(regNoRef.stdout.split('\n')[0]).toBe('REFUSED_REGRESSION: --regression needs --ref <the fix commit>: a regression cut releases up to the fix, never whatever is newest');
expect(regNoRef.stdout).toContain('::error title=release-cut REFUSED_REGRESSION::');
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.4'] + ':refs/heads/stable');
f.release('1.0.5', 'Even later.');
+1 -1
View File
@@ -224,7 +224,7 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh as the /
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(fallback.stderr).toContain('origin has no stable release yet; following origin/main until the first stable release is cut');
expect(git(early.install, 'rev-parse', 'refs/remotes/origin/main')).toBe(early.shas.C);
expect(early.head()).toBe(early.shas.A);
+8 -3
View File
@@ -4,8 +4,8 @@
* 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 security input reaches the script only
* through an env var, beside a ref. The release-cut CLI the jobs call is
* never reaches the scheduled run; the security and regression inputs reach the
* script only through env vars, beside a ref. The release-cut CLI the jobs call is
* covered by test/release-cut.test.ts.
*/
import { describe, expect, test } from 'bun:test';
@@ -24,13 +24,14 @@ describe('weekly-release.yml', () => {
// Value: protects=Garry's "twice a week" (2026-10-27): Monday and Thursday at 14:00 UTC, 72 h then 96 h apart, so a scheduled Thursday cut clears the
// floor once the grace absorbs a late Monday start; fails_when=the cron drifts off Mon/Thu 14:00 or the gap and floor stop matching; why_new=the cadence was
// weekly; seam=none; no_claim=GitHub's cron start times
test('Monday and Thursday schedule, a dispatch with dry_run (default true), a full-SHA ref and a security reason, and a pull request only for the release tooling', () => {
test('Monday and Thursday schedule, a dispatch with dry_run (default true), a full-SHA ref, a security or regression reason, and a pull request only for the release tooling', () => {
expect((wf as { name?: string }).name).toBe('Stable release');
expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']);
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1,4' }]);
expect((4 - 1) * 24).toBe(FLOOR_HOURS);
expect(SCHEDULED_GRACE_HOURS).toBeLessThan(FLOOR_HOURS / 12);
expect(wf.on.workflow_dispatch.inputs.security).toMatchObject({ type: 'string', default: '' });
expect(wf.on.workflow_dispatch.inputs.regression).toMatchObject({ type: 'string', default: '' });
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'] });
@@ -56,6 +57,10 @@ describe('weekly-release.yml', () => {
expect(plan.run).toContain('if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then');
expect(plan.run).toContain('[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON")');
expect(plan.run).not.toContain('inputs.security');
expect(plan.env!.REGRESSION_REASON).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }}");
expect(plan.run).toContain('if [ -n "$REGRESSION_REASON" ] && [ -z "$RELEASE_REF" ]; then');
expect(plan.run).toContain('[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")');
expect(plan.run).not.toContain('inputs.regression');
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'");
});