mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* feat(releases): weekly tagged releases and a stable update channel for team mode (GSTA-98)
Every Monday morning .github/workflows/weekly-release.yml tags the newest green
commit on main as v<VERSION>, publishes a GitHub Release from the CHANGELOG
headlines and fast-forwards refs/heads/stable to it (scripts/release-cut.ts;
workflow_dispatch with dry_run, and ref for a hotfix). Team-mode installs
follow stable through the new update_channel key (auto: stable when
team_mode, else main) in bin/gstack-session-update, bin/gstack-update-check,
bin/gstack-upgrade and /gstack-upgrade. Per-merge stamping and the merge
train are unchanged. docs/releases.md explains it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(weekly-release): check out the dispatched ref so a dry run exercises that branch's script
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(weekly-release): a PR touching the release tooling runs the dry run against main (read-only); the cut job checks out the dispatched ref
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(gstack-upgrade): no $1 in the rendered skill (Claude Code rewrites $N), trim Step 4 under the size budget, pin the new floor-check literal
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: leave setup untouched (its channel note moves to README and docs/releases.md); the release workflow and script are free-only PR files
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: record the pr-profile free-only classification as non-behavioral (HARNESS_VERSION 6)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(releases): gate the cut on a main-backstop check run, finish half-done cuts, keep team installs on their channel
/ship review fixes (GSTA-98, PR #3429):
- The weekly release read the Main backstop gate run's conclusion, but
scripts/train-backstop.ts exits 0 when main moved past the push before it
was gated (superseded), so a never-gated tree read as green. The backstop
workflow now publishes its verdict on the commit as the `main-backstop`
check run from a code-free checks:write job (success for covered|gated,
neutral for inert|superseded|deferred, failure for frozen|failed), fed by
the new `--outputs` flag; release-cut.ts requires that check run and
free-tests to succeed and holds a commit whose check runs are still going.
- A cut that pushed the tag and stable but failed to create the release can
be finished: stable itself stays a candidate until its release exists, the
notes compare against the release tag behind it, and a dry run with nothing
green exits 0 with the skips named (the real cut still fails).
- weekly-release.yml: the cut job runs only from refs/heads/main, `ref` must
be a full 40-hex SHA, and actions:read is dropped (no workflow-runs call).
- /gstack-upgrade: the destructive fallback requires the carried UPSTREAM_REF
instead of defaulting to origin/main in a fresh shell; a team install past
the release stops with AHEAD_OF_STABLE instead of FF_REFUSED and a reset
backwards. gstack-update-channel.sh --fetch-ref keeps origin/stable on any
fetch failure other than a missing release; dead gstack_update_channel_ref
removed. gstack-update-check follows main before the first release instead
of caching CHECK_FAILED; gstack-upgrade treats a failed ls-remote as a
fetch failure, not an empty answer.
- Tests: release-cut (check-run fixture, partial cut, neutral/pending),
weekly-release-workflow (new), evals-train-workflow and train-backstop
(backstop-check job, --outputs), update-channel (no-release fallback,
offline --fetch-ref), template pins.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(releases): the upgrade skill's ahead-of-stable exit works in depth-1 clones; update-check keeps its cache identity on the pre-release fallback
/ship second-pass review of 73e278d4 (GSTA-98, PR #3429):
- gstack-upgrade/SKILL.md.tmpl: README installs are depth-1 clones, where
origin/stable never connects to HEAD, so the AHEAD_OF_STABLE shortcut now
also fires when HEAD is not behind stable and stable's VERSION is not
higher than the installed one (the order bin/gstack-upgrade uses). The
fence prints the channel helper's warnings instead of discarding them, and
a 'fetching origin/stable failed' line is a STOP. Prose trimmed to stay
under the skill size budget. New behavioral test runs the Step 4 fence on
a depth-1 install past the release (AHEAD_OF_STABLE, nothing moved) and on
one behind it (goes on to the fast-forward).
- bin/gstack-update-check: the no-stable fallback keeps #stable in the
cache identity (so the outcome replays within its TTL instead of two
ls-remote calls per skill start), triggers only when ls-remote answered
with no such branch (a failed ls-remote stays CHECK_FAILED on stable), and
the branch-pinned URL derives from REMOTE_RAW_BASE so tests stay offline.
- scripts/release-cut.ts: a cancelled check run is red; the previous-release
lookups check .ok before using git output. docs/releases.md says which
pushes get no main-backstop check run at all.
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>
396 lines
26 KiB
TypeScript
396 lines
26 KiB
TypeScript
/**
|
|
* release-cut (free): the weekly release picks the newest green commit on
|
|
* main's first-parent history, tags it v<VERSION>, fast-forwards stable and
|
|
* publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a
|
|
* named main commit; stable never moves backwards; a frozen queue, a pending
|
|
* check run or a red one keeps a commit out; a cut that stopped half-way is
|
|
* finished by the next run (docs/releases.md).
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { bunIsolationArgs } from '../lib/bun-isolation';
|
|
import {
|
|
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
|
|
planRelease, releaseNotes, type Gh,
|
|
} from '../scripts/release-cut';
|
|
|
|
const bases: string[] = [];
|
|
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
|
|
|
function git(cwd: string, ...args: string[]): string {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } }).trim();
|
|
}
|
|
|
|
type CheckRun = { name: string; status: string; conclusion: string | null };
|
|
interface State { checks: Record<string, CheckRun[]>; released: Set<string>; createFails?: string; calls: string[][] }
|
|
|
|
const GREEN: CheckRun[] = [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }, { name: 'typecheck', status: 'completed', conclusion: 'success' }];
|
|
const backstop = (status: string, conclusion: string | null): CheckRun[] => [...GREEN.filter(c => c.name !== BACKSTOP_CHECK), { name: BACKSTOP_CHECK, status, conclusion }];
|
|
|
|
function fakeGh(state: State): Gh {
|
|
return (args) => {
|
|
state.calls.push(args);
|
|
const api = args[0] === 'api' ? args[args.length - 1]! : '';
|
|
const m = /commits\/([0-9a-f]+)\/check-runs/.exec(api);
|
|
if (m) return { status: 0, stdout: JSON.stringify([{ check_runs: state.checks[m[1]!] ?? GREEN }]), stderr: '' };
|
|
if (args[0] === 'release' && args[1] === 'view') return state.released.has(args[2]!) ? { status: 0, stdout: '{}', stderr: '' } : { status: 1, stdout: '', stderr: 'release not found' };
|
|
if (args[0] === 'release' && args[1] === 'create') {
|
|
if (state.createFails === args[2]) return { status: 1, stdout: '', stderr: 'HTTP 502 Bad Gateway' };
|
|
state.released.add(args[2]!); return { status: 0, stdout: 'https://github.com/o/r/releases/tag/' + args[2], stderr: '' };
|
|
}
|
|
return { status: 1, stdout: '', stderr: 'unexpected gh ' + args.join(' ') };
|
|
};
|
|
}
|
|
|
|
function fixture() {
|
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-cut-'));
|
|
bases.push(base);
|
|
const origin = path.join(base, 'origin.git');
|
|
const seed = path.join(base, 'seed');
|
|
const work = path.join(base, 'work');
|
|
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 });
|
|
fs.mkdirSync(seed);
|
|
git(seed, 'init', '-q', '-b', 'main');
|
|
git(seed, 'remote', 'add', 'origin', origin);
|
|
const shas: Record<string, string> = {};
|
|
let changelog = '# Changelog\n';
|
|
const release = (version: string, headline: string) => {
|
|
changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n');
|
|
fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n');
|
|
fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog);
|
|
git(seed, 'add', '-A');
|
|
git(seed, 'commit', '-q', '-m', 'v' + version);
|
|
git(seed, 'push', '-q', 'origin', 'main');
|
|
shas[version] = git(seed, 'rev-parse', 'HEAD');
|
|
return shas[version]!;
|
|
};
|
|
release('1.0.0', 'First one.');
|
|
release('1.0.1', 'Second: the updater no longer skips.');
|
|
release('1.0.2', 'Third fixes Windows.');
|
|
release('1.0.3', 'Fourth trims the preamble.');
|
|
execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 });
|
|
const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); };
|
|
const state: State = { checks: {}, released: new Set(), calls: [] };
|
|
return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' };
|
|
}
|
|
|
|
describe('release-cut: planning', () => {
|
|
test('the newest green first-parent commit wins; a pending backstop or a red check keeps a commit out and is named', () => {
|
|
const f = fixture();
|
|
f.state.checks[f.shas['1.0.3']!] = backstop('in_progress', null);
|
|
f.state.checks[f.shas['1.0.2']!] = [...GREEN, { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }];
|
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(r.ok, JSON.stringify(r)).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.plan.sha).toBe(f.shas['1.0.1']);
|
|
expect(r.plan.version).toBe('1.0.1');
|
|
expect(r.plan.tag).toBe('v1.0.1');
|
|
expect(r.plan.prevStable).toBeNull();
|
|
expect(r.plan.skipped).toEqual([
|
|
f.shas['1.0.3']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' is still running',
|
|
f.shas['1.0.2']!.slice(0, 12) + ': red check runs: windows-free-tests',
|
|
]);
|
|
expect(r.plan.notes).toContain('First tagged release: the 2 versions, one line each');
|
|
expect(r.plan.notes).toContain('- **1.0.1** (2026-10-21): Second: the updater no longer skips.');
|
|
expect(r.plan.notes).toContain('- **1.0.0**');
|
|
expect(r.plan.notes).not.toContain('1.0.2');
|
|
const lines = describePlan(r.plan, true);
|
|
expect(lines.some(l => l.startsWith('DRY_RUN:'))).toBe(true);
|
|
expect(lines).toContain(' git push origin ' + f.shas['1.0.1'] + ':refs/heads/stable');
|
|
});
|
|
|
|
test('a missing free-tests run, a running free-tests run, no backstop check run and a superseded (neutral) backstop are not green', () => {
|
|
const f = fixture();
|
|
const head = f.shas['1.0.3']!;
|
|
f.state.checks[head] = [{ name: 'typecheck', status: 'completed', conclusion: 'success' }];
|
|
f.state.checks[f.shas['1.0.2']!] = [{ name: REQUIRED_CHECK, status: 'in_progress', conclusion: null }];
|
|
f.state.checks[f.shas['1.0.1']!] = GREEN.filter(c => c.name !== BACKSTOP_CHECK);
|
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.plan.sha).toBe(f.shas['1.0.0']);
|
|
expect(r.plan.skipped.map(s => s.split(': ')[1])).toEqual(['no free-tests check run', 'free-tests is still running', 'no ' + BACKSTOP_CHECK + ' check run']);
|
|
// A push main moved past before the backstop gated it (train-backstop.ts
|
|
// 'superseded', exit 0) publishes a neutral check run: never green.
|
|
f.state.checks[f.shas['1.0.1']!] = backstop('completed', 'neutral');
|
|
const neutral = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(neutral.ok).toBe(true);
|
|
if (neutral.ok) { expect(neutral.plan.sha).toBe(f.shas['1.0.0']); expect(neutral.plan.skipped[2]).toBe(f.shas['1.0.1']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral'); }
|
|
});
|
|
|
|
test('no green commit in the window is NO_GREEN_COMMIT with every skip named; an empty window is NOTHING_NEW', () => {
|
|
const f = fixture();
|
|
for (const sha of Object.values(f.shas)) f.state.checks[sha] = backstop('completed', 'failure');
|
|
const r = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(r.ok).toBe(false);
|
|
if (r.ok) return;
|
|
expect(r.code).toBe('NO_GREEN_COMMIT');
|
|
expect(r.skipped).toHaveLength(4);
|
|
expect(r.skipped![0]).toContain(BACKSTOP_CHECK + ' concluded failure');
|
|
const stale = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => Date.now() + 30 * 86_400_000 });
|
|
expect(stale.ok).toBe(false);
|
|
if (!stale.ok) expect(stale.code).toBe('NOTHING_NEW');
|
|
});
|
|
|
|
// Value: protects=gateVerdict names a failed free-tests run and an unreadable check-runs API as not green, holds a commit with any check run still going as
|
|
// pending, and merges paginated check pages; fails_when=a required-check failure is treated as red-list noise, a gh error reads as green or pending, a queued
|
|
// unrelated check run is read as green, or only the first page of check runs is read; why_new=the planning tests cover missing, running and red runs only,
|
|
// never a failed required run, a gh error, an unrelated running check or a second page; seam=none; no_claim=GitHub's real check-runs payload shape (a fake gh answers; unit only)
|
|
test('gateVerdict: a failed free-tests run, an unreadable check-runs API, an unrelated running check and a paginated check list', () => {
|
|
const checks = (runs: unknown[]): Gh => (args) => {
|
|
const api = args[args.length - 1]!;
|
|
if (/check-runs/.test(api)) return { status: 0, stdout: JSON.stringify(runs), stderr: '' };
|
|
return { status: 1, stdout: '', stderr: 'unexpected gh ' + args.join(' ') };
|
|
};
|
|
const failed = checks([{ check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'failure' }] }]);
|
|
expect(gateVerdict(failed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: REQUIRED_CHECK + ' concluded failure', pending: false });
|
|
const unreadable: Gh = () => ({ status: 1, stdout: '', stderr: 'HTTP 403' });
|
|
expect(gateVerdict(unreadable, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'cannot read its check runs', pending: false });
|
|
const garbled: Gh = () => ({ status: 0, stdout: 'not json', stderr: '' });
|
|
expect(gateVerdict(garbled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'cannot read its check runs', pending: false });
|
|
const queued = checks([{ check_runs: [...GREEN, { name: 'slices-report', status: 'queued', conclusion: null }] }]);
|
|
expect(gateVerdict(queued, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'check runs still going: slices-report', pending: true });
|
|
const paged = checks([{ check_runs: [{ name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }] }]);
|
|
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
|
const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]);
|
|
expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false });
|
|
const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
|
expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false });
|
|
const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
|
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
|
});
|
|
|
|
test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => {
|
|
const f = fixture();
|
|
const freeze = { schema: 1, kind: 'freeze', repo_key: 'k', train: 'abc', reason: 'backstop red on 2 cases', created_at: 'x', cleared_at: null };
|
|
git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-m', JSON.stringify(freeze), 'HEAD');
|
|
git(f.seed, 'push', '-q', 'origin', NOTES_REF);
|
|
const frozen = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(frozen.ok).toBe(false);
|
|
if (!frozen.ok) { expect(frozen.code).toBe('REFUSED'); expect(frozen.message).toContain('queue frozen since train abc: backstop red on 2 cases'); }
|
|
git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', JSON.stringify({ ...freeze, cleared_at: 'y' }), 'HEAD');
|
|
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
|
expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
|
|
test('a cut tags, moves stable and publishes; re-running reports nothing new; a hotfix ref promotes a later main commit with a compare link', () => {
|
|
const f = fixture();
|
|
const first = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(first.ok).toBe(true);
|
|
if (!first.ok) return;
|
|
expect(first.plan.sha).toBe(f.shas['1.0.3']);
|
|
const cut = cutRelease(first.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
|
expect(cut.ok, cut.lines.join('\n')).toBe(true);
|
|
expect(cut.lines).toEqual(['TAG: v1.0.3 -> ' + f.shas['1.0.3']!.slice(0, 12) + ' pushed', 'STABLE: none -> ' + f.shas['1.0.3']!.slice(0, 12), 'RELEASE: v1.0.3 published https://github.com/o/r/releases/tag/v1.0.3']);
|
|
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.3']);
|
|
expect(git(f.origin, 'rev-parse', 'v1.0.3^{commit}')).toBe(f.shas['1.0.3']);
|
|
expect(git(f.origin, 'cat-file', '-t', 'v1.0.3')).toBe('tag');
|
|
const create = f.state.calls.find(c => c[0] === 'release' && c[1] === 'create')!;
|
|
expect(create).toContain('--verify-tag');
|
|
expect(fs.readFileSync(create[create.indexOf('--notes-file') + 1]!, 'utf8')).toContain('Fourth trims the preamble.');
|
|
|
|
f.refresh();
|
|
const again = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(again.ok).toBe(false);
|
|
if (!again.ok) expect(again.code).toBe('NOTHING_NEW');
|
|
|
|
const hot = f.release('1.0.4', 'Hotfix: the lease never blocks.');
|
|
f.refresh();
|
|
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh });
|
|
expect(promo.ok, JSON.stringify(promo)).toBe(true);
|
|
if (!promo.ok) return;
|
|
expect(promo.plan.hotfix).toBe(true);
|
|
expect(promo.plan.prevStable).toBe(f.shas['1.0.3']);
|
|
expect(promo.plan.prevTag).toBe('v1.0.3');
|
|
expect(promo.plan.notes).toContain('## gstack v1.0.4 (hotfix release)');
|
|
expect(promo.plan.notes).toContain('1 version since v1.0.3');
|
|
expect(promo.plan.notes).toContain('compare/v1.0.3...v1.0.4');
|
|
expect(promo.plan.notes).toContain('- **1.0.4**');
|
|
expect(promo.plan.notes).not.toContain('- **1.0.3**');
|
|
const cut2 = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
|
expect(cut2.ok, cut2.lines.join('\n')).toBe(true);
|
|
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(hot);
|
|
f.refresh();
|
|
const redo = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
|
expect(redo.lines).toEqual(['TAG: v1.0.4 already names ' + hot.slice(0, 12), 'STABLE: ' + f.shas['1.0.3']!.slice(0, 12) + ' -> ' + hot.slice(0, 12), 'RELEASE: v1.0.4 already published']);
|
|
});
|
|
|
|
// Value: protects=a cut that pushed the tag and stable but failed to create the release is finished by the next run (same commit, notes against the release
|
|
// before, release created) instead of being refused as 'already stable' forever; fails_when=planRelease drops stable itself from the candidates, the notes
|
|
// compare the commit with itself, or a finished cut is cut again; why_new=the cut test re-runs only a completed cut (NOTHING_NEW); seam=none; no_claim=none beyond protects
|
|
test('a cut that stopped after pushing the tag and stable is finished by the next run, by window or by --ref', () => {
|
|
const f = fixture();
|
|
f.state.createFails = 'v1.0.3';
|
|
const first = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(first.ok).toBe(true);
|
|
if (!first.ok) return;
|
|
const half = cutRelease(first.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
|
expect(half.ok).toBe(false);
|
|
expect(half.lines.at(-1)).toContain('RELEASE: create failed: HTTP 502');
|
|
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.3']);
|
|
delete f.state.createFails;
|
|
|
|
f.refresh();
|
|
let resumed = first.plan;
|
|
for (const ref of [undefined, f.shas['1.0.3']]) {
|
|
const again = planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh });
|
|
expect(again.ok, JSON.stringify(again)).toBe(true);
|
|
if (!again.ok) return;
|
|
resumed = again.plan;
|
|
expect(again.plan.sha).toBe(f.shas['1.0.3']);
|
|
expect(again.plan.stableAt).toBe(true);
|
|
expect(again.plan.tagExists).toBe('same');
|
|
expect(again.plan.prevVersion).toBeNull();
|
|
expect(again.plan.notes).toContain('First tagged release: the 4 versions, one line each');
|
|
expect(describePlan(again.plan, true)[2]).toBe('STABLE: already at ' + f.shas['1.0.3']!.slice(0, 12) + ' (finishing an earlier cut)');
|
|
}
|
|
const done = cutRelease(resumed, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
|
expect(done.ok, done.lines.join('\n')).toBe(true);
|
|
expect(done.lines).toEqual(['TAG: v1.0.3 already names ' + f.shas['1.0.3']!.slice(0, 12), 'STABLE: already at ' + f.shas['1.0.3']!.slice(0, 12), 'RELEASE: v1.0.3 published https://github.com/o/r/releases/tag/v1.0.3']);
|
|
|
|
// A later half-done cut reads its notes against the release before it.
|
|
f.state.createFails = 'v1.0.4';
|
|
const hot = f.release('1.0.4', 'Hotfix: the lease never blocks.');
|
|
f.refresh();
|
|
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh });
|
|
expect(promo.ok).toBe(true);
|
|
if (!promo.ok) return;
|
|
expect(cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
|
|
delete f.state.createFails;
|
|
f.refresh();
|
|
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(finish.ok, JSON.stringify(finish)).toBe(true);
|
|
if (!finish.ok) return;
|
|
expect(finish.plan.sha).toBe(hot);
|
|
expect(finish.plan.prevTag).toBe('v1.0.3');
|
|
expect(finish.plan.notes).toContain('1 version since v1.0.3');
|
|
expect(finish.plan.notes).toContain('compare/v1.0.3...v1.0.4');
|
|
expect(finish.plan.notes).not.toContain('- **1.0.3**');
|
|
const once = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(cutRelease(finish.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
|
|
expect(once.ok).toBe(true);
|
|
f.refresh();
|
|
const nothing = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(nothing.ok).toBe(false);
|
|
if (!nothing.ok) expect(nothing.message).toBe('origin/main ' + hot.slice(0, 12) + ' is already released as v1.0.4; nothing newer is green');
|
|
});
|
|
|
|
test('stable never moves backwards, a side-branch ref is refused, and a tag naming another commit is refused', () => {
|
|
const f = fixture();
|
|
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
|
|
f.refresh();
|
|
const back = planRelease({ repo: f.repo, ref: f.shas['1.0.1'], git: f.git, gh: f.gh });
|
|
expect(back.ok).toBe(false);
|
|
if (!back.ok) { expect(back.code).toBe('REFUSED'); expect(back.message).toContain('non-fast-forward'); }
|
|
|
|
// Value: protects=a hotfix --ref that is not green or not a commit is REFUSED (exit 1 in the workflow), never promoted; fails_when=--ref skips the gate or an unknown
|
|
// ref falls through to the window; why_new=the hotfix test promotes a green ref only and the side-branch case covers first-parent, not the gate; seam=none;
|
|
// no_claim=none beyond protects
|
|
f.state.checks[f.shas['1.0.3']!] = backstop('completed', 'failure');
|
|
const red = planRelease({ repo: f.repo, ref: f.shas['1.0.3'], git: f.git, gh: f.gh });
|
|
expect(red.ok).toBe(false);
|
|
if (!red.ok) { expect(red.code).toBe('REFUSED'); expect(red.message).toBe('--ref ' + f.shas['1.0.3']!.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded failure'); }
|
|
const unknown = planRelease({ repo: f.repo, ref: 'f'.repeat(40), git: f.git, gh: f.gh });
|
|
expect(unknown.ok).toBe(false);
|
|
if (!unknown.ok) { expect(unknown.code).toBe('REFUSED'); expect(unknown.message).toContain('is not a commit here'); }
|
|
delete f.state.checks[f.shas['1.0.3']!];
|
|
|
|
git(f.seed, 'checkout', '-q', '-b', 'side', f.shas['1.0.3']!);
|
|
fs.writeFileSync(path.join(f.seed, 'side.txt'), 'x');
|
|
git(f.seed, 'add', '-A'); git(f.seed, 'commit', '-q', '-m', 'side');
|
|
const side = git(f.seed, 'rev-parse', 'HEAD');
|
|
git(f.seed, 'push', '-q', 'origin', 'side');
|
|
f.refresh();
|
|
const off = planRelease({ repo: f.repo, ref: side, git: f.git, gh: f.gh });
|
|
expect(off.ok).toBe(false);
|
|
if (!off.ok) expect(off.message).toContain("not on main's first-parent history");
|
|
|
|
git(f.seed, 'tag', 'v1.0.3', f.shas['1.0.0']!);
|
|
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.3');
|
|
f.refresh();
|
|
const clash = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
|
expect(clash.ok).toBe(false);
|
|
if (!clash.ok) expect(clash.message).toContain('tag v1.0.3 already names');
|
|
});
|
|
});
|
|
|
|
describe.skipIf(process.platform === 'win32')('release-cut: the workflow entry point', () => {
|
|
// Value: protects=the CLI weekly-release.yml runs: --dry-run plans, prints CANDIDATE/DRY_RUN lines, appends the plan and notes to --summary and pushes nothing,
|
|
// exits 0 on NOTHING_NEW, 1 on REFUSED and 2 without --repo; fails_when=an argument is misread, an exit code flips so the workflow goes red on a quiet week or
|
|
// green on a refusal, or a dry run pushes; why_new=every other test calls the exported functions, never the argv/exit/summary boundary the workflow uses; seam=none;
|
|
// no_claim=GitHub's API or a real gh (a gh shim answers from PATH; the git side is a real bare origin)
|
|
test('--dry-run plans and pushes nothing, the summary carries the plan and notes, and the exit codes are the workflow contract', () => {
|
|
const f = fixture();
|
|
const shim = path.join(f.base, 'shim');
|
|
fs.mkdirSync(shim);
|
|
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\necho "gh $*" >> "' + path.join(f.base, 'gh.log') + '"\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN }]) + '\' ;;\n "release view v1.0.3 "*) echo \'{\"tagName\":\"v1.0.3\"}\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
|
|
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), ...args],
|
|
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, PATH: shim + path.delimiter + process.env.PATH } });
|
|
|
|
const summary = path.join(f.base, 'summary.md');
|
|
const dry = run('--repo', f.repo, '--dry-run', '--summary', summary);
|
|
expect(dry.status, dry.stdout + dry.stderr).toBe(0);
|
|
expect(dry.stdout).toContain('CANDIDATE: ' + f.shas['1.0.3'] + ' (');
|
|
expect(dry.stdout).toContain('VERSION: 1.0.3 -> tag v1.0.3');
|
|
expect(dry.stdout).toContain('DRY_RUN: nothing pushed, nothing created.');
|
|
expect(dry.stdout).not.toContain('--- notes ---');
|
|
const written = fs.readFileSync(summary, 'utf8');
|
|
expect(written).toContain('- CANDIDATE: ' + f.shas['1.0.3']);
|
|
expect(written).toContain('<details><summary>Release notes</summary>');
|
|
expect(written).toContain('- **1.0.3** (2026-10-23): Fourth trims the preamble.');
|
|
expect(execFileSync('git', ['ls-remote', f.origin, 'refs/heads/stable', 'refs/tags/v1.0.3'], { encoding: 'utf8', timeout: 30_000 }).trim()).toBe('');
|
|
expect(fs.readFileSync(path.join(f.base, 'gh.log'), 'utf8')).not.toContain('gh release');
|
|
|
|
const usage = run('--dry-run');
|
|
expect(usage.status).toBe(2);
|
|
expect(usage.stderr).toContain('usage: release-cut.ts --repo owner/name');
|
|
|
|
git(f.seed, 'tag', 'v1.0.3', f.shas['1.0.3']!);
|
|
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.3', f.shas['1.0.3'] + ':refs/heads/stable');
|
|
f.refresh();
|
|
const quiet = run('--repo', f.repo, '--dry-run');
|
|
expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0);
|
|
expect(quiet.stdout.split('\n')[0]).toBe('NOTHING_NEW: origin/main ' + f.shas['1.0.3']!.slice(0, 12) + ' is already released as v1.0.3; nothing newer is green');
|
|
|
|
const refused = run('--repo', f.repo, '--dry-run', '--ref', f.shas['1.0.1']!);
|
|
expect(refused.status).toBe(1);
|
|
expect(refused.stdout).toContain('REFUSED: non-fast-forward');
|
|
|
|
// Nothing green: the real cut goes red (exit 1), a dry run has answered (exit 0) and names every skip.
|
|
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN.filter(c => c.name !== BACKSTOP_CHECK) }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
|
|
git(f.seed, 'push', '-q', '--delete', 'origin', 'stable', 'refs/tags/v1.0.3');
|
|
f.refresh(); git(f.work, 'tag', '-d', 'v1.0.3');
|
|
const noneDry = run('--repo', f.repo, '--dry-run');
|
|
expect(noneDry.status, noneDry.stdout + noneDry.stderr).toBe(0);
|
|
expect(noneDry.stdout.split('\n')[0]).toContain('NO_GREEN_COMMIT: no green commit among 4 on origin/main');
|
|
expect(noneDry.stdout).toContain('SKIPPED: ' + f.shas['1.0.3']!.slice(0, 12) + ': no ' + BACKSTOP_CHECK + ' check run');
|
|
const noneCut = run('--repo', f.repo);
|
|
expect(noneCut.status).toBe(1);
|
|
expect(execFileSync('git', ['ls-remote', f.origin, 'refs/heads/stable', 'refs/tags/*'], { encoding: 'utf8', timeout: 30_000 }).trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('release-cut: notes', () => {
|
|
test('changelog headlines are the bold first line of each entry; versions order numerically', () => {
|
|
const entries = changelogEntries('# Changelog\n\n## [1.91.221.0] - 2026-10-25\n\n**The updater no longer skips.** More.\n\n### Fixed\n- x\n\n## [1.91.99.0] - 2026-10-01\n\n| a | b |\n\n**Older.**\n');
|
|
expect(entries).toEqual([{ version: '1.91.221.0', date: '2026-10-25', headline: 'The updater no longer skips.' }, { version: '1.91.99.0', date: '2026-10-01', headline: 'Older.' }]);
|
|
expect(changelogEntries('## [1.0.0] - 2026-01-01\n\nPlain lead with **some** bold.\n')[0]!.headline).toBe('Plain lead with some bold.');
|
|
expect(compareVersions('1.91.221.0', '1.91.99.0')).toBeGreaterThan(0);
|
|
expect(compareVersions('1.91.9.0', '1.91.10.0')).toBeLessThan(0);
|
|
});
|
|
|
|
test('a span of a thousand versions stays under the notes cap and says how many were cut', () => {
|
|
const entries = Array.from({ length: 1000 }, (_, i) => ({ version: '1.91.' + (i + 1) + '.0', date: '2026-10-20', headline: 'Headline number ' + i + ' with enough words to take some room in the body.' }));
|
|
const notes = releaseNotes({ repo: 'o/r', version: '1.91.1000.0', date: '2026-10-26', prevVersion: '1.91.0.0', prevTag: null, entries, hotfix: false });
|
|
expect(notes.length).toBeLessThanOrEqual(NOTES_CAP);
|
|
expect(notes).toMatch(/- \.\.\.and \d+ more versions; see CHANGELOG\.md/);
|
|
expect(notes).toContain('1000 versions since v1.91.0.0');
|
|
expect(notes.split('\n')[0]).toBe('## gstack v1.91.1000.0 (weekly release)');
|
|
});
|
|
});
|