mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
* 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>
116 lines
7.9 KiB
TypeScript
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;
|
|
}
|