Files
gstack/crew/scripts/release-cadence.ts
T
Garry Tanandcapy-ai[bot] 6417abf7a6 Move the tools only Capy crews use into a crew/ folder (GST-1833) (#4145)
* Load the Capy crew skills only on Capy machines (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add changelog entry for Capy-only crew skills (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Move the Capy-only crew tooling into crew/ and run its tests only when needed (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Test that the Capy launch file puts crew/bin on PATH

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count any ~/.capy as a Capy machine, so a Mac running Capy keeps /captain

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Fix the tests and fixtures that broke when crew tooling moved into crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Plan every crew test when a pull request's merge-base can't be read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add a test that checks each crew-only module left the root tree for crew/

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Run crew tests on pull requests that change bin/ commands or workflows they drive

Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep running every crew test on every pull request; the skip comes in a later change

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep copied installs and older release bisects working after the crew/ move

Setup now copies crew/lib into every host's runtime root beside bin and lib, so Windows installs (which copy instead of linking) can still start gstack-ship-receipt, gstack-pregate and the other commands that import it. The release bisect runs the train-gate script from wherever the checked-out commit has it, so a bisect over commits from before the move still gets a verdict.

Review-Finding: p1/outside-voice#1 reader=outside-voice
Review-Finding: p1/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog that Windows installs keep working after the crew/ move

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the harness guide's merge-queue rows at crew/lib/ where those modules moved

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Point the plain-title test at the wave module's new home in crew/ (GST-1833)

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>
2026-11-08 14:26:14 +00:00

116 lines
7.9 KiB
TypeScript

/**
* 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 crew/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;
}