storebound/tests/release-pipeline.test.ts

117 lines
8.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { cliKeyOf, keyContent, keyPaths } from '../tools/cli-key';
import { staleReason } from '../tools/acceptance';
import { channelUrl, seedPlan } from '../tools/desktop-release';
import { COPIES } from '../tools/desktop-version';
import { candidateTag, isCandidateTag, isTestTag, pruneList, type Release } from '../tools/forgejo-release';
import { isPrerelease, tagVersion, taskState, type Task, versionOnlyProblems } from '../tools/release-candidate';
import { cutChangelog, newer, timingTable } from '../tools/release';
const ROOT = join(import.meta.dir, '..');
const read = (f: string) => readFileSync(join(ROOT, f), 'utf8');
const SHA = 'a'.repeat(40);
const bump = (to: string) => COPIES.map(c => ({ file: c.file, status: 'M', before: read(c.file), after: c.write(read(c.file), to) }));
describe('version-only commits (ci.yml gate)', () => {
const from = COPIES[0]!.read(read('package.json'))!;
test('a bump of every copy plus the changelog is version-only', () => {
const r = versionOnlyProblems([...bump('9.9.9'), { file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }, { file: 'docs/acceptance/macOS-bun.json', status: 'M', before: '{}', after: '[]' }]);
expect(r).toEqual({ from, to: '9.9.9', problems: [] });
});
test('any other file, or a copy with one more change, is not', () => {
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'cli/index.ts', status: 'M', before: 'a', after: 'b' }]).problems).toEqual(['cli/index.ts: not a version copy (M)']);
const sneaky = bump('9.9.9').map(c => (c.file === 'package.json' ? { ...c, after: c.after.replace('"type": "module"', '"type": "commonjs"') } : c));
expect(versionOnlyProblems(sneaky).problems).toEqual(['package.json: changes more than the version']);
const dep = bump('9.9.9').map(c => (c.file === 'apps/desktop/src-tauri/Cargo.lock' ? { ...c, after: `${c.after}\n# extra` } : c));
expect(versionOnlyProblems(dep).problems).toEqual(['apps/desktop/src-tauri/Cargo.lock: changes more than the version']);
expect(versionOnlyProblems([{ file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }]).problems[0]).toMatch(/version does not change/);
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'docs/acceptance/x.json', status: 'A', before: null, after: '{}' }]).problems).toEqual([]);
expect(versionOnlyProblems([...bump('9.9.9'), { file: 'gemini-extension.json', status: 'D', before: '{}', after: null }]).problems.some(p => /gemini-extension\.json: D/.test(p))).toBe(true);
});
});
describe('candidate state from the ci.yml jobs', () => {
const t = (name: string, status: string, run = 5, sha = SHA, wf = 'ci.yml'): Task => ({ id: 1, name, run_number: run, workflow_id: wf, head_sha: sha, status });
const url = (n: number) => `run/${n}`;
test('none, pending, red; the newest run counts', () => {
expect(taskState([t('linux', 'success', 5, 'b'.repeat(40))], SHA, url)).toEqual({ state: 'none' });
expect(taskState([t('ci', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] });
expect(taskState([t('linux', 'success'), t('windows', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['windows'], cancelledOnly: false });
// a cancelled run (old-tag runs freed, superseded) is not a failure: a version-only child runs everything
expect(taskState([t('ci', 'success'), t('linux', 'cancelled')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['linux'], cancelledOnly: true });
expect(taskState([t('linux', 'failure', 4), t('linux', 'running', 6)], SHA, url).state).toBe('pending');
expect(taskState([t('promote', 'failure', 9, SHA, 'release.yml')], SHA, url)).toEqual({ state: 'none' });
// background tests never gate: a red ci / acceptance job leaves the candidate pending on its installers
expect(taskState([t('ci', 'failure'), t('acceptance (bun)', 'failure'), t('linux', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] });
});
test('tags and prereleases', () => {
expect(tagVersion('v0.8.5')).toBe('0.8.5');
expect(tagVersion('v0.0.0-rehearsal.3')).toBe('0.0.0-rehearsal.3');
expect(tagVersion('0.8.5')).toBeNull();
expect(isPrerelease('0.0.0-rehearsal.3')).toBe(true);
expect(isPrerelease('0.8.5')).toBe(false);
});
});
describe('forgejo: candidates and rehearsals', () => {
const rel = (id: number, tag: string, draft = false): Release => ({ id, tag_name: tag, name: tag, draft, prerelease: false, body: '', assets: [] });
test('prune keeps candidate drafts; rehearsal tags are deletable test tags', () => {
const rc = candidateTag(SHA);
expect(isCandidateTag(rc)).toBe(true);
expect(pruneList([rel(1, 'v0.8.4'), rel(2, 'v0.8.5'), rel(3, rc, true)], 'v0.8.5').remove.map(r => r.tag_name)).toEqual(['v0.8.4']);
expect(isTestTag('v0.0.0-rehearsal.12')).toBe(true);
expect(isTestTag('v0.8.5')).toBe(false);
expect(isTestTag('v0.0.1-rehearsal.1')).toBe(false);
});
test('seed plan: files the server fetches from the release by hash (renamed installer too)', () => {
const h = (c: string) => c.repeat(64);
const sums = `${h('a')} storebound_1.0.0_amd64.AppImage\n${h('b')} storebound_1.0.0_x64-setup.exe\n`;
const assets = [{ name: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a' }, { name: 'storebound_1.0.0_x64-setup.exe', url: 'https://x/b' }];
expect(seedPlan([{ file: 'storebound_1.0.0_amd64.AppImage', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', sha256: h('b') }, { file: 'storebound_1.0.0_universal.dmg', sha256: h('c') }], sums, assets))
.toEqual([{ file: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', url: 'https://x/b', sha256: h('b') }]);
expect(channelUrl('stable')).toBe('https://storebound.dev/desktop/latest.json');
expect(channelUrl('rehearsal')).toBe('https://storebound.dev/desktop/rehearsal/latest.json');
});
});
describe('CLI key and acceptance runs of a version-only parent', () => {
test('the key ignores the version copies and the prose, not the code', () => {
const files: Record<string, string> = { 'package.json': read('package.json'), 'cli/a.ts': 'x', 'CHANGELOG.md': 'one' };
const key = (f: Record<string, string>) => cliKeyOf(Object.keys(f), n => Buffer.from(f[n]!));
const base = key(files);
expect(key({ ...files, 'package.json': COPIES[0]!.write(files['package.json']!, '9.9.9'), 'CHANGELOG.md': 'two' })).toBe(base);
expect(key({ ...files, 'cli/a.ts': 'y' })).not.toBe(base);
expect(keyContent('cli/a.ts', Buffer.from('0.8.4')).toString()).toBe('0.8.4');
expect(keyPaths(['dist/storebound.mjs', 'cli'])).toContain('tests/fixtures');
expect(keyPaths(['dist/storebound.mjs', 'cli'])).not.toContain('dist/storebound.mjs');
});
test('a run of another version counts only with the same CLI key', () => {
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.4', 'k')).toBeNull();
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'k' }, '0.8.5', 'k')).toBeNull();
expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'j' }, '0.8.5', 'k')).toMatch(/other CLI code/);
expect(staleReason({ version: '0.8.4', quick: false }, '0.8.5', 'k')).toMatch(/version 0.8.4/);
expect(staleReason({ version: '0.8.5', quick: true, cliKey: 'k' }, '0.8.5', 'k')).toBe('a quick run');
});
});
describe('release.ts', () => {
test('changelog cut', () => {
const cl = '# Changelog\n\n## Unreleased\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n';
expect(cutChangelog(cl, '0.8.5', '2026-10-15')).toBe('# Changelog\n\n## Unreleased\n\n## 0.8.5 · 2026-10-15\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n');
expect(() => cutChangelog('# Changelog\n\n## Unreleased\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/empty/);
expect(() => cutChangelog('# Changelog\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/Unreleased/);
expect(() => cutChangelog(cl, '0.8.4', 'd')).toThrow(/already/);
});
test('versions and the timing table', () => {
expect(newer('0.8.5', '0.8.4')).toBe(true);
expect(newer('0.10.0', '0.9.9')).toBe(true);
expect(newer('0.8.4', '0.8.4')).toBe(false);
expect(newer('0.8.5', '0.8.5-rc.1')).toBe(true);
expect(newer('0.8.5-rc.1', '0.8.5')).toBe(false);
const tbl = timingTable([{ name: 'a', start: 1000, end: 61_000 }, { name: 'b', start: 2000, end: 302_000 }], 1000);
expect(tbl).toContain('took 1:00');
expect(tbl).toMatch(/total .* 5:01/);
});
});