Files
pipu-music/scripts/release.mjs
T
mrleo1nidandClaude Opus 5.5 a239650075
ci / ts / ts (packages + web) (push) Successful in 1m24s
release / release (версия и заметки) (push) Successful in 45s
release / android (APK в релиз) (push) Failing after 12m0s
fix(release): public links in release notes and a cancelled status
The job reaches the Gitea API at its internal address, so html_url in the
response pointed at http://10.10.1.129:3000 and every link in the notes was
unreachable from outside. Build links from PUBLIC_URL (repo variable,
defaults to https://gitea.hsrv.site). A cancelled Android build now shows
"cancelled" instead of staying at "building".

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

266 lines
12 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|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 } 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 === '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');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
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 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 }),
});
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', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]);
}
throw new Error('использование: release.mjs create | android building|failure|cancelled | 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);
});
}