#!/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 — приложить APK и .sha256, статус «собран» // node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions // Окружение: GITEA_API (/api/v1/repos//), 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 = ''; export const STATUS_END = ''; export const DESKTOP_PLATFORMS = ['linux', 'windows']; /** Сколько последних опубликованных релизов оставляем с файлами (APK, .deb, NSIS). */ export const KEEP_RELEASE_FILES = 8; /** Сколько последних прогонов Actions оставляем (логи/статусы). */ export const KEEP_ACTION_RUNS = 8; export function desktopStatusMarks(platform) { if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`); return { start: ``, end: `` }; } /** Раскладывает коммиты (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-установщик' : '.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.deb\` — \`sudo apt install ./pipu-music-${version}-linux.deb\` (нужны WebKitGTK 4.1 и GStreamer).`, `- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`, '', `Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`, '', ); return out.join('\n'); } function publishedAt(item) { return Date.parse(item.published_at || item.created_at || item.started_at || 0); } /** Релизы, с которых снимаем вложения: черновики не трогаем, текущий тег всегда оставляем. */ export function staleReleases(releases, { keep = KEEP_RELEASE_FILES, currentTag } = {}) { const published = releases.filter((r) => !r.draft); published.sort((a, b) => publishedAt(b) - publishedAt(a)); const keepIds = new Set(published.slice(0, keep).map((r) => r.id)); const current = published.find((r) => r.tag_name === currentTag); if (current) keepIds.add(current.id); return published.filter((r) => !keepIds.has(r.id) && (r.assets?.length ?? 0) > 0); } /** Завершённые прогоны Actions, которые можно удалить (текущий прогон не трогаем). */ export function staleActionRuns(runs, { keep = KEEP_ACTION_RUNS, currentRunId } = {}) { const done = runs.filter((r) => r.status === 'completed' && String(r.id) !== String(currentRunId ?? '')); done.sort((a, b) => publishedAt(b) - publishedAt(a)); const keepIds = new Set(done.slice(0, keep).map((r) => r.id)); return done.filter((r) => !keepIds.has(r.id)); } function actionRunsFrom(body) { if (Array.isArray(body)) return body; if (Array.isArray(body?.workflow_runs)) return body.workflow_runs; if (Array.isArray(body?.runs)) return body.runs; return []; } // --------------------------------------------------------------------------------------------------------- // 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 listPages(path, itemsOf) { const all = []; for (let page = 1; page <= 40; page += 1) { const sep = path.includes('?') ? '&' : '?'; const body = await api('GET', `${path}${sep}limit=50&page=${page}`); const batch = itemsOf(body); if (!batch.length) break; all.push(...batch); if (batch.length < 50) break; } return all; } async function prune() { const tag = process.env.TAG; const currentRunId = process.env.GITHUB_RUN_ID; const releases = await listPages('/releases', (body) => (Array.isArray(body) ? body : [])); let files = 0; for (const rel of staleReleases(releases, { currentTag: tag })) { for (const a of rel.assets ?? []) { await api('DELETE', `/releases/${rel.id}/assets/${a.id}`); files += 1; console.log(`удалён файл ${rel.tag_name}/${a.name}`); } } const runs = await listPages('/actions/runs', actionRunsFrom); let statuses = 0; for (const run of staleActionRuns(runs, { currentRunId })) { await api('DELETE', `/actions/runs/${run.id}`); statuses += 1; console.log(`удалён прогон Actions ${run.id} (${run.path ?? run.name ?? run.event ?? ''})`); } console.log(`очистка: файлов ${files}, прогонов ${statuses}`); } 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)); } if (cmd === 'prune') return prune(); throw new Error( 'использование: release.mjs create | android building|failure|cancelled | android success | desktop linux|windows building|failure|cancelled | desktop linux|windows success | prune', ); } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { main().catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); }); }