From d3b2e7bfc320e1e246624b7cb6f96c18e668e846 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:45:21 +0300 Subject: [PATCH] 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 --- .gitea/workflows/release.yml | 81 ++++++----- docs/ci-cd.md | 31 +++-- package.json | 2 +- scripts/release.mjs | 254 +++++++++++++++++++++++++++++++++++ scripts/release.test.mjs | 111 +++++++++++++++ 5 files changed, 424 insertions(+), 55 deletions(-) create mode 100644 scripts/release.mjs create mode 100644 scripts/release.test.mjs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index dcd355f..9d1ca00 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,4 +1,6 @@ -# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release. +# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → Gitea Release с заметками → APK. +# Релиз создаётся сразу после проверок (статус Android — «собирается»); сборка APK докладывает в него файлы +# и переписывает статус: собран / не удалась (scripts/release.mjs). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # # Секреты (Settings → Actions → Secrets): @@ -42,16 +44,43 @@ jobs: - name: TypeScript run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test - android-release: - name: android + release + release: + name: release (заметки) needs: verify runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # вся история и теги: заметки строятся по коммитам с прошлого релиза + with: { fetch-depth: 0 } + - uses: actions/setup-node@v4 + with: { node-version: "${{ env.NODE_VERSION }}" } + - name: Gitea Release + env: + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + run: node scripts/release.mjs create + + android: + name: android (APK в релиз) + needs: [verify, release] + runs-on: ubuntu-latest env: PIPU_VERSION: ${{ needs.verify.outputs.version }} PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }} + # для scripts/release.mjs + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + VERSION_CODE: ${{ needs.verify.outputs.version_code }} steps: - uses: actions/checkout@v4 - with: { fetch-depth: 0 } + # при перезапуске одной этой сборки статус «не удалась» сменится на «собирается» + - name: Статус в релизе — собирается + run: node scripts/release.mjs android building - uses: actions/setup-java@v4 with: { distribution: temurin, java-version: "17" } - uses: android-actions/setup-android@v3 @@ -106,9 +135,11 @@ jobs: "$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \ --ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src" rm release.keystore + echo "SIGNING=release" >> "$GITHUB_ENV" elif [ "$PRERELEASE" = "true" ]; then echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного" cp "$src" "$out" + echo "SIGNING=debug" >> "$GITHUB_ENV" else echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)" exit 1 @@ -117,41 +148,9 @@ jobs: sha256sum "$out" | cut -d' ' -f1 > "$out.sha256" echo "APK=$out" >> "$GITHUB_ENV" - - name: Changelog - env: - PRERELEASE: ${{ needs.verify.outputs.prerelease }} - run: | - # для финального релиза — изменения с прошлого финального, а не с последнего rc - exclude=() - [ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*') - prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)" - range="${prev:+$prev..}${GITHUB_REF_NAME}" - { - echo "## Pipu Music ${PIPU_VERSION}" - echo - for t in feat fix perf refactor docs ci chore; do - lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)" - [ -n "$lines" ] || continue - case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac - echo "### $h"; echo "$lines" | sed 's/^/- /'; echo - done - } > CHANGELOG_RELEASE.md - cat CHANGELOG_RELEASE.md + - name: APK в релиз + run: node scripts/release.mjs android success "$APK" - - name: Gitea Release - env: - TOKEN: ${{ secrets.RELEASE_TOKEN }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - PRERELEASE: ${{ needs.verify.outputs.prerelease }} - run: | - set -euo pipefail - body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \ - --rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \ - '{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')" - id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ - -d "$body" "$API/releases" | jq -r .id)" - for f in "$APK" "$APK.sha256"; do - curl -fsS -X POST -H "Authorization: token $TOKEN" \ - -F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null - echo "uploaded $(basename "$f")" - done + - name: Статус в релизе — сборка не удалась + if: failure() + run: node scripts/release.mjs android failure diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8b4ec27..35d10d4 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -48,20 +48,25 @@ on: tags: ['v*.*.*'] jobs: - verify: # все проверки ci.yml (reusable workflow) - - android-release: # needs: verify - # setup-java 17, Android SDK, pnpm install - # VERSION / VERSION_CODE из тега → app.config.ts - # expo prebuild --platform android --clean - # ./gradlew assembleRelease (signingConfig из секретов) - # apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256 - - # changelog из Conventional Commits между тегами - # создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу - # загрузить ассеты: APK и .sha256 + verify: # все проверки ci.yml + release: # needs: verify — сразу создаёт Gitea Release (scripts/release.mjs create) + android: # needs: verify, release — собирает APK и докладывает его в релиз ``` +Релиз появляется сразу после проверок, не дожидаясь APK. Текст строит `scripts/release.mjs`: + +- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер, + versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог; +- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления», + «Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого + финального, а не от последнего `-rc`; ссылка на полный diff; +- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256. + +Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease` (только arm64-v8a), подпись `apksigner`, +затем `release.mjs android success ` прикладывает APK и `.sha256` и меняет статус. При любом сбое — +`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются. +Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй. + Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013). Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR. @@ -69,7 +74,7 @@ Docker-образы **не публикуются**: сервер собирае | Секрет | Назначение | |---|---| -| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов | +| `RELEASE_TOKEN` | Создание и редактирование релиза, загрузка ассетов (scope `write:repository`) | | `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. | ## Деплой diff --git a/package.json b/package.json index d96f201..1b373b2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "generate": "turbo run generate", "i18n:check": "pnpm --filter @pipu/i18n check", "version:info": "node scripts/version.mjs", - "test:scripts": "node --test scripts/version.test.mjs" + "test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs" }, "devDependencies": { "turbo": "2.5.4", diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..678bfa8 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,254 @@ +#!/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 — приложить APK и .sha256, статус «собран» +// Окружение: GITEA_API (/api/v1/repos//), 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 = ''; +export const STATUS_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 === '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 '); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main().catch((e) => { + process.stderr.write(`${e.message}\n`); + process.exit(1); + }); +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 0000000..2d72e99 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + groupCommits, + renderAndroidStatus, + renderNotes, + replaceAndroidStatus, + 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' }), /не удалась — \[лог сборки\]/); +}); + +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('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' }), + }); + 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, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/); + assert.doesNotMatch(notes, /Предварительная версия/); +}); + +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/); +});