ci / ts / ts (packages + web) (push) Failing after 1m42s
release / android (APK в релиз) (push) Successful in 7m49s
release / release (версия и заметки) (push) Successful in 8s
ci / desktop / rust (desktop proxy) (push) Successful in 7m35s
release / desktop (Linux в релиз) (push) Canceled after 2m48s
release / desktop (Windows в релиз) (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
345 lines
16 KiB
JavaScript
345 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
// Релиз в Gitea: сначала создаётся релиз с заметками, потом сборка Android
|
||
// докладывает в него APK и переписывает блок статуса в тексте.
|
||
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
||
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
|
||
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
||
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
|
||
// из ответа указывает на него же).
|
||
|
||
import { execFileSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { basename, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const SECTIONS = [
|
||
['feat', 'Новое'],
|
||
['fix', 'Исправления'],
|
||
['perf', 'Производительность'],
|
||
['refactor', 'Рефакторинг'],
|
||
['docs', 'Документация'],
|
||
['build', 'Сборка и CI'],
|
||
['ci', 'Сборка и CI'],
|
||
];
|
||
const OTHER = 'Прочее';
|
||
const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||
|
||
export const STATUS_START = '<!-- android-status -->';
|
||
export const STATUS_END = '<!-- /android-status -->';
|
||
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
||
|
||
export function desktopStatusMarks(platform) {
|
||
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
||
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
|
||
}
|
||
|
||
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||
export function groupCommits(commits) {
|
||
const breaking = [];
|
||
const byTitle = new Map();
|
||
for (const { sha, subject } of commits) {
|
||
const m = COMMIT_RE.exec(subject);
|
||
const item = m ? { sha, scope: m[2] ?? null, text: m[4] } : { sha, scope: null, text: subject };
|
||
if (m?.[3]) breaking.push(item);
|
||
const title = (m && SECTIONS.find(([t]) => t === m[1])?.[1]) ?? OTHER;
|
||
if (!byTitle.has(title)) byTitle.set(title, []);
|
||
byTitle.get(title).push(item);
|
||
}
|
||
const order = [...new Set(SECTIONS.map(([, t]) => t)), OTHER];
|
||
return { breaking, sections: order.filter((t) => byTitle.has(t)).map((t) => [t, byTitle.get(t)]) };
|
||
}
|
||
|
||
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} МБ`;
|
||
|
||
/** Блок статуса сборки Android в тексте релиза. */
|
||
export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing }) {
|
||
const lines =
|
||
status === 'success'
|
||
? [
|
||
`✅ **Android:** APK собран — \`${apk.name}\` (${formatSize(apk.size)}), versionCode \`${versionCode}\`.`,
|
||
`SHA-256: \`${apk.sha256}\``,
|
||
...(signing === 'debug'
|
||
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
|
||
: []),
|
||
]
|
||
: status === 'cancelled'
|
||
? [
|
||
`⏹ **Android:** сборка APK отменена — [лог сборки](${runUrl}).`,
|
||
'APK появится здесь после перезапуска сборки.',
|
||
]
|
||
: status === 'failure'
|
||
? [
|
||
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
|
||
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
|
||
]
|
||
: [
|
||
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
|
||
'Файл появится в этом релизе автоматически, а этот текст обновится.',
|
||
];
|
||
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||
}
|
||
|
||
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
|
||
export function renderDesktopStatus({ platform, status, runUrl, files }) {
|
||
const label = platform === 'windows' ? 'Windows' : 'Linux';
|
||
const pending = platform === 'windows' ? 'NSIS-установщик' : 'AppImage и .deb';
|
||
const lines =
|
||
status === 'success'
|
||
? [
|
||
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
|
||
]
|
||
: status === 'cancelled'
|
||
? [
|
||
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
|
||
'Файлы появятся здесь после перезапуска сборки.',
|
||
]
|
||
: status === 'failure'
|
||
? [
|
||
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
|
||
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
|
||
]
|
||
: [
|
||
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
|
||
`${pending} появится в этом релизе автоматически.`,
|
||
];
|
||
const { start, end } = desktopStatusMarks(platform);
|
||
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
|
||
}
|
||
|
||
function replaceBlock(body, startMark, endMark, block) {
|
||
const start = body.indexOf(startMark);
|
||
const end = body.indexOf(endMark);
|
||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||
return body.slice(0, start) + block + body.slice(end + endMark.length);
|
||
}
|
||
|
||
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
|
||
export function replaceAndroidStatus(body, block) {
|
||
return replaceBlock(body, STATUS_START, STATUS_END, block);
|
||
}
|
||
|
||
export function replaceDesktopStatus(body, platform, block) {
|
||
const { start, end } = desktopStatusMarks(platform);
|
||
return replaceBlock(body, start, end, block);
|
||
}
|
||
|
||
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
|
||
export function renderNotes({
|
||
version,
|
||
tag,
|
||
prevTag,
|
||
prerelease,
|
||
repoUrl,
|
||
groups,
|
||
androidStatus,
|
||
desktopLinuxStatus,
|
||
desktopWindowsStatus,
|
||
}) {
|
||
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||
const out = [];
|
||
|
||
if (prerelease) {
|
||
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||
}
|
||
out.push(androidStatus, '');
|
||
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
|
||
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
|
||
out.push('## Что изменилось', '');
|
||
if (groups.breaking.length) {
|
||
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||
}
|
||
if (groups.sections.length) {
|
||
for (const [title, items] of groups.sections) out.push(`### ${title}`, '', ...items.map(item), '');
|
||
} else {
|
||
out.push('Изменений с прошлой версии нет.', '');
|
||
}
|
||
out.push(
|
||
prevTag
|
||
? `Все изменения: [${prevTag}...${tag}](${repoUrl}/compare/${prevTag}...${tag})`
|
||
: `Все изменения: [история ${tag}](${repoUrl}/commits/tag/${tag})`,
|
||
'',
|
||
'## Обновление сервера',
|
||
'',
|
||
'Обновление:',
|
||
'',
|
||
'```bash',
|
||
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
|
||
'```',
|
||
'',
|
||
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
|
||
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
|
||
'',
|
||
'```bash',
|
||
'curl -s https://<ваш-домен>/api/v1/server-info',
|
||
'```',
|
||
'',
|
||
`Ожидается \`"version":"${version}"\`.`,
|
||
'',
|
||
'## Клиенты',
|
||
'',
|
||
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
|
||
'',
|
||
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
|
||
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
|
||
`- **Linux:** \`pipu-music-${version}-linux.AppImage\` (сделайте исполняемым) или \`.deb\`.`,
|
||
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||
'',
|
||
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||
'',
|
||
);
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------
|
||
// CLI
|
||
|
||
function env(name) {
|
||
const v = process.env[name];
|
||
if (!v) throw new Error(`не задана переменная окружения ${name}`);
|
||
return v;
|
||
}
|
||
|
||
async function api(method, path, { json, form } = {}) {
|
||
const res = await fetch(`${env('GITEA_API')}${path}`, {
|
||
method,
|
||
headers: {
|
||
Authorization: `token ${env('GITEA_TOKEN')}`,
|
||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||
},
|
||
body: json ? JSON.stringify(json) : form,
|
||
});
|
||
if (res.status === 404 && method === 'GET') return null;
|
||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
||
return res.status === 204 ? null : res.json();
|
||
}
|
||
|
||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||
|
||
function previousTag(tag, prerelease) {
|
||
// для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||
const exclude = prerelease ? [] : ['--exclude', '*-rc*'];
|
||
try {
|
||
return git('describe', '--tags', '--abbrev=0', ...exclude, `${tag}^`);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function commitsBetween(prevTag, tag) {
|
||
const out = git('log', '--no-merges', '--format=%H%x09%s', prevTag ? `${prevTag}..${tag}` : tag);
|
||
return out
|
||
? out.split('\n').map((l) => {
|
||
const [sha, ...rest] = l.split('\t');
|
||
return { sha, subject: rest.join('\t') };
|
||
})
|
||
: [];
|
||
}
|
||
|
||
async function context() {
|
||
const repo = await api('GET', '');
|
||
const publicUrl = process.env.PUBLIC_URL?.replace(/\/+$/, '');
|
||
const repoUrl = publicUrl ? `${publicUrl}/${repo.full_name}` : repo.html_url;
|
||
const tag = env('TAG');
|
||
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
|
||
return { repoUrl, tag, release, runUrl: `${repoUrl}/actions/runs/${env('GITHUB_RUN_ID')}` };
|
||
}
|
||
|
||
async function create() {
|
||
const { repoUrl, tag, release, runUrl } = await context();
|
||
const prerelease = env('PRERELEASE') === 'true';
|
||
const prevTag = previousTag(tag, prerelease);
|
||
const body = renderNotes({
|
||
version: env('VERSION'),
|
||
tag,
|
||
prevTag,
|
||
prerelease,
|
||
repoUrl,
|
||
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
|
||
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
|
||
});
|
||
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||
const saved = release
|
||
? await api('PATCH', `/releases/${release.id}`, { json: fields })
|
||
: await api('POST', '/releases', { json: { tag_name: tag, ...fields } });
|
||
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
|
||
}
|
||
|
||
async function upload(release, file) {
|
||
const name = basename(file);
|
||
for (const old of release.assets ?? []) {
|
||
if (old.name === name) await api('DELETE', `/releases/${release.id}/assets/${old.id}`);
|
||
}
|
||
const form = new FormData();
|
||
form.append('attachment', new Blob([readFileSync(file)]), name);
|
||
await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { form });
|
||
console.log(`приложен ${name}`);
|
||
}
|
||
|
||
async function android(status, apkPath) {
|
||
const { release, runUrl } = await context();
|
||
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||
let apk;
|
||
if (status === 'success') {
|
||
if (!apkPath) throw new Error('android success: укажите путь к APK');
|
||
const data = readFileSync(apkPath);
|
||
apk = { name: basename(apkPath), size: data.length, sha256: createHash('sha256').update(data).digest('hex') };
|
||
for (const f of [apkPath, `${apkPath}.sha256`]) if (existsSync(f)) await upload(release, f);
|
||
}
|
||
const block = renderAndroidStatus({
|
||
status,
|
||
runUrl,
|
||
apk,
|
||
versionCode: process.env.VERSION_CODE,
|
||
signing: process.env.SIGNING,
|
||
});
|
||
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceAndroidStatus(release.body ?? '', block) } });
|
||
console.log(`статус Android: ${status}`);
|
||
}
|
||
|
||
async function desktop(platform, status, paths) {
|
||
const { release, runUrl } = await context();
|
||
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||
const files = [];
|
||
if (status === 'success') {
|
||
if (!paths.length) throw new Error('desktop success: укажите файлы');
|
||
for (const file of paths) {
|
||
const data = readFileSync(file);
|
||
files.push({ name: basename(file), size: data.length });
|
||
const sum = `${file}.sha256`;
|
||
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
|
||
await upload(release, file);
|
||
if (existsSync(sum)) await upload(release, sum);
|
||
}
|
||
}
|
||
const block = renderDesktopStatus({ platform, status, runUrl, files });
|
||
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
|
||
console.log(`статус десктопа (${platform}): ${status}`);
|
||
}
|
||
|
||
async function main() {
|
||
const [cmd, ...args] = process.argv.slice(2);
|
||
if (cmd === 'create') return create();
|
||
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||
return android(args[0], args[1]);
|
||
}
|
||
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
|
||
return desktop(args[0], args[1], args.slice(2));
|
||
}
|
||
throw new Error(
|
||
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
|
||
);
|
||
}
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||
main().catch((e) => {
|
||
process.stderr.write(`${e.message}\n`);
|
||
process.exit(1);
|
||
});
|
||
}
|