From cb8f9eaeacf950180a5fc9ecb6e78a2787d35768 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 09:16:24 -0800 Subject: [PATCH] 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> --- .github/workflows/weekly-release.yml | 33 +- README.md | 2 +- changelog.d/README.md | 2 +- .../capy-gst-1646-daily-stable-check.md | 7 + docs/captain.md | 4 +- docs/install.md | 2 +- docs/recommended-harness.md | 8 +- docs/releases.md | 192 ++++++++++- docs/ship-policy.md | 19 +- docs/troubleshooting.md | 5 +- lib/changelog-fragments.ts | 18 +- lib/result-codes-release.ts | 4 +- scripts/release-cadence.ts | 115 +++++++ scripts/release-cut.ts | 109 +++++-- test/changelog-fragments.test.ts | 10 + test/release-cadence.test.ts | 300 ++++++++++++++++++ test/weekly-release-workflow.test.ts | 29 +- 17 files changed, 785 insertions(+), 74 deletions(-) create mode 100644 changelog.d/capy-gst-1646-daily-stable-check.md create mode 100644 scripts/release-cadence.ts create mode 100644 test/release-cadence.test.ts diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml index c80b70364..655f73b8d 100644 --- a/.github/workflows/weekly-release.yml +++ b/.github/workflows/weekly-release.yml @@ -2,9 +2,13 @@ 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 -# 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 # it fixes and the earlier stable release v 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 diff --git a/README.md b/README.md index 47e3e4ee9..01fd989b5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/changelog.d/README.md b/changelog.d/README.md index 16edc326c..177f3258d 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -1,3 +1,3 @@ # CHANGELOG fragments -A PR adds its CHANGELOG entry here as `changelog.d/.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/.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. diff --git a/changelog.d/capy-gst-1646-daily-stable-check.md b/changelog.d/capy-gst-1646-daily-stable-check.md new file mode 100644 index 000000000..b7bc680c5 --- /dev/null +++ b/changelog.d/capy-gst-1646-daily-stable-check.md @@ -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. diff --git a/docs/captain.md b/docs/captain.md index 4bf03e654..50c61e90e 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -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 --pin ` (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 --pin ` (no /ship receipt or READY note needed for a `release/` head). The cut then tags the new version diff --git a/docs/install.md b/docs/install.md index 0e3006604..52432a0c1 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 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. diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index b5348f167..4fa52ce95 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -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`, publishes a GitHub Release whose notes are one line per CHANGELOG version since the diff --git a/docs/releases.md b/docs/releases.md index fd358d6e6..dc6b0a871 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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` (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. + +## 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` 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. + + +## 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^{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` 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 --prerelease --title "gstack v (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= -f dry_run=false`. The notes list every version since + `v`, 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 fetch --tags origin && git -C +reset --hard 'v'`. 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 () 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 already names ` | a version was stamped twice; needs a look before anything is cut | +| `REFUSED: tag v already names ` | 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 at