Merge remote-tracking branch 'origin/main' into gsta-96-preamble-hoist

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

# Conflicts:
#	CHANGELOG.md
#	autoplan/SKILL.md
#	canary/SKILL.md
#	codex/SKILL.md
#	context-restore/SKILL.md
#	context-save/SKILL.md
#	design-consultation/SKILL.md
#	design-html/SKILL.md
#	design-review/SKILL.md
#	design-shotgun/SKILL.md
#	devex-review/SKILL.md
#	document-generate/SKILL.md
#	document-release/SKILL.md
#	eval-plan/SKILL.md
#	health/SKILL.md
#	investigate/SKILL.md
#	ios-clean/SKILL.md
#	ios-design-review/SKILL.md
#	ios-fix/SKILL.md
#	ios-qa/SKILL.md
#	ios-sync/SKILL.md
#	land-and-deploy/SKILL.md
#	landing-report/SKILL.md
#	learn/SKILL.md
#	office-hours/SKILL.md
#	pair-agent/SKILL.md
#	plan-ceo-review/SKILL.md
#	plan-devex-review/SKILL.md
#	plan-tune/SKILL.md
#	qa-only/SKILL.md
#	qa/SKILL.md
#	retro/SKILL.md
#	review/SKILL.md
#	setup-deploy/SKILL.md
#	setup-gbrain/SKILL.md
#	ship/SKILL.md
#	skillify/SKILL.md
#	spec/SKILL.md
#	sync-gbrain/SKILL.md
#	test-audit/SKILL.md
#	test/fixtures/golden/claude-ship-SKILL.md
#	test/helpers/carve-guards.ts
This commit is contained in:
garrytan committed 2026-10-26 12:22:41 +00:00
commit 1a4dcaf079
282 files changed
+14364 -1522

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -eu
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
CSO_BUILD_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
cd "$CSO_BUILD_ROOT"
@@ -227,7 +228,7 @@ cso_build_evaluation_candidate() {
cp -R lib scripts "$source_root/"
cp -R test/helpers "$source_root/test/"
cp "$candidate_catalog" "$source_root/lib/cso/runtime-catalog.json"
(cd "$source_root" && "$BUN_CMD" --no-env-file --config=/dev/null scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json)
(cd "$source_root" && "$BUN_CMD" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json)
(cd "$source_root" && cso_compile_bun lib/cso/cli.ts "$CSO_EVALUATION_UNIT/gstack-cso-core")
(cd "$source_root" && cso_compile_bun scripts/cso-eval-producer.ts "$CSO_EVALUATION_UNIT/cso-eval-producer")
cso_compile_watchdog "$CSO_EVALUATION_UNIT/gstack-cso-watchdog"
+1 -1
View File
@@ -41,7 +41,7 @@ const prVersionEnv = (process.env.PR_VERSION ?? "").trim();
if (process.env.BASE_REF && process.env.HEAD_SHA) {
const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA });
if (train.kind === "unstamped") {
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION, package.json and the release outputs match its merge base, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`);
process.exit(0);
}
if (train.kind === "invalid") {
+2 -2
View File
@@ -15,9 +15,9 @@
"scripts/lib/paid-plan.ts": {"blob":"e8ce3cb98474dded38c5128a631d283b0ca35b63","since":6,"nonBehavioral":"F1 rule-16 flaky rerun: report aggregation, rerun planning and the gate slice exit code; no case's execution, trial outcome, trial record or panel verdict changes, so no pass-rate series resets"},
"scripts/lib/paid-report.ts": {"blob":"19a87cb02bb3cd0f57da7719a5c30b7a4df81b2b","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
"scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":6},
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"scripts/test-pr-profile.ts": {"blob":"d334195b0d0393622e2c380dc3e2feebe099b6e6","since":6,"nonBehavioral":"classify the new evals-main-backstop.yml workflow as free-lane only: it dispatches evals.yml on main and runs inside no case, so no case's selection on a PR, input or verdict changes"},
"scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"},
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
+8
View File
@@ -13,6 +13,7 @@ import {
judgePanelMedian, judgePanelMajority,
} from '../test/helpers/llm-judge';
import { buildWorkflowJudgePrompt, WORKFLOW_JUDGE_RESPONSE_SCHEMA } from '../test/helpers/workflow-judge-input';
import { COOKIE_WORKFLOW_JUDGE } from '../test/helpers/cookie-workflow-judge-input';
import { CLAUDE_JUDGE_EVAL_MODEL } from '../lib/eval-model';
import type { CalibrationConfig, Verdict } from './judge-calibration';
@@ -106,4 +107,11 @@ export const CALIBRATION_CONFIGS: CalibrationConfig[] = [
'land-and-deploy/SKILL.md workflow', 'canary/SKILL.md monitoring loop', 'benchmark/SKILL.md perf collection',
'setup-deploy/SKILL.md platform setup', 'sync-gbrain/SKILL.md read-only readiness', 'retro/SKILL.md instructions',
'qa-only/SKILL.md workflow', 'gstack-upgrade/SKILL.md upgrade flow']),
{
id: 'cookie-workflow', priority: 8, cases: ['setup-browser-cookies/SKILL.md workflow'], model: COOKIE_WORKFLOW_JUDGE.model,
build: inputs => buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, { files: [], text: inputs.bundle! }),
oldOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS },
newOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA },
...scoreJudge(COOKIE_WORKFLOW_JUDGE.thresholds), estimatedOutputTokens: 1200,
},
];
+9
View File
@@ -582,9 +582,18 @@ export function createShardSandbox(
...baseEnv, TMPDIR: tmp, TEMP: tmp, TMP: tmp,
CHROMIUM_PROFILE: path.join(stateDir, 'chromium-profile'),
};
for (const name of CAPY_LAUNCH_MARKERS) delete env[name];
return { stateDir, tmp, env };
}
/**
* The Capy installer's `GSTACK_LAUNCH` markers. A lane started from a shell
* that sourced the launch file would hand them to every product-under-test
* child a shard spawns, so fixtures would see an unattended Capy session they
* never set up (GSTA-99); each fixture sets its own session kind.
*/
export const CAPY_LAUNCH_MARKERS = ['GSTACK_SESSION_KIND', 'GSTACK_CAPY', 'GSTACK_EPHEMERAL', 'GSTACK_INSTALL_VERDICT'] as const;
/**
* Asynchronous best-effort backstop removal: a SIGKILLed shard can leave a
* full git workspace plus a Chromium profile, and a synchronous recursive
+1
View File
@@ -314,6 +314,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{ file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' },
{ file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' },
{ file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' },
{ file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' },
{ file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' },
{ file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' },
{ file: 'test/generator-eexist.test.ts',
+2 -2
View File
@@ -548,8 +548,8 @@ export const QUESTIONS = {
skill: 'preamble',
category: 'approval',
door_type: 'two-way',
options: ['on', 'off'],
description: "Let gstack proactively suggest skills based on conversation context?",
options: ['core', 'all', 'off'],
description: "Let gstack proactively suggest skills based on conversation context? core (the four core skills, default) / all / off",
},
'preamble-routing-injection': {
id: 'preamble-routing-injection',
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* 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.
*
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
*
* The checkout needs origin's full history (fetch-depth 0). Green, for a
* commit on main's first-parent history, means: its free-tests check run
* succeeded (the status main requires), its main-backstop check run succeeded
* (evals-main-backstop.yml publishes it: success only when a green train
* record covers the tree or the backstop gated it green; a push main moved
* past before it was gated is neutral, never green), every check run on it
* has completed and none failed, and refs/notes/gstack-trains holds no active
* freeze. The newest such commit in the window (7 days, 200 first-parent
* commits) is the candidate; a commit with a check run still going is
* skipped, not waited for.
*
* stable moves only forward: a candidate behind the current stable is refused
* (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
* 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.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
export type Git = (args: string[]) => { ok: boolean; out: string };
export const REQUIRED_CHECK = 'free-tests';
export const BACKSTOP_CHECK = 'main-backstop';
export const NOTES_REF = 'refs/notes/gstack-trains';
const NOTES_LOCAL = 'refs/notes/gstack-trains-release';
const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale']);
export const WINDOW_DAYS = 7;
export const WINDOW_COMMITS = 200;
export const NOTES_CAP = 60_000;
export const runGh: Gh = (args) => {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.error ? String(r.error) : r.stderr ?? '' };
};
export const gitIn = (cwd: string): Git => (args) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return r.status === 0 ? { ok: true, out: (r.stdout ?? '').replace(/\n$/, '') } : { ok: false, out: (r.stderr || String(r.error ?? '')).trim() };
};
function parseJson<T>(r: { status: number | null; stdout: string }): T | null {
if (r.status !== 0) return null;
try { return JSON.parse(r.stdout) as T; } catch { return null; }
}
/** Four-part versions compare numerically, part by part. */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d;
}
return 0;
}
export type Verdict = { green: true } | { green: false; why: string; pending: boolean };
interface CheckRun { name: string; status: string; conclusion: string | null }
/** Why a commit is not releasable, or green. Pending means a check run on it is still going. */
export function gateVerdict(gh: Gh, repo: string, sha: string): Verdict {
const paged = parseJson<unknown>(gh(['api', '--paginate', '--slurp', `repos/${repo}/commits/${sha}/check-runs?per_page=100`]));
if (!paged) return { green: false, why: 'cannot read its check runs', pending: false };
const pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>;
const runs = pages.flatMap(p => p.check_runs ?? []);
for (const name of [REQUIRED_CHECK, BACKSTOP_CHECK]) {
const named = runs.filter(c => c.name === name);
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true };
if (!named.some(c => c.conclusion === 'success')) return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
}
const red = runs.filter(c => c.status === 'completed' && RED.has(c.conclusion ?? ''));
if (red.length > 0) return { green: false, why: `red check runs: ${[...new Set(red.map(c => c.name))].join(', ')}`, pending: false };
const running = runs.filter(c => c.status !== 'completed');
if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true };
return { green: true };
}
/** An active freeze record (kind freeze, cleared_at null) in the trains notes ref, after fetching it. */
export function activeFreeze(git: Git): string | null {
const f = git(['fetch', '--no-tags', '-q', 'origin', `+${NOTES_REF}:${NOTES_LOCAL}`]);
if (!f.ok) return /couldn't find remote ref|no such ref/i.test(f.out) ? null : `cannot fetch ${NOTES_REF}: ${f.out.slice(0, 160)}`;
const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
if (!listed.ok || !listed.out.trim()) return null;
for (const line of listed.out.split('\n')) {
const blob = line.split(' ')[0];
if (!blob) continue;
try {
const r = JSON.parse(git(['cat-file', 'blob', blob]).out) as { kind?: string; cleared_at?: string | null; reason?: string; train?: string };
if (r.kind === 'freeze' && !r.cleared_at) return `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
} catch { /* not a record */ }
}
return null;
}
export interface ChangelogEntry { version: string; date: string; headline: string }
/** Every "## [X.Y.Z.W] - date" entry of a CHANGELOG with its first prose line, markdown stripped. */
export function changelogEntries(text: string): ChangelogEntry[] {
const out: ChangelogEntry[] = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const m = /^## \[(\d+(?:\.\d+)+)\](?: - (\S+))?/.exec(lines[i]!);
if (!m) continue;
let headline = '';
for (let j = i + 1; j < lines.length && !lines[j]!.startsWith('## '); j++) {
const l = lines[j]!.trim();
if (!l || l.startsWith('#') || l.startsWith('|') || l.startsWith('-') || l.startsWith('```')) continue;
const bold = /^\*\*(.+?)\*\*/.exec(l);
headline = (bold ? bold[1]! : l.replace(/\*\*/g, '')).replace(/\s+/g, ' ');
break;
}
out.push({ version: m[1]!, date: m[2] ?? '', headline });
}
return out;
}
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean }
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
export function releaseNotes(n: NotesInput): string {
const span = n.entries.filter(e => compareVersions(e.version, n.version) <= 0 && (!n.prevVersion || compareVersions(e.version, n.prevVersion) > 0))
.sort((a, b) => compareVersions(b.version, a.version));
const plural = span.length === 1 ? '' : 's';
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)'),
'',
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 + ').',
'',
'Team-mode installs follow the stable branch, which now points here; gstack-config set update_channel main follows every merge instead ([docs/releases.md](https://github.com/' + n.repo + '/blob/v' + n.version + '/docs/releases.md)).',
'',
].join('\n');
const bullets = span.map(e => '- **' + e.version + '**' + (e.date ? ' (' + e.date + ')' : '') + ': ' + (e.headline || 'see CHANGELOG.md'));
let body = head + bullets.join('\n') + '\n';
if (body.length > NOTES_CAP) {
const kept: string[] = [];
let size = head.length;
for (const b of bullets) { if (size + b.length + 1 > NOTES_CAP - 200) break; kept.push(b); size += b.length + 1; }
body = head + kept.join('\n') + '\n- ...and ' + (bullets.length - kept.length) + ' more versions; see CHANGELOG.md\n';
}
return body;
}
export interface Plan {
sha: string; version: string; tag: string; date: string; hotfix: boolean;
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
}
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
export interface PlanOptions { repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number }
export function planRelease(o: PlanOptions): PlanResult {
const { git, gh, repo } = o;
const windowDays = o.windowDays ?? WINDOW_DAYS;
const skipped: string[] = [];
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}']);
const prevStable = stable.ok ? stable.out : null;
const frozen = activeFreeze(git);
if (frozen) return { ok: false, code: 'REFUSED', message: frozen };
let candidates: string[];
if (o.ref) {
const r = git(['rev-parse', '--verify', o.ref + '^{commit}']);
if (!r.ok) return { ok: false, code: 'REFUSED', message: '--ref ' + o.ref + ' is not a commit here: ' + r.out };
const fp = git(['rev-list', '--first-parent', 'refs/remotes/origin/main']);
if (!fp.out.split('\n').includes(r.out)) return { ok: false, code: 'REFUSED', message: '--ref ' + r.out.slice(0, 12) + " is not on main's first-parent history; a release is always a merge commit of main" };
candidates = [r.out];
} else {
const since = new Date((o.now?.() ?? Date.now()) - windowDays * 86_400_000).toISOString();
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' };
}
// stable itself stays a candidate so a cut that stopped half-way (tag and
// stable pushed, release missing) is finished; nothing older is looked at.
let sha: string | null = null;
for (const c of candidates) {
const v = gateVerdict(gh, repo, c);
if (v.green) { sha = c; break; }
skipped.push(c.slice(0, 12) + ': ' + v.why);
if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; }
}
if (!sha) {
if (o.ref) return { ok: false, code: 'REFUSED', message: '--ref ' + candidates[0]!.slice(0, 12) + ' is not green: ' + (skipped[0] ?? '').split(': ').slice(1).join(': '), skipped };
return { ok: false, code: 'NO_GREEN_COMMIT', message: 'no green commit among ' + candidates.length + ' on origin/main; see the skipped list', skipped };
}
if (prevStable && sha !== prevStable && !git(['merge-base', '--is-ancestor', prevStable, sha]).ok) {
return { ok: false, code: 'REFUSED', message: 'non-fast-forward: stable ' + prevStable.slice(0, 12) + ' is not an ancestor of ' + sha.slice(0, 12) + '; stable only moves forward along main' };
}
const version = git(['show', sha + ':VERSION']).out.trim();
if (!/^\d+(\.\d+)+$/.test(version)) return { ok: false, code: 'REFUSED', message: 'VERSION at ' + sha.slice(0, 12) + " is not a version: '" + version + "'" };
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';
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' };
}
// The release this one follows: stable, or when stable already sits here
// (a cut that stopped half-way), the newest release tag behind it.
let before = prevStable;
if (prevStable === sha) {
const prior = git(['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', '--first-parent', sha + '^']);
const resolved = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']) : null;
before = resolved?.ok ? resolved.out : null;
}
let prevVersion: string | null = null, prevTag: string | null = null;
if (before) {
const shown = git(['show', before + ':VERSION']);
prevVersion = shown.ok ? shown.out.trim() || null : null;
if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
} else {
const date = git(['show', '-s', '--format=%cI', sha]).out;
const before = new Date(new Date(date).getTime() - windowDays * 86_400_000).toISOString();
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;
}
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), prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
}
export interface CutResult { ok: boolean; lines: string[] }
/** Tag, push stable and create the release for a plan; each step is skipped when already done. */
export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDir?: string }): CutResult {
const { git, gh } = o;
const lines: string[] = [];
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)')]);
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] };
lines.push('TAG: ' + p.tag + ' -> ' + p.sha.slice(0, 12) + ' pushed');
} else {
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] };
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));
}
const view = gh(['release', 'view', p.tag, '--repo', o.repo, '--json', 'tagName']);
if (view.status === 0) {
lines.push('RELEASE: ' + p.tag + ' already published');
} else {
const dir = o.notesDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-'));
const file = path.join(dir, p.tag + '.md');
fs.writeFileSync(file, p.notes);
const created = gh(['release', 'create', p.tag, '--repo', o.repo, '--verify-tag', '--title', 'gstack ' + p.tag, '--notes-file', file]);
if (created.status !== 0) return { ok: false, lines: [...lines, 'RELEASE: create failed: ' + created.stderr.trim().slice(0, 300)] };
lines.push(('RELEASE: ' + p.tag + ' published ' + created.stdout.trim()).trim());
}
return { ok: true, lines };
}
export function describePlan(p: Plan, dryRun: boolean): string[] {
const lines = [
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
'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',
];
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',
' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
}
return lines;
}
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
}
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>]'); 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 });
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(describePlan(planned.plan, dryRun));
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
else console.log('--- notes ---\n' + planned.plan.notes);
if (dryRun) process.exit(0);
const cut = cutRelease(planned.plan, { repo, git, gh: runGh });
emit(cut.lines);
process.exit(cut.ok ? 0 : 1);
}
@@ -8,7 +8,7 @@ import type { TemplateContext } from '../types';
* the fence prose scopes and the model follows.
*/
export function generateUpgradeCheck(ctx: TemplateContext): string {
return `If \`PROACTIVE\` is \`false\`, do not auto-invoke or suggest skills, including by asking whether to run one. Only run skills the user explicitly invokes.
return `If \`PROACTIVE\` is \`off\`: suggest no skills unasked, nor ask. \`core\`: only /office-hours, /review, /qa, /ship. \`all\`: any. Skills the user names or this one invokes run.
If \`SKILL_PREFIX\` is \`"true"\`, suggest/invoke \`/gstack-*\` names. Disk paths stay \`${ctx.paths.skillRoot}/[skill-name]/SKILL.md\`.`;
If \`SKILL_PREFIX\` is \`true\` use \`/gstack-*\` names; paths stay \`${ctx.paths.skillRoot}/[skill-name]/SKILL.md\`.`;
}
+2
View File
@@ -275,6 +275,8 @@ export const FREE_ONLY_PR_FILES = [
'.github/workflows/test-health.yml',
'.github/workflows/eval-sweep.yml', // The weekly off-ship sweep on main; no PR-gate case reads it, and it never gates a PR.
'.github/workflows/evals-main-backstop.yml', // The merge-train backstop on main: it dispatches evals.yml after a push to main; no PR-gate case reads it, and it never gates a PR.
'.github/workflows/weekly-release.yml', // The Monday release cut (tag, stable, GitHub Release): reads check runs on main; no paid case reads it, and it never gates a PR.
'scripts/release-cut.ts', // The release cut's logic, run only by weekly-release.yml; covered by the free test/release-cut.test.ts.
'.github/actionlint.yaml', // Runner labels for actionlint only.
'scripts/ship-measure-sweep.ts', // The weekly sweep CLI; it launches paid cases, never runs inside one.
'.github/workflows/version-gate.yml',
+5 -2
View File
@@ -192,8 +192,9 @@ function freeze(o: BackstopOptions, c: { gh: GhRunner; git: Git; repoKey: string
}
const USAGE = `usage: bun run scripts/train-backstop.ts run --repo <owner/name> --branch <main> --sha <sha>
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>]
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.`;
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>] [--outputs <file>]
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.
--outputs appends action=<action> (the workflow reads it as a step output and publishes the main-backstop check run from it).`;
if (import.meta.main) {
const [command, ...rest] = process.argv.slice(2);
@@ -208,5 +209,7 @@ if (import.meta.main) {
console.log(line);
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
}
const outputs = flag('outputs');
if (outputs) fs.appendFileSync(outputs, `action=${action}\n`);
process.exit(action === 'frozen' || action === 'failed' ? 1 : 0);
}