Files
gstack/scripts/release-cadence.ts
T
Garry Tanandcapy-ai[bot] cb8f9eaeac 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>
2026-11-06 17:16:24 +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 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;
}