diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml index bb4566aef..70f53aca5 100644 --- a/.github/workflows/weekly-release.yml +++ b/.github/workflows/weekly-release.yml @@ -1,9 +1,15 @@ -name: Weekly release +name: Stable release # Tags the newest green commit on main as v, publishes the GitHub # Release, and fast-forwards refs/heads/stable to it (docs/releases.md). Team # installs follow stable, so this is the only thing that moves them. Runs -# every Monday morning Pacific; run it by hand with a ref for a hotfix, or -# with dry_run to see the plan and change nothing. A pull request that touches +# twice a week, Monday and Thursday at 14:00 UTC (the file keeps its +# weekly-release.yml name so its run history and every `gh workflow run` +# reference stay intact). stable moves at most once per 72 hours: a cut inside +# that floor is refused with REFUSED_FLOOR and NEXT_ALLOWED; a scheduled run +# gets 3 hours of grace for cron start jitter, a hand-run cut none. The only +# exception is a security cut: the security input with a ref, naming a GHSA, +# CVE or # the script ties to the released commits. Run it by hand with a +# ref for a hotfix, or with dry_run to see the plan and change nothing. A pull request that touches # the release tooling runs the dry run against main with a read-only token, # which is the only pre-merge exercise of the real workflow. The candidate's # gate must already be green (free-tests and main-backstop check runs @@ -12,8 +18,9 @@ name: Weekly release # runs only there, with main's tooling. on: schedule: - # Monday 14:00 UTC: 07:00 PDT, 06:00 PST. - - cron: '0 14 * * 1' + # Monday and Thursday 14:00 UTC (07:00 PDT, 06:00 PST): exactly 72 h, then + # 96 h, apart; the floor's scheduled grace covers a late Monday start. + - cron: '0 14 * * 1,4' workflow_dispatch: inputs: dry_run: @@ -24,6 +31,10 @@ on: description: 'Hotfix: release this commit of main instead of the newest green one (full 40-hex SHA)' type: string default: '' + security: + description: 'Security cut (needs ref): the reason, naming the GHSA-…, CVE-… or # it fixes; passes the 72-hour floor once verified and lands in the release notes' + type: string + default: '' pull_request: paths: - '.github/workflows/weekly-release.yml' @@ -48,6 +59,9 @@ jobs: contents: write # Reads the candidate's check runs. checks: read + # A security cut reads the security label on the # its reason names. + issues: read + pull-requests: read steps: # The dispatched ref supplies the tooling; the release candidate always # comes from origin/main, which fetch-depth 0 brings along with every @@ -73,16 +87,22 @@ jobs: GH_TOKEN: ${{ github.token }} DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }} run: | set -euo pipefail if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::ref must be the full 40-hex SHA of a commit on main, not '$RELEASE_REF'" exit 1 fi + if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then + echo "::error::a security cut needs ref: the full SHA of the fix commit on main" + exit 1 + fi args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY") [ "$DRY_RUN" = true ] && args+=(--dry-run) [ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF") - echo "## Weekly release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'))" >> "$GITHUB_STEP_SUMMARY" + [ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON") + echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY" bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}" dry-run: @@ -110,5 +130,5 @@ jobs: set -euo pipefail git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet" git fetch --quiet --tags origin - echo "## Weekly release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY" + echo "## Stable release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY" bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run diff --git a/docs/install.md b/docs/install.md index 6d78ed0af..c126edd23 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel From inside your repo, `(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required` switches you to team mode and bootstraps the repo so teammates get gstack automatically (the README has the full paste, which also commits the change). -No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the weekly tagged release (Monday mornings, plus off-cycle hotfixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md). +No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index 84d2710f7..a71255010 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -3,7 +3,7 @@ This is the operating model a team adopts when it runs gstack under a Capy Captain: one person, one Captain thread that directs a crew, crew threads that each own a branch and stop at an unstamped READY, one controller that merges -through trains, a daily flaky pass, a weekly release, and a scorecard that says +through trains, a daily flaky pass, a twice-weekly release, and a scorecard that says whether the factory is getting better. It was written from two crews that ran it for a week on their own repositories (about 100 merges a week each) and from the recurring problems four Captains reported while doing so. Every path and @@ -33,7 +33,7 @@ flowchart LR C -->|train build: one gate for up to 5 PRs| G[(Gate workflow)] G -->|GREEN or RED with flake records| C C -->|slot: stamp, verify, merge, verify| M[(main)] - M -->|Monday| R[Weekly release: tag, notes, stable] + M -->|Mon + Thu, 72 h floor| R[Stable release: tag, notes, stable] G -.->|flake-records artifacts| F[Daily flaky pass] M -.->|free-suite retries| F M -.->|/retro Step 7.5| S[Factory scorecard] @@ -84,7 +84,7 @@ a time; each is independent. |---|---|---| | `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` fails every touched file with `PREGATE_NO_LANE`. Write it once, from one owner, before the crew starts. | | `.gstack/ship-policy.json` | `queue_mode`, the `train` block (gate and free workflow names, the full-gate check, the train size and fill wait), the stamp files ([docs/ship-policy.md](ship-policy.md)) | Per-PR queue; `train …` refuses with `QUEUE_TRAIN_OFF`. | -| `CHANGELOG.md` + `VERSION` | A stamp per merge and a weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. | +| `CHANGELOG.md` + `VERSION` | A stamp per merge and a twice-weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. | | `AGENTS.md` validation discipline | Diagnose before fixing, focused checks during repair, one full suite at the end on a frozen tree | Threads retry blindly and report passes from older revisions. | A `.github/workflows` gate that the train can dispatch is the one hard @@ -221,17 +221,20 @@ repeats first, start one fix thread with the Open section as its brief, and report the count to the Captain". Pass `thread: "this"` to keep the history in one place, and run it once by hand before enabling it. -## The weekly release +## The stable release -`.github/workflows/weekly-release.yml` runs `scripts/release-cut.ts` every -Monday morning: it takes the newest green commit on main's first-parent history +`.github/workflows/weekly-release.yml` ("Stable release") runs +`scripts/release-cut.ts` every Monday and Thursday at 14:00 UTC, and never moves +`stable` more than once in 72 hours except for a verified security fix +(`--security` with `--ref`): it takes the newest green commit on main's first-parent history (its required check succeeded, the backstop check succeeded, every check run completed and none failed, no active freeze), tags it `v`, publishes a GitHub Release whose notes are one line per CHANGELOG version since the previous release, and fast-forwards `stable`. Team-mode installs follow `stable`; `gstack-config set update_channel main` follows every merge. A -hotfix is `--ref `. Details and the "nothing green in the window" -failure mode: [docs/releases.md](releases.md). A repository adopting this +hotfix is `--ref `, held by the same floor. Details, the floor's +refusal codes and the "nothing green in the window" failure mode: +[docs/releases.md](releases.md). A repository adopting this copies the workflow, sets its required check and backstop check names (`REQUIRED_CHECK`, `BACKSTOP_CHECK`), and keeps the CHANGELOG headline convention ([docs/CHANGELOG_STYLE.md](CHANGELOG_STYLE.md)). @@ -428,5 +431,5 @@ allowlist; no cost ceiling surfaced at autoplan gates. | Ship policy, queue and trains | `docs/ship-policy.md`, `lib/ship-queue*.ts`, `bin/gstack-ship-queue`, `bin/gstack-restamp` | | Pregate and lanes | `docs/pregate.md`, `bin/gstack-pregate` | | Flaky reruns and records | `docs/TESTING_INTERNALS.md#flaky-reruns`, `scripts/lib/paid-rerun.ts`, `scripts/lib/ci-history.ts` | -| Weekly release | `docs/releases.md`, `scripts/release-cut.ts`, `.github/workflows/weekly-release.yml` | +| Stable release | `docs/releases.md`, `scripts/release-cut.ts`, `.github/workflows/weekly-release.yml` | | Scorecard | `bin/gstack-factory-scorecard`, `lib/factory-scorecard.ts`, `retro/SKILL.md.tmpl` Step 7.5, `test/factory-scorecard.test.ts` | diff --git a/docs/releases.md b/docs/releases.md index bc8693c6d..15216137f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,14 +1,16 @@ # Releases and update channels gstack stamps a version at every merge to main, about a dozen a day. Since -late October 2026 there is also a weekly tagged release, and team-mode -installs follow it instead of every merge. +late October 2026 there is also a tagged release, cut twice a week, and +team-mode installs follow it instead of every merge. ## What a release is -Every Monday at 07:00 Pacific (06:00 in winter) the **Weekly release** -workflow (`.github/workflows/weekly-release.yml`, which runs -`scripts/release-cut.ts`) takes the newest green commit on main and: +Every Monday and Thursday at 14:00 UTC (07:00 Pacific, 06:00 in winter) the +**Stable release** workflow (`.github/workflows/weekly-release.yml`, which +runs `scripts/release-cut.ts`; the file kept its old name so its run history +and the `gh workflow run` commands below stay valid) takes the newest green +commit on main and: 1. tags it `v` (the VERSION file at that commit, an annotated tag); 2. publishes a GitHub Release whose notes are one line per CHANGELOG version @@ -18,7 +20,7 @@ workflow (`.github/workflows/weekly-release.yml`, which runs A tag and its release never change. `stable` is a moving pointer: always a commit on main's first-parent history, moved only by this workflow, only -forward. +forward, and at most once every 72 hours except for a security fix (below). **Green** means, on that exact commit: the `free-tests` check run succeeded (the status main requires), the commit was gated (below), every check run on @@ -60,11 +62,93 @@ finished by the next run, by cron or by hand: `stable` itself stays a candidate until its release exists, and its notes compare against the release tag before it. +## The 72-hour floor + +`stable` moves at most once every 72 hours (Garry, 2026-10-27). Every cut, +scheduled or by hand, with or without `ref`, checks when `stable` last moved: +the creation time of the newest annotated `v*` tag pointing at `stable`, or +the publish time of that tag's GitHub Release when it is later (a cut whose +`stable` push was retried after the tag was pushed is dated by its release). +A cut inside the floor changes nothing and prints, as its first two lines: + +``` +REFUSED_FLOOR: stable moved to v1.91.300.0 at 2026-11-02T14:04:51Z, 47.2 h ago; a hand-run cut needs 72 h; a security fix passes with --security ": why" --ref +NEXT_ALLOWED: 2026-11-05T14:04:51Z for a hand-run cut; 2026-11-05T11:04:51Z for a scheduled run +``` + +followed by the plan it would have run. Monday to Thursday is exactly 72 +hours, so scheduled runs, and only scheduled runs (`GITHUB_EVENT_NAME=schedule`, +which no dispatch input can set), get a 3-hour grace for GitHub's late cron +starts: a Thursday run 69 hours after a late Monday cut still cuts. Two +regular moves are therefore never less than 69 hours apart, and a hand-run cut +always needs the full 72. Thursday to Monday is 96 hours. + +A refused hand cut exits 1 with an error annotation. A refused scheduled run +exits 0 with a notice annotation and the same lines in the step summary: that +is the policy working (a security cut on Wednesday holds Thursday, and +Monday's cut carries everything), not a fault. A dry run prints the plan and +the floor verdict (`FLOOR:` line) and exits 0. + +Two cases are not held. The first cut, before `stable` exists, has nothing to +measure. Finishing a half-done cut (`stable` already at the candidate, release +missing) is not a new move. A `stable` that no annotated tag dates is +`FLOOR_UNKNOWN`, red on every trigger: only this tool moves `stable` and it +tags first, so that state means someone moved it by hand. The floor binds the +release tool, not a writer pushing `stable` directly; that would take a branch +ruleset limiting `stable` to the Actions bot. + +The floor applies to non-security breakage too. If a cut breaks team +installs, the fix waits for the floor; an affected install can follow main +meanwhile (`gstack-config set update_channel main`) and the next scheduled +cut brings it back. + +## Security releases + +A security fix is the one exception to the floor. Run the workflow by hand +with `ref` set to the fix commit on main and `security` set to a reason that +names what it fixes: + +```bash +gh workflow run weekly-release.yml -f ref= -f security="GHSA-xxxx-xxxx-xxxx: tar path traversal" -f dry_run=false +``` + +The reason must name a `GHSA-…`, a `CVE-…` or an issue or pull request +`#`, and the tool checks that it is tied to the commits being released +(`stable`..`ref`). One of these must hold: + +1. a commit message in that span names the same GHSA or CVE; +2. issue or pull request `#` carries the `security` label and a commit in + the span references `#` (the squash subject `(#)` does); +3. the GitHub Advisory Database entry for the GHSA or CVE (the data OSV + imports) lists a package whose line changes in the span's `package.json` + or `bun.lock`: a dependency bump that fixes a known advisory. + +**Verified:** the identifier exists in the reason, the label exists on the +issue, and the link between the identifier and a released commit or bumped +package. **Attested, not verified:** that the change actually fixes the +vulnerability, how severe it is, and that it cannot wait; the `security` +label itself is applied by a person with triage access. A reason the tool +cannot tie to the span is `REFUSED_SECURITY` with what it checked, red on +every trigger, as is `security` without `ref`: a security cut releases up to +the fix, never whatever happens to be newest. Every version between `stable` +and the fix still ships with it (there are no cherry-picks); the plan's +`RIDING_ALONG:` line counts them. + +The reason is public. It goes, as one line of at most 300 characters (control +characters and backticks stripped), into the tag message and the release +notes: `**Security release:** (verified: )`. For an +embargoed fix, use path 2 with a neutral commit subject and a labelled issue +whose title says no more than the fix does, and keep advisory details out of +the reason until disclosure. +A security cut that stopped half-way is finished by the next run with the +same reason, read back from its tag. The floor then counts from the security +release, and the next regular cut's notes list only the versions after it. + ## Channels | Channel | Follows | Who | |---|---|---| -| `stable` | `refs/heads/stable`: the weekly release, plus any hotfix cut off-cycle | team mode (`./setup --team`) by default | +| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security cut off-cycle | team mode (`./setup --team`) by default | | `main` | every merge to main | plain `./setup`, contributors, Capy crew machines | The config key is `update_channel` (`bin/gstack-config`): @@ -72,12 +156,12 @@ The config key is `update_channel` (`bin/gstack-config`): ```bash gstack-config get update_channel # auto (default) | stable | main gstack-config set update_channel main # a team install that wants every merge -gstack-config set update_channel stable # a solo install that wants weekly releases +gstack-config set update_channel stable # a solo install that wants stable releases gstack-config unset update_channel # back to auto ``` `auto` resolves to `stable` when `team_mode` is true and to `main` otherwise, -so existing team installs moved to weekly releases with nothing to migrate and +so existing team installs moved to stable releases with nothing to migrate and contributor installs did not move at all. `GSTACK_UPDATE_CHANNEL=stable|main` overrides the key for one command. The resolution lives in one helper, `bin/gstack-update-channel.sh`, used by: @@ -111,14 +195,15 @@ still holds and nothing about version stamping changed. ## Cutting a release by hand (hotfixes) A hotfix is an ordinary PR: it merges to main through a train and gets its -version like any other change. To put it on stable before Monday, run the -workflow by hand with the merge commit's SHA: +version like any other change. To put it on stable before the next scheduled +cut, run the workflow by hand with the merge commit's SHA, once the 72-hour +floor allows it (a security fix uses the security input instead): ```bash gh workflow run weekly-release.yml -f ref= -f dry_run=false ``` -or Actions → **Weekly release** → Run workflow, on main (the cut job runs +or Actions → **Stable release** → Run workflow, on main (the cut job runs only from `refs/heads/main`, with main's tooling; `ref` must be the full 40-hex SHA). The same green checks apply. The commit is tagged with its VERSION, the release notes say "hotfix release" and list every version since the previous release (everything merged in @@ -134,8 +219,8 @@ first, then promote. Run the workflow with `dry_run=true` (the default for a manual run) to see the plan without changing anything: the candidate, its VERSION and tag, the stable move, how many version lines the notes would carry, every skipped commit with -its reason, and the exact commands a real cut would run. The scheduled Monday -run is never a dry run. +its reason, the floor verdict, and the exact commands a real cut would run. The scheduled +runs are never dry runs. ```bash gh workflow run weekly-release.yml -f dry_run=true @@ -159,4 +244,10 @@ is exercised against real check runs before it merges. | `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward | | `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) | | `REFUSED: tag v already names ` | a version was stamped twice; needs a look before anything is cut | +| `REFUSED_FLOOR: stable moved to v at