mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
release: daily stable check: important gated changes reach stable within a day, forced cut at 7 days (GST-1646, R74) (#4083)
* release: daily stable check cuts important gated changes after 24 h and forces a cut at 7 days (GST-1646, R74) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * release-cut: no release PR is NO_RELEASE_PR, a warning on a dry run, so the PR dry run against main stays green between cuts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes release-cut --daily: a plan that clears the 24 h floor while the daily check cannot date stable is held as FLOOR_UNKNOWN, so a null decision never reaches a cut (red-team p1#1). Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the important: fragment field in changelog.d/README.md and CHANGELOG_FRAGMENT_INVALID 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
64862e4187
commit
cb8f9eaeac
17 files changed
+785
-74
No files matched your search
@@ -2,9 +2,13 @@ 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
|
||||
# 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
|
||||
# every day at 14:00 UTC as the daily check (scripts/release-cadence.ts, R74):
|
||||
# it cuts when a gated change stable lacks carries an important: changelog
|
||||
# fragment and stable is 24 hours old, when stable is 7 days old and main has
|
||||
# gated changes, or on Monday and Thursday past the 72-hour floor; otherwise it
|
||||
# does nothing and says why (the file keeps its weekly-release.yml name so its
|
||||
# run history and every `gh workflow run` reference stay intact). A hand-run
|
||||
# cut moves stable 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. Two
|
||||
# exceptions pass it, one per cut and each with a ref: a security cut (the
|
||||
@@ -21,9 +25,10 @@ name: Stable release
|
||||
# runs only there, with main's tooling.
|
||||
on:
|
||||
schedule:
|
||||
# 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'
|
||||
# Every day 14:00 UTC (07:00 PDT, 06:00 PST): the daily check. Monday and
|
||||
# Thursday are the regular cuts, exactly 72 h, then 96 h, apart; each floor's
|
||||
# scheduled grace covers a late start the day before.
|
||||
- cron: '0 14 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
@@ -42,10 +47,16 @@ on:
|
||||
description: 'Regression cut (needs ref): the reason, naming the regression-labelled #<n> it fixes and the earlier stable release v<X> it worked in; passes the 72-hour floor once verified and lands in the release notes'
|
||||
type: string
|
||||
default: ''
|
||||
daily:
|
||||
description: 'Run the daily check now (an important change past 24 h, a forced cut at 7 days, the regular cut on Monday or Thursday) instead of a plain cut; no ref, security or regression'
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/weekly-release.yml'
|
||||
- 'scripts/release-cut.ts'
|
||||
- 'scripts/release-cadence.ts'
|
||||
- 'lib/changelog-fragments.ts'
|
||||
|
||||
# One cut at a time; a second dispatch waits for the first. A pull request's
|
||||
# dry run is its own group.
|
||||
@@ -96,6 +107,7 @@ jobs:
|
||||
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }}
|
||||
REGRESSION_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }}
|
||||
DAILY: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.daily)) && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
@@ -110,12 +122,17 @@ jobs:
|
||||
echo "::error::a regression cut needs ref: the full SHA of the fix commit on main"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DAILY" = true ] && [ -n "$RELEASE_REF$SECURITY_REASON$REGRESSION_REASON" ]; then
|
||||
echo "::error::the daily check picks its own candidate: run ref, security or regression without daily"
|
||||
exit 1
|
||||
fi
|
||||
args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY")
|
||||
[ "$DAILY" = true ] && args+=(--daily)
|
||||
[ "$DRY_RUN" = true ] && args+=(--dry-run)
|
||||
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
|
||||
[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON")
|
||||
[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")
|
||||
echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut')$([ "$DAILY" = true ] && echo ', daily check'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY"
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}"
|
||||
|
||||
dry-run:
|
||||
@@ -145,3 +162,5 @@ jobs:
|
||||
git fetch --quiet --tags origin
|
||||
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
|
||||
echo "## Daily check (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 --daily
|
||||
@@ -38,7 +38,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
|
||||
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
|
||||
```
|
||||
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the twice-weekly tagged release (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
|
||||
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 (Monday and Thursday at most once every 72 hours, an important fix within about a day, never more than a week stale, plus verified security and regression fixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
|
||||
|
||||
Swap `required` for `optional` if you'd rather nudge teammates than block them.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# CHANGELOG fragments
|
||||
|
||||
A PR adds its CHANGELOG entry here as `changelog.d/<branch-slug>.md`: the entry text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front matter only when the change warrants more than a patch. It never edits VERSION, `package.json`'s version or a released CHANGELOG section. The merge step (`gstack-ship-queue land`, or `train land` in a stamped train) assigns the version (under `merge: direct`, the release PR from `gstack-release-stamp open` does), folds every fragment into one CHANGELOG section and deletes the files. This README is skipped. See docs/ship-policy.md#changelog-fragments.
|
||||
A PR adds its CHANGELOG entry here as `changelog.d/<branch-slug>.md`: the entry text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front matter only when the change warrants more than a patch, and `important: security|regression|data-loss|blocker` only when it must reach stable within about a day (docs/releases.md#the-daily-check). It never edits VERSION, `package.json`'s version or a released CHANGELOG section. The merge step (`gstack-ship-queue land`, or `train land` in a stamped train) assigns the version (under `merge: direct`, the release PR from `gstack-release-stamp open` does), folds every fragment into one CHANGELOG section and deletes the files. This README is skipped. See docs/ship-policy.md#changelog-fragments.
|
||||
@@ -0,0 +1,7 @@
|
||||
**Important fixes reach stable within a day, and stable is never more than a week stale.** The Stable release workflow now runs every day at 14:00 UTC as a daily check (R74).
|
||||
|
||||
### Added
|
||||
- A changelog fragment can mark its change important with `important: security|regression|data-loss|blocker` in its front matter. The daily check cuts stable once it is 24 hours old when a gated change stable lacks carries the field.
|
||||
- The daily check also forces a cut when stable is 7 days old and main has gated changes. Monday and Thursday stay the regular cuts with the 72-hour floor. On a day with nothing to do it changes nothing and logs why.
|
||||
- A due cut that finds no release PR reports `NO_RELEASE_PR: important change waiting, no release PR` and goes red. A week-old stable whose main has nothing newer gated reports `STABLE_STALE`.
|
||||
- docs/releases.md documents the daily check, when the Captain stamps a release PR, the exit codes, and the exact commands to roll stable back.
|
||||
+3
-1
@@ -390,7 +390,9 @@ commits. Each PR merges at its own green head, and versions are set at release
|
||||
2. Captain: `gstack-ship-queue land --pr <n> --pin <sha>` (add `--allow-repo-commands` when
|
||||
the base policy lists `land.merged_checks`, so a stale base can run them). It merges with
|
||||
`--match-head-commit`, checks main's tree and writes a land note.
|
||||
3. Release: before each stable cut (Monday and Thursday, 14:00 UTC), the Captain runs
|
||||
3. Release: before each regular stable cut (Monday and Thursday, 14:00 UTC), and in the same
|
||||
turn it lands a PR whose fragment carries `important:` (the READY says `rides the daily
|
||||
cut`; [releases.md](releases.md#the-daily-check)), the Captain runs
|
||||
`gstack-release-stamp open`, waits for the release PR's checks, then lands it with
|
||||
`gstack-ship-queue land --pr <n> --pin <head>` (no /ship receipt or READY note needed for a
|
||||
`release/<version>` head). The cut then tags the new version
|
||||
|
||||
+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 tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security or regression fix, 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 an important change (within about a day) or a security or regression fix, and never more than a week stale, 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.
|
||||
|
||||
|
||||
@@ -232,9 +232,11 @@ one place, and run it once by hand before enabling it.
|
||||
## The stable release
|
||||
|
||||
`.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 or regression
|
||||
fix (`--security` or `--regression`, with `--ref`): it takes the newest green commit on main's first-parent history
|
||||
`scripts/release-cut.ts --daily` every day at 14:00 UTC: a regular cut on Monday
|
||||
and Thursday, an important change's cut after 24 hours and a forced cut when
|
||||
`stable` is a week old ([the daily check](releases.md#the-daily-check)). It never
|
||||
moves `stable` more than once in 72 hours otherwise, except for a verified
|
||||
security or regression fix (`--security` or `--regression`, with `--ref`): it takes the newest green commit on main's first-parent history
|
||||
(its required check succeeded, the backstop check succeeded, every check run
|
||||
completed and none failed, no active freeze), tags it `v<VERSION>`, publishes a
|
||||
GitHub Release whose notes are one line per CHANGELOG version since the
|
||||
|
||||
+175
-17
@@ -4,12 +4,15 @@ gstack used to stamp a version at every merge to main. Under `merge: direct`
|
||||
PRs land unstamped, with a `changelog.d` fragment each, and the version moves
|
||||
only when the Captain lands a release PR (`gstack-release-stamp open`, see
|
||||
[ship-policy.md](ship-policy.md#release-stamp)). Since late October 2026 there
|
||||
is also a tagged release, cut twice a week, and team-mode installs follow it
|
||||
instead of every merge.
|
||||
is also a tagged release, and team-mode installs follow it instead of every
|
||||
merge. It is cut twice a week, and since November 2026 a daily check also cuts
|
||||
it within about a day of an important change and never lets it go more than a
|
||||
week stale while main has gated changes ([the daily check](#the-daily-check)).
|
||||
|
||||
Land a release PR before each cut. The cut tags the VERSION it finds, and a
|
||||
version is tagged once: if no release PR landed since the last cut, the next
|
||||
cut finds that version's tag on an older commit and refuses. A release PR
|
||||
cut finds that version's tag on an older commit and refuses with
|
||||
`NO_RELEASE_PR` (a dry run reports it and exits 0). A release PR
|
||||
lands only on the main it was stamped on, so open it again if main moved. The
|
||||
cut tags the newest green commit, so a PR that lands between the release PR
|
||||
and the cut ships in that tag, and its CHANGELOG entry appears in the next
|
||||
@@ -17,10 +20,12 @@ release.
|
||||
|
||||
## What a release is
|
||||
|
||||
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
|
||||
Every day 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 --daily`; the file kept its old name so its run
|
||||
history and the `gh workflow run` commands below stay valid) runs the daily
|
||||
check. On Monday and Thursday that is the regular cut; on any day an important
|
||||
change or a week-old stable can trigger one. A cut takes the newest green
|
||||
commit on main and:
|
||||
|
||||
1. tags it `v<VERSION>` (the VERSION file at that commit, an annotated tag);
|
||||
@@ -31,8 +36,9 @@ commit on main and:
|
||||
|
||||
A tag and its release never change. `stable` is a moving pointer: always a
|
||||
commit on main's first-parent history, moved only by this workflow, only
|
||||
forward, and at most once every 72 hours except for a security or regression
|
||||
fix (below).
|
||||
forward, and at most once every 72 hours, except for an important change
|
||||
(24 hours, [the daily check](#the-daily-check)) or a verified security or
|
||||
regression fix (below).
|
||||
|
||||
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
||||
(the status main requires), the commit was gated (below), every check run on
|
||||
@@ -81,10 +87,95 @@ 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.
|
||||
|
||||
<a id="the-daily-check"></a>
|
||||
## The daily check
|
||||
|
||||
Garry, 2026-11-06 (R74): "make sure anything important makes it to stable as
|
||||
frequently as once a day and as infrequently as once a week". Every scheduled
|
||||
run is the daily check (`scripts/release-cut.ts --daily`, decision logic in
|
||||
`scripts/release-cadence.ts`). It picks the candidate exactly as any cut does
|
||||
(the newest green, gated commit; nothing ungated ever ships) and then cuts when
|
||||
one of three triggers holds, each over its own floor since `stable` last moved:
|
||||
|
||||
| Trigger | When | Floor |
|
||||
|---|---|---|
|
||||
| important | a commit in `stable`..candidate added a `changelog.d` fragment marked `important:` | 24 hours |
|
||||
| forced | `stable` is 7 days old and the candidate is newer than `stable` | 168 hours |
|
||||
| regular | it is Monday or Thursday (UTC) | 72 hours ([the floor](#the-72-hour-floor)) |
|
||||
|
||||
A scheduled run gets the same 3-hour grace for late cron starts on every floor
|
||||
(21, 165 and 69 hours); a hand-run daily check gets none. With no trigger the
|
||||
run changes nothing, exits 0 with a notice and says why:
|
||||
`DAILY: no-op: nothing important since stable; stable moved 50.0 h ago; …`. An
|
||||
important change still under its floor is `DAILY: wait: …` with
|
||||
`NEXT_ALLOWED:`. An important change that landed after the candidate (the
|
||||
release gate has not gated it yet) is listed as `IMPORTANT_UNGATED:` and
|
||||
counts toward nothing until a green, gated commit holds it.
|
||||
|
||||
**Marking a change important.** The PR's fragment carries the field in its
|
||||
front matter, beside any `bump:`, so every reviewer and the Captain read it in
|
||||
the PR diff before it lands:
|
||||
|
||||
```markdown
|
||||
---
|
||||
important: security
|
||||
---
|
||||
**Fixed the tar path traversal in gstack-upgrade.**
|
||||
```
|
||||
|
||||
| Value | Use it for |
|
||||
|---|---|
|
||||
| `security` | a fix for a vulnerability or a leaked secret path |
|
||||
| `regression` | behavior that worked in an earlier stable release and is broken on the current one |
|
||||
| `data-loss` | a fix that stops gstack destroying or corrupting a user's files, state or work |
|
||||
| `blocker` | a fix for something that stops crews or users from working (an install, upgrade or core skill that fails) |
|
||||
|
||||
Any other value is `CHANGELOG_FRAGMENT_INVALID`. The marker is attested, not
|
||||
verified: it is the PR author's claim, reviewed with the diff like `bump:`.
|
||||
The words match the two verified exceptions: a fix that cannot wait 24 hours
|
||||
still uses a hand-run [security](#security-releases) or
|
||||
[regression](#regression-releases) cut, which checks labels and passes the
|
||||
floor. The daily check reads each fragment at the commit that added it, so the
|
||||
release stamp folding and deleting the file does not hide it. A fragment that
|
||||
lands after the release PR was stamped still ships its code in the next cut;
|
||||
its CHANGELOG entry appears in the release after that.
|
||||
|
||||
**The release PR.** A cut tags the VERSION it finds, so an important change
|
||||
needs a release PR after it. The daily run never stamps or opens one: the
|
||||
Captain runs `gstack-release-stamp open` in the same turn it lands a PR whose
|
||||
fragment carries `important:`, and lands the release PR once its checks pass
|
||||
([captain.md](captain.md#direct-land-merge-direct)). When any trigger is due
|
||||
but the candidate's VERSION is already released on an older commit, the run is
|
||||
`NO_RELEASE_PR: important change waiting, no release PR: …` (or `stable is 7
|
||||
days old, no release PR`, or `regular cut due, no release PR`) and goes red
|
||||
so someone stamps. A week-old `stable` whose main moved but holds nothing
|
||||
newer that is green and gated is `STABLE_STALE`, also red: the gate or the
|
||||
checks are holding main.
|
||||
|
||||
**How long it takes.** An important PR lands; the Captain stamps and lands the
|
||||
release PR; the [release gate](ship-policy.md#release-gate) gates main's head
|
||||
at its next 8-hour slot (up to about 4.5 hours of run); the next 14:00 UTC run
|
||||
cuts once `stable` is 24 hours old. That is usually within a day and at worst
|
||||
about 40 hours (estimated, not measured). Running the daily check by hand once
|
||||
the gate is green skips the wait for 14:00:
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f daily=true -f dry_run=false
|
||||
```
|
||||
|
||||
| Daily outcome | Real run | Dry run |
|
||||
|---|---|---|
|
||||
| `DAILY: cut (…)`, then the plan and `TAG:` / `STABLE:` / `RELEASE:` | exit 0 (1 if a cut step fails) | plan, exit 0 |
|
||||
| `DAILY: no-op: …` or `DAILY: wait: …` | exit 0, notice | exit 0, notice |
|
||||
| `NO_RELEASE_PR: …` or `STABLE_STALE: …` | exit 1, error | exit 0, warning |
|
||||
| any other refusal (a freeze, `NO_GREEN_COMMIT`, `FLOOR_UNKNOWN`, a non-fast-forward, a version tagged on an unrelated commit) | exit 1, as for any cut | as for any dry run |
|
||||
|
||||
## 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:
|
||||
`stable` moves at most once every 72 hours (Garry, 2026-10-27), except for an
|
||||
important change, whose floor is 24 hours ([the daily check](#the-daily-check),
|
||||
R74). 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
|
||||
@@ -230,7 +321,7 @@ held.
|
||||
|
||||
| Channel | Follows | Who |
|
||||
|---|---|---|
|
||||
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security or regression cut off-cycle | team mode (`./setup --team`) by default |
|
||||
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), an important change's cut within about a day (24-hour floor), a forced cut when it is a week old, plus any security or regression cut off-cycle | team mode (`./setup --team`) by default |
|
||||
| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines |
|
||||
|
||||
The config key is `update_channel` (`bin/gstack-config`):
|
||||
@@ -312,15 +403,77 @@ runs are never dry runs.
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f dry_run=true
|
||||
gh workflow run weekly-release.yml -f dry_run=true -f daily=true # the daily check's decision
|
||||
gh run watch
|
||||
```
|
||||
|
||||
The step summary carries the same plan plus the full release notes.
|
||||
|
||||
A pull request that touches `.github/workflows/weekly-release.yml` or
|
||||
`scripts/release-cut.ts` runs the same dry run against main as its own job
|
||||
(`Dry run against main`, read-only token), so a change to the release tooling
|
||||
is exercised against real check runs before it merges.
|
||||
A pull request that touches `.github/workflows/weekly-release.yml`,
|
||||
`scripts/release-cut.ts`, `scripts/release-cadence.ts` or
|
||||
`lib/changelog-fragments.ts` runs the same dry run against main as its own job
|
||||
(`Dry run against main`, read-only token), once plain and once as the daily
|
||||
check, so a change to the release tooling is exercised against real check runs
|
||||
before it merges.
|
||||
|
||||
<a id="rolling-stable-back"></a>
|
||||
## Rolling stable back
|
||||
|
||||
`stable` only moves forward through the tool, and the updater never moves an
|
||||
install backwards, so a rollback stops the bad release from reaching more
|
||||
installs and the fix ships forward. Installs that already updated stay on the
|
||||
bad release until the fix-forward cut. Do the steps in order; step 1 matters,
|
||||
because the next daily run would otherwise cut the bad commit again (the floor
|
||||
dates the move by the older release's tag, so nothing holds it).
|
||||
|
||||
1. Hold every cut, scheduled and hand-run:
|
||||
|
||||
```bash
|
||||
gh workflow disable weekly-release.yml
|
||||
```
|
||||
|
||||
2. Move `stable` back to the last good release, leased on the bad commit so a
|
||||
concurrent move fails instead of being overwritten:
|
||||
|
||||
```bash
|
||||
git fetch origin '+refs/heads/stable:refs/remotes/origin/stable' --tags
|
||||
BAD=$(git rev-parse origin/stable)
|
||||
GOOD=$(git rev-parse 'v<good version>^{commit}')
|
||||
git merge-base --is-ancestor "$GOOD" "$BAD" && \
|
||||
git push --force-with-lease=refs/heads/stable:"$BAD" origin "$GOOD:refs/heads/stable"
|
||||
```
|
||||
|
||||
Team installs that have not updated yet now stay on `v<good version>` at
|
||||
their next hourly check. The bad tag and its release stay (a tag never
|
||||
changes); say so on the release, which release-cut reads only for existence
|
||||
and publish time:
|
||||
|
||||
```bash
|
||||
gh release edit v<bad version> --prerelease --title "gstack v<bad version> (withdrawn)"
|
||||
```
|
||||
|
||||
3. Fix forward on main: a revert or fix PR whose fragment carries
|
||||
`important: regression` (or `security`, `data-loss`). The Captain lands
|
||||
it, stamps and lands the release PR, and the release gate gates it.
|
||||
|
||||
4. Release the fix and lift the hold. The floor counts from the good release,
|
||||
so it is clear; the dry run shows the candidate first:
|
||||
|
||||
```bash
|
||||
gh workflow enable weekly-release.yml
|
||||
gh workflow run weekly-release.yml -f daily=true -f dry_run=true
|
||||
gh workflow run weekly-release.yml -f daily=true -f dry_run=false
|
||||
```
|
||||
|
||||
Or cut the fix commit itself: `gh workflow run weekly-release.yml -f
|
||||
ref=<full sha> -f dry_run=false`. The notes list every version since
|
||||
`v<good version>`, the withdrawn one included.
|
||||
|
||||
One install that must leave the bad release before the fix-forward cut can be
|
||||
parked on the good one by hand (it discards local changes in that checkout):
|
||||
`git -C <install checkout> fetch --tags origin && git -C <install checkout>
|
||||
reset --hard 'v<good version>'`. Its next update check fast-forwards it to
|
||||
`stable` again.
|
||||
|
||||
## Outcomes
|
||||
|
||||
@@ -332,7 +485,7 @@ 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: release gate red on <sha> (<cases>) and no gate on a later commit is green; ...` | the release gate's hold: land the revert or fix-forward, then `gstack-release-gate run --ignore-cadence` ([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) |
|
||||
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice; needs a look before anything is cut |
|
||||
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice (the tag is on a commit that is not an ancestor of the candidate); 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 |
|
||||
@@ -341,3 +494,8 @@ is exercised against real check runs before it merges.
|
||||
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security|regression exception (...)` | the floor verdict, in every plan |
|
||||
| ``SECURITY: `<reason>` (verified: <evidence>)`` or ``REGRESSION: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | an exception cut's plan |
|
||||
| `TAG:`, `STABLE:`, `RELEASE:` | what the cut did; a re-run reports each step as already done and finishes a step an earlier run did not reach |
|
||||
| `DAILY: cut (important|forced|regular: ...)` | the daily check's trigger and floor; the plan and the cut follow |
|
||||
| `DAILY: no-op: ...`, `DAILY: wait: ...` | the daily check found no trigger, or an important change still under its 24-hour floor (`NEXT_ALLOWED:` follows); exit 0 with a notice |
|
||||
| `IMPORTANT_UNGATED: ...` | an important change landed but no green, gated commit holds it yet; informational |
|
||||
| `NO_RELEASE_PR: ...` | the candidate's VERSION is already released on an earlier commit (no release PR landed since that cut): the Captain stamps a release PR. A plain cut always reports it; the daily check reports it only when a trigger is due. Exit 1 (0, with a warning, on a dry run) |
|
||||
| `STABLE_STALE: ...` | stable is 7 days old and main moved, but nothing newer is green and gated; exit 1 (0 on a dry run) |
|
||||
+16
-3
@@ -1147,8 +1147,8 @@ Trains gate each batch of PRs, and the main backstop gates every push. The
|
||||
release gate replaces the backstop with one full paid gate on main's head at
|
||||
most every `cadence_hours`, and only when main moved since the last gate. It
|
||||
feeds the stable release (docs/releases.md) and never writes `stable` itself:
|
||||
`scripts/release-cut.ts` stays the only writer, with its 72-hour floor and its
|
||||
security and regression exceptions unchanged.
|
||||
`scripts/release-cut.ts` stays the only writer, with its floors (72 hours, 24
|
||||
for an important change) and its security and regression exceptions unchanged.
|
||||
|
||||
The surface is off unless main's `.gstack/ship-policy.json` declares the
|
||||
block, with `min_gstack_version` at least the release that introduced the key
|
||||
@@ -2270,6 +2270,17 @@ bump: minor
|
||||
- ...
|
||||
```
|
||||
|
||||
The front matter may also mark the change important for the stable release:
|
||||
`important: security|regression|data-loss|blocker` (one value, beside any
|
||||
`bump:`; any other key or value is `CHANGELOG_FRAGMENT_INVALID`). A gated change
|
||||
with that field reaches `stable` through [the daily
|
||||
check](releases.md#the-daily-check) once `stable` is 24 hours old, instead of
|
||||
waiting for Monday or Thursday. Mark only what the table there allows; the
|
||||
reviewer checks the claim with the diff. A READY whose fragment carries the
|
||||
field says `rides the daily cut` and names the value, and the Captain stamps a
|
||||
release PR in the same turn it lands the PR. The field does not change the
|
||||
CHANGELOG text: the fold drops front matter.
|
||||
|
||||
The effective bump of a stamp is the highest of the policy bump and every
|
||||
folded fragment's `bump:` (`micro` < `patch` < `minor` < `major`). Fragments
|
||||
are read as Git blobs at the commit being stamped (`git ls-tree` and
|
||||
@@ -2531,7 +2542,9 @@ cleared by that train's verify.
|
||||
|
||||
Under `merge: direct`, VERSION, the `package.json` version, CHANGELOG.md, the
|
||||
release outputs and the `stamp_paths` constants move only in a release PR.
|
||||
Before each stable cut, the Captain runs:
|
||||
Before each regular stable cut (Monday and Thursday), and in the same turn it
|
||||
lands a PR whose fragment carries `important:` ([the daily
|
||||
check](releases.md#the-daily-check)), the Captain runs:
|
||||
|
||||
```bash
|
||||
gstack-release-stamp open # --dry-run prints the stamp and creates nothing
|
||||
|
||||
@@ -5337,12 +5337,13 @@ stays on the trusted-branch path.
|
||||
|
||||
**Meaning.** A CHANGELOG fragment under the policy's `changelog_fragments`
|
||||
directory is not one regular `.md` file directly in that directory with entry
|
||||
text, optional `bump:` front matter and no release heading, or it is over
|
||||
text, optional `bump:` and `important:` front matter and no release heading, or it is over
|
||||
64 KiB, or a fragment of that name already landed with other text. Nothing was
|
||||
allocated or written. See [changelog fragments](ship-policy.md#changelog-fragments).
|
||||
|
||||
**Fix.** Rewrite the named fragment as a flat `<dir>/<slug>.md` holding only
|
||||
the entry text (and optional `bump: minor|major` front matter); rename it if
|
||||
the entry text (and optional `bump: minor|major` and
|
||||
`important: security|regression|data-loss|blocker` front matter); rename it if
|
||||
the name already landed. Then rerun.
|
||||
|
||||
<a id="queue-land-locked"></a>
|
||||
|
||||
@@ -16,7 +16,11 @@ export const FRAGMENT_README = 'README.md';
|
||||
export const MAX_FRAGMENT_BYTES = 64 * 1024;
|
||||
const BUMP_RANK: Record<Bump, number> = { micro: 0, patch: 1, minor: 2, major: 3 };
|
||||
|
||||
export interface Fragment { path: string; body: string; bump: Bump | null }
|
||||
/** Why a change must reach stable within a day (docs/releases.md#the-daily-check): the fragment's `important:` field. */
|
||||
export const IMPORTANCE = ['security', 'regression', 'data-loss', 'blocker'] as const;
|
||||
export type Importance = (typeof IMPORTANCE)[number];
|
||||
|
||||
export interface Fragment { path: string; body: string; bump: Bump | null; important?: Importance }
|
||||
export interface FragmentError { code: 'CHANGELOG_FRAGMENT_INVALID'; path: string; message: string }
|
||||
export interface FragmentSet { fragments: Fragment[]; errors: FragmentError[] }
|
||||
|
||||
@@ -31,19 +35,21 @@ export function fragmentSlug(branch: string): string {
|
||||
return slug || 'change';
|
||||
}
|
||||
|
||||
/** One fragment's text: optional `---` front matter holding only `bump:`, then a non-empty body with no release heading. */
|
||||
/** One fragment's text: optional `---` front matter holding only `bump:` and `important:`, then a non-empty body with no release heading. */
|
||||
export function parseFragment(rel: string, text: string): Fragment | FragmentError {
|
||||
const bad = (message: string): FragmentError => ({ code: 'CHANGELOG_FRAGMENT_INVALID', path: rel, message });
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
let body = normalized;
|
||||
let bump: Bump | null = null;
|
||||
let important: Importance | null = null;
|
||||
const fm = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
|
||||
if (fm) {
|
||||
body = normalized.slice(fm[0].length);
|
||||
for (const line of fm[1]!.split('\n').filter(l => l.trim())) {
|
||||
const kv = /^\s*bump\s*:\s*['"]?(micro|patch|minor|major)['"]?\s*$/.exec(line);
|
||||
if (!kv) return bad(`front matter may hold only "bump: micro|patch|minor|major", found ${JSON.stringify(line.trim().slice(0, 60))}`);
|
||||
bump = kv[1] as Bump;
|
||||
const kv = /^\s*(bump|important)\s*:\s*['"]?([a-z-]+)['"]?\s*$/.exec(line);
|
||||
if (kv?.[1] === 'bump' && /^(micro|patch|minor|major)$/.test(kv[2]!)) { bump = kv[2] as Bump; continue; }
|
||||
if (kv?.[1] === 'important' && (IMPORTANCE as readonly string[]).includes(kv[2]!)) { important = kv[2] as Importance; continue; }
|
||||
return bad(`front matter may hold only "bump: micro|patch|minor|major" and "important: ${IMPORTANCE.join('|')}", found ${JSON.stringify(line.trim().slice(0, 60))}`);
|
||||
}
|
||||
}
|
||||
const lines = body.split('\n');
|
||||
@@ -52,7 +58,7 @@ export function parseFragment(rel: string, text: string): Fragment | FragmentErr
|
||||
if (!lines.length) return bad('the fragment has no entry text');
|
||||
const heading = lines.find(l => /^##\s/.test(l) && headingVersion(l));
|
||||
if (heading) return bad(`a fragment carries no release heading (the merge step writes it), found ${JSON.stringify(heading.trim())}`);
|
||||
return { path: rel, body: lines.join('\n'), bump };
|
||||
return { path: rel, body: lines.join('\n'), bump, ...(important ? { important } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -443,8 +443,8 @@ export const RELEASE_RESULT_CODES = {
|
||||
// Changelog fragments and the per-PR merge step (lib/changelog-fragments.ts, lib/ship-queue-land.ts).
|
||||
CHANGELOG_FRAGMENT_INVALID: {
|
||||
anchor: 'changelog-fragment-invalid',
|
||||
summary: 'a CHANGELOG fragment is not one regular .md file directly in the fragment directory with entry text, optional bump front matter and no release heading, or one of that name already landed with other text',
|
||||
fix: 'rewrite the named fragment as a flat <dir>/<slug>.md with only the entry text (and optional "bump: minor|major" front matter), rename it if the name already landed, then rerun',
|
||||
summary: 'a CHANGELOG fragment is not one regular .md file directly in the fragment directory with entry text, optional bump and important front matter and no release heading, or one of that name already landed with other text',
|
||||
fix: 'rewrite the named fragment as a flat <dir>/<slug>.md with only the entry text (and optional "bump: minor|major" and "important: security|regression|data-loss|blocker" front matter), rename it if the name already landed, then rerun',
|
||||
},
|
||||
QUEUE_LAND_LOCKED: {
|
||||
anchor: 'queue-land-locked',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* release-cadence: when stable moves, and the daily check's decision (docs/releases.md#the-daily-check).
|
||||
* Garry, 2026-11-06 (R74): "make sure anything important makes it to stable as frequently as once a day
|
||||
* and as infrequently as once a week".
|
||||
*
|
||||
* .github/workflows/weekly-release.yml runs scripts/release-cut.ts --daily every day at 14:00 UTC. A
|
||||
* day's run cuts when one trigger holds, each over its own floor since stable last moved:
|
||||
* important a changelog.d fragment with `important: security|regression|data-loss|blocker` was added
|
||||
* in stable..candidate (the newest green, gated commit): IMPORTANT_FLOOR_HOURS (24);
|
||||
* forced main has gated changes stable lacks and stable is FORCED_AFTER_HOURS (168, 7 days) old;
|
||||
* regular Monday or Thursday (UTC): FLOOR_HOURS (72), the twice-weekly cut.
|
||||
* A scheduled run gets SCHEDULED_GRACE_HOURS of cron jitter on every floor. With no trigger it does
|
||||
* nothing and says why. A trigger whose candidate carries a VERSION that is already released (no
|
||||
* release PR landed since the last cut) is NO_RELEASE_PR: the cut tags the VERSION it finds, so the
|
||||
* Captain stamps one (gstack-release-stamp open) and the next run cuts. A week-old stable whose main
|
||||
* moved with nothing newer green and gated is STABLE_STALE. Both are blocked: the run goes red. Fragments are read from the
|
||||
* commits that added them, so the release stamp deleting the files does not hide them.
|
||||
*/
|
||||
import { parseFragment, type Importance } from '../lib/changelog-fragments';
|
||||
import type { Git } from './release-cut';
|
||||
|
||||
export const FLOOR_HOURS = 72;
|
||||
export const SCHEDULED_GRACE_HOURS = 3;
|
||||
export const IMPORTANT_FLOOR_HOURS = 24;
|
||||
export const FORCED_AFTER_HOURS = 7 * 24;
|
||||
/** Monday and Thursday, as Date.getUTCDay() numbers them. */
|
||||
export const REGULAR_DAYS = [1, 4];
|
||||
export const FRAGMENTS_DIR = 'changelog.d';
|
||||
const HOUR = 3_600_000;
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
export interface ImportantChange { kind: Importance; path: string; commit: string; pr: string | null }
|
||||
export type Trigger = 'important' | 'forced' | 'regular';
|
||||
export interface DailyDecision { action: 'cut' | 'wait' | 'noop' | 'blocked'; trigger: Trigger | null; lines: string[] }
|
||||
export interface DailyInput {
|
||||
now: number;
|
||||
/** GITHUB_EVENT_NAME=schedule: every floor allows SCHEDULED_GRACE_HOURS of cron jitter. */
|
||||
scheduled: boolean;
|
||||
/** The release candidate (the newest green, gated commit on main), for the log. */
|
||||
candidate: string;
|
||||
/** When stable last moved (epoch ms); null when there is no stable yet. */
|
||||
stableMovedAt: number | null;
|
||||
/** The candidate is newer than stable. */
|
||||
gatedChanges: boolean;
|
||||
/** origin/main is ahead of stable, gated or not. */
|
||||
mainAhead: boolean;
|
||||
/** Important fragments added in stable..candidate. */
|
||||
important: ImportantChange[];
|
||||
/** Important fragments added in candidate..main: landed, not yet in a green gated commit. */
|
||||
ungated: ImportantChange[];
|
||||
/** The candidate's VERSION is not yet released on an older commit: a release PR landed since the last cut. */
|
||||
releasePr: boolean;
|
||||
}
|
||||
|
||||
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const describe = (changes: ImportantChange[]) => changes.map(c => c.kind + ' in ' + (c.pr ? '#' + c.pr : c.commit.slice(0, 12)) + ' (' + c.path + ')').join(', ');
|
||||
|
||||
/** The day's verdict. Lines go to the log and the step summary; the first says what happened and why. */
|
||||
export function decideDaily(i: DailyInput): DailyDecision {
|
||||
const grace = i.scheduled ? SCHEDULED_GRACE_HOURS : 0;
|
||||
const movedAt = i.stableMovedAt === null ? null : Math.min(i.stableMovedAt, i.now);
|
||||
const age = movedAt === null ? Infinity : (i.now - movedAt) / HOUR;
|
||||
const since = movedAt === null ? 'no stable yet' : 'stable moved ' + age.toFixed(1) + ' h ago';
|
||||
const ungated = i.ungated.length ? ['IMPORTANT_UNGATED: ' + describe(i.ungated) + ' landed after the candidate; no green, gated commit holds it yet (the release gate runs every 8 h), so a later daily run picks it up'] : [];
|
||||
if (!i.gatedChanges && i.mainAhead && age >= FORCED_AFTER_HOURS - grace) {
|
||||
return { action: 'blocked', trigger: 'forced', lines: ['STABLE_STALE: stable is 7 days old (' + since + ') and main has moved, but no newer commit is green and gated; see why the release gate or the checks hold main', ...ungated] };
|
||||
}
|
||||
if (!i.gatedChanges) return { action: 'noop', trigger: null, lines: ['DAILY: no-op: nothing gated since stable (' + since + ')', ...ungated] };
|
||||
const day = new Date(i.now).getUTCDay();
|
||||
const floorOf = (hours: number) => hours - grace;
|
||||
const trigger: Trigger | null = i.important.length && age >= floorOf(IMPORTANT_FLOOR_HOURS) ? 'important'
|
||||
: age >= floorOf(FORCED_AFTER_HOURS) ? 'forced'
|
||||
: REGULAR_DAYS.includes(day) && age >= floorOf(FLOOR_HOURS) ? 'regular'
|
||||
: null;
|
||||
const why = {
|
||||
important: 'important: ' + describe(i.important) + '; ' + since + ', the important floor is ' + floorOf(IMPORTANT_FLOOR_HOURS) + ' h',
|
||||
forced: 'forced: ' + since + ', past the 7-day limit, and main has gated changes',
|
||||
regular: 'regular: ' + DAY_NAMES[day] + ', ' + since + ', the regular floor is ' + floorOf(FLOOR_HOURS) + ' h',
|
||||
};
|
||||
if (trigger && !i.releasePr) {
|
||||
const what = trigger === 'important' ? 'important change waiting, no release PR' : trigger === 'forced' ? 'stable is 7 days old, no release PR' : 'regular cut due, no release PR';
|
||||
return { action: 'blocked', trigger, lines: ['NO_RELEASE_PR: ' + what + ': the VERSION at candidate ' + i.candidate.slice(0, 12) + ' is already released (' + why[trigger] + '); the Captain runs gstack-release-stamp open and lands the release PR, and the next daily run cuts', ...ungated] };
|
||||
}
|
||||
if (trigger) return { action: 'cut', trigger, lines: ['DAILY: cut (' + why[trigger] + ')', ...ungated] };
|
||||
if (i.important.length && movedAt !== null) {
|
||||
return { action: 'wait', trigger: null, lines: ['DAILY: wait: important: ' + describe(i.important) + '; ' + since + ', under the ' + floorOf(IMPORTANT_FLOOR_HOURS) + ' h important floor', 'NEXT_ALLOWED: ' + iso(movedAt + floorOf(IMPORTANT_FLOOR_HOURS) * HOUR) + ' (the first daily run after it cuts)', ...ungated] };
|
||||
}
|
||||
const forcedAt = movedAt === null ? '' : ' (' + iso(movedAt + floorOf(FORCED_AFTER_HOURS) * HOUR) + ')';
|
||||
return { action: 'noop', trigger: null, lines: ['DAILY: no-op: nothing important since stable; ' + since + '; the regular cut runs Monday and Thursday past ' + floorOf(FLOOR_HOURS) + ' h, the forced cut past ' + floorOf(FORCED_AFTER_HOURS) + ' h' + forcedAt, ...ungated] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Important fragments added in (from, to]: each commit that added a file directly under dir, read at
|
||||
* that commit, so a fragment the release stamp has since folded and deleted still counts. A
|
||||
* fragment that does not parse is not important. With no from (no stable yet) the newest 200
|
||||
* commits up to to are read.
|
||||
*/
|
||||
export function importantChanges(git: Git, from: string | null, to: string, dir = FRAGMENTS_DIR): ImportantChange[] {
|
||||
if (from === to) return [];
|
||||
const prefix = dir.replace(/\/+$/, '') + '/';
|
||||
const log = git(['log', '--diff-filter=A', '--no-renames', '--format=%x1e%H%x1f%s', '--name-only', ...(from ? [from + '..' + to] : ['--max-count=200', to]), '--', prefix]);
|
||||
if (!log.ok) return [];
|
||||
const out: ImportantChange[] = [];
|
||||
for (const record of log.out.split('\x1e').filter(r => r.trim())) {
|
||||
const [head, ...files] = record.split('\n');
|
||||
const [commit, subject] = head!.split('\x1f');
|
||||
for (const file of files.map(f => f.trim()).filter(f => f.startsWith(prefix) && f.endsWith('.md') && !f.slice(prefix.length).includes('/') && f !== prefix + 'README.md')) {
|
||||
const blob = git(['show', commit + ':' + file]);
|
||||
const parsed = blob.ok ? parseFragment(file, blob.out) : null;
|
||||
if (!parsed || 'code' in parsed || !parsed.important) continue;
|
||||
out.push({ kind: parsed.important, path: file, commit: commit!, pr: /\(#(\d+)\)\s*$/.exec(subject ?? '')?.[1] ?? null });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+88
-21
@@ -3,11 +3,13 @@
|
||||
* 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 ("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.
|
||||
* .github/workflows/weekly-release.yml ("Stable release") every day at 14:00
|
||||
* UTC with --daily (the daily check, scripts/release-cadence.ts: an important
|
||||
* change after 24 h, a forced cut at 7 days, the regular cut on Monday and
|
||||
* Thursday) and by hand (--ref <sha on main> for a hotfix). Dry runs print the
|
||||
* plan and change nothing.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--dry-run]
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--daily [--fragments-dir <dir>]] [--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
|
||||
@@ -58,6 +60,9 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { FLOOR_HOURS, IMPORTANT_FLOOR_HOURS, SCHEDULED_GRACE_HOURS, decideDaily, importantChanges, type DailyDecision } from './release-cadence';
|
||||
|
||||
export { FLOOR_HOURS, SCHEDULED_GRACE_HOURS };
|
||||
|
||||
export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
|
||||
export type Git = (args: string[]) => { ok: boolean; out: string };
|
||||
@@ -70,8 +75,6 @@ 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) => {
|
||||
@@ -285,7 +288,8 @@ export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): {
|
||||
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'regression' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
|
||||
|
||||
/** A regression cut passes only a dated floor; an undatable or unpublished stable stays security-only. */
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number }): Floor {
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number; floorHours?: number }): Floor {
|
||||
const floor = o.floorHours ?? FLOOR_HOURS;
|
||||
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);
|
||||
@@ -298,11 +302,11 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
|
||||
}
|
||||
// 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 need = floor - (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';
|
||||
const next = iso(moved.at + floor * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (floor - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
|
||||
if (o.exception) return { verdict: o.exception, line: 'FLOOR: bypassed by the ' + o.exception + ' exception (' + since + ')' };
|
||||
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
|
||||
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + floor + ' 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>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>', next };
|
||||
}
|
||||
@@ -409,8 +413,9 @@ export interface Plan {
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
exception: Exception | null; floor: Floor; ridingAlong: number;
|
||||
}
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'NO_RELEASE_PR' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
|
||||
/** `sha` names the candidate on NO_RELEASE_PR, which comes after the candidate was picked but before a plan exists. */
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan; sha?: string };
|
||||
|
||||
export interface PlanOptions {
|
||||
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
|
||||
@@ -421,6 +426,8 @@ export interface PlanOptions {
|
||||
security?: string;
|
||||
/** --regression <reason>: bypass a dated floor for a verified regression fix; needs ref. */
|
||||
regression?: string;
|
||||
/** The floor in hours (default FLOOR_HOURS); the daily check plans at IMPORTANT_FLOOR_HOURS and applies each trigger's own floor (scripts/release-cadence.ts). */
|
||||
floorHours?: number;
|
||||
}
|
||||
|
||||
const refusalFor = (kind: Exception['kind']): RefusalCode => (kind === 'security' ? 'REFUSED_SECURITY' : 'REFUSED_REGRESSION');
|
||||
@@ -484,6 +491,9 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const tag = 'v' + version;
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
|
||||
const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other';
|
||||
// The tag on an earlier commit of the same line means VERSION has not moved since that release: no
|
||||
// release PR landed (docs/ship-policy.md#release-stamp). Anywhere else, a version was stamped twice.
|
||||
if (tagExists === 'other' && git(['merge-base', '--is-ancestor', existing.out, sha]).ok) return { ok: false, code: 'NO_RELEASE_PR', sha, message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', an earlier commit, not ' + sha.slice(0, 12) + ': VERSION has not moved since that release, so no release PR landed; the Captain runs gstack-release-stamp open and lands it, then the cut runs' };
|
||||
if (tagExists === 'other') return { ok: false, code: 'REFUSED', message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + sha.slice(0, 12) + '; a version is stamped once per merge, so this needs a look' };
|
||||
if (prevStable === sha && tagExists === 'same' && gh(['release', 'view', tag, '--repo', repo, '--json', 'tagName']).status === 0) {
|
||||
return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + sha.slice(0, 12) + ' is already released as ' + tag + '; nothing newer is green' };
|
||||
@@ -523,7 +533,7 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, exception });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now(), floorHours: o.floorHours });
|
||||
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, exception, floor, ridingAlong };
|
||||
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
|
||||
@@ -590,6 +600,41 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily check (--daily, scripts/release-cadence.ts): plan at the 24-hour important floor, then let
|
||||
* decideDaily pick the trigger, if any; NO_RELEASE_PR becomes a blocked decision only when a trigger
|
||||
* is due. decision is null when the plan's refusal stands on its own (a freeze, nothing green,
|
||||
* FLOOR_UNKNOWN, a non-fast-forward, a version stamped on an unrelated commit); lines then say which
|
||||
* important changes are still waiting on the gate. A plan that cleared while the daily check itself
|
||||
* cannot date stable is held as FLOOR_UNKNOWN, so a null decision never reaches a cut.
|
||||
*/
|
||||
export function planDaily(o: PlanOptions & { fragmentsDir?: string }): { planned: PlanResult; decision: DailyDecision | null; lines: string[] } {
|
||||
const { git, gh, repo } = o;
|
||||
const planned = planRelease({ ...o, floorHours: IMPORTANT_FLOOR_HOURS });
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
const prev = stable.ok ? stable.out : null;
|
||||
const main = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/main^{commit}']);
|
||||
const noReleasePr = !planned.ok && planned.code === 'NO_RELEASE_PR';
|
||||
const sha = planned.ok ? planned.plan.sha
|
||||
: planned.code === 'REFUSED_FLOOR' ? planned.plan?.sha
|
||||
: noReleasePr ? planned.sha
|
||||
: planned.code === 'NOTHING_NEW' ? prev ?? undefined
|
||||
: undefined;
|
||||
const moved = prev ? stableMovedAt(git, gh, repo, prev) : null;
|
||||
if (!sha || !main.ok || (moved && moved.at === null)) {
|
||||
const waiting = main.ok ? importantChanges(git, prev, main.out, o.fragmentsDir) : [];
|
||||
const why = moved && moved.at === null ? moved.why : 'origin/main does not resolve';
|
||||
const held: PlanResult = planned.ok ? { ok: false, code: 'FLOOR_UNKNOWN', message: why + '; the daily check cannot pick a trigger, so nothing is cut', next: 'none until the next daily run can date stable', plan: planned.plan } : planned;
|
||||
return { planned: held, decision: null, lines: waiting.length ? ['IMPORTANT_UNGATED: ' + waiting.map(c => c.kind + ' in ' + (c.pr ? '#' + c.pr : c.commit.slice(0, 12))).join(', ') + ' is on main but no cut can run (below)'] : [] };
|
||||
}
|
||||
if (planned.ok && planned.plan.stableAt) return { planned, decision: { action: 'cut', trigger: null, lines: ['DAILY: cut (finishing the cut that already moved stable)'] }, lines: [] };
|
||||
const decision = decideDaily({
|
||||
now: o.now?.() ?? Date.now(), scheduled: Boolean(o.scheduled), candidate: sha, stableMovedAt: moved?.at ?? null, gatedChanges: sha !== prev, mainAhead: main.out !== prev,
|
||||
important: importantChanges(git, prev, sha, o.fragmentsDir), ungated: importantChanges(git, sha, main.out, o.fragmentsDir), releasePr: !noReleasePr,
|
||||
});
|
||||
return { planned, decision, lines: [] };
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
@@ -603,26 +648,48 @@ if (import.meta.main) {
|
||||
const regression = arg('--regression');
|
||||
const empty = (flag: string, value: string | undefined) => process.argv.includes(flag) && (!value || value.startsWith('--'));
|
||||
if (!repo || empty('--security', security) || empty('--regression', regression)) {
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--daily [--fragments-dir <dir>]] [--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 daily = process.argv.includes('--daily');
|
||||
if (daily && (arg('--ref') || security !== undefined || regression !== undefined)) {
|
||||
console.error('release-cut.ts: --daily picks its own candidate and trigger; run --ref, --security or --regression without it');
|
||||
process.exit(2);
|
||||
}
|
||||
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'), scheduled, security, regression });
|
||||
const annotate = (level: 'notice' | 'warning' | 'error', code: string, text: string) => {
|
||||
if (process.env.GITHUB_ACTIONS === 'true') console.log('::' + level + ' title=release-cut ' + code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
|
||||
};
|
||||
const options = { repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression };
|
||||
let planned: PlanResult;
|
||||
if (daily) {
|
||||
const day = planDaily({ ...options, fragmentsDir: arg('--fragments-dir') });
|
||||
planned = day.planned;
|
||||
emit([...(day.decision?.lines ?? []), ...day.lines]);
|
||||
// A day with no trigger or a trigger still under its floor is the policy working: exit 0 with a
|
||||
// notice. A due cut that cannot run (NO_RELEASE_PR, STABLE_STALE) goes red so someone acts; a dry
|
||||
// run has answered and exits 0. Every refusal the decision did not absorb keeps its own exit.
|
||||
const d = day.decision;
|
||||
if (d && d.action !== 'cut') {
|
||||
const red = d.action === 'blocked';
|
||||
annotate(red ? (dryRun ? 'warning' : 'error') : 'notice', red ? d.lines[0]!.split(':')[0]! : 'daily', d.lines[0]!);
|
||||
process.exit(red && !dryRun ? 1 : 0);
|
||||
}
|
||||
} else {
|
||||
planned = planRelease(options);
|
||||
}
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why),
|
||||
// A dry run that finds nothing green or no release PR has done its job,
|
||||
// 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'));
|
||||
}
|
||||
const hold = (planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled)) || (planned.code === 'NO_RELEASE_PR' && dryRun);
|
||||
if (planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') annotate(hold ? (planned.code === 'NO_RELEASE_PR' ? 'warning' : 'notice') : 'error', planned.code, planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : ''));
|
||||
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
|
||||
}
|
||||
emit(describePlan(planned.plan, dryRun));
|
||||
|
||||
@@ -39,6 +39,16 @@ describe('parseFragment', () => {
|
||||
expect(parseFragment('a.md', '## [1.2.4.0] - 2026-10-29\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
|
||||
expect(parseFragment('a.md', '## [Unreleased]\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
|
||||
});
|
||||
// Value: protects=R74's crew-readable marker: a fragment's important: field (security, regression, data-loss, blocker) parses beside bump: and the daily
|
||||
// stable check reads it; fails_when=the field is refused, an unknown class passes, or the marker leaks into the CHANGELOG body; why_new=fragments held only
|
||||
// bump:; seam=none; no_claim=that the change is really important (attested in review)
|
||||
test('important: security|regression|data-loss|blocker parses beside bump and stays out of the body; any other class is refused', () => {
|
||||
expect(parseFragment('changelog.d/a.md', '---\nimportant: security\n---\n- x\n')).toEqual({ path: 'changelog.d/a.md', body: '- x', bump: null, important: 'security' });
|
||||
expect(parseFragment('changelog.d/a.md', "---\nbump: minor\nimportant: 'data-loss'\n---\n- x\n")).toEqual({ path: 'changelog.d/a.md', body: '- x', bump: 'minor', important: 'data-loss' });
|
||||
for (const kind of ['regression', 'blocker']) expect(parseFragment('a.md', '---\nimportant: ' + kind + '\n---\n- x\n')).toMatchObject({ important: kind });
|
||||
expect(parseFragment('a.md', '---\nimportant: urgent\n---\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID', message: expect.stringContaining('important: security|regression|data-loss|blocker') });
|
||||
expect(parseFragment('a.md', '---\nbump: huge\n---\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' });
|
||||
});
|
||||
test('slug is one flat path segment', () => {
|
||||
expect(fragmentSlug('gsta154/changelog-fragments')).toBe('gsta154-changelog-fragments');
|
||||
expect(fragmentSlug('../..')).toBe('change');
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* release-cadence (free): the daily stable check (docs/releases.md#the-daily-check, R74). decideDaily is
|
||||
* the decision table; importantChanges reads important: fragments from the commits that added them;
|
||||
* planDaily wires both to planRelease against a real git origin with gh faked.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { FLOOR_HOURS, FORCED_AFTER_HOURS, IMPORTANT_FLOOR_HOURS, SCHEDULED_GRACE_HOURS, decideDaily, importantChanges, type DailyInput, type ImportantChange } from '../scripts/release-cadence';
|
||||
import { gitIn, planDaily, type Gh } from '../scripts/release-cut';
|
||||
|
||||
const H = 3_600_000;
|
||||
// Tuesday 2026-11-10 14:00 UTC: not a regular day.
|
||||
const TUESDAY = Date.parse('2026-11-10T14:00:00Z');
|
||||
const MONDAY = Date.parse('2026-11-09T14:00:00Z');
|
||||
const SEC: ImportantChange = { kind: 'security', path: 'changelog.d/fix.md', commit: 'a'.repeat(40), pr: '4101' };
|
||||
const input = (o: Partial<DailyInput> & { hours?: number } = {}): DailyInput => {
|
||||
const now = o.now ?? TUESDAY;
|
||||
return { now, scheduled: false, candidate: 'b'.repeat(40), stableMovedAt: now - (o.hours ?? 30) * H, gatedChanges: true, mainAhead: true, important: [], ungated: [], releasePr: true, ...o };
|
||||
};
|
||||
|
||||
describe('decideDaily', () => {
|
||||
// Value: protects=R74's cadence: an important gated change reaches stable once 24 h have passed since stable moved, never sooner, and a day with nothing
|
||||
// important is a logged no-op until the 7-day forced cut; fails_when=an important cut fires inside 24 h, waits past it, a quiet day cuts, a week-old stable
|
||||
// with gated changes does not, or an ungated change cuts; why_new=stable moved only Mon/Thu; seam=pure; no_claim=GitHub's cron timing
|
||||
test('important + 24 h cuts; important under 24 h waits with NEXT_ALLOWED; nothing important under 7 days is a no-op; 7 days + gated cuts; ungated is a no-op', () => {
|
||||
const cut = decideDaily(input({ important: [SEC], hours: IMPORTANT_FLOOR_HOURS }));
|
||||
expect(cut).toEqual({ action: 'cut', trigger: 'important', lines: ['DAILY: cut (important: security in #4101 (changelog.d/fix.md); stable moved 24.0 h ago, the important floor is 24 h)'] });
|
||||
|
||||
const wait = decideDaily(input({ important: [SEC], hours: IMPORTANT_FLOOR_HOURS - 1 / 3600 }));
|
||||
expect(wait.action).toBe('wait');
|
||||
expect(wait.trigger).toBeNull();
|
||||
expect(wait.lines[0]).toStartWith('DAILY: wait: important: security in #4101');
|
||||
expect(wait.lines[1]).toBe('NEXT_ALLOWED: ' + new Date(TUESDAY - (IMPORTANT_FLOOR_HOURS - 1 / 3600) * H + IMPORTANT_FLOOR_HOURS * H).toISOString().replace(/\.\d{3}Z$/, 'Z') + ' (the first daily run after it cuts)');
|
||||
|
||||
const quiet = decideDaily(input({ hours: FORCED_AFTER_HOURS - 1 }));
|
||||
expect(quiet.action).toBe('noop');
|
||||
expect(quiet.lines[0]).toStartWith('DAILY: no-op: nothing important since stable; stable moved 167.0 h ago;');
|
||||
|
||||
const forced = decideDaily(input({ hours: FORCED_AFTER_HOURS }));
|
||||
expect(forced).toEqual({ action: 'cut', trigger: 'forced', lines: ['DAILY: cut (forced: stable moved 168.0 h ago, past the 7-day limit, and main has gated changes)'] });
|
||||
|
||||
const ungated = decideDaily(input({ gatedChanges: false, mainAhead: false, ungated: [SEC], hours: FORCED_AFTER_HOURS + 24 }));
|
||||
expect(ungated.action).toBe('noop');
|
||||
expect(ungated.lines).toHaveLength(2);
|
||||
expect(ungated.lines[0]).toBe('DAILY: no-op: nothing gated since stable (stable moved 192.0 h ago)');
|
||||
expect(ungated.lines[1]).toStartWith('IMPORTANT_UNGATED: security in #4101 (changelog.d/fix.md) landed after the candidate');
|
||||
// An important change past the candidate never counts toward a cut, only toward the log.
|
||||
expect(decideDaily(input({ ungated: [SEC], hours: 30 })).action).toBe('noop');
|
||||
});
|
||||
|
||||
// Value: protects=the twice-weekly cut inside the daily run, the 3 h cron grace on every floor for scheduled runs only, and the first cut; fails_when=Mon/Thu
|
||||
// stop cutting past 72 h, another day cuts as regular, the grace leaks to hand runs, or a repository with no stable never cuts; why_new=the daily run replaced
|
||||
// the Mon/Thu cron; seam=pure; no_claim=none beyond protects
|
||||
test('Monday past 72 h is a regular cut and under it a no-op; scheduled runs get 3 h on each floor, hand runs none; no stable yet cuts', () => {
|
||||
expect(decideDaily(input({ now: MONDAY, hours: FLOOR_HOURS }))).toMatchObject({ action: 'cut', trigger: 'regular', lines: ['DAILY: cut (regular: Monday, stable moved 72.0 h ago, the regular floor is 72 h)'] });
|
||||
expect(decideDaily(input({ now: MONDAY, hours: FLOOR_HOURS - 1 })).action).toBe('noop');
|
||||
// Monday at 30 h: the important floor, not the regular one, decides.
|
||||
expect(decideDaily(input({ now: MONDAY, hours: 30, important: [SEC] })).trigger).toBe('important');
|
||||
expect(decideDaily(input({ now: MONDAY, hours: 30 })).action).toBe('noop');
|
||||
expect(decideDaily(input({ hours: FLOOR_HOURS + 1 })).action).toBe('noop');
|
||||
expect(SCHEDULED_GRACE_HOURS).toBe(3);
|
||||
expect(decideDaily(input({ now: MONDAY, hours: FLOOR_HOURS - 3, scheduled: true })).trigger).toBe('regular');
|
||||
expect(decideDaily(input({ important: [SEC], hours: 21, scheduled: true })).trigger).toBe('important');
|
||||
expect(decideDaily(input({ important: [SEC], hours: 21 - 1 / 3600, scheduled: true })).action).toBe('wait');
|
||||
expect(decideDaily(input({ important: [SEC], hours: 21 })).action).toBe('wait');
|
||||
expect(decideDaily(input({ hours: FORCED_AFTER_HOURS - 3, scheduled: true })).trigger).toBe('forced');
|
||||
expect(decideDaily(input({ stableMovedAt: null })).trigger).toBe('forced');
|
||||
expect(decideDaily(input({ stableMovedAt: null, important: [SEC] })).trigger).toBe('important');
|
||||
});
|
||||
|
||||
// Value: protects=the release-PR dependency: a due important or forced cut whose VERSION is already released reports "important change waiting, no release
|
||||
// PR" instead of cutting or failing silently; fails_when=the cut proceeds, the report loses its words or the trigger, or a cut that is not due reports it;
|
||||
// why_new=no daily path existed; seam=pure; no_claim=that the Captain reads it (docs/captain.md says when to stamp)
|
||||
test('a due cut with no release PR is blocked and says which wait it is; an undue one is not', () => {
|
||||
const blocked = decideDaily(input({ important: [SEC], releasePr: false }));
|
||||
expect(blocked.action).toBe('blocked');
|
||||
expect(blocked.trigger).toBe('important');
|
||||
expect(blocked.lines[0]).toStartWith('NO_RELEASE_PR: important change waiting, no release PR: the VERSION at candidate bbbbbbbbbbbb is already released (important: security in #4101');
|
||||
expect(blocked.lines[0]).toEndWith('the Captain runs gstack-release-stamp open and lands the release PR, and the next daily run cuts');
|
||||
expect(decideDaily(input({ hours: FORCED_AFTER_HOURS, releasePr: false })).lines[0]).toStartWith('NO_RELEASE_PR: stable is 7 days old, no release PR:');
|
||||
expect(decideDaily(input({ now: MONDAY, hours: FLOOR_HOURS, releasePr: false }))).toMatchObject({ action: 'blocked', trigger: 'regular' });
|
||||
expect(decideDaily(input({ important: [SEC], hours: 2, releasePr: false })).action).toBe('wait');
|
||||
expect(decideDaily(input({ releasePr: false })).action).toBe('noop');
|
||||
});
|
||||
|
||||
// Value: protects=the once-a-week bound's alarm (review d1): a week-old stable whose main moved but holds nothing newer green and gated is STABLE_STALE,
|
||||
// blocked, while a week-old stable with nothing new on main is a quiet no-op; fails_when=a stalled gate leaves stable weeks old in silence, or an idle repo
|
||||
// goes red; why_new=no daily path existed; seam=pure; no_claim=why the gate holds main
|
||||
test('stable 7 days old with main moved but nothing newer gated is STABLE_STALE; with nothing new on main it is a no-op', () => {
|
||||
const stale = decideDaily(input({ gatedChanges: false, hours: FORCED_AFTER_HOURS }));
|
||||
expect(stale).toMatchObject({ action: 'blocked', trigger: 'forced' });
|
||||
expect(stale.lines[0]).toStartWith('STABLE_STALE: stable is 7 days old (stable moved 168.0 h ago) and main has moved');
|
||||
expect(decideDaily(input({ gatedChanges: false, mainAhead: false, hours: FORCED_AFTER_HOURS + 100 })).action).toBe('noop');
|
||||
expect(decideDaily(input({ gatedChanges: false, hours: FORCED_AFTER_HOURS - 1 })).action).toBe('noop');
|
||||
});
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); });
|
||||
const ENV = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_CONFIG_NOSYSTEM: '1' };
|
||||
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: ENV }).trim();
|
||||
|
||||
/** origin.git with v1.0.0 released (an annotated tag dated tagAgeHours ago, stable), and seed to push more. */
|
||||
function repo(tagAgeHours = 0) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cadence-'));
|
||||
roots.push(root);
|
||||
const origin = path.join(root, 'origin.git'), seed = path.join(root, 'seed'), work = path.join(root, 'work');
|
||||
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000, env: ENV });
|
||||
fs.mkdirSync(seed);
|
||||
git(seed, 'init', '-q', '-b', 'main');
|
||||
git(seed, 'remote', 'add', 'origin', origin);
|
||||
const commit = (message: string, files: Record<string, string | null>) => {
|
||||
for (const [file, text] of Object.entries(files)) {
|
||||
const at = path.join(seed, file);
|
||||
if (text === null) fs.rmSync(at); else { fs.mkdirSync(path.dirname(at), { recursive: true }); fs.writeFileSync(at, text); }
|
||||
}
|
||||
git(seed, 'add', '-A');
|
||||
git(seed, 'commit', '-q', '-m', message);
|
||||
git(seed, 'push', '-q', 'origin', 'main');
|
||||
return git(seed, 'rev-parse', 'HEAD');
|
||||
};
|
||||
const base = commit('v1.0.0', { VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-11-01\n\n**First.**\n', 'changelog.d/README.md': '# fragments\n' });
|
||||
execFileSync('git', ['tag', '-a', 'v1.0.0', '-m', 'gstack v1.0.0 (regular release)'], { cwd: seed, timeout: 30_000, env: { ...ENV, GIT_COMMITTER_DATE: new Date(Date.now() - tagAgeHours * H).toISOString() } });
|
||||
git(seed, 'push', '-q', 'origin', 'refs/tags/v1.0.0', base + ':refs/heads/stable');
|
||||
const refresh = () => {
|
||||
if (!fs.existsSync(work)) execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000, env: ENV });
|
||||
git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*');
|
||||
git(work, 'fetch', '-q', '--tags', 'origin');
|
||||
};
|
||||
return { root, origin, seed, work, base, commit, refresh };
|
||||
}
|
||||
|
||||
const GREEN = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }, { name: 'main-backstop', status: 'completed', conclusion: 'success' }];
|
||||
function fakeGh(pending: Set<string> = new Set()): Gh {
|
||||
return (args) => {
|
||||
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: pending.has(m[1]!) ? [{ name: 'main-backstop', status: 'in_progress', conclusion: null }] : GREEN }]), stderr: '' };
|
||||
if (args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.0') return { status: 0, stdout: '{}', stderr: '' };
|
||||
return { status: 1, stdout: '', stderr: 'gh: Not Found (HTTP 404)' };
|
||||
};
|
||||
}
|
||||
|
||||
describe('importantChanges', () => {
|
||||
// Value: protects=the marker surviving the release stamp: the daily check reads each fragment at the commit that added it, so the release PR folding and
|
||||
// deleting the file does not hide an important change; fails_when=a folded fragment is missed, a fragment before stable counts, an unmarked, invalid or
|
||||
// nested file counts, or the PR number is lost; why_new=nothing read fragment history; seam=real git; no_claim=none beyond protects
|
||||
test('reads fragments added in the span from history after the release stamp deleted them; skips unmarked, invalid, nested and out-of-span files', () => {
|
||||
const r = repo();
|
||||
const before = r.commit('older (#1)', { 'changelog.d/old.md': '---\nimportant: blocker\n---\n- old\n' });
|
||||
const from = r.commit('stamp 1.0.1 (#2)', { VERSION: '1.0.1\n', 'changelog.d/old.md': null });
|
||||
const fix = r.commit('fix the tar path (#4101)', { 'changelog.d/fix.md': '---\nimportant: security\n---\n- tar\n', 'changelog.d/plain.md': '- plain\n', 'changelog.d/bad.md': '---\nimportant: urgent\n---\n- x\n', 'changelog.d/sub/n.md': '---\nimportant: blocker\n---\n- n\n' });
|
||||
const to = r.commit('stamp 1.0.2', { VERSION: '1.0.2\n', 'changelog.d/fix.md': null, 'changelog.d/plain.md': null, 'changelog.d/bad.md': null });
|
||||
r.refresh();
|
||||
const g = gitIn(r.work);
|
||||
expect(importantChanges(g, from, to)).toEqual([{ kind: 'security', path: 'changelog.d/fix.md', commit: fix, pr: '4101' }]);
|
||||
expect(importantChanges(g, before, from)).toEqual([]);
|
||||
expect(importantChanges(g, r.base, from).map(c => c.kind)).toEqual(['blocker']);
|
||||
expect(importantChanges(g, to, to)).toEqual([]);
|
||||
expect(importantChanges(g, null, to).map(c => c.path).sort()).toEqual(['changelog.d/fix.md', 'changelog.d/old.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planDaily', () => {
|
||||
// Value: protects=the daily path end to end against real refs: an important fragment plus a landed release PR cuts at 24 h with a plan release-cut can
|
||||
// execute, waits under it, reports NO_RELEASE_PR when VERSION is still the released one, and a gated change still pending is IMPORTANT_UNGATED; fails_when=
|
||||
// the 72 h floor still binds an important cut, a cut decision comes without a plan, the release-PR test misreads the tag, or a pending commit counts;
|
||||
// why_new=no daily path existed; seam=gh faked (check runs, release view), git real; no_claim=GitHub's API shapes beyond what release-cut already reads
|
||||
test('important + release PR cuts at 24 h and waits under it; no release PR is blocked; a pending important change is ungated', () => {
|
||||
const r = repo();
|
||||
r.commit('fix the tar path (#4101)', { 'changelog.d/fix.md': '---\nimportant: security\n---\n- tar\n' });
|
||||
r.refresh();
|
||||
const g = gitIn(r.work);
|
||||
const day = (hours: number, gh = fakeGh()) => planDaily({ repo: 'o/r', git: g, gh, now: () => Date.now() + hours * H });
|
||||
|
||||
const blocked = day(25);
|
||||
expect(blocked.planned).toMatchObject({ ok: false, code: 'NO_RELEASE_PR' });
|
||||
expect(blocked.decision?.action).toBe('blocked');
|
||||
expect(blocked.decision?.lines[0]).toStartWith('NO_RELEASE_PR: important change waiting, no release PR');
|
||||
expect(blocked.planned.ok).toBe(false);
|
||||
|
||||
const stamped = r.commit('release 1.0.1', { VERSION: '1.0.1\n', 'changelog.d/fix.md': null });
|
||||
r.refresh();
|
||||
const cut = day(25);
|
||||
expect(cut.decision).toMatchObject({ action: 'cut', trigger: 'important' });
|
||||
expect(cut.planned.ok).toBe(true);
|
||||
if (cut.planned.ok) { expect(cut.planned.plan.sha).toBe(stamped); expect(cut.planned.plan.tag).toBe('v1.0.1'); }
|
||||
expect(day(20).decision).toMatchObject({ action: 'wait' });
|
||||
|
||||
const later = r.commit('lose no data (#4102)', { 'changelog.d/data.md': '---\nimportant: data-loss\n---\n- d\n' });
|
||||
r.refresh();
|
||||
const pending = day(25, fakeGh(new Set([later])));
|
||||
expect(pending.decision?.action).toBe('cut');
|
||||
expect(pending.decision?.lines[1]).toStartWith('IMPORTANT_UNGATED: data-loss in #4102');
|
||||
if (pending.planned.ok) expect(pending.planned.plan.sha).toBe(stamped);
|
||||
|
||||
// A tag on a commit off the candidate's line is a version stamped twice: REFUSED, which the daily check does not absorb.
|
||||
git(r.seed, 'checkout', '-q', '-b', 'side', r.base);
|
||||
fs.writeFileSync(path.join(r.seed, 'side.txt'), 'x\n');
|
||||
git(r.seed, 'add', '-A'); git(r.seed, 'commit', '-q', '-m', 'side');
|
||||
git(r.seed, 'tag', 'v1.0.1');
|
||||
git(r.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.1');
|
||||
r.refresh();
|
||||
const clash = day(25);
|
||||
expect(clash.planned).toMatchObject({ ok: false, code: 'REFUSED' });
|
||||
expect(clash.decision).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=the daily check failing closed when its own read of stable's move fails after planRelease cleared the 24 h floor; fails_when=a cleared
|
||||
// plan comes back with a null decision, which the CLI would cut on any day with no trigger; why_new=planDaily dates stable separately from planRelease;
|
||||
// seam=gh faked (only the last release view fails), git real; no_claim=which gh errors are transient
|
||||
test('a plan that clears while the daily check cannot date stable is FLOOR_UNKNOWN, never a cuttable plan with no decision', () => {
|
||||
const r = repo();
|
||||
r.commit('release 1.0.1', { VERSION: '1.0.1\n' });
|
||||
r.refresh();
|
||||
const g = gitIn(r.work);
|
||||
const green = fakeGh();
|
||||
let views = 0, failAt = 0;
|
||||
const gh: Gh = (args) => {
|
||||
if (args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.0' && ++views === failAt) return { status: 1, stdout: '', stderr: 'HTTP 502: Bad Gateway' };
|
||||
return green(args);
|
||||
};
|
||||
const day = () => planDaily({ repo: 'o/r', git: g, gh, now: () => Date.now() + 30 * H });
|
||||
const control = day();
|
||||
expect(control.planned.ok).toBe(true);
|
||||
expect(control.decision).not.toBeNull();
|
||||
[failAt, views] = [views, 0];
|
||||
const held = day();
|
||||
expect(held.planned).toMatchObject({ ok: false, code: 'FLOOR_UNKNOWN' });
|
||||
if (!held.planned.ok) expect(held.planned.message).toStartWith('cannot read the release for v1.0.0 (HTTP 502: Bad Gateway)');
|
||||
expect(held.decision).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('release-cut --daily (the workflow entry point)', () => {
|
||||
// Value: protects=the exit-code contract the daily schedule depends on: a quiet day and a wait exit 0 with a notice (no red run every day), an important
|
||||
// change waiting on a release PR goes red (a dry run warns and exits 0), a due cut plans and, as a dry run, pushes nothing, and --daily refuses a hand-picked
|
||||
// ref; fails_when=a quiet day goes red, NO_RELEASE_PR is silent, a dry run pushes, or --daily accepts --ref; why_new=no daily entry point existed; seam=gh shim on PATH, real bare
|
||||
// origin, the stable tag back-dated through GIT_COMMITTER_DATE; no_claim=GitHub's API or a real gh
|
||||
test('quiet and waiting days exit 0 with a notice; NO_RELEASE_PR is red (a dry run warns, exit 0); a due cut plans; --daily refuses --ref', () => {
|
||||
const r = repo(30);
|
||||
const shim = path.join(r.root, '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.0 "*) echo \'{}\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
|
||||
const env: Record<string, string | undefined> = { ...process.env, PATH: shim + path.delimiter + process.env.PATH, GITHUB_ACTIONS: 'true' };
|
||||
for (const k of ['GITHUB_EVENT_NAME', 'GITHUB_STEP_SUMMARY']) delete env[k];
|
||||
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), '--repo', 'o/r', ...args],
|
||||
{ cwd: r.work, encoding: 'utf8', timeout: 60_000, env });
|
||||
|
||||
r.commit('docs (#4100)', { 'changelog.d/docs.md': '- docs\n' });
|
||||
r.refresh();
|
||||
const quiet = run('--daily');
|
||||
expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0);
|
||||
expect(quiet.stdout.split('\n')[0]).toStartWith('DAILY: no-op: nothing important since stable; stable moved 30.0 h ago');
|
||||
expect(quiet.stdout).toContain('::notice title=release-cut daily::DAILY: no-op');
|
||||
|
||||
r.commit('fix the tar path (#4101)', { 'changelog.d/fix.md': '---\nimportant: security\n---\n- tar\n' });
|
||||
r.refresh();
|
||||
const blocked = run('--daily');
|
||||
expect(blocked.status, blocked.stdout + blocked.stderr).toBe(1);
|
||||
expect(blocked.stdout.split('\n')[0]).toStartWith('NO_RELEASE_PR: important change waiting, no release PR');
|
||||
expect(blocked.stdout).toContain('::error title=release-cut NO_RELEASE_PR::NO_RELEASE_PR: important change waiting');
|
||||
const blockedDry = run('--daily', '--dry-run');
|
||||
expect(blockedDry.status, blockedDry.stdout + blockedDry.stderr).toBe(0);
|
||||
expect(blockedDry.stdout).toContain('::warning title=release-cut NO_RELEASE_PR::');
|
||||
// The plain cut reports the same state as NO_RELEASE_PR: red for a real run, a warning and exit 0 for a dry run (the pull-request dry run against main).
|
||||
const plainDry = run('--dry-run');
|
||||
expect(plainDry.status, plainDry.stdout + plainDry.stderr).toBe(0);
|
||||
expect(plainDry.stdout.split('\n')[0]).toStartWith('NO_RELEASE_PR: tag v1.0.0 already names ' + r.base.slice(0, 12) + ', an earlier commit');
|
||||
expect(plainDry.stdout).toContain('::warning title=release-cut NO_RELEASE_PR::');
|
||||
const plainCut = run();
|
||||
expect(plainCut.status).toBe(1);
|
||||
expect(plainCut.stdout).toContain('::error title=release-cut NO_RELEASE_PR::');
|
||||
|
||||
const stamped = r.commit('release 1.0.1', { VERSION: '1.0.1\n', 'changelog.d/fix.md': null, 'changelog.d/docs.md': null });
|
||||
r.refresh();
|
||||
const due = run('--daily', '--dry-run');
|
||||
expect(due.status, due.stdout + due.stderr).toBe(0);
|
||||
expect(due.stdout.split('\n')[0]).toStartWith('DAILY: cut (important: security in #4101 (changelog.d/fix.md); stable moved 30.0 h ago');
|
||||
expect(due.stdout).toContain('CANDIDATE: ' + stamped + ' (');
|
||||
expect(due.stdout).toContain('DRY_RUN: nothing pushed, nothing created.');
|
||||
expect(execFileSync('git', ['ls-remote', r.origin, 'refs/tags/v1.0.1'], { encoding: 'utf8', timeout: 30_000 }).trim()).toBe('');
|
||||
|
||||
const fresh = repo(1);
|
||||
fresh.commit('fix the tar path (#4101)', { 'changelog.d/fix.md': '---\nimportant: security\n---\n- tar\n' });
|
||||
fresh.refresh();
|
||||
const wait = spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), '--repo', 'o/r', '--daily'], { cwd: fresh.work, encoding: 'utf8', timeout: 60_000, env });
|
||||
expect(wait.status, wait.stdout + wait.stderr).toBe(0);
|
||||
expect(wait.stdout.split('\n')[0]).toStartWith('DAILY: wait: important: security in #4101');
|
||||
expect(wait.stdout.split('\n')[1]).toStartWith('NEXT_ALLOWED: ');
|
||||
|
||||
const ref = run('--daily', '--ref', stamped);
|
||||
expect(ref.status).toBe(2);
|
||||
expect(ref.stderr).toContain('--daily picks its own candidate');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* weekly-release.yml ("Stable release", free): the Monday and Thursday cut's wiring (docs/releases.md). The
|
||||
* weekly-release.yml ("Stable release", free): the daily check's wiring (docs/releases.md#the-daily-check). 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
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
import { FORCED_AFTER_HOURS, IMPORTANT_FLOOR_HOURS, REGULAR_DAYS } from '../scripts/release-cadence';
|
||||
|
||||
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[] };
|
||||
@@ -21,20 +22,25 @@ 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', () => {
|
||||
// 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, a security or regression reason, and a pull request only for the release tooling', () => {
|
||||
// Value: protects=Garry's "twice a week" (2026-10-27) inside R74's daily check (2026-11-06: "as frequently as once a day and as infrequently as once a
|
||||
// week"): one daily 14:00 UTC run, whose Monday and Thursday runs are the regular cuts 72 h then 96 h apart; fails_when=the cron drifts off a daily 14:00, the
|
||||
// regular days or the gap and floor stop matching, or the important and forced floors leave once a day / once a week; why_new=the schedule was Mon/Thu only;
|
||||
// seam=none; no_claim=GitHub's cron start times
|
||||
test('a daily 14:00 schedule, a dispatch with dry_run (default true), a full-SHA ref, a security or regression reason or daily, and a pull request only for the release tooling', () => {
|
||||
expect((wf as { name?: string }).name).toBe('Stable release');
|
||||
expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']);
|
||||
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1,4' }]);
|
||||
expect((4 - 1) * 24).toBe(FLOOR_HOURS);
|
||||
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * *' }]);
|
||||
expect(REGULAR_DAYS).toEqual([1, 4]);
|
||||
expect((REGULAR_DAYS[1]! - REGULAR_DAYS[0]!) * 24).toBe(FLOOR_HOURS);
|
||||
expect(IMPORTANT_FLOOR_HOURS).toBe(24);
|
||||
expect(FORCED_AFTER_HOURS).toBe(7 * 24);
|
||||
expect(wf.on.workflow_dispatch.inputs.daily).toMatchObject({ type: 'boolean', default: false });
|
||||
expect(SCHEDULED_GRACE_HOURS).toBeLessThan(FLOOR_HOURS / 12);
|
||||
expect(wf.on.workflow_dispatch.inputs.security).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.workflow_dispatch.inputs.regression).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.workflow_dispatch.inputs.dry_run).toMatchObject({ type: 'boolean', default: true });
|
||||
expect(wf.on.workflow_dispatch.inputs.ref).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts'] });
|
||||
expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts', 'scripts/release-cadence.ts', 'lib/changelog-fragments.ts'] });
|
||||
expect(wf.concurrency).toEqual({ group: "weekly-release-${{ github.event.pull_request.number || 'cut' }}", 'cancel-in-progress': false });
|
||||
expect(wf.permissions).toEqual({});
|
||||
expect(Object.keys(wf.jobs)).toEqual(['cut', 'dry-run']);
|
||||
@@ -62,6 +68,10 @@ describe('weekly-release.yml', () => {
|
||||
expect(plan.run).toContain('[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")');
|
||||
expect(plan.run).not.toContain('inputs.regression');
|
||||
expect(plan.run).toContain(BUN_RUN + ' "${args[@]}"');
|
||||
expect(plan.env!.DAILY).toBe("${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.daily)) && 'true' || 'false' }}");
|
||||
expect(plan.run).toContain('[ "$DAILY" = true ] && args+=(--daily)');
|
||||
expect(plan.run).toContain('if [ "$DAILY" = true ] && [ -n "$RELEASE_REF$SECURITY_REASON$REGRESSION_REASON" ]; then');
|
||||
expect(plan.run).not.toContain('inputs.daily');
|
||||
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'");
|
||||
});
|
||||
|
||||
@@ -71,7 +81,8 @@ describe('weekly-release.yml', () => {
|
||||
expect(dry.permissions).toEqual({ contents: 'read', checks: 'read' });
|
||||
expect(dry.steps.find(s => s.uses?.startsWith('actions/checkout@'))!.with).toEqual({ 'fetch-depth': 0, 'persist-credentials': false });
|
||||
const plan = dry.steps.find(s => s.name === 'Plan only')!;
|
||||
expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run');
|
||||
expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run\n');
|
||||
expect(plan.run).toContain(BUN_RUN + ' --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run --daily\n');
|
||||
expect(plan.run).not.toContain('--ref');
|
||||
for (const job of Object.values(wf.jobs)) {
|
||||
expect(job['timeout-minutes']).toBe(20);
|
||||
|
||||
Reference in new issue
Block a user