Files
pipu-music/scripts/release.mjs
T
mrleo1nidandClaude Opus 5.5 d3b2e7bfc3
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 29s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / api (push) Successful in 1m23s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m33s
release / release (заметки) (push) Failing after 5s
release / android (APK в релиз) (push) Skipped
release / verify (push) Successful in 1m19s
ci(release): publish the release first, then attach the APK
The release job now creates the Gitea release right after verification with
generated notes: Android build status, changes grouped by Conventional Commit
type with commit links, server update steps and APK install/check notes.
The android job attaches the APK and .sha256 and rewrites only the status
block (building / built with size, versionCode and SHA-256 / failed with a
log link). scripts/release.mjs is idempotent on re-runs and unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:45:21 +03:00

255 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
// докладывает в него APK и переписывает блок статуса в тексте.
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure — статус сборки 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).
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } 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 -->';
/** Раскладывает коммиты (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 === 'failure'
? [
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
]
: [
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
'Файл появится в этом релизе автоматически, а этот текст обновится.',
];
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
}
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
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 (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 && docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump',
'```',
'',
'Обновление:',
'',
'```bash',
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
'```',
'',
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
'',
'```bash',
'curl -s https://<ваш-домен>/api/v1/server-info',
'```',
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`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 tag = env('TAG');
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
return { repoUrl: repo.html_url, tag, release, runUrl: `${repo.html_url}/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 }),
});
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 main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure'].includes(args[0])) return android(args[0], args[1]);
throw new Error('использование: release.mjs create | android building|failure | android success <file.apk>');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main().catch((e) => {
process.stderr.write(`${e.message}\n`);
process.exit(1);
});
}