Files
pipu-music/scripts/release.test.mjs
T
mrleo1nidandCursor 8eb6b36888
ci / ts / ts (packages + web) (push) Successful in 3m30s
release / android (APK в релиз) (push) Successful in 8m57s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m4s
release / prune (старые файлы и прогоны) (push) Successful in 8s
ci(release): keep files and Actions runs for the last 8 releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:32:18 +03:00

183 lines
7.6 KiB
JavaScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
groupCommits,
renderAndroidStatus,
renderDesktopStatus,
renderNotes,
replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
staleActionRuns,
staleReleases,
STATUS_END,
STATUS_START,
} from './release.mjs';
const sha = (n) => String(n).repeat(40).slice(0, 40);
const repoUrl = 'https://git.example/owner/pipu-music';
test('groups conventional commits by section in a fixed order', () => {
const groups = groupCommits([
{ sha: sha(1), subject: 'fix(api): handle empty token' },
{ sha: sha(2), subject: 'feat: add wave' },
{ sha: sha(3), subject: 'ci(release): build arm64 only' },
{ sha: sha(4), subject: 'build(deploy): pin images' },
{ sha: sha(5), subject: 'Update README' },
{ sha: sha(6), subject: 'chore: bump deps' },
]);
assert.deepEqual(
groups.sections.map(([title, items]) => [title, items.map((i) => i.text)]),
[
['Новое', ['add wave']],
['Исправления', ['handle empty token']],
['Сборка и CI', ['build arm64 only', 'pin images']],
['Прочее', ['Update README', 'bump deps']],
],
);
assert.equal(groups.sections[1][1][0].scope, 'api');
assert.deepEqual(groups.breaking, []);
});
test('marks breaking changes', () => {
const groups = groupCommits([{ sha: sha(1), subject: 'feat(api)!: drop v0 endpoints' }]);
assert.equal(groups.breaking.length, 1);
assert.equal(groups.sections[0][0], 'Новое');
});
test('android status blocks', () => {
const building = renderAndroidStatus({ status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(STATUS_START) && building.endsWith(STATUS_END));
assert.match(building, /собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderAndroidStatus({
status: 'success',
versionCode: '10299',
signing: 'release',
apk: { name: 'pipu-music-0.1.2.apk', size: 25 * 1024 * 1024 + 300_000, sha256: 'ab'.repeat(32) },
});
assert.match(ok, /`pipu-music-0\.1\.2\.apk` \(25,3 МБ\), versionCode `10299`/);
assert.doesNotMatch(ok, /отладочным/);
const debug = renderAndroidStatus({ status: 'success', signing: 'debug', apk: { name: 'a.apk', size: 1, sha256: 'x' } });
assert.match(debug, /отладочным ключом/);
assert.match(renderAndroidStatus({ status: 'failure', runUrl: 'https://run' }), /не удалась — \[лог сборки\]/);
assert.match(renderAndroidStatus({ status: 'cancelled', runUrl: 'https://run' }), /отменена — \[лог сборки\]/);
});
test('replaces only the status block and is idempotent', () => {
const body = `intro\n\n${renderAndroidStatus({ status: 'building', runUrl: 'u' })}\n\n## Что изменилось\n`;
const failed = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
const once = replaceAndroidStatus(body, failed);
assert.equal(replaceAndroidStatus(once, failed), once);
assert.ok(once.startsWith('intro\n\n') && once.endsWith('\n\n## Что изменилось\n'));
assert.doesNotMatch(once, /собирается/);
assert.equal(once.split(STATUS_START).length, 2);
});
test('adds a status block when the body has none', () => {
const block = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
});
test('desktop status blocks', () => {
const linuxMarks = desktopStatusMarks('linux');
const winMarks = desktopStatusMarks('windows');
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderDesktopStatus({
platform: 'linux',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-linux.deb', size: 4 * 1024 * 1024 }],
});
assert.match(ok, /`pipu-music-1\.4\.0-linux\.deb` \(4,0 МБ\)/);
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
const winOk = renderDesktopStatus({
platform: 'windows',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
});
const both = `${building}\n\n${winBuilding}`;
const patched = replaceDesktopStatus(both, 'windows', winOk);
assert.match(patched, /Linux/);
assert.match(patched, /windows-setup\.exe/);
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
});
test('release notes', () => {
const notes = renderNotes({
version: '0.2.0',
tag: 'v0.2.0',
prevTag: 'v0.1.7',
prerelease: false,
repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
});
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
assert.match(notes, /"version":"0\.2\.0"/);
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
assert.match(notes, /pipu-music-0\.2\.0-linux\.deb/);
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
assert.doesNotMatch(notes, /Предварительная версия/);
});
test('keeps files on the newest eight releases', () => {
const releases = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
tag_name: `v1.0.${i}`,
draft: false,
published_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
assets: i === 0 ? [] : [{ id: 100 + i, name: `a${i}.apk` }],
}));
const stale = staleReleases(releases, { keep: 8, currentTag: 'v1.0.9' });
assert.deepEqual(
stale.map((r) => r.tag_name).sort(),
['v1.0.1'],
);
});
test('does not delete the current Actions run or unfinished jobs', () => {
const runs = [
{ id: 20, status: 'completed', created_at: '2026-01-20T00:00:00Z' },
{ id: 19, status: 'in_progress', created_at: '2026-01-19T00:00:00Z' },
...Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
status: 'completed',
created_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
})),
];
const stale = staleActionRuns(runs, { keep: 8, currentRunId: 20 });
assert.equal(stale.some((r) => r.id === 20 || r.id === 19), false);
assert.deepEqual(
stale.map((r) => r.id).sort((a, b) => a - b),
[1, 2, 3, 4],
);
});
test('release notes for a first prerelease without changes', () => {
const notes = renderNotes({
version: '0.1.0-rc.1',
tag: 'v0.1.0-rc.1',
prevTag: null,
prerelease: true,
repoUrl,
groups: groupCommits([]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'u' }),
});
assert.match(notes, /Предварительная версия/);
assert.match(notes, /Изменений с прошлой версии нет/);
assert.match(notes, /commits\/tag\/v0\.1\.0-rc\.1/);
});