diff --git a/.gitea/workflows/ci-desktop.yml b/.gitea/workflows/ci-desktop.yml index 0991543..8472ace 100644 --- a/.gitea/workflows/ci-desktop.yml +++ b/.gitea/workflows/ci-desktop.yml @@ -1,5 +1,5 @@ -# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально -# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK. +# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml +# (Linux на ubuntu-latest, Windows NSIS на windows-latest). name: ci / desktop on: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5f013af..8bd56e4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,6 +1,6 @@ -# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK. -# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы -# и переписывает статус: собран / не удалась (scripts/release.mjs). +# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows. +# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы +# и переписывают статус: собран / не удалась (scripts/release.mjs). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # # Секреты (Settings → Actions → Secrets): @@ -197,3 +197,141 @@ jobs: - name: Статус в релизе — сборка отменена if: cancelled() run: node scripts/release.mjs android cancelled + + desktop-linux: + name: desktop (Linux в релиз) + needs: release + timeout-minutes: 60 + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + PIPU_VERSION: ${{ needs.release.outputs.version }} + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.release.outputs.version }} + APPIMAGE_EXTRACT_AND_RUN: "1" + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + steps: + - uses: actions/checkout@v4 + - name: Статус в релизе — собирается + run: node scripts/release.mjs desktop linux building + - name: Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libdbus-1-dev libsecret-1-dev patchelf libfuse2 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm } + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }} + restore-keys: cargo-desktop- + - run: pnpm install --frozen-lockfile + - name: Сборка AppImage и deb + working-directory: apps/desktop + run: | + set -euo pipefail + pnpm bundle:linux + bundle=src-tauri/target/release/bundle + shopt -s nullglob + for f in "$bundle"/appimage/*.AppImage; do + cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage" + done + for f in "$bundle"/deb/*.deb; do + cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb" + done + ls -lh "$GITHUB_WORKSPACE"/pipu-music-${PIPU_VERSION}-linux.* + - name: Файлы в релиз + run: | + set -euo pipefail + files=() + for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do + [ -f "$f" ] && files+=("$f") + done + if [ ${#files[@]} -eq 0 ]; then + echo "::error::нет AppImage и deb после сборки" + exit 1 + fi + node scripts/release.mjs desktop linux success "${files[@]}" + - name: Статус в релизе — сборка не удалась + if: failure() + run: node scripts/release.mjs desktop linux failure + - name: Статус в релизе — сборка отменена + if: cancelled() + run: node scripts/release.mjs desktop linux cancelled + + # Нужен act_runner с меткой windows-latest (Windows + MSVC Build Tools + WebView2). + # Без такого раннера job останется в очереди; Tauri не кросс-компилирует NSIS с Linux. + desktop-windows: + name: desktop (Windows в релиз) + needs: release + timeout-minutes: 60 + runs-on: windows-latest + defaults: + run: + shell: pwsh + env: + PIPU_VERSION: ${{ needs.release.outputs.version }} + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.release.outputs.version }} + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm } + - name: Статус в релизе — собирается + run: node scripts/release.mjs desktop windows building + - uses: ilammy/msvc-dev-cmd@v1 + - name: Rust + run: | + $init = Join-Path $env:TEMP 'rustup-init.exe' + Invoke-WebRequest -Uri 'https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe' -OutFile $init + & $init -y --default-toolchain stable --profile minimal --default-host x86_64-pc-windows-msvc --no-modify-path + Add-Content -Path $env:GITHUB_PATH -Value "$env:USERPROFILE\.cargo\bin" + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-desktop-windows-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }} + restore-keys: cargo-desktop-windows- + - run: pnpm install --frozen-lockfile + - name: Сборка NSIS + working-directory: apps/desktop + run: | + pnpm bundle:windows + $bundle = Join-Path (Get-Location) 'src-tauri/target/release/bundle/nsis' + $src = Get-ChildItem -Path $bundle -Filter '*-setup.exe' | Select-Object -First 1 + if (-not $src) { throw "нет NSIS setup.exe после сборки в $bundle" } + $dest = Join-Path $env:GITHUB_WORKSPACE "pipu-music-$env:PIPU_VERSION-windows-setup.exe" + Copy-Item $src.FullName $dest + Get-Item $dest | Format-List Name, Length + - name: Файл в релиз + run: | + $file = "pipu-music-$env:PIPU_VERSION-windows-setup.exe" + if (-not (Test-Path $file)) { throw "нет $file после сборки" } + node scripts/release.mjs desktop windows success $file + - name: Статус в релизе — сборка не удалась + if: failure() + run: node scripts/release.mjs desktop windows failure + - name: Статус в релизе — сборка отменена + if: cancelled() + run: node scripts/release.mjs desktop windows cancelled diff --git a/apps/desktop/inject-version.mjs b/apps/desktop/inject-version.mjs index 560ec3f..3a597a0 100644 --- a/apps/desktop/inject-version.mjs +++ b/apps/desktop/inject-version.mjs @@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url'; const root = dirname(fileURLToPath(import.meta.url)); const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8')); -let version = '0.0.0'; -try { - const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim(); - version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, ''); -} catch { - // local untagged build +let version = process.env.PIPU_VERSION ?? '0.0.0'; +if (!process.env.PIPU_VERSION) { + try { + const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim(); + version = tag.replace(/^v/, ''); + } catch { + // local untagged build + } } conf.version = version; writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2)); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 55bc4ff..b3d1994 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "tauri dev", "bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json", + "bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles appimage,deb", + "bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis", "tauri": "tauri", "icons": "tauri icon ../web/public/favicon.svg" }, diff --git a/docs/admin-guide.md b/docs/admin-guide.md index c84b387..165d78d 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -103,8 +103,9 @@ Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда): -сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера -для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля. +сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция. +APK, Linux (AppImage, deb) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа +и в меню профиля. ## 7. Наблюдение diff --git a/docs/desktop.md b/docs/desktop.md index 65dc253..423bae3 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -14,4 +14,4 @@ pnpm --filter @pipu/desktop bundle На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer. -Готовые файлы с последнего релиза Gitea сервер отдаёт на публичной странице `/download`: посетителю сразу предлагается сборка его ОС, остальные платформы и Android — ниже. +Релиз по тегу прикладывает Linux AppImage/`.deb` (`pipu-music-<версия>-linux.*`) и Windows NSIS (`pipu-music-<версия>-windows-setup.exe`). Windows-job в `release.yml` ждёт act_runner с меткой `windows-latest` (MSVC + WebView2). Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea. diff --git a/scripts/release.mjs b/scripts/release.mjs index d9d768b..f6f156b 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; export const STATUS_START = ''; export const STATUS_END = ''; +export const DESKTOP_PLATFORMS = ['linux', 'windows']; + +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) { @@ -76,16 +82,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing 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); +/** Блок статуса сборки десктопа в тексте релиза (отдельный для 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'); } -/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */ -export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) { +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 = []; @@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups if (prerelease) { out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', ''); } - out.push(androidStatus, '', '## Что изменилось', ''); + 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), ''); } @@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups '', `Ожидается \`"version":"${version}"\`.`, '', - '## Android', + '## Клиенты', '', - `- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`, - ' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.', - `- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`, + 'Скачайте с этой страницы или с `/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).`, '', @@ -202,6 +260,8 @@ async function create() { 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 @@ -242,13 +302,38 @@ async function android(status, apkPath) { 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]); } - throw new Error('использование: release.mjs create | android building|failure|cancelled | android success '); + 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 | desktop linux|windows building|failure|cancelled | desktop linux|windows success ', + ); } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 0c20dc5..4779ad8 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -4,8 +4,11 @@ import { test } from 'node:test'; import { groupCommits, renderAndroidStatus, + renderDesktopStatus, renderNotes, replaceAndroidStatus, + replaceDesktopStatus, + desktopStatusMarks, STATUS_END, STATUS_START, } from './release.mjs'; @@ -77,6 +80,34 @@ test('adds a status block when the body has none', () => { assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block)); }); +test('desktop status blocks', () => { + const linuxMarks = desktopStatusMarks('linux'); + const winMarks = desktopStatusMarks('windows'); + const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }); + assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end)); + assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/); + const ok = renderDesktopStatus({ + platform: 'linux', + status: 'success', + files: [{ name: 'pipu-music-1.4.0-linux.AppImage', size: 40 * 1024 * 1024 }], + }); + assert.match(ok, /`pipu-music-1\.4\.0-linux\.AppImage` \(40,0 МБ\)/); + assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false); + + const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }); + assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end)); + const winOk = renderDesktopStatus({ + platform: 'windows', + status: 'success', + files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }], + }); + const both = `${building}\n\n${winBuilding}`; + const patched = replaceDesktopStatus(both, 'windows', winOk); + assert.match(patched, /Linux/); + assert.match(patched, /windows-setup\.exe/); + assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/); +}); + test('release notes', () => { const notes = renderNotes({ version: '0.2.0', @@ -86,12 +117,16 @@ test('release notes', () => { repoUrl, groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]), androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }), + desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }), + desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', 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, /pipu-music-0\.2\.0-windows-setup\.exe/); + assert.match(notes, /pipu-music-0\.2\.0-linux\.AppImage/); assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/); assert.doesNotMatch(notes, /Предварительная версия/); });