diff --git a/.github/workflows/evals-main-backstop.yml b/.github/workflows/evals-main-backstop.yml index 2c3f30303..a864b8edf 100644 --- a/.github/workflows/evals-main-backstop.yml +++ b/.github/workflows/evals-main-backstop.yml @@ -12,6 +12,10 @@ name: Main backstop gate # (success when a green train covers the tree or the backstop gated it green, # neutral when the push was superseded or deferred, failure when it froze the # queue or could not finish); the stable release reads it (docs/releases.md). +# +# Once main's policy declares a `release_gate` block, the release gate +# (release-gate.yml, every 8 hours) gates main instead and this backstop stands +# down: no gate, no freeze and no check run on a push. on: push: branches: [main] @@ -60,9 +64,13 @@ jobs: done fi train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } + release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy") if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY" + elif [ "$release" = true ]; then + echo "train=false" >> "$GITHUB_OUTPUT" + echo "- Backstop stands down: main's .gstack/ship-policy.json declares a release_gate block; the scheduled release gate gates main (release-gate.yml, docs/ship-policy.md#release-gate)" >> "$GITHUB_STEP_SUMMARY" else echo "train=true" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index fd5ed557c..b662c8867 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -166,7 +166,8 @@ jobs: pull-requests: read outputs: mode: ${{ steps.route.outputs.mode }} - record_artifact: ${{ steps.policy.outputs.record_artifact }} + # A release bisect (gstack-release-gate) names its own prefix-record artifact; everything else uses the train block's. + record_artifact: ${{ steps.route.outputs.record_artifact || steps.policy.outputs.record_artifact }} full_gate_check: ${{ steps.policy.outputs.full_gate_check }} screen_head: ${{ steps.screen.outputs.head_sha }} screen_base: ${{ steps.screen.outputs.base_sha }} @@ -218,10 +219,23 @@ jobs: echo "::warning::the train block at ${base} names no valid record_artifact and full_gate_check; this run routes as before" fi + # The release gate's bisect (gstack-release-gate) routes only under a + # release_gate block at the same base revision; git and jq only. + - name: Read the release gate policy (base revision) + id: release_policy + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + policy=$(git show "${BASE_SHA:-origin/${DEFAULT_BRANCH}}:.gstack/ship-policy.json" 2>/dev/null || echo null) + release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy" 2>/dev/null || echo false) + echo "release=${release}" >> "$GITHUB_OUTPUT" + - name: Route this run id: route env: TRAIN_POLICY: ${{ steps.policy.outputs.train }} + RELEASE_POLICY: ${{ steps.release_policy.outputs.release }} NONCE: ${{ inputs.nonce }} EXPECTED_SHA: ${{ inputs.expected_sha }} EVALS_ALL: ${{ inputs.evals_all }} diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 000000000..c696e2793 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,125 @@ +name: Release gate +# The release gate (docs/ship-policy.md#release-gate; bin/gstack-release-gate, +# lib/release-gate.ts): the full paid gate on main's head every 8 hours, only +# when main moved since the last gate. It records the verdict as a release +# note in refs/notes/gstack-trains, publishes the main-backstop check run on +# the gated commit (what scripts/release-cut.ts reads as gated), and bisects +# a red gate's cases. It never writes the stable branch: release-cut is its +# only writer, and refuses every cut while the newest gate is red. Inert (no +# paid work, nothing written) unless main's .gstack/ship-policy.json declares +# a release_gate block; with one, the per-push main backstop +# (evals-main-backstop.yml) stands down and this replaces it. +on: + schedule: + # Every 8 hours (RELEASE_GATE_SCHEDULE_HOURS in lib/release-gate-policy.ts; + # test/release-gate-workflow.test.ts pins the two together), off the hour. + - cron: '17 */8 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Print what the gate would test and dispatch nothing' + type: boolean + default: false + ignore_cadence: + description: 'Gate now even if the last gate is younger than cadence_hours (main must still have moved)' + type: boolean + default: true + +# One gate at a time; a second run waits for the first. +concurrency: + group: release-gate + cancel-in-progress: false + +permissions: {} + +jobs: + release-gate: + runs-on: ubuntu-24.04 + # A dispatch gates only from the default branch, with its tooling. + if: github.ref_name == github.event.repository.default_branch + # The full gate (270) + a bisect round + margin; past --budget-minutes the + # record stays resumable by the next run. + timeout-minutes: 345 + outputs: + action: ${{ steps.gate.outputs.action }} + sha: ${{ steps.gate.outputs.sha }} + check: ${{ steps.gate.outputs.check }} + permissions: + # Pushes the release record to refs/notes/gstack-trains and a bisect's + # release-gate/ branch (deleted after its round). + contents: write + # Dispatches the gate and reads its runs and artifacts. + actions: write + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + filter: blob:none + + # Read with git and jq only: without a release_gate block nothing below runs. + - name: Read the release gate policy (main) + id: policy + run: | + file=.gstack/ship-policy.json + policy=$(git show "${GITHUB_SHA}:${file}" 2>/dev/null || echo null) + gate=$(jq -c 'if (.release_gate | type) == "object" then .release_gate else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:${file} is not valid JSON; fix the policy file" >&2; exit 1; } + if [ -z "$gate" ] || [ "$gate" = null ]; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "- Release gate inert: main's ${file} declares no release_gate block; the per-push backstop gates main" >> "$GITHUB_STEP_SUMMARY" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.policy.outputs.release == 'true' + with: + bun-version: 1.4.2 + + - name: Gate main's head when it moved + id: gate + if: steps.policy.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state + DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + IGNORE_CADENCE: ${{ inputs.ignore_cadence && '1' || '' }} + # held and failed are verdicts the release-check job publishes on the gated commit. Failing this job would + # leave a failed check on main's head, which release-cut reads as red even after that head gates green, so + # only a run that recorded no action (a crash) fails it. + run: | + rc=0 + ./bin/gstack-release-gate run --repo "$GITHUB_REPOSITORY" --budget-minutes 330 \ + --summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT" ${DRY_RUN:+--dry-run} ${IGNORE_CADENCE:+--ignore-cadence} || rc=$? + if [ "$rc" != 0 ] && ! grep -qE '^action=(held|failed)$' "$GITHUB_OUTPUT"; then exit "$rc"; fi + + # The gated commit's check run (release_gate.check_name, main-backstop), the + # evidence scripts/release-cut.ts reads. Its own job with checks: write and + # no repository code, as for backstop-check: only the recorded action word. + release-check: + runs-on: ubuntu-24.04 + needs: release-gate + if: ${{ !cancelled() && contains(fromJSON('["gated","held","failed"]'), needs.release-gate.outputs.action) && needs.release-gate.outputs.sha != '' }} + timeout-minutes: 5 + permissions: + checks: write + steps: + - name: Publish the release gate's check run on the gated commit + env: + GH_TOKEN: ${{ github.token }} + ACTION: ${{ needs.release-gate.outputs.action }} + SHA: ${{ needs.release-gate.outputs.sha }} + CHECK: ${{ needs.release-gate.outputs.check }} + run: | + case "$ACTION" in + gated) conclusion=success ;; + held) conclusion=failure ;; + failed) conclusion=neutral ;; + *) echo "::error::unknown release gate action '$ACTION'"; exit 1 ;; + esac + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::'$SHA' is not a commit SHA"; exit 1; } + [[ "$CHECK" =~ ^[A-Za-z0-9_./-]{1,64}$ ]] || { echo "::error::'$CHECK' is not a check-run name"; exit 1; } + gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name="$CHECK" -f head_sha="$SHA" -f status=completed -f conclusion="$conclusion" \ + -f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + -f "output[title]=Release gate: ${ACTION}" -f "output[summary]=${ACTION}: see the release-gate job summary of run ${GITHUB_RUN_ID}" >/dev/null + echo "- ${CHECK}: ${conclusion} (release gate ${ACTION}) on ${SHA}" >> "$GITHUB_STEP_SUMMARY" diff --git a/bin/gstack-release-gate b/bin/gstack-release-gate new file mode 100755 index 000000000..f523a0fd1 --- /dev/null +++ b/bin/gstack-release-gate @@ -0,0 +1,62 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-release-gate — the full paid gate on main every cadence_hours, only + * when main moved since the last gate (docs/ship-policy.md#release-gate; + * logic: lib/release-gate.ts). The gate never moves the stable branch: + * scripts/release-cut.ts is its only writer and holds every cut while the + * newest gate is red. + * + * gstack-release-gate run [--dry-run] [--sha
] [--ignore-cadence] + * gstack-release-gate status + * gstack-release-gate bisect [--sha ] [--spend-cap ] [--dry-run] + * + * Every subcommand takes --repo (default $GITHUB_REPOSITORY, else + * gh repo view), --policy (a whole ship policy, validated; default the + * reviewed .gstack/ship-policy.json on origin/main), --remote (origin), + * --summary (appends each line as a bullet) and --outputs + * (appends action= and sha= for the workflow). run and bisect + * take --poll-seconds (60) and --budget-minutes (330): past the budget nothing + * waits, and the record stays resumable by the next run or bisect. + * Exit 0: inert, skipped, dry run, gated green, or status with no hold; + * 1: red (held), incomplete or could not finish; 2: usage. + */ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import { bisectGate, gateStatus, loadReleaseGate, runGate, type GateEnv, type GateOut } from '../lib/release-gate'; +import { runGh } from '../scripts/recover-receipts'; +import { gitIn } from '../scripts/train-gate'; + +const USAGE = 'usage: gstack-release-gate run [--dry-run] [--sha ] [--ignore-cadence] | status | bisect [--sha ] [--spend-cap ] [--dry-run]\n' + + ' common: [--repo ] [--policy ] [--remote ] [--summary ] [--outputs ] [--poll-seconds ] [--budget-minutes ]'; +const [command, ...rest] = process.argv.slice(2); +const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; }; +const has = (name: string) => rest.includes(`--${name}`); +const usage = (why?: string): never => { console.error(why ? `${why}\n${USAGE}` : USAGE); process.exit(2); }; +if (command === '--help' || command === '-h') { console.log(USAGE); process.exit(0); } +if (!['run', 'status', 'bisect'].includes(command ?? '')) usage(); +const sha = flag('sha'); +if (sha !== undefined && !/^[0-9a-f]{40}$/.test(sha)) usage('--sha needs a full 40-hex commit'); +const spendCap = flag('spend-cap') === undefined ? undefined : Number(flag('spend-cap')); +if (spendCap !== undefined && !(spendCap >= 0)) usage('--spend-cap needs dollars'); +const repoRoot = process.cwd(); +const repo = flag('repo') ?? process.env.GITHUB_REPOSITORY ?? runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], 60_000).stdout.trim(); +if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) usage(`cannot name the repository (${JSON.stringify(repo)}); pass --repo `); +const loaded = loadReleaseGate(repoRoot, { policyFile: flag('policy') }); +const env: GateEnv = { + repo, repoRoot, remote: flag('remote') ?? 'origin', gh: runGh, git: gitIn(repoRoot), now: Date.now, sleep: ms => Bun.sleepSync(ms), + random: () => randomBytes(3).toString('hex'), pollMs: Number(flag('poll-seconds') ?? 60) * 1000, + deadline: Date.now() + Number(flag('budget-minutes') ?? 330) * 60_000, +}; +let out: GateOut; +if ('error' in loaded) out = { action: 'failed', sha: null, lines: [`RELEASE: policy refused: ${loaded.error}`] }; +else if (command === 'run') out = runGate(env, { ...loaded, sha, dryRun: has('dry-run'), ignoreCadence: has('ignore-cadence') }); +else if (command === 'bisect') out = bisectGate(env, { ...loaded, sha, spendCap, dryRun: has('dry-run') }); +else out = gateStatus(env, loaded); +const summary = flag('summary'); +for (const line of out.lines) { + console.log(line); + if (summary) fs.appendFileSync(summary, `- ${line}\n`); +} +const outputs = flag('outputs'); +if (outputs) fs.appendFileSync(outputs, `action=${out.action}\nsha=${out.sha ?? ''}\ncheck=${'gate' in loaded && loaded.gate ? loaded.gate.check_name : ''}\n`); +process.exit(out.action === 'held' || out.action === 'failed' ? 1 : 0); diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh index 2cb8ae4c2..f7f2eaa1b 100755 --- a/capy/skill/gstack/scripts/install.sh +++ b/capy/skill/gstack/scripts/install.sh @@ -17,6 +17,12 @@ # checkout it prints `gstack: ok not-needed for=none (...)` and exits 0; with # one it prints the same trailer with the checkout's version, running nothing # from the checkout. That ok admits no workflow (no verdict is written). +# From revision 4 the clone and the floor fast-forward follow the update +# channel (docs/releases.md#channels): GSTACK_UPDATE_CHANNEL=stable|main, else +# the checkout's bin/gstack-update-channel.sh (auto: stable in team mode), +# else main. On stable, a fresh clone checks out origin's stable branch and +# the floor fast-forward goes to stable, each only when stable exists and +# meets the supported minimum; otherwise main, as before. --revision wins. # Everything else (Bun, setup, the doctor, the verdict, the launch line) is # the checkout's. The last line is the `gstack:` trailer; exit 0 only on # `gstack: ok`. Contract: docs/capy-install.md; tests: @@ -27,9 +33,10 @@ # bash install.sh --check --for none this machine runs no workflow: ok not-needed, nothing installed # bash install.sh [--for | --require ] [--with-browser] [--revision ] [--project ] # Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO, -# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s). +# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s), +# GSTACK_UPDATE_CHANNEL=main (follow every merge even in team mode) or stable. set -u -GSTACK_CAPY_WRAPPER=3 +GSTACK_CAPY_WRAPPER=4 self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}" @@ -74,6 +81,24 @@ below() { return 1 } support_get() { printf '%s\n' "$1" | sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" | head -1; } +# channel_stable ROOT MIN [FETCH_FLAG] — true, with origin's stable commit in $stable_sha, when +# this install follows stable and origin's stable exists and meets MIN; says why not otherwise. +# FETCH_FLAG is --depth=1 for a fresh clone only (never --depth into an existing checkout). +channel_stable() { + local ch="${GSTACK_UPDATE_CHANNEL:-}" ver + case "$ch" in stable|main) ;; *) ch=$(bash "$1/bin/gstack-update-channel.sh" "$1" 2>/dev/null | head -1) ;; esac + [ "$ch" = stable ] || return 1 + if ! bounded git -C "$1" fetch --quiet ${3:+"$3"} origin stable 2>/dev/null; then + echo "gstack-capy-skill: channel stable, but origin has no stable branch yet (or the fetch failed); following main" + return 1 + fi + stable_sha=$(git -C "$1" rev-parse FETCH_HEAD) ver=$(git -C "$1" show FETCH_HEAD:VERSION 2>/dev/null | head -1) + if [ -n "$2" ] && below "${ver:-0}" "$2"; then + echo "gstack-capy-skill: channel stable is at ${ver:-unknown}, below the supported minimum $2; following main" + return 1 + fi + echo "gstack-capy-skill: channel stable ${ver:-unknown} (GSTACK_UPDATE_CHANNEL=main follows every merge)" +} mkdir -p "$(dirname "$root")" if [ "$check" -eq 0 ]; then @@ -100,6 +125,10 @@ if [ ! -f "$root/setup" ]; then fi remote_min=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_MIN_REVISION) remote_version=$(head -1 "$root/VERSION" 2>/dev/null) + if [ -z "$revision" ] && channel_stable "$root" "$remote_min" --depth=1; then + git -C "$root" reset --quiet --hard "$stable_sha" \ + || { echo "gstack: fail installer — could not check out origin/stable in $root; fix: GSTACK_UPDATE_CHANNEL=main $fix_cmd"; exit 1; } + fi export GSTACK_CAPY_CLONED="$repo" else # The remote's rules: fetch the default branch into FETCH_HEAD (never --depth @@ -140,9 +169,11 @@ else echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min ($cause); fix: $fix (REVISION_BELOW_MINIMUM)" exit 1 fi - if ! git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null; then + target=$(git -C "$root" rev-parse FETCH_HEAD) + if channel_stable "$root" "$remote_min" && git -C "$root" merge-base --is-ancestor HEAD "$stable_sha" 2>/dev/null; then target=$stable_sha; fi + if ! git -C "$root" merge --ff-only -q "$target" 2>/dev/null; then git -C "$root" fetch --quiet --unshallow origin "$default" 2>/dev/null - git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null \ + git -C "$root" merge --ff-only -q "$target" 2>/dev/null \ || { echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min (fast-forward failed); fix: git -C $root status, then $fix_cmd (REVISION_BELOW_MINIMUM)"; exit 1; } fi echo "gstack-capy-skill: upgraded $installed -> $(head -1 "$root/VERSION")" diff --git a/changelog.d/gsta258-l1-release-gate.md b/changelog.d/gsta258-l1-release-gate.md new file mode 100644 index 000000000..19c22ce53 --- /dev/null +++ b/changelog.d/gsta258-l1-release-gate.md @@ -0,0 +1,17 @@ +--- +bump: minor +--- +**A release gate can now gate main every 8 hours instead of on every push, and it feeds the stable release.** It is off until main's `.gstack/ship-policy.json` declares a `release_gate` block, so this release changes nothing on its own. With the block, `.github/workflows/release-gate.yml` runs `gstack-release-gate run` every 8 hours. When main moved since the last gate, it runs the full paid gate on main's head once, records the verdict as a `release` note in `refs/notes/gstack-trains`, and publishes the `main-backstop` check run on that commit. A red gate bisects its red cases within `bisect_spend_cap_usd` and names the first bad commit. The per-push main backstop stands down while the block is set, so main is never gated twice. `scripts/release-cut.ts` is still the only thing that moves `stable`, and its 72-hour floor and exceptions are unchanged. It now reads a green release record as proof the commit was gated, and it refuses every cut while the newest release gate is red with no green gate on a later commit. That hold is the freeze. See docs/ship-policy.md#release-gate. + +### Added +- **`release_gate` policy key** (`lib/release-gate-policy.ts`, which needs `min_gstack_version` at this release): `cadence_hours` (8 to 168), `gate_workflow`, `branch`, `stable_ref`, `check_name`, `on_red: hold-release` and `bisect_spend_cap_usd`, every field required. +- **`bin/gstack-release-gate`** (`lib/release-gate.ts`): `run [--dry-run] [--sha] [--ignore-cadence]`, `status` and `bisect`. `--policy ` reads the block from a file, for a dry run or a proof before adoption. Exit 0 means gated, skipped, inert or a dry run; 1 means held, failed or a refused policy; 2 is a usage error. +- **`.github/workflows/release-gate.yml`**: an 8-hour schedule and a manual dispatch. The check run comes from a separate job that holds `checks: write` and runs no repository code. +- **Capy skill installer follows the update channel** (`capy/skill/gstack/scripts/install.sh`, wrapper revision 4). With `GSTACK_UPDATE_CHANNEL=stable` or a stable `update_channel`, a fresh clone or a floor upgrade goes to `stable` when it meets the support floor, and to main otherwise. `--revision` still wins. + +### Changed +- **`evals-main-backstop.yml`** stands down (no gate, no freeze, no check run) when main's policy declares `release_gate`. +- **`evals.yml`** routes a release gate bisect (`release-gate/` refs) only under a `release_gate` block at the base revision, and that bisect publishes its prefix records as `gstack-release-record`. + +### Tests +- `test/release-gate.test.ts` drives run, bisect and status against a fake `gh` and a scratch remote. `test/release-gate-workflow.test.ts` runs the workflows' policy steps on scratch repositories and pins the cron to the cadence floor. `test/release-cut.test.ts` proves a red release record refuses the cut and a green one on a later commit releases it. `test/train-gate.test.ts` covers the bisect route, and `test/capy-skill-wrapper.test.ts` covers the stable channel and its fallback to main. diff --git a/docs/captain.md b/docs/captain.md index 62651c89d..bf9cae04a 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -370,7 +370,10 @@ through an urgent one-PR train (or `train build --repair` while the queue is fro refuses `--urgent`); all prefixes green earns exactly one unchanged rebuild; anything else goes to a repair lane. An infra-only census earns one re-dispatch. A backstop gate runs on every push to the base branch and freezes the queue when it finds -a tree no green train covered; one repair PR at its exact head clears the freeze. +a tree no green train covered; one repair PR at its exact head clears the freeze. A +policy with a `release_gate` block swaps that per-push backstop for one full gate on main +every 8 hours, whose red verdict holds the stable release instead of freezing the queue +([ship-policy.md#release-gate](ship-policy.md#release-gate)). **Wave PRs when the queue is long.** Every slot pays its own stamp, its own round of required checks and its own version, one after another. When more diff --git a/docs/capy-install.md b/docs/capy-install.md index a1da5fd13..8e4075529 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -4,7 +4,7 @@ Three layers, each owned by gstack: | Layer | Where | Job | |---|---|---| -| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | +| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env` (from wrapper 4 a fresh clone or the floor fast-forward follows `stable` instead when the update channel resolves to it and `stable` meets that minimum, see [releases.md](releases.md#channels)); in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper | | Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair | | Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer | @@ -606,7 +606,10 @@ Wrapper 2 only added optional exports (the trailer's `latest:` note and the `cloned` line). Wrapper 3 answers `--check --for none` itself (a copy at 1 or 2 prints `fail not-installed` for a coordinator machine and still works for every other call), so the minimum is 3: every older Drive copy prints the owner -action until the owner recopies `capy/skill/gstack/`. +action until the owner recopies `capy/skill/gstack/`. Wrapper 4 only adds the +update-channel choice for the clone and the floor fast-forward +(`GSTACK_UPDATE_CHANNEL=stable|main`, else the checkout's channel, else main); +the minimum stays 3, so a copy at 3 keeps working. ## Exit codes diff --git a/docs/releases.md b/docs/releases.md index de38b8ff4..520a22987 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,11 +29,12 @@ it has completed and none failed, and `refs/notes/gstack-trains` holds no active freeze. A cancelled check run counts as red. A commit with a check run still going is skipped and the next older one is considered, back through 7 days or 200 first-parent commits. The workflow runs no tests and no paid evals -of its own; it only reads what the trains and the backstop already recorded. +of its own; it only reads what the trains, the backstop and the release gate +already recorded. With nothing green in the window it fails and lists why each commit was skipped. -**Gated** means one of two pieces of evidence. The first is the +**Gated** means one of three pieces of evidence. The first is the `main-backstop` check run: the Main backstop gate workflow publishes it on each push to main from a job of its own, success when a green train record covers the tree or the backstop gated it green, neutral when main moved past @@ -45,11 +46,17 @@ its tree: those entries are the main commits `gstack-ship-queue verify --train` confirmed, so a commit a green train merged is gated even when trains merged faster than the backstop could settle and its own backstop run ended neutral or never ran. The `CANDIDATE` line says which: `gated by -main-backstop` or `gated by train `. Train evidence relaxes nothing +main-backstop` or `gated by train `. The third is a GREEN release +record (kind `release`) on the commit, written by the release gate +([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) when the +policy declares `release_gate`; the line then says `gated by release gate run +`. A RED release record with no GREEN one on a later commit that +contains it refuses the cut like a freeze. Train evidence relaxes nothing else: a red backstop run, a failed or missing `free-tests` run, any other red check run or a run still going keeps the commit out, and a record in any other state (`red`, `superseded`, `free-green`, …) is not evidence. A commit with -neither a successful backstop run nor a train record is not released: a +none of the three (no successful backstop run, train record or GREEN release +record) is not released: a dependabot push, a policy without a train block, or a tree that was never gated. @@ -240,6 +247,10 @@ overrides the key for one command. The resolution lives in one helper, - `bin/gstack-upgrade` and `/gstack-upgrade`: they fast-forward to the channel ref (`UPSTREAM_REF=origin/stable` in the skill's output) and say which channel they followed. +- `capy/skill/gstack/scripts/install.sh`, the Capy skill's installer (wrapper + revision 4): a fresh clone or a floor upgrade follows `stable` when the channel + resolves to it and `stable` meets the support floor, and follows main + otherwise. `--revision` still pins an exact commit. Two edge cases are handled on purpose: @@ -302,11 +313,12 @@ is exercised against real check runs before it merges. | Line | Meaning | |---|---| -| `CANDIDATE: () gated by main-backstop|train ` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) | +| `CANDIDATE: () gated by main-backstop|train |release gate run ` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) | | `NOTHING_NEW: origin/main is already released as v` | nothing merged since the last release; exit 0 | | `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) | | `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_FLOOR: stable moved to v at