mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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:
1 parent
7204a39a27
commit
cd4fc4be73
11 files changed
+800
-70
No files matched your search
@@ -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
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 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 ', 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)');
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user