v1.91.288.0 feat(release): stable moves at most every 72 hours, with a verified security exception and Monday/Thursday cuts (#3523)

* wip: stable 72-hour floor, security exception, twice-weekly cadence

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

* wip: changelog, coverage cases for tag-before-stable and multi-tag

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

* fix: floor fails closed on undatable moves, recorded security evidence on finish, npm-only advisory match

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

* docs: prose fixes for the floor and security sections

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

* docs: FLOOR_UNKNOWN wording names the security exception; sanitizer and outcome rows match the code

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

* fix: the floor dates stable from its v<VERSION> tag, so a stray extra tag cannot stall cuts silently

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 03:13:55 +00:00
1 parent 7204a39a27
commit cd4fc4be73
11 files changed
+800 -70

No files matched your search

+28 -8
View File
@@ -1,9 +1,15 @@
name: Weekly release
name: Stable release
# Tags the newest green commit on main as v<VERSION>, publishes the GitHub
# Release, and fast-forwards refs/heads/stable to it (docs/releases.md). Team
# installs follow stable, so this is the only thing that moves them. Runs
# every Monday morning Pacific; run it by hand with a ref for a hotfix, or
# with dry_run to see the plan and change nothing. A pull request that touches
# twice a week, Monday and Thursday at 14:00 UTC (the file keeps its
# 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
# ref for a hotfix, or with dry_run to see the plan and change nothing. A pull request that touches
# the release tooling runs the dry run against main with a read-only token,
# which is the only pre-merge exercise of the real workflow. The candidate's
# gate must already be green (free-tests and main-backstop check runs
@@ -12,8 +18,9 @@ name: Weekly release
# runs only there, with main's tooling.
on:
schedule:
# Monday 14:00 UTC: 07:00 PDT, 06:00 PST.
- cron: '0 14 * * 1'
# Monday and Thursday 14:00 UTC (07:00 PDT, 06:00 PST): exactly 72 h, then
# 96 h, apart; the floor's scheduled grace covers a late Monday start.
- cron: '0 14 * * 1,4'
workflow_dispatch:
inputs:
dry_run:
@@ -24,6 +31,10 @@ on:
description: 'Hotfix: release this commit of main instead of the newest green one (full 40-hex SHA)'
type: string
default: ''
security:
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: ''
pull_request:
paths:
- '.github/workflows/weekly-release.yml'
@@ -48,6 +59,9 @@ jobs:
contents: write
# Reads the candidate's check runs.
checks: read
# A security cut reads the security label on the #<n> its reason names.
issues: read
pull-requests: read
steps:
# The dispatched ref supplies the tooling; the release candidate always
# comes from origin/main, which fetch-depth 0 brings along with every
@@ -73,16 +87,22 @@ jobs:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }}
run: |
set -euo pipefail
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::ref must be the full 40-hex SHA of a commit on main, not '$RELEASE_REF'"
echo "::error::ref must be the full 40-hex SHA of a commit on main"
exit 1
fi
if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then
echo "::error::a security 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")
echo "## Weekly release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'))" >> "$GITHUB_STEP_SUMMARY"
[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_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[@]}"
dry-run:
@@ -110,5 +130,5 @@ jobs:
set -euo pipefail
git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet"
git fetch --quiet --tags origin
echo "## Weekly release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY"
echo "## Stable release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY"
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [1.91.288.0] - 2026-10-28
**Stable moves twice a week, and never twice in 72 hours unless a security fix needs it.** Garry asked, 2026-10-27: "Do we have rules around stable where we update stable except for security fixes at most once every 72 hours?" There were none: the Monday cut and any hand-run cut moved team installs whenever someone ran it. Now the release cut runs Monday and Thursday at 14:00 UTC ("Ok twice a week"), refuses any move inside 72 hours of the last one, and lets a security fix through only when it can tie the fix to the commits being released.
### Added
- **The 72-hour floor in `scripts/release-cut.ts`.** Every cut, scheduled or by hand, measures stable's last move from the annotated `v<VERSION>` tag on stable (or its GitHub Release's publish time when later; any other tag on that commit is ignored) and refuses inside 72 hours. The refusal's first two lines are `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; …` and `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run`, followed by the plan it would have run. Scheduled runs alone get 3 hours of grace for GitHub's late cron starts, so a Thursday run after a late Monday cut still cuts. The floor fails closed, so a stable no annotated `v<VERSION>` tag dates, a release that cannot be read, or a cut while stable's own release is unpublished is `FLOOR_UNKNOWN`, and a run without `--ref` finishes an unpublished cut before moving on. A refused hand cut exits 1 with an error annotation; a refused scheduled run or dry run exits 0 with a notice, since the policy is working.
- **Security cuts: `--security "<reason>"` with `--ref <fix sha>`** (workflow inputs `security` and `ref`). The reason must name a `GHSA-…`, `CVE-…` or `#<n>`, and the cut passes the floor only when a commit in the span names that GHSA or CVE, `#<n>` carries the `security` label and a commit references it, or the GitHub Advisory Database lists an npm package whose own entry changes in the span's `package.json` or `bun.lock`. Anything else is `REFUSED_SECURITY`. The reason (one public line) goes into the tag message and the release notes as `**Security release:** …`, finishing a half-done security cut reuses the reason and evidence its tag recorded (a security tag that never moved stable must verify again), and the plan's `RIDING_ALONG:` line counts the other versions that ship with the fix.
### Changed
- **`.github/workflows/weekly-release.yml` is now "Stable release" and runs Monday and Thursday** (`0 14 * * 1,4`). The file keeps its name so its run history and every `gh workflow run weekly-release.yml` stay valid. The cut job can read issue and pull-request labels for the security check.
- **Release notes say `(regular release)`, `(hotfix release)` or `(security release)`** instead of "weekly release", and the next regular cut after a security release lists only the versions since it.
- **The stable push is pinned to the stable the plan read** (`--force-with-lease=refs/heads/stable:<sha>`), so two overlapping cuts cannot both move it.
- **Docs:** `docs/releases.md` gains "The 72-hour floor" and "Security releases" (what is verified and what is attested) and the new outcome codes; the cadence sentence in `docs/install.md` and the release section of `docs/recommended-harness.md` follow.
## [1.91.287.0] - 2026-10-28
**Write, change and slim skills with `/write-skill`; the first diet halves /learn's template with the same results.** Garry asked, 2026-10-27: "Can we incorporate skill writing and improvement into gstack? https://github.com/nicobailon/skill-diet". gstack now has one skill for writing a new skill, changing an existing one, and putting one on a diet, plus a size bill, a static check that skills only call flags their bins accept, and a contract for the shared before/after skill eval the gstack-evals crew is building. /learn is the pilot: its template drops from 5,797 to 2,955 bytes and its rendered SKILL.md from 11,217 to 8,375. An A/B of 30 sessions per version on claude-opus-5-5 passed 30/30 before and 30/30 after (29/30 with no skill), with 10% fewer tokens per session. Adapted from nicobailon/skill-diet at 5f41df1 (MIT; NOTICE.md). Plan approved by Garry, 2026-10-27: "A1, B1, C1 D1 E1" and "Rec".
+1 -1
View File
@@ -1 +1 @@
1.91.287.0
1.91.288.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.287.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.288.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,
+1 -1
View File
@@ -18,7 +18,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel
From inside your repo, `(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required` switches you to team mode and bootstraps the repo so teammates get gstack automatically (the README has the full paste, which also commits the change).
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 [releases.md](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 tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md).
Swap `required` for `optional` if you'd rather nudge teammates than block them.
+12 -9
View File
@@ -3,7 +3,7 @@
This is the operating model a team adopts when it runs gstack under a Capy
Captain: one person, one Captain thread that directs a crew, crew threads that
each own a branch and stop at an unstamped READY, one controller that merges
through trains, a daily flaky pass, a weekly release, and a scorecard that says
through trains, a daily flaky pass, a twice-weekly release, and a scorecard that says
whether the factory is getting better. It was written from two crews that ran
it for a week on their own repositories (about 100 merges a week each) and from
the recurring problems four Captains reported while doing so. Every path and
@@ -33,7 +33,7 @@ flowchart LR
C -->|train build: one gate for up to 5 PRs| G[(Gate workflow)]
G -->|GREEN or RED with flake records| C
C -->|slot: stamp, verify, merge, verify| M[(main)]
M -->|Monday| R[Weekly release: tag, notes, stable]
M -->|Mon + Thu, 72 h floor| R[Stable release: tag, notes, stable]
G -.->|flake-records artifacts| F[Daily flaky pass]
M -.->|free-suite retries| F
M -.->|/retro Step 7.5| S[Factory scorecard]
@@ -84,7 +84,7 @@ a time; each is independent.
|---|---|---|
| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` fails every touched file with `PREGATE_NO_LANE`. Write it once, from one owner, before the crew starts. |
| `.gstack/ship-policy.json` | `queue_mode`, the `train` block (gate and free workflow names, the full-gate check, the train size and fill wait), the stamp files ([docs/ship-policy.md](ship-policy.md)) | Per-PR queue; `train …` refuses with `QUEUE_TRAIN_OFF`. |
| `CHANGELOG.md` + `VERSION` | A stamp per merge and a weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. |
| `CHANGELOG.md` + `VERSION` | A stamp per merge and a twice-weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. |
| `AGENTS.md` validation discipline | Diagnose before fixing, focused checks during repair, one full suite at the end on a frozen tree | Threads retry blindly and report passes from older revisions. |
A `.github/workflows` gate that the train can dispatch is the one hard
@@ -221,17 +221,20 @@ repeats first, start one fix thread with the Open section as its brief, and
report the count to the Captain". Pass `thread: "this"` to keep the history in
one place, and run it once by hand before enabling it.
## The weekly release
## The stable release
`.github/workflows/weekly-release.yml` runs `scripts/release-cut.ts` every
Monday morning: it takes the newest green commit on main's first-parent history
`.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
(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
previous release, and fast-forwards `stable`. Team-mode installs follow
`stable`; `gstack-config set update_channel main` follows every merge. A
hotfix is `--ref <sha on main>`. Details and the "nothing green in the window"
failure mode: [docs/releases.md](releases.md). A repository adopting this
hotfix is `--ref <sha on main>`, held by the same floor. Details, the floor's
refusal codes and the "nothing green in the window" failure mode:
[docs/releases.md](releases.md). A repository adopting this
copies the workflow, sets its required check and backstop check names
(`REQUIRED_CHECK`, `BACKSTOP_CHECK`), and keeps the CHANGELOG headline
convention ([docs/CHANGELOG_STYLE.md](CHANGELOG_STYLE.md)).
@@ -428,5 +431,5 @@ allowlist; no cost ceiling surfaced at autoplan gates.
| Ship policy, queue and trains | `docs/ship-policy.md`, `lib/ship-queue*.ts`, `bin/gstack-ship-queue`, `bin/gstack-restamp` |
| Pregate and lanes | `docs/pregate.md`, `bin/gstack-pregate` |
| Flaky reruns and records | `docs/TESTING_INTERNALS.md#flaky-reruns`, `scripts/lib/paid-rerun.ts`, `scripts/lib/ci-history.ts` |
| Weekly release | `docs/releases.md`, `scripts/release-cut.ts`, `.github/workflows/weekly-release.yml` |
| Stable release | `docs/releases.md`, `scripts/release-cut.ts`, `.github/workflows/weekly-release.yml` |
| Scorecard | `bin/gstack-factory-scorecard`, `lib/factory-scorecard.ts`, `retro/SKILL.md.tmpl` Step 7.5, `test/factory-scorecard.test.ts` |
+121 -14
View File
@@ -1,14 +1,16 @@
# Releases and update channels
gstack stamps a version at every merge to main, about a dozen a day. Since
late October 2026 there is also a weekly tagged release, and team-mode
installs follow it instead of every merge.
late October 2026 there is also a tagged release, cut twice a week, and
team-mode installs follow it instead of every merge.
## What a release is
Every Monday at 07:00 Pacific (06:00 in winter) the **Weekly release**
workflow (`.github/workflows/weekly-release.yml`, which runs
`scripts/release-cut.ts`) takes the newest green commit on main and:
Every Monday and Thursday at 14:00 UTC (07:00 Pacific, 06:00 in winter) the
**Stable release** workflow (`.github/workflows/weekly-release.yml`, which
runs `scripts/release-cut.ts`; the file kept its old name so its run history
and the `gh workflow run` commands below stay valid) takes the newest green
commit on main and:
1. tags it `v<VERSION>` (the VERSION file at that commit, an annotated tag);
2. publishes a GitHub Release whose notes are one line per CHANGELOG version
@@ -18,7 +20,7 @@ workflow (`.github/workflows/weekly-release.yml`, which runs
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.
forward, and at most once every 72 hours except for a security 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
@@ -60,11 +62,109 @@ 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.
## The 72-hour floor
`stable` moves at most once every 72 hours (Garry, 2026-10-27). Every cut,
scheduled or by hand, with or without `ref`, checks when `stable` last moved:
the creation time of the annotated `v<VERSION>` tag (VERSION read at `stable`)
pointing at `stable`, or the publish time of that tag's GitHub Release when it
is later (a cut whose `stable` push was retried after the tag was pushed is
dated by its release). Any other tag on that commit is ignored, so a hand-made
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>
NEXT_ALLOWED: 2026-11-05T14:04:51Z for a hand-run cut; 2026-11-05T11:04:51Z for a scheduled run
```
followed by the plan it would have run. Monday to Thursday is exactly 72
hours, so scheduled runs, and only scheduled runs (`GITHUB_EVENT_NAME=schedule`,
which no dispatch input can set), get a 3-hour grace for GitHub's late cron
starts: a Thursday run 69 hours after a late Monday cut still cuts. Two
regular moves are therefore never less than 69 hours apart, and a hand-run cut
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
the floor verdict (`FLOOR:` line) and exits 0.
Two cases are not held. The first cut, before `stable` exists, has nothing to
measure. Finishing a half-done cut (`stable` already at the candidate, release
missing) is not a new move, and a run without `ref` finishes that cut before
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
(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
unpublished (its tag may be days older than the push). A tag dated in the
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.
## Security releases
A security fix is the one exception to the floor. 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:
```bash
gh workflow run weekly-release.yml -f ref=<full sha of the fix> -f security="GHSA-xxxx-xxxx-xxxx: tar path traversal" -f dry_run=false
```
The reason must name a `GHSA-…`, a `CVE-…` or an issue or pull request
`#<n>`, and the tool checks that it is tied to the commits being released
(`stable`..`ref`). One of these must hold:
1. a commit message in that span names the same GHSA or CVE;
2. issue or pull request `#<n>` carries the `security` label and a commit in
the span references `#<n>` (the squash subject `(#<n>)` does);
3. the GitHub Advisory Database entry for the GHSA or CVE (the data OSV
imports) lists an npm package whose own entry changes in the span's
`package.json` or `bun.lock` (a dependency line or a lockfile package key,
never a name listed inside another package's entry): a dependency bump that
fixes a known advisory. The direction of the bump and the patched range are
not checked.
**Verified:** the identifier exists in the reason, the label exists on the
issue, and the link between the identifier and a released commit or bumped
package. **Attested, not verified:** that the change actually fixes the
vulnerability, how severe it is, and that it cannot wait; the `security`
label itself is applied by a person with triage access. A reason the tool
cannot tie to the span is `REFUSED_SECURITY` with what it checked, red on
every trigger, as is `security` without `ref`: a security cut releases up to
the fix, never whatever happens to be newest. Every version between `stable`
and the fix still ships with it (there are no cherry-picks); the plan's
`RIDING_ALONG:` line counts them.
The reason is public. It goes, as one line of at most 300 characters (control
characters become spaces and backticks become apostrophes), into the tag
message and the release notes: ``**Security release:** `<reason>` (verified: <evidence>)``. For an
embargoed fix, use path 2 with a neutral commit subject and a labelled issue
whose title says no more than the fix does, and keep advisory details out of
the reason until disclosure.
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
release, and the next regular cut's notes list only the versions after it.
## Channels
| Channel | Follows | Who |
|---|---|---|
| `stable` | `refs/heads/stable`: the weekly release, plus any hotfix 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 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`):
@@ -72,12 +172,12 @@ The config key is `update_channel` (`bin/gstack-config`):
```bash
gstack-config get update_channel # auto (default) | stable | main
gstack-config set update_channel main # a team install that wants every merge
gstack-config set update_channel stable # a solo install that wants weekly releases
gstack-config set update_channel stable # a solo install that wants stable releases
gstack-config unset update_channel # back to auto
```
`auto` resolves to `stable` when `team_mode` is true and to `main` otherwise,
so existing team installs moved to weekly releases with nothing to migrate and
so existing team installs moved to stable releases with nothing to migrate and
contributor installs did not move at all. `GSTACK_UPDATE_CHANNEL=stable|main`
overrides the key for one command. The resolution lives in one helper,
`bin/gstack-update-channel.sh`, used by:
@@ -111,14 +211,15 @@ still holds and nothing about version stamping changed.
## Cutting a release by hand (hotfixes)
A hotfix is an ordinary PR: it merges to main through a train and gets its
version like any other change. To put it on stable before Monday, run the
workflow by hand with the merge commit's SHA:
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):
```bash
gh workflow run weekly-release.yml -f ref=<full sha on main> -f dry_run=false
```
or Actions → **Weekly release** → Run workflow, on main (the cut job runs
or open Actions, pick **Stable release** and press Run workflow, on main (the cut job runs
only from `refs/heads/main`, with main's tooling; `ref` must be the full
40-hex SHA). The same green checks apply. The commit is tagged with its VERSION, the release notes say "hotfix release"
and list every version since the previous release (everything merged in
@@ -134,8 +235,8 @@ first, then promote.
Run the workflow with `dry_run=true` (the default for a manual run) to see the
plan without changing anything: the candidate, its VERSION and tag, the stable
move, how many version lines the notes would carry, every skipped commit with
its reason, and the exact commands a real cut would run. The scheduled Monday
run is never a dry run.
its reason, the floor verdict, and the exact commands a real cut would run. The scheduled
runs are never dry runs.
```bash
gh workflow run weekly-release.yml -f dry_run=true
@@ -159,4 +260,10 @@ is exercised against real check runs before it merges.
| `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward |
| `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) |
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice; needs a look before anything is cut |
| `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 |
| `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
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.287",
"version": "1.91.288",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+187 -19
View File
@@ -3,11 +3,12 @@
* release-cut: tag the newest green commit on main as v<VERSION>, publish a
* GitHub Release whose notes are that span's CHANGELOG headlines, and
* fast-forward refs/heads/stable to it (docs/releases.md). Run by
* .github/workflows/weekly-release.yml every Monday morning and by hand for a
* hotfix (--ref <sha on main>). Dry runs print the plan and change nothing.
* .github/workflows/weekly-release.yml ("Stable release") every Monday and
* 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>] [--dry-run] [--summary <file>]
* [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <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
* commit on main's first-parent history, means: its required check run
@@ -34,6 +35,17 @@
* 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.
*
* The floor: stable moves at most once per FLOOR_HOURS (72). The last move is
* the annotated v<VERSION> tag pointing at stable, or its GitHub Release's
* publish time when that is later; a cut inside the floor is REFUSED_FLOOR
* 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.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -51,6 +63,9 @@ const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'st
export const WINDOW_DAYS = 7;
export const WINDOW_COMMITS = 200;
export const NOTES_CAP = 60_000;
export const FLOOR_HOURS = 72;
export const SCHEDULED_GRACE_HOURS = 3;
const HOUR = 3_600_000;
export const runGh: Gh = (args) => {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
@@ -168,7 +183,14 @@ 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 }
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; security?: Security | null }
/** A verified security cut: the sanitized reason and what tied it to the released commits. */
export interface Security { reason: string; evidence: string }
export function releaseKind(hotfix: boolean, security: Security | null | undefined): 'security' | 'hotfix' | 'regular' {
return security ? 'security' : hotfix ? 'hotfix' : 'regular';
}
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
export function releaseNotes(n: NotesInput): string {
@@ -178,8 +200,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 + (n.hotfix ? ' (hotfix release)' : ' (weekly release)'),
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.security) + ' release)',
'',
...(n.security ? ['**Security release:** `' + n.security.reason + '` (verified: ' + n.security.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 + ').',
@@ -198,22 +221,133 @@ export function releaseNotes(n: NotesInput): string {
return body;
}
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
/**
* When stable last moved: the annotated v<VERSION> tag a cut made on it (VERSION
* read at stable), or that tag's release publish time when later. Any other tag
* on that commit is ignored, so a hand-made extra tag cannot date the move or
* pin later runs to stable. published is false when the release does not exist
* yet (a half-done cut, whose tag may predate the stable push); a release that
* cannot be read leaves the move undated.
*/
export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string; published: boolean } | { at: null; why: string } {
const version = git(['show', stable + ':VERSION']);
const tag = 'v' + (version.ok ? version.out.trim() : '<VERSION>');
const listed = version.ok ? git(['for-each-ref', '--points-at=' + stable, '--format=%(objecttype)%09%(creatordate:unix)', 'refs/tags/' + tag]) : null;
const [type, created] = listed?.ok && listed.out ? listed.out.split('\t') : [];
if (type !== 'tag') return { at: null, why: 'no annotated ' + tag + ' tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
const at = Number(created) * 1000;
const view = gh(['release', 'view', tag, '--repo', repo, '--json', 'publishedAt']);
if (view.status !== 0) {
if (/not found|HTTP 404/i.test(view.stderr)) return { tag, at, published: false };
return { at: null, why: 'cannot read the release for ' + tag + ' (' + (view.stderr.trim().slice(0, 120) || 'gh exit ' + view.status) + '), so its last move cannot be dated' };
}
const published = Date.parse(parseJson<{ publishedAt?: string }>(view)?.publishedAt ?? '');
return { tag, at: Number.isFinite(published) ? Math.max(at, published) : at, published: true };
}
/** 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 function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; security: boolean; 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 + ')' };
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) {
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.
moved.at = Math.min(moved.at, o.now);
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 + ')' };
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 };
}
/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
export function sanitizeReason(reason: string): string {
return reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/`/g, "'").replace(/\s+/g, ' ').trim().slice(0, 300);
}
/**
* Ties a security reason to the commits in (from, to]. The reason must name a
* GHSA, CVE or #<n>; it is verified when (a) a commit message in the span names
* that GHSA/CVE, (b) issue or PR #<n> carries the security label and a commit
* in the span references #<n>, or (c) the GitHub Advisory Database entry for
* that GHSA/CVE lists a package whose line changes in the span's package.json
* or bun.lock. What the fix fixes, and how bad it was, stays attested.
*/
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]!))];
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] ?? '' }));
// 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]!] : []; }));
const why: string[] = [];
for (const id of advisories) {
const named = commits.find(c => c.body.toUpperCase().includes(id.toUpperCase()));
if (named) return { ok: true, evidence: 'commit ' + named.sha + ' names ' + id };
const read = parseJson<unknown>(o.gh(['api', id.startsWith('GHSA') ? 'advisories/' + id : 'advisories?cve_id=' + id]));
const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string; ecosystem?: string } }> }>;
const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).filter(v => v.package?.ecosystem?.toLowerCase() === 'npm').map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
const bumped = packages.find(p => changed.has(p));
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('; ') };
}
/** 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 {
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
const reason = body.ok ? /^Security: (.+)$/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 };
}
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;
}
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY';
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
export interface PlanOptions {
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
requiredCheck?: string; backstopCheck?: string; notesRef?: string;
/** A scheduled run (GITHUB_EVENT_NAME=schedule): the floor allows SCHEDULED_GRACE_HOURS of cron jitter. */
scheduled?: boolean;
/** --security <reason>: bypass the floor for a verified security fix; needs ref. */
security?: string;
}
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' };
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}']);
@@ -238,6 +372,9 @@ export function planRelease(o: PlanOptions): PlanResult {
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
candidates = list.ok && list.out ? list.out.split('\n') : [];
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
// A cut that moved stable but never published its release is finished before stable moves again.
const moved = prevStable ? stableMovedAt(git, gh, repo, prevStable) : null;
if (prevStable && moved && moved.at !== null && !moved.published) candidates = [prevStable];
}
// stable itself stays a candidate so a cut that stopped half-way (tag and
@@ -287,10 +424,25 @@ 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 };
}
const changelog = git(['show', sha + ':CHANGELOG.md']);
const date = git(['show', '-s', '--format=%cs', sha]).out;
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before });
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
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 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 };
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 };
}
export interface CutResult { ok: boolean; lines: string[] }
@@ -302,7 +454,8 @@ 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 t = git(['tag', '-a', p.tag, p.sha, '-m', 'gstack ' + p.tag + (p.hotfix ? ' (hotfix release)' : ' (weekly release)')]);
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.security) + ' release)' + (p.security ? '\n\nSecurity: ' + p.security.reason + '\nVerified: ' + p.security.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]);
if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] };
@@ -311,8 +464,10 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
}
if (!p.stableAt) {
const push = git(['push', 'origin', p.sha + ':refs/heads/stable']);
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed: ' + push.out] };
if (p.prevStable && !git(['merge-base', '--is-ancestor', p.prevStable, p.sha]).ok) return { ok: false, lines: [...lines, 'STABLE: refused: ' + p.sha.slice(0, 12) + ' does not descend from stable ' + p.prevStable.slice(0, 12)] };
// The lease pins origin's stable to the commit the floor and the ancestry check read; a move in between fails the push.
const push = git(['push', '--force-with-lease=refs/heads/stable:' + (p.prevStable ?? ''), 'origin', p.sha + ':refs/heads/stable']);
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed (stable moved since this plan read it, or the push was rejected): ' + push.out] };
lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12));
} else {
lines.push('STABLE: already at ' + p.sha.slice(0, 12));
@@ -337,12 +492,14 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
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');
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
if (dryRun) {
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag,
' git push origin ' + p.sha + ':refs/heads/stable',
' git push --force-with-lease=refs/heads/stable:' + (p.prevStable ?? '') + ' origin ' + p.sha + ':refs/heads/stable',
' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
}
return lines;
@@ -357,18 +514,29 @@ if (import.meta.main) {
const repo = arg('--repo');
const dryRun = process.argv.includes('--dry-run');
const summary = arg('--summary');
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]'); process.exit(2); }
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>]');
process.exit(2);
}
const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
const git = gitIn(process.cwd());
const emit = (lines: string[]) => {
for (const l of lines) console.log(l);
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
};
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref') });
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security });
if (!planned.ok) {
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
// A dry run that finds nothing green has done its job (the skips say why);
// the real cut goes red so someone looks.
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') ? 0 : 1);
emit([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),
// and so has a floor hold on a dry run or on the schedule: the policy
// working, not a fault. Every other refusal goes red so someone looks.
const hold = planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled);
if (process.env.GITHUB_ACTIONS === 'true' && planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') {
const text = planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : '');
console.log('::' + (hold ? 'notice' : 'error') + ' title=release-cut ' + planned.code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
}
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
}
emit(describePlan(planned.plan, dryRun));
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
+416 -11
View File
@@ -13,11 +13,19 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import {
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
planRelease, readTrainNotes, releaseNotes, type Gh, type Git,
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,
} from '../scripts/release-cut';
const bases: string[] = [];
// A cut made a moment ago clears the 72-hour floor at this clock.
const pastFloor = () => Date.now() + (FLOOR_HOURS + 1) * 3_600_000;
// The CLI reads GITHUB_EVENT_NAME (schedule grace) and GITHUB_ACTIONS (annotations); a test sets them itself, never inherits the runner's.
function cliEnv(shim: string, extra: Record<string, string> = {}): Record<string, string | undefined> {
const env: Record<string, string | undefined> = { ...process.env, PATH: shim + path.delimiter + process.env.PATH };
for (const k of ['GITHUB_EVENT_NAME', 'GITHUB_ACTIONS', 'GITHUB_STEP_SUMMARY']) delete env[k];
return { ...env, ...extra };
}
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
@@ -25,7 +33,7 @@ function git(cwd: string, ...args: string[]): string {
}
type CheckRun = { name: string; status: string; conclusion: string | null };
interface State { checks: Record<string, CheckRun[]>; released: Set<string>; createFails?: string; calls: string[][] }
interface State { checks: Record<string, CheckRun[]>; released: Set<string>; createFails?: string; calls: string[][]; api?: Record<string, unknown> }
const GREEN: CheckRun[] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }];
const backstop = (status: string, conclusion: string | null): CheckRun[] => [...GREEN.filter(c => c.name !== BACKSTOP_CHECK), { name: BACKSTOP_CHECK, status, conclusion }];
@@ -36,6 +44,8 @@ function fakeGh(state: State): Gh {
const api = args[0] === 'api' ? args[args.length - 1]! : '';
const m = /commits\/([0-9a-f]+)\/check-runs/.exec(api);
if (m) return { status: 0, stdout: JSON.stringify([{ check_runs: state.checks[m[1]!] ?? GREEN }]), stderr: '' };
if (api && state.api && api in state.api) return { status: 0, stdout: JSON.stringify(state.api[api]), stderr: '' };
if (api) return { status: 1, stdout: '', stderr: 'gh: Not Found (HTTP 404)' };
if (args[0] === 'release' && args[1] === 'view') return state.released.has(args[2]!) ? { status: 0, stdout: '{}', stderr: '' } : { status: 1, stdout: '', stderr: 'release not found' };
if (args[0] === 'release' && args[1] === 'create') {
if (state.createFails === args[2]) return { status: 1, stdout: '', stderr: 'HTTP 502 Bad Gateway' };
@@ -57,12 +67,13 @@ function fixture() {
git(seed, 'remote', 'add', 'origin', origin);
const shas: Record<string, string> = {};
let changelog = '# Changelog\n';
const release = (version: string, headline: string) => {
const release = (version: string, headline: string, o: { message?: string; files?: Record<string, string> } = {}) => {
changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n');
fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n');
fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog);
for (const [file, text] of Object.entries(o.files ?? {})) fs.writeFileSync(path.join(seed, file), text);
git(seed, 'add', '-A');
git(seed, 'commit', '-q', '-m', 'v' + version);
git(seed, 'commit', '-q', '-m', o.message ?? 'v' + version);
git(seed, 'push', '-q', 'origin', 'main');
shas[version] = git(seed, 'rev-parse', 'HEAD');
return shas[version]!;
@@ -99,7 +110,7 @@ describe('release-cut: planning', () => {
expect(r.plan.notes).not.toContain('1.0.2');
const lines = describePlan(r.plan, true);
expect(lines.some(l => l.startsWith('DRY_RUN:'))).toBe(true);
expect(lines).toContain(' git push origin ' + f.shas['1.0.1'] + ':refs/heads/stable');
expect(lines).toContain(' git push --force-with-lease=refs/heads/stable: origin ' + f.shas['1.0.1'] + ':refs/heads/stable');
});
test('a missing free-tests run, a running free-tests run, no backstop check run and a superseded (neutral) backstop are not green', () => {
@@ -335,7 +346,7 @@ describe('release-cut: train evidence gates a commit whose backstop never settle
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: f.state.checks[head] }]) + '\' ;;\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 } });
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: cliEnv(shim) });
const cli = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop', '--notes-ref', 'refs/notes/other-trains');
expect(cli.status, cli.stdout + cli.stderr).toBe(0);
expect(cli.stdout.split('\n')[0]).toBe('CANDIDATE: ' + head + ' (' + git(f.seed, 'show', '-s', '--format=%cs', head) + ') [hotfix: --ref] gated by train t9');
@@ -369,7 +380,7 @@ describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
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 });
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: pastFloor });
expect(promo.ok, JSON.stringify(promo)).toBe(true);
if (!promo.ok) return;
expect(promo.plan.hotfix).toBe(true);
@@ -425,7 +436,7 @@ describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
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 });
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: pastFloor });
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);
@@ -498,7 +509,7 @@ describe.skipIf(process.platform === 'win32')('release-cut: the workflow entry p
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 } });
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: cliEnv(shim) });
const summary = path.join(f.base, 'summary.md');
const dry = run('--repo', f.repo, '--dry-run', '--summary', summary);
@@ -543,6 +554,400 @@ describe.skipIf(process.platform === 'win32')('release-cut: the workflow entry p
});
});
const H = 3_600_000;
const isoZ = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
/** Cut 1.0.3 for real (an annotated tag dated now); returns the floor's anchor, the tag time. */
function cutFirst(f: ReturnType<typeof fixture>) {
const first = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
if (!first.ok) throw new Error(JSON.stringify(first));
const cut = cutRelease(first.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
if (!cut.ok) throw new Error(cut.lines.join('\n'));
f.refresh();
const moved = stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.3']!);
if (moved.at === null) throw new Error(moved.why);
return moved.at;
}
describe('release-cut: the 72-hour floor', () => {
// Value: protects=Garry's rule that stable moves at most once per 72 h (2026-10-27), measured from the tag time of the release stable points at, with a 3 h
// cron-jitter grace for scheduled runs only; fails_when=a hand cut lands before 72:00, a scheduled run before 69:00, the grace leaks to hand-run cuts, or a
// refusal loses its code or NEXT_ALLOWED; why_new=no floor existed; seam=now is injected, the tag time is read back from the real annotated tag; no_claim=GitHub's cron timing
test('a cut inside 72 h is REFUSED_FLOOR with both next-allowed times; 72:00 clears a hand cut, 69:00 a scheduled run, and the grace is schedule-only', () => {
const f = fixture();
const at = cutFirst(f);
f.release('1.0.4', 'Later work.');
f.refresh();
const plan = (hours: number, scheduled = false) => planRelease({ repo: f.repo, git: f.git, gh: f.gh, scheduled, now: () => at + hours * H });
const early = plan(FLOOR_HOURS - 1 / 3600);
expect(early.ok).toBe(false);
if (early.ok) return;
expect(early.code).toBe('REFUSED_FLOOR');
expect(early.message).toStartWith('stable moved to v1.0.3 at ' + isoZ(at) + ', 72.0 h ago; a hand-run cut needs 72 h;');
expect(early.message).toContain('--security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>');
expect(early.next).toBe(isoZ(at + 72 * H) + ' for a hand-run cut; ' + isoZ(at + 69 * H) + ' for a scheduled run');
expect(early.plan!.sha).toBe(f.shas['1.0.4']);
expect(early.plan!.floor.line).toBe('FLOOR: refused');
const onTime = plan(FLOOR_HOURS);
expect(onTime.ok, JSON.stringify(onTime)).toBe(true);
if (onTime.ok) expect(onTime.plan.floor).toEqual({ verdict: 'clear', line: 'FLOOR: clear (stable moved to v1.0.3 at ' + isoZ(at) + ', 72.0 h ago; a hand-run cut needs 72 h)' });
expect(SCHEDULED_GRACE_HOURS).toBe(3);
const schedEarly = plan(FLOOR_HOURS - SCHEDULED_GRACE_HOURS - 1 / 3600, true);
expect(schedEarly.ok).toBe(false);
if (!schedEarly.ok) { expect(schedEarly.code).toBe('REFUSED_FLOOR'); expect(schedEarly.message).toContain('a scheduled run needs 69 h (72 h less 3 h cron grace)'); }
const schedOk = plan(FLOOR_HOURS - SCHEDULED_GRACE_HOURS, true);
expect(schedOk.ok, JSON.stringify(schedOk)).toBe(true);
if (schedOk.ok) expect(schedOk.plan.notes).toContain('## gstack v1.0.4 (regular release)');
const handInGrace = plan(70);
expect(handInGrace.ok).toBe(false);
if (!handInGrace.ok) expect(handInGrace.code).toBe('REFUSED_FLOOR');
});
// Value: protects=the floor's anchor is the annotated v<VERSION> tag on stable or its later release publish time, a stable it cannot date fails closed,
// and the first cut and a finishing cut are not held; fails_when=a lightweight tag, an extra tag or the commit date stands in for the move, a delayed
// release is ignored, or FLOOR_UNKNOWN passes; why_new=no floor existed; seam=gh release view is faked; no_claim=none beyond protects
test('stableMovedAt: annotated tags only, the later release publish time wins; an undatable stable is FLOOR_UNKNOWN; the first and a finishing cut skip the floor', () => {
const f = fixture();
git(f.seed, 'tag', 'v1.0.2', f.shas['1.0.2']!);
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.2', f.shas['1.0.2'] + ':refs/heads/stable');
f.refresh();
const unknown = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: pastFloor });
expect(unknown.ok).toBe(false);
if (!unknown.ok) {
expect(unknown.code).toBe('FLOOR_UNKNOWN');
expect(unknown.message).toBe('no annotated v1.0.2 tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated; only a --security cut can move it until one does');
expect(unknown.next).toBe('none until an annotated v<VERSION> tag dates the release stable points at');
}
// 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() }))
.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');
git(f.work, 'tag', '-d', 'v1.0.2');
git(f.seed, 'tag', '-d', 'v1.0.2');
execFileSync('git', ['tag', '-a', 'v1.0.2', f.shas['1.0.2']!, '-m', 'gstack v1.0.2'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-10-20T10:00:00Z' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.2');
f.refresh();
expect(stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-20T10:00:00Z'), tag: 'v1.0.2', published: false });
const late: Gh = (args) => args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.2' ? { status: 0, stdout: '{"publishedAt":"2026-10-21T09:30:00Z"}', stderr: '' } : f.gh(args);
expect(stableMovedAt(f.git, late, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-21T09:30:00Z'), tag: 'v1.0.2', published: true });
const held = planRelease({ repo: f.repo, git: f.git, gh: late, now: () => Date.parse('2026-10-24T09:00:00Z') });
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() };
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');
// A newer hand-made annotated tag on stable, with no release, neither dates the move nor pins later runs to stable (a silent NOTHING_NEW stall).
execFileSync('git', ['tag', '-a', 'v1.0.2-rc', f.shas['1.0.2']!, '-m', 'again'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-10-22T08:00:00Z' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.2-rc');
f.refresh();
expect(stableMovedAt(f.git, late, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-21T09:30:00Z'), tag: 'v1.0.2', published: true });
const pastStray = planRelease({ repo: f.repo, git: f.git, gh: late, now: () => Date.parse('2026-10-25T10:00:00Z') });
expect(pastStray.ok, JSON.stringify(pastStray)).toBe(true);
if (pastStray.ok) expect(pastStray.plan.tag).toBe('v1.0.3');
});
// Value: protects=the stable push is conditioned on the stable the floor read, so two overlapping cuts cannot both move it; fails_when=the push is a plain
// fast-forward that succeeds over a move made after planning; why_new=cuts never raced before the floor made the move time matter; seam=none (real bare origin);
// no_claim=none beyond protects
// Value: protects=a cut whose tag was pushed long before stable moved (a failed stable push, retried later) is still held by the floor and is dated by
// its release, not by the old tag; fails_when=the old tag time lets the next cut through early or the retry skips the floor; why_new=the half-done tests
// cover a missing release only; seam=gh release view is faked; no_claim=none beyond protects
test('a tag pushed earlier without moving stable is held by the floor, and once promoted its release publish time dates the move', () => {
const f = fixture();
const at = cutFirst(f);
const hot = f.release('1.0.4', 'Later work.');
execFileSync('git', ['tag', '-a', 'v1.0.4', hot, '-m', 'gstack v1.0.4 (regular release)'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.4');
f.refresh();
const held = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 10 * H });
expect(held.ok).toBe(false);
if (!held.ok) { expect(held.code).toBe('REFUSED_FLOOR'); expect(held.plan!.tagExists).toBe('same'); }
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 72 * H });
expect(promo.ok, JSON.stringify(promo)).toBe(true);
if (!promo.ok) return;
const cut = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
expect(cut.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 published https://github.com/o/r/releases/tag/v1.0.4']);
f.refresh();
const published = isoZ(at + 72 * H);
const viaRelease: Gh = (args) => args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.4' ? { status: 0, stdout: JSON.stringify({ publishedAt: published }), stderr: '' } : f.gh(args);
expect(stableMovedAt(f.git, viaRelease, f.repo, hot)).toEqual({ at: Date.parse(published), tag: 'v1.0.4', published: true });
f.release('1.0.5', 'Even later.');
f.refresh();
const next = planRelease({ repo: f.repo, git: f.git, gh: viaRelease, now: () => at + 100 * H });
expect(next.ok).toBe(false);
if (!next.ok) { expect(next.code).toBe('REFUSED_FLOOR'); expect(next.message).toStartWith('stable moved to v1.0.4 at ' + published + ', 28.0 h ago'); }
});
// Value: protects=the floor fails closed when the move cannot be dated: a release that cannot be read is FLOOR_UNKNOWN, a stable whose release was never
// published is finished before stable moves again, and a future-dated tag holds from now; fails_when=a transient gh failure or a half-done cut lets the
// floor fall back to an old tag date; why_new=both adversarial reviews reproduced that bypass; seam=gh is faked; no_claim=none beyond protects
test('an unreadable release is FLOOR_UNKNOWN; an unpublished stable release is finished first; a future-dated tag holds from now', () => {
const f = fixture();
const at = cutFirst(f);
const hot = f.release('1.0.4', 'Later work.');
f.refresh();
const flaky: Gh = (args) => args[0] === 'release' && args[1] === 'view' ? { status: 1, stdout: '', stderr: 'HTTP 503: Service Unavailable' } : f.gh(args);
const unread = planRelease({ repo: f.repo, git: f.git, gh: flaky, now: () => at + 100 * H });
expect(unread.ok).toBe(false);
if (!unread.ok) { expect(unread.code).toBe('FLOOR_UNKNOWN'); expect(unread.message).toStartWith('cannot read the release for v1.0.3 (HTTP 503: Service Unavailable), so its last move cannot be dated'); }
// stable moved to 1.0.4 but its release failed: the next window run finishes 1.0.4 instead of moving on to 1.0.5.
f.state.createFails = 'v1.0.4';
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 72 * H });
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;
const newer = f.release('1.0.5', 'Even later.');
f.refresh();
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => at + 100 * H });
expect(finish.ok, JSON.stringify(finish)).toBe(true);
if (finish.ok) { expect(finish.plan.sha).toBe(hot); expect(finish.plan.floor.verdict).toBe('finishing'); }
const past = planRelease({ repo: f.repo, ref: newer, git: f.git, gh: f.gh, now: () => at + 200 * H });
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 });
expect(future.verdict).toBe('refused');
expect(future.next).toStartWith(isoZ(at + 72 * H) + ' for a hand-run cut');
});
test('cutRelease refuses to push a stable that the candidate does not descend from', () => {
const f = fixture();
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
f.refresh();
const planned = planRelease({ repo: f.repo, ref: f.shas['1.0.3'], git: f.git, gh: f.gh, security: undefined });
const plan = planned.ok ? planned.plan : planned.plan!;
const cut = cutRelease({ ...plan, sha: f.shas['1.0.1']!, tag: 'v1.0.1', stableAt: false, prevStable: f.shas['1.0.2']! }, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
expect(cut.ok).toBe(false);
expect(cut.lines.at(-1)).toBe('STABLE: refused: ' + f.shas['1.0.1']!.slice(0, 12) + ' does not descend from stable ' + f.shas['1.0.2']!.slice(0, 12));
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.2']);
});
test('a stable that moved after planning fails the push with a lease error and stays where the other cut put it', () => {
const f = fixture();
const planned = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
expect(planned.ok).toBe(true);
if (!planned.ok) return;
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.1'] + ':refs/heads/stable');
const cut = cutRelease(planned.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
expect(cut.ok).toBe(false);
expect(cut.lines.at(-1)).toStartWith('STABLE: push failed (stable moved since this plan read it, or the push was rejected): ');
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.1']);
});
});
describe('release-cut: the security exception', () => {
// Value: protects=a security cut passes the floor only with --ref and a reason the tool ties to the released commits (a commit naming the GHSA/CVE, a
// security-labelled #n a commit references, or an advisory whose package changes in package.json/bun.lock), its reason reaches the tag and the notes, a
// retry recovers it from the tag, and the next regular cut lists the versions since it; fails_when=a free-text reason or one naming an unrelated id
// bypasses the floor, a security cut runs without --ref, or the notes misstate either release; why_new=no exception existed; seam=gh advisories and
// issues are faked; no_claim=that the change fixes the advisory (attested)
test('verified reasons bypass the floor; unverifiable ones are REFUSED_SECURITY; the reason survives a retry; the next regular cut starts at the security release', () => {
const f = fixture();
const at = cutFirst(f);
const fix = f.release('1.0.4', 'Hardens tar extraction.', { message: 'v1.0.4: harden tar paths (GHSA-2345-cfgh-jmpq)' });
const labelled = f.release('1.0.5', 'Escapes the summary.', { message: 'v1.0.5 (#77)' });
const bump = f.release('1.0.6', 'Bumps left-pad.', { message: 'chore(deps): bump left-pad', files: { 'package.json': '{\n "dependencies": {\n "left-pad": "1.3.0"\n }\n}\n' } });
f.refresh();
const now = () => at + H;
const sec = (ref: string | undefined, reason: string) => planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh, now, security: reason });
const noRef = sec(undefined, 'GHSA-2345-cfgh-jmpq: tar path traversal');
expect(noRef.ok).toBe(false);
if (!noRef.ok) expect(noRef).toMatchObject({ code: 'REFUSED_SECURITY', message: '--security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest' });
const bare = sec(fix, 'urgent, trust me');
expect(bare.ok).toBe(false);
if (!bare.ok) expect(bare).toMatchObject({ code: 'REFUSED_SECURITY', message: 'the reason names no GHSA-…, CVE-… or #<n> to verify' });
const unrelated = sec(fix, 'CVE-2026-12345 in tar');
expect(unrelated.ok).toBe(false);
if (!unrelated.ok) expect(unrelated.message).toBe('nothing in ' + f.shas['1.0.3']!.slice(0, 12) + '..' + fix.slice(0, 12) + ' ties the reason to the release: CVE-2026-12345: no commit names it and the advisory database has no entry for it');
const unlabelled = sec(labelled, '#77: summary injection');
expect(unlabelled.ok).toBe(false);
if (!unlabelled.ok) expect(unlabelled.message).toEndWith('#77: cannot read it');
const unreferenced = sec(fix, '#78');
expect(unreferenced.ok).toBe(false);
if (!unreferenced.ok) expect(unreferenced.message).toEndWith('#78: cannot read it, no commit in the span references it');
f.state.api = {
'repos/o/r/issues/77': { labels: [{ name: 'bug' }] },
'repos/o/r/issues/79': { labels: [{ name: 'Security' }] },
'advisories/GHSA-9999-xxxx-wwww': { vulnerabilities: [{ package: { name: 'left-pad', ecosystem: 'npm' } }] },
'advisories?cve_id=CVE-2026-55555': [{ vulnerabilities: [{ package: { name: 'left-pad', ecosystem: 'npm' } }] }],
};
const noLabel = sec(labelled, '#77: summary injection');
if (!noLabel.ok) expect(noLabel.message).toEndWith('#77: no security label');
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); }
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');
}
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.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).');
expect(describePlan(verified.plan, false)).toEqual(expect.arrayContaining(['SECURITY: `' + reason + '` (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq)', 'RIDING_ALONG: 0 other versions ship with the fix']));
// The release step fails; a scheduled retry with no --security finishes it with the same reason, read back from the tag.
f.state.createFails = 'v1.0.4';
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.4')).toContain('gstack v1.0.4 (security release)\n\nSecurity: ' + reason + '\nVerified: commit ' + fix.slice(0, 12));
f.refresh();
const retry = planRelease({ repo: f.repo, ref: fix, git: f.git, gh: f.gh, now, scheduled: true });
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.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();
// The floor now counts from the security release; the next regular cut lists only what came after it.
const moved = stableMovedAt(f.git, f.gh, f.repo, fix);
if (moved.at === null) throw new Error(moved.why);
const movedAt = moved.at;
const held = planRelease({ repo: f.repo, git: f.git, gh: f.gh, scheduled: true, now: () => movedAt + 68 * H });
expect(held.ok).toBe(false);
if (!held.ok) { expect(held.code).toBe('REFUSED_FLOOR'); expect(held.message).toStartWith('stable moved to v1.0.4 at '); }
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.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');
expect(next.plan.notes).not.toContain('- **1.0.4**');
expect(next.plan.notes).not.toContain('Security release');
});
// Value: protects=a finishing security cut keeps its tag's recorded evidence (no live re-check that a removed label or a gh failure can break), a tag that
// recorded a security cut but never moved stable cannot pass the floor without an explicit verified --security, and the advisory path counts only npm
// packages whose own entry changed; fails_when=a forged or stale tag bypasses the floor, finishing is blocked, or a transitive name verifies; why_new=adversarial findings
// 1, 4 and 5; seam=gh is faked; no_claim=none beyond protects
test('recorded evidence finishes a security cut; an unmoved security tag needs --security again; advisories match only npm packages whose own entry changed', () => {
const f = fixture();
const at = cutFirst(f);
const fix = f.release('1.0.4', 'Escapes the summary.', { message: 'v1.0.4 (#77)' });
f.refresh();
f.state.api = { 'repos/o/r/issues/77': { labels: [{ name: 'security' }] } };
const sec = planRelease({ repo: f.repo, ref: fix, git: f.git, gh: f.gh, now: () => at + H, security: '#77: summary injection' });
expect(sec.ok, JSON.stringify(sec)).toBe(true);
if (!sec.ok) return;
f.state.createFails = 'v1.0.4';
expect(cutRelease(sec.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
delete f.state.createFails;
f.refresh();
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);
// A security-recorded tag at a commit stable never reached.
const later = f.release('1.0.5', 'Later.', { message: 'v1.0.5 (#78)' });
execFileSync('git', ['tag', '-a', 'v1.0.5', later, '-m', 'gstack v1.0.5 (security release)\n\nSecurity: #78: 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.5');
f.refresh();
const forged = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => at + 3 * H, scheduled: true });
expect(forged.ok).toBe(false);
if (!forged.ok) expect(forged).toMatchObject({ code: 'REFUSED_SECURITY', message: 'tag v1.0.5 records a security cut that has not moved stable; run it again with --security and --ref ' + later.slice(0, 12) + ' so the reason is verified' });
const lock = '{\n "packages": {\n "app": ["app@1.0.0", "", { "dependencies": { "zod": "^3" } }, "sha512-x"],\n }\n}\n';
const bump = f.release('1.0.6', 'Bumps app.', { message: 'chore(deps): bump app', files: { 'bun.lock': lock } });
f.refresh();
const verify = (api: Record<string, unknown>, reason: string) => { f.state.api = api; return verifySecurity({ reason, git: f.git, gh: f.gh, repo: f.repo, from: later, to: bump }); };
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'zod', ecosystem: 'npm' } }] } }, 'GHSA-2222-3333-4444').ok).toBe(false);
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'app', ecosystem: 'pip' } }] } }, 'GHSA-2222-3333-4444').ok).toBe(false);
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'app', ecosystem: 'npm' } }] } }, 'GHSA-2222-3333-4444'))
.toEqual({ ok: true, evidence: 'advisory GHSA-2222-3333-4444 lists app, which changes in package.json/bun.lock' });
});
test('sanitizeReason keeps one public line: no control characters or backticks, at most 300 characters; #12a and &#13; are not issue numbers', () => {
expect(sanitizeReason(' CVE-2026-1234:\r\n a\tb \`c\` ')).toBe("CVE-2026-1234: a b 'c'");
expect(sanitizeReason('#1 ' + 'x'.repeat(400)).length).toBe(300);
expect(verifySecurity({ reason: 'see #12a and &#13;', git: () => ({ ok: true, out: '' }), gh: () => ({ status: 1, stdout: '', stderr: '' }), repo: 'o/r', from: null, to: 'f'.repeat(40) }))
.toEqual({ ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' });
});
});
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,
// and usage (exit 2) for an empty --security; fails_when=a refusal is silent, exits green on a hand cut, red on a scheduled hold, or pushes; why_new=no
// refusal codes existed; seam=a gh shim on PATH; no_claim=GitHub's rendering of annotations
test('refusal lines, annotations and exit codes for hand, scheduled and dry runs', () => {
const f = fixture();
const at = cutFirst(f);
f.release('1.0.4', 'Later work.');
f.refresh();
const shim = path.join(f.base, 'shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN }]) + '\' ;;\n "release view v1.0.3 "*) echo \'{"publishedAt":"' + isoZ(at) + '"}\' ;;\n *) echo "release not found" >&2; exit 1 ;;\nesac\n', { mode: 0o755 });
const run = (env: Record<string, string>, ...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), '--repo', f.repo, ...args],
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: cliEnv(shim, env) });
const stable = () => git(f.origin, 'rev-parse', 'refs/heads/stable');
const hand = run({});
expect(hand.status, hand.stdout + hand.stderr).toBe(1);
const lines = hand.stdout.split('\n');
expect(lines[0]).toStartWith('REFUSED_FLOOR: stable moved to v1.0.3 at ');
expect(lines[1]).toMatch(/^NEXT_ALLOWED: \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ for a hand-run cut; \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ for a scheduled run$/);
expect(lines).toContain('FLOOR: refused');
expect(hand.stdout).not.toContain('::');
expect(stable()).toBe(f.shas['1.0.3']);
const annotated = run({ GITHUB_ACTIONS: 'true' });
expect(annotated.status).toBe(1);
expect(annotated.stdout).toContain('::error title=release-cut REFUSED_FLOOR::REFUSED_FLOOR: stable moved to v1.0.3');
const scheduled = run({ GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'schedule' });
expect(scheduled.status, scheduled.stdout).toBe(0);
expect(scheduled.stdout).toContain('::notice title=release-cut REFUSED_FLOOR::');
expect(scheduled.stdout).toContain('a scheduled run needs 69 h');
expect(run({}, '--dry-run').status).toBe(0);
expect(stable()).toBe(f.shas['1.0.3']);
const noRef = run({ GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'schedule' }, '--security', 'CVE-2026-1: x');
expect(noRef.status).toBe(1);
expect(noRef.stdout.split('\n')[0]).toBe('REFUSED_SECURITY: --security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest');
expect(noRef.stdout).toContain('::error title=release-cut REFUSED_SECURITY::');
// Value: protects=a refusal annotation stays one workflow command: % CR and LF in the message are escaped as %25 %0D %0A; fails_when=the escaping is
// dropped, so caller-supplied text in --ref or a git error splits the ::error:: line and starts a new workflow command; why_new=every other annotation
// message here is plain text; seam=none; no_claim=GitHub's rendering of the decoded annotation
const escaped = run({ GITHUB_ACTIONS: 'true' }, '--ref', 'no%such\r\nref');
expect(escaped.status).toBe(1);
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);
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.4'] + ':refs/heads/stable');
f.release('1.0.5', 'Even later.');
f.refresh();
const unknown = run({ GITHUB_EVENT_NAME: 'schedule' });
expect(unknown.status).toBe(1);
expect(unknown.stdout.split('\n')[0]).toStartWith('FLOOR_UNKNOWN: no annotated v1.0.4 tag points at stable ' + f.shas['1.0.4']!.slice(0, 12));
expect(stable()).toBe(f.shas['1.0.4']);
});
});
describe('release-cut: notes', () => {
test('changelog headlines are the bold first line of each entry; versions order numerically', () => {
const entries = changelogEntries('# Changelog\n\n## [1.91.221.0] - 2026-10-25\n\n**The updater no longer skips.** More.\n\n### Fixed\n- x\n\n## [1.91.99.0] - 2026-10-01\n\n| a | b |\n\n**Older.**\n');
@@ -558,6 +963,6 @@ describe('release-cut: notes', () => {
expect(notes.length).toBeLessThanOrEqual(NOTES_CAP);
expect(notes).toMatch(/- \.\.\.and \d+ more versions; see CHANGELOG\.md/);
expect(notes).toContain('1000 versions since v1.91.0.0');
expect(notes.split('\n')[0]).toBe('## gstack v1.91.1000.0 (weekly release)');
expect(notes.split('\n')[0]).toBe('## gstack v1.91.1000.0 (regular release)');
});
});
+18 -5
View File
@@ -1,15 +1,17 @@
/**
* weekly-release.yml (free): the Monday cut's wiring (docs/releases.md). The
* weekly-release.yml ("Stable release", free): the Monday and Thursday 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
* 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
* 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';
import { FLOOR_HOURS, SCHEDULED_GRACE_HOURS } from '../scripts/release-cut';
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[] };
@@ -19,9 +21,16 @@ const wf = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/wee
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', () => {
// 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', () => {
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' }]);
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.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'] });
@@ -33,7 +42,7 @@ describe('weekly-release.yml', () => {
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.permissions).toEqual({ contents: 'write', checks: 'read', issues: 'read', 'pull-requests': '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 }}');
@@ -43,6 +52,10 @@ describe('weekly-release.yml', () => {
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.env!.SECURITY_REASON).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }}");
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.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'");
});