mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 01:38:10 +01:00
Merge remote-tracking branch 'origin/main' into gsta134/skill-diet-pilots
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
20d92ebdda
76 files changed
+4143
-488
No files matched your search
+187
-19
@@ -3,11 +3,12 @@
|
||||
* 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 every Monday morning and by hand for a
|
||||
* hotfix (--ref <sha on main>). Dry runs print the plan and change nothing.
|
||||
* .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.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
|
||||
* [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason>] [--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
|
||||
* commit on main's first-parent history, means: its required check run
|
||||
@@ -34,6 +35,17 @@
|
||||
* commit. A cut that stopped half-way (tag and stable pushed, release not
|
||||
* created) is finished by the next run; a completed cut reports NOTHING_NEW
|
||||
* and exits 0.
|
||||
*
|
||||
* The floor: stable moves at most once per FLOOR_HOURS (72). The last move is
|
||||
* the annotated v<VERSION> tag pointing at stable, or its GitHub Release's
|
||||
* publish time when that is later; a cut inside the floor is REFUSED_FLOOR
|
||||
* with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
|
||||
* scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
|
||||
* slack for cron start jitter; nothing else does. Finishing a half-done cut is
|
||||
* not a new move. The one exception is a security cut: --security <reason>
|
||||
* with --ref, where the reason names a GHSA, CVE or #<n> that verifySecurity
|
||||
* ties to the commits being released; the reason and its evidence go into the
|
||||
* tag message and the notes, and a retry recovers them from the tag.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
@@ -51,6 +63,9 @@ 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) => {
|
||||
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
@@ -168,7 +183,14 @@ export function changelogEntries(text: string): ChangelogEntry[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean }
|
||||
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; security?: Security | null }
|
||||
|
||||
/** A verified security cut: the sanitized reason and what tied it to the released commits. */
|
||||
export interface Security { reason: string; evidence: string }
|
||||
|
||||
export function releaseKind(hotfix: boolean, security: Security | null | undefined): 'security' | 'hotfix' | 'regular' {
|
||||
return security ? 'security' : hotfix ? 'hotfix' : 'regular';
|
||||
}
|
||||
|
||||
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
|
||||
export function releaseNotes(n: NotesInput): string {
|
||||
@@ -178,8 +200,9 @@ export function releaseNotes(n: NotesInput): string {
|
||||
const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
|
||||
const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
|
||||
const head = [
|
||||
'## gstack v' + n.version + (n.hotfix ? ' (hotfix release)' : ' (weekly release)'),
|
||||
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.security) + ' release)',
|
||||
'',
|
||||
...(n.security ? ['**Security release:** `' + n.security.reason + '` (verified: ' + n.security.evidence + '). Every version since the previous release ships with it.', ''] : []),
|
||||
n.first || !n.prevVersion
|
||||
? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
|
||||
: span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
|
||||
@@ -198,22 +221,133 @@ export function releaseNotes(n: NotesInput): string {
|
||||
return body;
|
||||
}
|
||||
|
||||
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
/**
|
||||
* When stable last moved: the annotated v<VERSION> tag a cut made on it (VERSION
|
||||
* read at stable), or that tag's release publish time when later. Any other tag
|
||||
* on that commit is ignored, so a hand-made extra tag cannot date the move or
|
||||
* pin later runs to stable. published is false when the release does not exist
|
||||
* yet (a half-done cut, whose tag may predate the stable push); a release that
|
||||
* cannot be read leaves the move undated.
|
||||
*/
|
||||
export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string; published: boolean } | { at: null; why: string } {
|
||||
const version = git(['show', stable + ':VERSION']);
|
||||
const tag = 'v' + (version.ok ? version.out.trim() : '<VERSION>');
|
||||
const listed = version.ok ? git(['for-each-ref', '--points-at=' + stable, '--format=%(objecttype)%09%(creatordate:unix)', 'refs/tags/' + tag]) : null;
|
||||
const [type, created] = listed?.ok && listed.out ? listed.out.split('\t') : [];
|
||||
if (type !== 'tag') return { at: null, why: 'no annotated ' + tag + ' tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
|
||||
const at = Number(created) * 1000;
|
||||
const view = gh(['release', 'view', tag, '--repo', repo, '--json', 'publishedAt']);
|
||||
if (view.status !== 0) {
|
||||
if (/not found|HTTP 404/i.test(view.stderr)) return { tag, at, published: false };
|
||||
return { at: null, why: 'cannot read the release for ' + tag + ' (' + (view.stderr.trim().slice(0, 120) || 'gh exit ' + view.status) + '), so its last move cannot be dated' };
|
||||
}
|
||||
const published = Date.parse(parseJson<{ publishedAt?: string }>(view)?.publishedAt ?? '');
|
||||
return { tag, at: Number.isFinite(published) ? Math.max(at, published) : at, published: true };
|
||||
}
|
||||
|
||||
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
|
||||
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
|
||||
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; security: boolean; scheduled: boolean; now: number }): Floor {
|
||||
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);
|
||||
if (moved.at === null) {
|
||||
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
|
||||
return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v<VERSION> tag dates the release stable points at' };
|
||||
}
|
||||
if (!moved.published && !o.security) {
|
||||
return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
|
||||
}
|
||||
// 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 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';
|
||||
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security 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';
|
||||
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>', next };
|
||||
}
|
||||
|
||||
/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
|
||||
export function sanitizeReason(reason: string): string {
|
||||
return reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/`/g, "'").replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ties a security reason to the commits in (from, to]. The reason must name a
|
||||
* GHSA, CVE or #<n>; it is verified when (a) a commit message in the span names
|
||||
* that GHSA/CVE, (b) issue or PR #<n> carries the security label and a commit
|
||||
* in the span references #<n>, or (c) the GitHub Advisory Database entry for
|
||||
* that GHSA/CVE lists a package whose line changes in the span's package.json
|
||||
* or bun.lock. What the fix fixes, and how bad it was, stays attested.
|
||||
*/
|
||||
export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string }): { ok: true; evidence: string } | { ok: false; why: string } {
|
||||
const advisories = [...new Set([...o.reason.matchAll(/\bGHSA(?:-[2-9cfghjmpqrvwx]{4}){3}\b/gi)].map(m => 'GHSA' + m[0].slice(4).toLowerCase())
|
||||
.concat([...o.reason.matchAll(/\bCVE-\d{4}-\d{4,}\b/gi)].map(m => m[0].toUpperCase())))];
|
||||
const issues = [...new Set([...o.reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
|
||||
if (advisories.length === 0 && issues.length === 0) return { ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' };
|
||||
const range = o.from ? [o.from + '..' + o.to] : ['--max-count=' + WINDOW_COMMITS, o.to];
|
||||
const log = o.git(['log', '--format=%H%x1f%B%x1e', ...range]);
|
||||
const commits = (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
|
||||
// Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
|
||||
const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
|
||||
.flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
|
||||
const why: string[] = [];
|
||||
for (const id of advisories) {
|
||||
const named = commits.find(c => c.body.toUpperCase().includes(id.toUpperCase()));
|
||||
if (named) return { ok: true, evidence: 'commit ' + named.sha + ' names ' + id };
|
||||
const read = parseJson<unknown>(o.gh(['api', id.startsWith('GHSA') ? 'advisories/' + id : 'advisories?cve_id=' + id]));
|
||||
const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string; ecosystem?: string } }> }>;
|
||||
const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).filter(v => v.package?.ecosystem?.toLowerCase() === 'npm').map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
|
||||
const bumped = packages.find(p => changed.has(p));
|
||||
if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
|
||||
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
|
||||
}
|
||||
for (const n of issues) {
|
||||
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
|
||||
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(o.gh(['api', 'repos/' + o.repo + '/issues/' + n]));
|
||||
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === 'security'));
|
||||
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the security label and commit ' + ref.sha + ' references it' };
|
||||
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no security label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
|
||||
}
|
||||
return { ok: false, why: 'nothing in ' + (o.from ? o.from.slice(0, 12) + '..' : 'up to ') + o.to.slice(0, 12) + ' ties the reason to the release: ' + why.join('; ') };
|
||||
}
|
||||
|
||||
/** The security reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
|
||||
export function securityFromTag(git: Git, tag: string): Security | null {
|
||||
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
|
||||
const reason = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
|
||||
if (!reason) return null;
|
||||
return { reason: reason[1]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
|
||||
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
security: Security | null; floor: Floor; ridingAlong: number;
|
||||
}
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY';
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
|
||||
|
||||
export interface PlanOptions {
|
||||
repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
|
||||
requiredCheck?: string; backstopCheck?: string; notesRef?: string;
|
||||
/** A scheduled run (GITHUB_EVENT_NAME=schedule): the floor allows SCHEDULED_GRACE_HOURS of cron jitter. */
|
||||
scheduled?: boolean;
|
||||
/** --security <reason>: bypass the floor for a verified security fix; needs ref. */
|
||||
security?: string;
|
||||
}
|
||||
|
||||
export function planRelease(o: PlanOptions): PlanResult {
|
||||
const { git, gh, repo } = o;
|
||||
const windowDays = o.windowDays ?? WINDOW_DAYS;
|
||||
const skipped: string[] = [];
|
||||
if (o.security !== undefined && !o.ref) return { ok: false, code: 'REFUSED_SECURITY', message: '--security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest' };
|
||||
const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
|
||||
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
@@ -238,6 +372,9 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
|
||||
candidates = list.ok && list.out ? list.out.split('\n') : [];
|
||||
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
|
||||
// A cut that moved stable but never published its release is finished before stable moves again.
|
||||
const moved = prevStable ? stableMovedAt(git, gh, repo, prevStable) : null;
|
||||
if (prevStable && moved && moved.at !== null && !moved.published) candidates = [prevStable];
|
||||
}
|
||||
|
||||
// stable itself stays a candidate so a cut that stopped half-way (tag and
|
||||
@@ -287,10 +424,25 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
|
||||
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
|
||||
}
|
||||
// Finishing a security cut keeps the reason and evidence its tag recorded; a tag
|
||||
// that recorded one but never moved stable needs --security verified again.
|
||||
const recorded = tagExists === 'same' ? securityFromTag(git, tag) : null;
|
||||
let security: Security | null = recorded && prevStable === sha ? recorded : null;
|
||||
if (recorded && !security && o.security === undefined) return { ok: false, code: 'REFUSED_SECURITY', message: 'tag ' + tag + ' records a security cut that has not moved stable; run it again with --security and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
|
||||
if (!security && o.security !== undefined) {
|
||||
const reason = sanitizeReason(o.security);
|
||||
const verified = verifySecurity({ reason, git, gh, repo, from: before, to: sha });
|
||||
if (!verified.ok) return { ok: false, code: 'REFUSED_SECURITY', message: verified.why };
|
||||
security = { reason, evidence: verified.evidence };
|
||||
}
|
||||
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 });
|
||||
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, security });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, security: Boolean(security), scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
|
||||
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, security, 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 };
|
||||
return { ok: true, plan };
|
||||
}
|
||||
|
||||
export interface CutResult { ok: boolean; lines: string[] }
|
||||
@@ -302,7 +454,8 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
|
||||
if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
|
||||
if (!existing.ok) {
|
||||
const t = git(['tag', '-a', p.tag, p.sha, '-m', 'gstack ' + p.tag + (p.hotfix ? ' (hotfix release)' : ' (weekly release)')]);
|
||||
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.security) + ' release)' + (p.security ? '\n\nSecurity: ' + p.security.reason + '\nVerified: ' + p.security.evidence : '');
|
||||
const t = git(['tag', '-a', p.tag, p.sha, '-m', message]);
|
||||
if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
|
||||
const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
|
||||
if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] };
|
||||
@@ -311,8 +464,10 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
|
||||
lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
|
||||
}
|
||||
if (!p.stableAt) {
|
||||
const push = git(['push', 'origin', p.sha + ':refs/heads/stable']);
|
||||
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed: ' + push.out] };
|
||||
if (p.prevStable && !git(['merge-base', '--is-ancestor', p.prevStable, p.sha]).ok) return { ok: false, lines: [...lines, 'STABLE: refused: ' + p.sha.slice(0, 12) + ' does not descend from stable ' + p.prevStable.slice(0, 12)] };
|
||||
// The lease pins origin's stable to the commit the floor and the ancestry check read; a move in between fails the push.
|
||||
const push = git(['push', '--force-with-lease=refs/heads/stable:' + (p.prevStable ?? ''), 'origin', p.sha + ':refs/heads/stable']);
|
||||
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed (stable moved since this plan read it, or the push was rejected): ' + push.out] };
|
||||
lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12));
|
||||
} else {
|
||||
lines.push('STABLE: already at ' + p.sha.slice(0, 12));
|
||||
@@ -337,12 +492,14 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
|
||||
'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
|
||||
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
|
||||
p.floor.line,
|
||||
];
|
||||
if (p.security) lines.push('SECURITY: `' + p.security.reason + '` (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
|
||||
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
|
||||
if (dryRun) {
|
||||
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
|
||||
' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag,
|
||||
' git push origin ' + p.sha + ':refs/heads/stable',
|
||||
' git push --force-with-lease=refs/heads/stable:' + (p.prevStable ?? '') + ' origin ' + p.sha + ':refs/heads/stable',
|
||||
' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
|
||||
}
|
||||
return lines;
|
||||
@@ -357,18 +514,29 @@ if (import.meta.main) {
|
||||
const repo = arg('--repo');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const summary = arg('--summary');
|
||||
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]'); process.exit(2); }
|
||||
const security = arg('--security');
|
||||
if (!repo || (process.argv.includes('--security') && (!security || security.startsWith('--')))) {
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n>] [--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 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') });
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security });
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why);
|
||||
// the real cut goes red so someone looks.
|
||||
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') ? 0 : 1);
|
||||
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),
|
||||
// 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'));
|
||||
}
|
||||
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
|
||||
}
|
||||
emit(describePlan(planned.plan, dryRun));
|
||||
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* Size ledger for a carved skill's union bytes (GSTA-132; format in
|
||||
* test/helpers/size-ledger.ts).
|
||||
*
|
||||
* size-ledger.ts record <skill> --why <text> [--branch <name>]
|
||||
* set this branch's entry to what this tree's union bytes differ from the
|
||||
* base plus every other entry; a zero difference deletes the entry
|
||||
* size-ledger.ts check <skill>
|
||||
* print measured, expected and each entry; exit 1 on a mismatch
|
||||
* size-ledger.ts fold <skill> --measured-at <text>
|
||||
* move the entries' sum into base.json and delete them (a tidy-up PR,
|
||||
* never a stamp commit); refuses on a mismatch
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import { CARVE_GUARDS } from '../test/helpers/carve-guards';
|
||||
import { readSkillForParity } from '../test/helpers/parity-harness';
|
||||
import { foldLedger, ledgerExpected, ledgerFailure, readSizeLedger, recordEntry } from '../test/helpers/size-ledger';
|
||||
|
||||
const USAGE = 'usage: size-ledger.ts record <skill> --why <text> [--branch <name>] | check <skill> | fold <skill> --measured-at <text>';
|
||||
|
||||
function fail(msg: string, code = 2): never {
|
||||
console.error(`size-ledger: ${msg}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
if (i < 0) return undefined;
|
||||
const v = args[i + 1];
|
||||
if (!v || v.startsWith('--')) fail(`${name} needs a value`);
|
||||
return v;
|
||||
}
|
||||
|
||||
const [cmd, skill, ...rest] = process.argv.slice(2);
|
||||
if (!cmd || !skill || !['record', 'check', 'fold'].includes(cmd)) fail(USAGE);
|
||||
const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 10_000 }).stdout?.trim() || process.cwd();
|
||||
const dir = CARVE_GUARDS[skill]?.sizeLedger;
|
||||
if (!dir) fail(`${skill} has no sizeLedger in test/helpers/carve-guards.ts`);
|
||||
const measured = readSkillForParity(root, skill, true).unionBytes;
|
||||
const ledger = readSizeLedger(root, dir);
|
||||
if ('error' in ledger) fail(ledger.error, 1);
|
||||
|
||||
if (cmd === 'check') {
|
||||
console.log(`base ${ledger.base.union_bytes} (${ledger.base.measured_at})`);
|
||||
for (const e of ledger.entries) console.log(`${e.bytes > 0 ? '+' : ''}${e.bytes} ${e.file}: ${e.why}`);
|
||||
console.log(`expected ${ledgerExpected(ledger)} measured ${measured}`);
|
||||
const failure = ledgerFailure(skill, measured, ledger);
|
||||
if (failure) fail(failure, 1);
|
||||
console.log('SIZE_LEDGER: ok');
|
||||
} else if (cmd === 'record') {
|
||||
const why = flag(rest, '--why');
|
||||
if (!why?.trim()) fail('record needs --why "<what changed>"');
|
||||
const branch = flag(rest, '--branch') ?? spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf-8', timeout: 10_000 }).stdout?.trim();
|
||||
if (!branch || branch === 'HEAD') fail('detached HEAD: pass --branch <name>');
|
||||
if (branch === 'main' || branch === 'master') fail(`refusing to record on ${branch}; record on the branch that made the change`);
|
||||
let r: { file: string; bytes: number };
|
||||
try { r = recordEntry(root, ledger, measured, branch, why); } catch (err) { fail((err as Error).message); }
|
||||
console.log(r.bytes === 0
|
||||
? `SIZE_LEDGER: ${dir}/${r.file} not needed (this tree matches the ledger without it); expected ${measured}`
|
||||
: `SIZE_LEDGER: ${dir}/${r.file} ${r.bytes > 0 ? '+' : ''}${r.bytes}; expected ${measured}`);
|
||||
} else {
|
||||
const at = flag(rest, '--measured-at');
|
||||
if (!at?.trim()) fail('fold needs --measured-at "<tree it was measured on>"');
|
||||
const failure = ledgerFailure(skill, measured, ledger);
|
||||
if (failure) fail(`fold refused: ${failure}`, 1);
|
||||
foldLedger(root, ledger, at);
|
||||
console.log(`SIZE_LEDGER: folded ${ledger.entries.length} entries into ${dir}/base.json = ${measured}`);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* Static skill-to-bin drift scanner (GSTA-133). Skills teach gstack's own bins;
|
||||
* when a template names a bin that is gone, or passes a flag the bin's parser
|
||||
* does not accept, the model runs a wrong command that still passes CI.
|
||||
*
|
||||
* Inputs: SKILL.md.tmpl (root and one level down), <skill>/sections/*.md.tmpl,
|
||||
* <skill>/references/*.md, and the Claude-host renders (SKILL.md, sections/*.md, which carry the
|
||||
* AUTO-GENERATED banner) so resolver-generated commands count. A render finding
|
||||
* that its own skill's templates already report is dropped. --skill NAME limits
|
||||
* the scan to one skill folder (the /write-skill diet step). Only fenced code
|
||||
* blocks and inline backtick spans are read, so prose placeholders never match.
|
||||
*
|
||||
* Bin spellings recognised (every form the templates and renders use today):
|
||||
* bin/gstack-x (repo-relative)
|
||||
* ~/.claude/skills/gstack/bin/gstack-x (Claude install; also .claude/skills/...)
|
||||
* $HOME/.claude/skills/gstack/bin/gstack-x, "$HOME"/.claude/skills/gstack/bin/gstack-x
|
||||
* $GSTACK_BIN/gstack-x, ${GSTACK_BIN}/gstack-x (env-var hosts)
|
||||
* {{BIN_DIR}}/gstack-x, {{INSTALLED_BIN_DIR}}/gstack-x (template placeholders)
|
||||
* $GSTACK_ROOT/bin/gstack-x, $RUNTIME_ROOT/bin/gstack-x, {{RUNTIME_ROOT}}/bin/gstack-x
|
||||
* ${B%/browse/*}/bin/gstack-x (any ${...}/bin/ expansion)
|
||||
*
|
||||
* A call's arguments end at an unquoted pipe, ;, &, &&, ||, redirect, $( ),
|
||||
* backtick, closing parenthesis, comment or line end (backslash continuations
|
||||
* join). <VALUE> and <two words> placeholders are words, not redirects. Flags after -- are
|
||||
* positional.
|
||||
*
|
||||
* Flag discovery never executes a bin. Bash: case arm labels (--a), --a|--b),
|
||||
* --a=*)) whose body does not only refuse, and test comparisons
|
||||
* ([ "$1" = "--a" ]). TS/JS/Python: --flag tokens in code and string
|
||||
* literals (usage text included), in the bin and in its relative imports one
|
||||
* level deep. A bash bin that hands off to a .ts/.js/.mjs/.py file, or passes
|
||||
* "$@" to a .sh file or sibling gstack-* bin, gains that file's flags. A bin
|
||||
* .gitignore lists as a build output is read from bin/<name>.ts; with no
|
||||
* source its flags are unchecked (UNCHECKED list).
|
||||
*
|
||||
* Reported, never failed: PERMISSIVE (a bash arg loop whose catch-all *) arm
|
||||
* neither errors nor has a -*) error arm beside it, or a bash bin that never
|
||||
* reads its arguments) and NO_HELP (no --help handling and no usage text
|
||||
* outside comments). A sourced .sh library that never reads arguments gets
|
||||
* neither label. Failures: DRIFT_UNKNOWN_BIN, DRIFT_UNKNOWN_FLAG.
|
||||
* test/skill-bin-drift.test.ts holds today's misses in a shrink-only allowlist
|
||||
* (test/fixtures/skill-bin-drift-allowlist.json).
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export const FIX_LINE = "fix the template or the bin's parser; the allowlist only shrinks";
|
||||
export const ALLOWLIST_FILE = 'test/fixtures/skill-bin-drift-allowlist.json';
|
||||
|
||||
export type DriftCode = 'DRIFT_UNKNOWN_BIN' | 'DRIFT_UNKNOWN_FLAG';
|
||||
|
||||
export interface BinCall {
|
||||
file: string;
|
||||
line: number;
|
||||
bin: string;
|
||||
flags: string[];
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
code: DriftCode;
|
||||
file: string;
|
||||
line: number;
|
||||
bin: string;
|
||||
flag?: string;
|
||||
key: string;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
export interface BinProfile {
|
||||
bin: string;
|
||||
exists: boolean;
|
||||
flagsKnown: boolean;
|
||||
flags: Set<string>;
|
||||
permissive: boolean;
|
||||
hasHelp: boolean;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface DriftReport {
|
||||
inputs: string[];
|
||||
calls: BinCall[];
|
||||
findings: Finding[];
|
||||
binsInvoked: string[];
|
||||
permissive: string[];
|
||||
noHelp: string[];
|
||||
unchecked: string[];
|
||||
}
|
||||
|
||||
type Lang = 'sh' | 'ts' | 'py';
|
||||
|
||||
const BIN_NAME = 'gstack-[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?';
|
||||
const BIN_PREFIX = String.raw`(?:~|\$HOME|\$\{HOME\}|"\$HOME")?/?\.claude/skills/gstack/|\$\{[^}\s]*\}/|\$\{?[A-Z_][A-Z0-9_]*\}?/|\{\{[A-Z_]+\}\}/`;
|
||||
const BIN_RE = new RegExp(
|
||||
String.raw`(?<![\w./$}-])(?:(?:${BIN_PREFIX})?bin/|\$\{?GSTACK_BIN\}?/|\{\{(?:INSTALLED_)?BIN_DIR\}\}/)(${BIN_NAME})(?![\w.-]*[\w])`,
|
||||
'g',
|
||||
);
|
||||
const FLAG_WORD = /^--[A-Za-z0-9][A-Za-z0-9_-]*/;
|
||||
|
||||
/** Shell-style words after a bin, up to the first command boundary. */
|
||||
export function binArgs(rest: string, openQuote: string | null = null): string[] {
|
||||
const words: string[] = [];
|
||||
let i = 0;
|
||||
if (openQuote) {
|
||||
const close = rest.indexOf(openQuote);
|
||||
if (close < 0) return [];
|
||||
i = close + 1;
|
||||
}
|
||||
let cur = '';
|
||||
let has = false;
|
||||
let quote: string | null = null;
|
||||
const push = () => { if (has) words.push(cur); cur = ''; has = false; };
|
||||
for (; i < rest.length; i++) {
|
||||
const c = rest[i]!;
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
else if (c === '\\' && quote === '"') cur += rest[++i] ?? '';
|
||||
else cur += c;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') { quote = c; has = true; continue; }
|
||||
if (c === '\\') { cur += rest[++i] ?? ''; has = true; continue; }
|
||||
if (/\s/.test(c)) { push(); continue; }
|
||||
if (c === '>') {
|
||||
if (/^\d+$/.test(cur)) { cur = ''; has = false; }
|
||||
push();
|
||||
if ('>&|'.includes(rest[i + 1] ?? '')) i++;
|
||||
while (/[ \t]/.test(rest[i + 1] ?? '')) i++;
|
||||
while (i + 1 < rest.length && !/\s/.test(rest[i + 1]!) && !'|;&`)'.includes(rest[i + 1]!)) i++;
|
||||
continue;
|
||||
}
|
||||
if ('|;&`)'.includes(c) || (c === '$' && rest[i + 1] === '(')) break;
|
||||
if (c === '#' && !has) break;
|
||||
if (c === '<') {
|
||||
const ph = /^<[A-Za-z_][^<>\n]*>/.exec(rest.slice(i));
|
||||
if (!ph) break;
|
||||
cur += ph[0]; has = true; i += ph[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
cur += c; has = true;
|
||||
}
|
||||
push();
|
||||
return words;
|
||||
}
|
||||
|
||||
/** The quote character open at `end` in a shell line, if any. */
|
||||
function openQuoteAt(text: string, end: number): string | null {
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const c = text[i]!;
|
||||
if (quote) { if (c === quote) quote = null; else if (c === '\\' && quote === '"') i++; continue; }
|
||||
if (c === '\\') { i++; continue; }
|
||||
if (c === "'" || c === '"') quote = c;
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
function flagsOf(words: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const w of words) {
|
||||
if (w === '--') break;
|
||||
const m = FLAG_WORD.exec(w);
|
||||
if (m) out.push(m[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function callsInSnippet(snippet: string, file: string, line: number, out: BinCall[]): void {
|
||||
for (const m of snippet.matchAll(BIN_RE)) {
|
||||
const end = m.index! + m[0].length;
|
||||
const words = binArgs(snippet.slice(end), openQuoteAt(snippet, m.index!));
|
||||
out.push({ file, line, bin: m[1]!, flags: flagsOf(words) });
|
||||
}
|
||||
}
|
||||
|
||||
const FENCE = /^\s*(`{3,}|~{3,})/;
|
||||
|
||||
/** Bin calls inside fenced code blocks and inline backtick spans of one markdown file. */
|
||||
export function scanText(text: string, file: string): BinCall[] {
|
||||
const out: BinCall[] = [];
|
||||
const lines = text.split('\n');
|
||||
let fence: string | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i]!;
|
||||
const marker = FENCE.exec(raw);
|
||||
if (marker) {
|
||||
if (!fence) fence = marker[1]!;
|
||||
else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (fence) {
|
||||
let logical = raw;
|
||||
const start = i;
|
||||
while (/\\$/.test(logical) && i + 1 < lines.length && !FENCE.test(lines[i + 1]!)) {
|
||||
logical = logical.slice(0, -1) + ' ' + lines[++i]!;
|
||||
}
|
||||
callsInSnippet(logical, file, start + 1, out);
|
||||
continue;
|
||||
}
|
||||
for (const span of raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)) callsInSnippet(span[2]!, file, i + 1, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readText(file: string): string {
|
||||
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
||||
}
|
||||
|
||||
function isFile(file: string): boolean {
|
||||
return fs.existsSync(file) && fs.statSync(file).isFile();
|
||||
}
|
||||
|
||||
function langOf(file: string, text: string): Lang {
|
||||
if (/\.py$/.test(file)) return 'py';
|
||||
if (/\.(?:[cm]?[jt]s|tsx)$/.test(file)) return 'ts';
|
||||
const first = text.split('\n', 1)[0] ?? '';
|
||||
if (/^#!.*\bpython/.test(first)) return 'py';
|
||||
if (/^#!.*\b(?:bun|node|deno)\b/.test(first)) return 'ts';
|
||||
return 'sh';
|
||||
}
|
||||
|
||||
const COMMENT: Record<Lang, RegExp> = { sh: /^\s*#/, py: /^\s*#/, ts: /^\s*(?:\/\/|\*|\/\*)/ };
|
||||
|
||||
function codeLines(text: string, lang: Lang): string[] {
|
||||
return text.split('\n').filter(l => !COMMENT[lang].test(l));
|
||||
}
|
||||
|
||||
/** --flag tokens in TS/JS/Python code and string literals, usage text included. */
|
||||
export function literalFlags(text: string, lang: Lang = 'ts'): Set<string> {
|
||||
const flags = new Set<string>();
|
||||
for (const line of codeLines(text, lang)) {
|
||||
for (const m of line.matchAll(/(?<![\w-])(--[A-Za-z][A-Za-z0-9_-]*)/g)) flags.add(m[1]!);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const ARM = /^[ \t]*\(?[ \t]*((?:["']?-[-\w=*]*["']?[ \t]*\|[ \t]*)*["']?-[-\w=*]*["']?)[ \t]*\)([\s\S]*?)(?:;;|\besac\b)/gm;
|
||||
const refuses = (body: string) => !/\bshift\b|\b[A-Za-z_][A-Za-z0-9_]*=/.test(body)
|
||||
&& /\b(?:refuse|die|fail)\b|\bexit\s+[1-9]|\breturn\s+[1-9]/.test(body);
|
||||
|
||||
/** Flags a bash bin's case arms (minus arms that only refuse) and test comparisons accept. */
|
||||
export function bashFlags(text: string): Set<string> {
|
||||
const flags = new Set<string>();
|
||||
const code = codeLines(text, 'sh').join('\n');
|
||||
for (const arm of code.matchAll(ARM)) {
|
||||
if (refuses(arm[2]!)) continue;
|
||||
for (const label of arm[1]!.split('|')) {
|
||||
const m = FLAG_WORD.exec(label.trim().replace(/^["']|["']$/g, ''));
|
||||
if (m) flags.add(m[0]);
|
||||
}
|
||||
}
|
||||
for (const m of code.matchAll(/(?:==?|!=)\s*["']?(--[A-Za-z0-9][A-Za-z0-9_-]*)/g)) flags.add(m[1]!);
|
||||
return flags;
|
||||
}
|
||||
|
||||
/** Top-level bash code plus the bodies of functions it (transitively) hands "$@" to; other functions' $1 is not the bin's. */
|
||||
function argScope(code: string): string {
|
||||
const top: string[] = [];
|
||||
const funcs = new Map<string, string[]>();
|
||||
let cur: string | null = null;
|
||||
for (const line of code.split('\n')) {
|
||||
const start = /^\s*(?:function\s+)?([\w-]+)\s*\(\)\s*\{?\s*$/.exec(line);
|
||||
if (cur === null && start) { cur = start[1]!; funcs.set(cur, []); continue; }
|
||||
if (cur !== null) {
|
||||
if (/^\}/.test(line)) cur = null;
|
||||
else funcs.get(cur)!.push(line);
|
||||
continue;
|
||||
}
|
||||
top.push(line);
|
||||
}
|
||||
const scope = [...top];
|
||||
const included = new Set<string>();
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
const text = scope.join('\n');
|
||||
for (const [name, body] of funcs) {
|
||||
if (included.has(name) || !new RegExp(`(?:^|[\\s;&|(])${name}\\s+"\\$@"`, 'm').test(text)) continue;
|
||||
included.add(name);
|
||||
scope.push(...body);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return scope.join('\n');
|
||||
}
|
||||
|
||||
const readsArgs = (code: string) => /\$\{?[1-9@*#]|getopts|\bshift\b/.test(code);
|
||||
|
||||
/** A sourced .sh function library: it never reads arguments at top level, so permissive and help checks do not apply. */
|
||||
export function isShellLibrary(bin: string, text: string): boolean {
|
||||
return bin.endsWith('.sh') && !readsArgs(argScope(codeLines(text, 'sh').join('\n')));
|
||||
}
|
||||
|
||||
/** In a case over the arguments, a catch-all *) arm with no error and no -*) error arm; or arguments never read. */
|
||||
export function bashPermissive(text: string): boolean {
|
||||
const code = argScope(codeLines(text, 'sh').join('\n'));
|
||||
if (!readsArgs(code)) return true;
|
||||
const argVars = new Set(['1']);
|
||||
for (const m of code.matchAll(/\bfor\s+(\w+)\s+in\s+"\$@"/g)) argVars.add(m[1]!);
|
||||
for (const m of code.matchAll(/\b(\w+)="?\$\{?1\b/g)) argVars.add(m[1]!);
|
||||
const blocks = [...code.matchAll(/\bcase\s+"?\$\{?(\w+)[^\n]*?\bin\b([\s\S]*?)\besac\b/g)].filter(m => argVars.has(m[1]!));
|
||||
const arms = blocks.flatMap(block => [...block[2]!.matchAll(/^[ \t]*\(?[ \t]*(["']?-?-?\*["']?)[ \t]*\)([\s\S]*?)(?:;;|$)/gm)]);
|
||||
const errors = (body: string) => /\bexit\b|\breturn\b|\bdie\b|usage|>&2|\berr\b|fail|refuse/i.test(body);
|
||||
const dashError = arms.some(a => /-/.test(a[1]!) && errors(a[2]!));
|
||||
const silentCatchAll = arms.some(a => !/-/.test(a[1]!) && !errors(a[2]!));
|
||||
return silentCatchAll && !dashError;
|
||||
}
|
||||
|
||||
function hasUsage(text: string, lang: Lang): boolean {
|
||||
return /usage/i.test(codeLines(text, lang).filter(l => !/^#!/.test(l)).join('\n'));
|
||||
}
|
||||
|
||||
function resolveModule(fromFile: string, spec: string): string | null {
|
||||
const base = path.resolve(path.dirname(fromFile), spec);
|
||||
for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, path.join(base, 'index.ts')]) {
|
||||
if (isFile(cand)) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Relative imports (static, dynamic and require) of one source file. */
|
||||
export function relativeImports(file: string, text: string): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const m of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm)) {
|
||||
const hit = resolveModule(file, m[1]!);
|
||||
if (hit) out.add(hit);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** Files a bash bin hands its arguments to: .ts/.js/.mjs/.py paths, and .sh files or sibling bins given "$@". */
|
||||
export function bashHandoffs(binFile: string, text: string, root: string): string[] {
|
||||
const out = new Set<string>();
|
||||
const binDir = path.dirname(binFile);
|
||||
for (const line of codeLines(text, 'sh')) {
|
||||
const forwards = /"\$@"/.test(line);
|
||||
for (const m of line.matchAll(/[^\s'"=;|&<>]+\.(?:[cm]?[jt]s|py|sh)(?![\w.])/g)) {
|
||||
if (m[0].endsWith('.sh') && !forwards) continue;
|
||||
const tail = m[0].replace(/^.*(?:\}|\)|\$[A-Za-z_][A-Za-z0-9_]*)/, '').replace(/^\//, '');
|
||||
if (!tail) continue;
|
||||
const hit = [binDir, root].map(dir => path.resolve(dir, tail)).find(c => c !== binFile && isFile(c));
|
||||
if (hit) out.add(hit);
|
||||
}
|
||||
if (!forwards) continue;
|
||||
for (const m of line.matchAll(new RegExp(`(?:^|[/\\s"])(${BIN_NAME})(?![\\w.-])`, 'g'))) {
|
||||
const cand = path.join(binDir, m[1]!);
|
||||
if (cand !== binFile && isFile(cand)) out.add(cand);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** Bin names .gitignore lists as build outputs (bin/<pattern> lines). */
|
||||
function builtPatterns(root: string): RegExp[] {
|
||||
return readText(path.join(root, '.gitignore')).split('\n')
|
||||
.map(l => /^\/?bin\/(gstack-[^\s/]+)$/.exec(l.trim())?.[1])
|
||||
.filter((p): p is string => Boolean(p))
|
||||
.map(p => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`));
|
||||
}
|
||||
|
||||
/** Static profile of one bin: accepted flags, permissive parsing, help text. */
|
||||
export function profileBin(root: string, bin: string, built: RegExp[] = builtPatterns(root)): BinProfile {
|
||||
const binFile = path.join(root, 'bin', bin);
|
||||
const profile: BinProfile = { bin, exists: false, flagsKnown: true, flags: new Set(), permissive: false, hasHelp: false, sources: [] };
|
||||
const seen = new Set<string>();
|
||||
const absorb = (file: string, text: string, lang: Lang, flags: Set<string>) => {
|
||||
seen.add(file);
|
||||
profile.sources.push(path.relative(root, file));
|
||||
for (const f of flags) profile.flags.add(f);
|
||||
if (hasUsage(text, lang)) profile.hasHelp = true;
|
||||
};
|
||||
const addScript = (file: string, followImports: boolean) => {
|
||||
if (seen.has(file)) return;
|
||||
const text = readText(file);
|
||||
const lang = langOf(file, text);
|
||||
absorb(file, text, lang, literalFlags(text, lang));
|
||||
if (followImports && lang === 'ts') for (const dep of relativeImports(file, text)) addScript(dep, false);
|
||||
};
|
||||
if (built.some(re => re.test(bin)) && !/\.(?:[cm]?[jt]s|py)$/.test(bin)) {
|
||||
profile.exists = true;
|
||||
if (isFile(`${binFile}.ts`)) addScript(`${binFile}.ts`, true);
|
||||
else profile.flagsKnown = false;
|
||||
} else if (isFile(binFile)) {
|
||||
profile.exists = true;
|
||||
const text = readText(binFile);
|
||||
if (langOf(binFile, text) !== 'sh') {
|
||||
addScript(binFile, true);
|
||||
} else {
|
||||
absorb(binFile, text, 'sh', bashFlags(text));
|
||||
if (isShellLibrary(bin, text)) profile.hasHelp = true;
|
||||
else profile.permissive = bashPermissive(text);
|
||||
for (const handoff of bashHandoffs(binFile, text, root)) {
|
||||
if (seen.has(handoff)) continue;
|
||||
const handoffText = readText(handoff);
|
||||
if (langOf(handoff, handoffText) !== 'sh') addScript(handoff, true);
|
||||
else absorb(handoff, handoffText, 'sh', bashFlags(handoffText));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (profile.flags.has('--help')) profile.hasHelp = true;
|
||||
return profile;
|
||||
}
|
||||
|
||||
function subdirs(root: string): string[] {
|
||||
return fs.readdirSync(root, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules' && d.name !== 'dist')
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Template sources, sections/references and Claude-host renders, repo-relative; one skill's when `skill` is set. */
|
||||
export function listInputs(root: string, skill?: string): string[] {
|
||||
const out: string[] = [];
|
||||
const add = (rel: string) => { if (isFile(path.join(root, rel))) out.push(rel); };
|
||||
if (!skill) { add('SKILL.md.tmpl'); add('SKILL.md'); }
|
||||
for (const dir of skill ? [skill] : subdirs(root)) {
|
||||
add(`${dir}/SKILL.md.tmpl`);
|
||||
add(`${dir}/SKILL.md`);
|
||||
for (const sub of ['sections', 'references']) {
|
||||
const full = path.join(root, dir, sub);
|
||||
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) continue;
|
||||
for (const name of fs.readdirSync(full).sort()) {
|
||||
if (name.endsWith('.md.tmpl') || name.endsWith('.md')) add(`${dir}/${sub}/${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isTemplate = (file: string) => file.endsWith('.tmpl');
|
||||
const skillDirOf = (file: string) => (file.includes('/') ? file.split('/')[0]! : '.');
|
||||
|
||||
export function findingKey(f: { file: string; bin: string; flag?: string }): string {
|
||||
return f.flag ? `${f.file}:${f.bin}:${f.flag}` : `${f.file}:${f.bin}`;
|
||||
}
|
||||
|
||||
function findingsFor(calls: BinCall[], profiles: Map<string, BinProfile>): Finding[] {
|
||||
const out: Finding[] = [];
|
||||
for (const call of calls) {
|
||||
const profile = profiles.get(call.bin)!;
|
||||
const make = (code: DriftCode, flag?: string): Finding => {
|
||||
const base = { code, file: call.file, line: call.line, bin: call.bin, flag };
|
||||
return { ...base, key: findingKey(base), fix: FIX_LINE };
|
||||
};
|
||||
if (!profile.exists) { out.push(make('DRIFT_UNKNOWN_BIN')); continue; }
|
||||
if (!profile.flagsKnown) continue;
|
||||
for (const flag of call.flags) if (!profile.flags.has(flag)) out.push(make('DRIFT_UNKNOWN_FLAG', flag));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop render findings whose skill's own templates already report the same bin and flag. */
|
||||
function dropRenderEchoes(findings: Finding[]): Finding[] {
|
||||
const id = (f: Finding) => `${skillDirOf(f.file)}|${f.code}|${f.bin}|${f.flag ?? ''}`;
|
||||
const fromTemplates = new Set(findings.filter(f => isTemplate(f.file)).map(id));
|
||||
return findings.filter(f => isTemplate(f.file) || !fromTemplates.has(id(f)));
|
||||
}
|
||||
|
||||
/** Scan a repository root (or a fixture laid out like one). */
|
||||
export function scanRepo(root: string, inputs: string[] = listInputs(root)): DriftReport {
|
||||
const calls = inputs.flatMap(file => scanText(readText(path.join(root, file)), file));
|
||||
const built = builtPatterns(root);
|
||||
const profiles = new Map<string, BinProfile>();
|
||||
for (const call of calls) if (!profiles.has(call.bin)) profiles.set(call.bin, profileBin(root, call.bin, built));
|
||||
const seen = new Set<string>();
|
||||
const findings = dropRenderEchoes(findingsFor(calls, profiles)).filter(f => {
|
||||
const id = `${f.key}|${f.line}`;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.key.localeCompare(b.key));
|
||||
const existing = [...profiles.values()].filter(p => p.exists).sort((a, b) => a.bin.localeCompare(b.bin));
|
||||
return {
|
||||
inputs,
|
||||
calls,
|
||||
findings,
|
||||
binsInvoked: [...profiles.keys()].sort(),
|
||||
permissive: existing.filter(p => p.permissive).map(p => p.bin),
|
||||
noHelp: existing.filter(p => p.flagsKnown && !p.hasHelp).map(p => p.bin),
|
||||
unchecked: existing.filter(p => !p.flagsKnown).map(p => p.bin),
|
||||
};
|
||||
}
|
||||
|
||||
export interface Allowlist { _comment?: string; entries: string[] }
|
||||
|
||||
export function loadAllowlist(file: string): Allowlist {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Allowlist;
|
||||
if (!Array.isArray(parsed.entries)) throw new Error(`${file}: "entries" must be an array of finding keys`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Findings the allowlist does not cover, and allowlist keys (for the scanned inputs, when given) that no longer reproduce. */
|
||||
export function compareAllowlist(findings: Finding[], allowlist: Allowlist, inputs?: string[]): { unlisted: Finding[]; stale: string[] } {
|
||||
const allowed = new Set(allowlist.entries);
|
||||
const live = new Set(findings.map(f => f.key));
|
||||
const scanned = (key: string) => !inputs || inputs.some(file => key.startsWith(`${file}:`));
|
||||
return {
|
||||
unlisted: findings.filter(f => !allowed.has(f.key)),
|
||||
stale: allowlist.entries.filter(k => scanned(k) && !live.has(k)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFinding(f: Finding): string {
|
||||
const what = f.code === 'DRIFT_UNKNOWN_BIN' ? `bin/${f.bin} does not exist` : `${f.bin} does not accept ${f.flag}`;
|
||||
return `${f.code} ${f.file}:${f.line} bin=${f.bin}${f.flag ? ` flag=${f.flag}` : ''}: ${what}\n fix: ${f.fix}`;
|
||||
}
|
||||
|
||||
const HELP = `Usage: scripts/skill-bin-drift.ts [--skill NAME] [--json] [--all] [--root DIR]
|
||||
|
||||
Statically checks every gstack bin that skill templates, carved sections and
|
||||
Claude-host renders invoke: the bin must exist in bin/ and every --flag passed
|
||||
to it must appear in its parser (bash case arms or comparisons, TS/JS/Python
|
||||
flag literals one relative import deep, bash hand-offs to .ts/.js/.py/.sh).
|
||||
Never executes a bin.
|
||||
|
||||
--skill NAME scan only NAME/: its template, sections, references and
|
||||
Claude render (stale allowlist entries are judged for those
|
||||
files only)
|
||||
--json print the report as JSON
|
||||
--all list every finding, allowlisted ones included
|
||||
--root DIR scan DIR instead of this checkout
|
||||
--help show this help
|
||||
|
||||
Exit codes: 0 no finding outside ${ALLOWLIST_FILE} and no stale entry;
|
||||
1 drift outside the allowlist or a stale allowlist entry; 2 bad usage.
|
||||
PERMISSIVE, NO_HELP and UNCHECKED bins are reported, never failed.`;
|
||||
|
||||
interface CliOptions { json: boolean; all: boolean; root: string; skill?: string }
|
||||
|
||||
function parseCli(argv: string[]): CliOptions | string {
|
||||
const opts: CliOptions = { json: false, all: false, root: path.resolve(import.meta.dir, '..') };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--json') opts.json = true;
|
||||
else if (a === '--all') opts.all = true;
|
||||
else if (a === '--root' || a === '--skill') {
|
||||
const v = argv[++i];
|
||||
if (!v || v.startsWith('--')) return `${a} needs a value`;
|
||||
if (a === '--root') opts.root = path.resolve(v);
|
||||
else opts.skill = v.replace(/\/+$/, '');
|
||||
} else return `unknown argument ${a}`;
|
||||
}
|
||||
if (opts.skill && listInputs(opts.root, opts.skill).length === 0) {
|
||||
return `no skill named ${opts.skill} under ${opts.root} (expected ${opts.skill}/SKILL.md.tmpl or ${opts.skill}/SKILL.md)`;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); return 0; }
|
||||
const opts = parseCli(argv);
|
||||
if (typeof opts === 'string') { console.error(`skill-bin-drift: ${opts}\n\n${HELP}`); return 2; }
|
||||
const report = scanRepo(opts.root, listInputs(opts.root, opts.skill));
|
||||
const allowFile = path.join(opts.root, ALLOWLIST_FILE);
|
||||
const allowlist = isFile(allowFile) ? loadAllowlist(allowFile) : { entries: [] };
|
||||
const { unlisted, stale } = compareAllowlist(report.findings, allowlist, opts.skill ? report.inputs : undefined);
|
||||
const unknownBins = report.findings.filter(f => f.code === 'DRIFT_UNKNOWN_BIN').length;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
inputs: report.inputs.length, calls: report.calls.length, binsInvoked: report.binsInvoked.length,
|
||||
unknownBins, unknownFlags: report.findings.length - unknownBins,
|
||||
findings: report.findings, unlisted: unlisted.map(f => f.key), stale,
|
||||
permissive: report.permissive, noHelp: report.noHelp, unchecked: report.unchecked,
|
||||
}, null, 2));
|
||||
} else {
|
||||
for (const f of opts.all ? report.findings : unlisted) console.log(formatFinding(f));
|
||||
for (const key of stale) console.log(`STALE_ALLOWLIST ${key}: no longer reproduces; delete it from ${ALLOWLIST_FILE}`);
|
||||
console.log(`PERMISSIVE (${report.permissive.length}): ${report.permissive.join(' ') || 'none'}`);
|
||||
console.log(`NO_HELP (${report.noHelp.length}): ${report.noHelp.join(' ') || 'none'}`);
|
||||
console.log(`UNCHECKED (${report.unchecked.length}): ${report.unchecked.join(' ') || 'none'}`);
|
||||
console.log(`skill-bin-drift: ${report.inputs.length} inputs, ${report.calls.length} calls, ${report.binsInvoked.length} bins, `
|
||||
+ `${unknownBins} unknown-bin and ${report.findings.length - unknownBins} unknown-flag findings `
|
||||
+ `(${unlisted.length} outside the allowlist, ${stale.length} stale entries)`);
|
||||
}
|
||||
return unlisted.length > 0 || stale.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|
||||
Reference in new issue
Block a user