mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
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>
This commit is contained in:
1 parent
64862e4187
commit
cb8f9eaeac
17 files changed
+785
-74
No files matched your search
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+88
-21
@@ -3,11 +3,13 @@
|
||||
* release-cut: tag the newest green commit on main as v<VERSION>, publish a
|
||||
* GitHub Release whose notes are that span's CHANGELOG headlines, and
|
||||
* fast-forward refs/heads/stable to it (docs/releases.md). Run by
|
||||
* .github/workflows/weekly-release.yml ("Stable release") every Monday and
|
||||
* Thursday at 14:00 UTC and by hand (--ref <sha on main> for a hotfix). Dry
|
||||
* runs print the plan and change nothing.
|
||||
* .github/workflows/weekly-release.yml ("Stable release") every day at 14:00
|
||||
* UTC with --daily (the daily check, scripts/release-cadence.ts: an important
|
||||
* change after 24 h, a forced cut at 7 days, the regular cut on Monday and
|
||||
* Thursday) and by hand (--ref <sha on main> for a hotfix). Dry runs print the
|
||||
* plan and change nothing.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--dry-run]
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--daily [--fragments-dir <dir>]] [--dry-run]
|
||||
* [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
|
||||
*
|
||||
* The checkout needs origin's full history (fetch-depth 0). Green, for a
|
||||
@@ -58,6 +60,9 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { FLOOR_HOURS, IMPORTANT_FLOOR_HOURS, SCHEDULED_GRACE_HOURS, decideDaily, importantChanges, type DailyDecision } from './release-cadence';
|
||||
|
||||
export { FLOOR_HOURS, SCHEDULED_GRACE_HOURS };
|
||||
|
||||
export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
|
||||
export type Git = (args: string[]) => { ok: boolean; out: string };
|
||||
@@ -70,8 +75,6 @@ const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'st
|
||||
export const WINDOW_DAYS = 7;
|
||||
export const WINDOW_COMMITS = 200;
|
||||
export const NOTES_CAP = 60_000;
|
||||
export const FLOOR_HOURS = 72;
|
||||
export const SCHEDULED_GRACE_HOURS = 3;
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
export const runGh: Gh = (args) => {
|
||||
@@ -285,7 +288,8 @@ export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): {
|
||||
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'regression' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
|
||||
|
||||
/** A regression cut passes only a dated floor; an undatable or unpublished stable stays security-only. */
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number }): Floor {
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number; floorHours?: number }): Floor {
|
||||
const floor = o.floorHours ?? FLOOR_HOURS;
|
||||
if (!o.prevStable) return { verdict: 'first', line: 'FLOOR: none (no stable yet; this cut creates it)' };
|
||||
if (o.prevStable === o.sha) return { verdict: 'finishing', line: 'FLOOR: not applied (finishing the cut that already moved stable)' };
|
||||
const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
|
||||
@@ -298,11 +302,11 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
|
||||
}
|
||||
// A tag dated in the future holds the floor from now, not from that date.
|
||||
moved.at = Math.min(moved.at, o.now);
|
||||
const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
|
||||
const need = floor - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
|
||||
const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
|
||||
const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
|
||||
const next = iso(moved.at + floor * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (floor - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
|
||||
if (o.exception) return { verdict: o.exception, line: 'FLOOR: bypassed by the ' + o.exception + ' exception (' + since + ')' };
|
||||
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
|
||||
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + floor + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
|
||||
if (o.now - moved.at >= need * HOUR) return { verdict: 'clear', line: 'FLOOR: clear (' + since + '; ' + kind + ')' };
|
||||
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>', next };
|
||||
}
|
||||
@@ -409,8 +413,9 @@ export interface Plan {
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
exception: Exception | null; floor: Floor; ridingAlong: number;
|
||||
}
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'NO_RELEASE_PR' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
|
||||
/** `sha` names the candidate on NO_RELEASE_PR, which comes after the candidate was picked but before a plan exists. */
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan; sha?: string };
|
||||
|
||||
export interface PlanOptions {
|
||||
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
|
||||
@@ -421,6 +426,8 @@ export interface PlanOptions {
|
||||
security?: string;
|
||||
/** --regression <reason>: bypass a dated floor for a verified regression fix; needs ref. */
|
||||
regression?: string;
|
||||
/** The floor in hours (default FLOOR_HOURS); the daily check plans at IMPORTANT_FLOOR_HOURS and applies each trigger's own floor (scripts/release-cadence.ts). */
|
||||
floorHours?: number;
|
||||
}
|
||||
|
||||
const refusalFor = (kind: Exception['kind']): RefusalCode => (kind === 'security' ? 'REFUSED_SECURITY' : 'REFUSED_REGRESSION');
|
||||
@@ -484,6 +491,9 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const tag = 'v' + version;
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
|
||||
const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other';
|
||||
// The tag on an earlier commit of the same line means VERSION has not moved since that release: no
|
||||
// release PR landed (docs/ship-policy.md#release-stamp). Anywhere else, a version was stamped twice.
|
||||
if (tagExists === 'other' && git(['merge-base', '--is-ancestor', existing.out, sha]).ok) return { ok: false, code: 'NO_RELEASE_PR', sha, message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', an earlier commit, not ' + sha.slice(0, 12) + ': VERSION has not moved since that release, so no release PR landed; the Captain runs gstack-release-stamp open and lands it, then the cut runs' };
|
||||
if (tagExists === 'other') return { ok: false, code: 'REFUSED', message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + sha.slice(0, 12) + '; a version is stamped once per merge, so this needs a look' };
|
||||
if (prevStable === sha && tagExists === 'same' && gh(['release', 'view', tag, '--repo', repo, '--json', 'tagName']).status === 0) {
|
||||
return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + sha.slice(0, 12) + ' is already released as ' + tag + '; nothing newer is green' };
|
||||
@@ -523,7 +533,7 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, exception });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now(), floorHours: o.floorHours });
|
||||
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
|
||||
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, exception, floor, ridingAlong };
|
||||
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
|
||||
@@ -590,6 +600,41 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily check (--daily, scripts/release-cadence.ts): plan at the 24-hour important floor, then let
|
||||
* decideDaily pick the trigger, if any; NO_RELEASE_PR becomes a blocked decision only when a trigger
|
||||
* is due. decision is null when the plan's refusal stands on its own (a freeze, nothing green,
|
||||
* FLOOR_UNKNOWN, a non-fast-forward, a version stamped on an unrelated commit); lines then say which
|
||||
* important changes are still waiting on the gate. A plan that cleared while the daily check itself
|
||||
* cannot date stable is held as FLOOR_UNKNOWN, so a null decision never reaches a cut.
|
||||
*/
|
||||
export function planDaily(o: PlanOptions & { fragmentsDir?: string }): { planned: PlanResult; decision: DailyDecision | null; lines: string[] } {
|
||||
const { git, gh, repo } = o;
|
||||
const planned = planRelease({ ...o, floorHours: IMPORTANT_FLOOR_HOURS });
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
const prev = stable.ok ? stable.out : null;
|
||||
const main = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/main^{commit}']);
|
||||
const noReleasePr = !planned.ok && planned.code === 'NO_RELEASE_PR';
|
||||
const sha = planned.ok ? planned.plan.sha
|
||||
: planned.code === 'REFUSED_FLOOR' ? planned.plan?.sha
|
||||
: noReleasePr ? planned.sha
|
||||
: planned.code === 'NOTHING_NEW' ? prev ?? undefined
|
||||
: undefined;
|
||||
const moved = prev ? stableMovedAt(git, gh, repo, prev) : null;
|
||||
if (!sha || !main.ok || (moved && moved.at === null)) {
|
||||
const waiting = main.ok ? importantChanges(git, prev, main.out, o.fragmentsDir) : [];
|
||||
const why = moved && moved.at === null ? moved.why : 'origin/main does not resolve';
|
||||
const held: PlanResult = planned.ok ? { ok: false, code: 'FLOOR_UNKNOWN', message: why + '; the daily check cannot pick a trigger, so nothing is cut', next: 'none until the next daily run can date stable', plan: planned.plan } : planned;
|
||||
return { planned: held, decision: null, lines: waiting.length ? ['IMPORTANT_UNGATED: ' + waiting.map(c => c.kind + ' in ' + (c.pr ? '#' + c.pr : c.commit.slice(0, 12))).join(', ') + ' is on main but no cut can run (below)'] : [] };
|
||||
}
|
||||
if (planned.ok && planned.plan.stableAt) return { planned, decision: { action: 'cut', trigger: null, lines: ['DAILY: cut (finishing the cut that already moved stable)'] }, lines: [] };
|
||||
const decision = decideDaily({
|
||||
now: o.now?.() ?? Date.now(), scheduled: Boolean(o.scheduled), candidate: sha, stableMovedAt: moved?.at ?? null, gatedChanges: sha !== prev, mainAhead: main.out !== prev,
|
||||
important: importantChanges(git, prev, sha, o.fragmentsDir), ungated: importantChanges(git, sha, main.out, o.fragmentsDir), releasePr: !noReleasePr,
|
||||
});
|
||||
return { planned, decision, lines: [] };
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
@@ -603,26 +648,48 @@ if (import.meta.main) {
|
||||
const regression = arg('--regression');
|
||||
const empty = (flag: string, value: string | undefined) => process.argv.includes(flag) && (!value || value.startsWith('--'));
|
||||
if (!repo || empty('--security', security) || empty('--regression', regression)) {
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--daily [--fragments-dir <dir>]] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
|
||||
process.exit(2);
|
||||
}
|
||||
const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
|
||||
const daily = process.argv.includes('--daily');
|
||||
if (daily && (arg('--ref') || security !== undefined || regression !== undefined)) {
|
||||
console.error('release-cut.ts: --daily picks its own candidate and trigger; run --ref, --security or --regression without it');
|
||||
process.exit(2);
|
||||
}
|
||||
const git = gitIn(process.cwd());
|
||||
const emit = (lines: string[]) => {
|
||||
for (const l of lines) console.log(l);
|
||||
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
|
||||
};
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression });
|
||||
const annotate = (level: 'notice' | 'warning' | 'error', code: string, text: string) => {
|
||||
if (process.env.GITHUB_ACTIONS === 'true') console.log('::' + level + ' title=release-cut ' + code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
|
||||
};
|
||||
const options = { repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression };
|
||||
let planned: PlanResult;
|
||||
if (daily) {
|
||||
const day = planDaily({ ...options, fragmentsDir: arg('--fragments-dir') });
|
||||
planned = day.planned;
|
||||
emit([...(day.decision?.lines ?? []), ...day.lines]);
|
||||
// A day with no trigger or a trigger still under its floor is the policy working: exit 0 with a
|
||||
// notice. A due cut that cannot run (NO_RELEASE_PR, STABLE_STALE) goes red so someone acts; a dry
|
||||
// run has answered and exits 0. Every refusal the decision did not absorb keeps its own exit.
|
||||
const d = day.decision;
|
||||
if (d && d.action !== 'cut') {
|
||||
const red = d.action === 'blocked';
|
||||
annotate(red ? (dryRun ? 'warning' : 'error') : 'notice', red ? d.lines[0]!.split(':')[0]! : 'daily', d.lines[0]!);
|
||||
process.exit(red && !dryRun ? 1 : 0);
|
||||
}
|
||||
} else {
|
||||
planned = planRelease(options);
|
||||
}
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why),
|
||||
// A dry run that finds nothing green or no release PR has done its job,
|
||||
// and so has a floor hold on a dry run or on the schedule: the policy
|
||||
// working, not a fault. Every other refusal goes red so someone looks.
|
||||
const hold = planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled);
|
||||
if (process.env.GITHUB_ACTIONS === 'true' && planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') {
|
||||
const text = planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : '');
|
||||
console.log('::' + (hold ? 'notice' : 'error') + ' title=release-cut ' + planned.code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
|
||||
}
|
||||
const hold = (planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled)) || (planned.code === 'NO_RELEASE_PR' && dryRun);
|
||||
if (planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') annotate(hold ? (planned.code === 'NO_RELEASE_PR' ? 'warning' : 'notice') : 'error', planned.code, planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : ''));
|
||||
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
|
||||
}
|
||||
emit(describePlan(planned.plan, dryRun));
|
||||
|
||||
Reference in new issue
Block a user