Compare commits

...
7 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 d3b2e7bfc3 ci(release): publish the release first, then attach the APK
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
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
mrleo1nidandClaude Opus 5.5 622eb84bc9 fix(mobile): hoist expo-modules-autolinking instead of depending on it
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 47s
ci / api (push) Successful in 54s
ci / recommender (push) Canceled after 8s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
expo-doctor rejects expo-modules-autolinking as a direct dependency, which
failed the CI mobile job. Expose it through publicHoistPattern so Expo can
still require it from the apps/mobile/node_modules/expo symlink and the
autolinking import stays expo.modules.ExpoModulesPackage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:28:58 +03:00
mrleo1nidandClaude Opus 5.5 65aef3164a fix(mobile): make expo-modules-autolinking resolvable from the app
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / recommender (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / api (push) Successful in 58s
ci / ts (packages + web) (push) Successful in 39s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 45s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 53s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
ci / mobile (Metro-бандл Android) (push) Failing after 38s
release / verify (push) Successful in 1m17s
release / android + release (push) Failing after 12m33s
pnpm 11 ignores node-linker=hoisted in .npmrc, so node_modules uses the
isolated layout. Expo evaluates expo's react-native.config.js through the
apps/mobile/node_modules/expo symlink, where require('expo-modules-autolinking')
fails silently; autolinking then guessed the import from the Gradle namespace
(expo.core.ExpoModulesPackage) and the release Java compile failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:18:23 +03:00
mrleo1nidandClaude Opus 5.5 12fc866c26 ci(release): cap memory of the Android release build
ci / api (push) Canceled after 5m58s
ci / recommender (push) Canceled after 25s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
release / verify (push) Successful in 1m21s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 45s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
release / android + release (push) Failing after 9m39s
v0.1.5 got past Metro, but the Gradle daemon was killed mid-bundle on the
8 GB runner. Compile Kotlin inside the Gradle JVM instead of a second
daemon, limit Gradle and Metro to two workers, set the heap explicitly and
print free/dmesg when the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:55:48 +03:00
mrleo1nidandClaude Opus 5.5 f76b3c89b3 fix(mobile): hide Gradle outputs inside node_modules from Metro
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / api (push) Successful in 51s
ci / recommender (push) Successful in 36s
ci / ts (packages + web) (push) Successful in 39s
ci / mobile (Metro-бандл Android) (push) Successful in 52s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 43s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 48s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
release / verify (push) Successful in 1m14s
release / android + release (push) Failing after 7m41s
Gradle builds native modules in place, under node_modules/<pkg>/android/
{build,.cxx,.gradle}. The root node_modules is a watch folder, so Metro still
crawled and hashed those artifacts and the release bundle step stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:09:32 +03:00
mrleo1nidandClaude Opus 5.5 38edcd3e0e docs(install): music on a local disk at /mnt/music, repo in /opt/pipu-music
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / recommender (push) Canceled after 0s
ci / ts (packages + web) (push) Canceled after 0s
Add an install step for mounting the music disk via fstab and wire it into
the stack: LIBRARY_HOST in .env points at the host folder, compose mounts it
as /library-host (read-only for api, nginx and analyzer; writable only for
storage-agent), nginx serves it through X-Accel, and install.sh validates the
path and records it in .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:51:26 +03:00
mrleo1nidandClaude Opus 5.5 42d64cd49f fix(mobile): keep Metro out of the native android/ios build directories
ci / api (push) Successful in 52s
ci / recommender (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 32s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 53s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 57s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 53s
release / verify (push) Successful in 1m11s
release / android + release (push) Canceled after 24m53s
Expo's monorepo config watches all of apps/mobile, so during the release
build Metro crawled and hashed gigabytes of Gradle/CMake intermediates in
android/ and the createBundleReleaseJsAndAssets task appeared to hang.
Run the release build with CI=1 (no file watcher) and NODE_ENV=production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:57 +03:00
15 changed files with 589 additions and 86 deletions

No files matched your search

+52 -43
View File
@@ -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
@@ -69,15 +98,25 @@ jobs:
- name: Сборка APK
working-directory: apps/mobile
env:
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
CI: "1"
NODE_ENV: production
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
run: |
npx expo prebuild --platform android --clean --no-install
cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
cd android
./gradlew --no-daemon --max-workers=2 assembleRelease -PreactNativeArchitectures=arm64-v8a -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m"
- name: Свободное место после сбоя
if: failure()
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
run: |
df -h / /tmp; free -m || true
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
- name: Подпись APK
env:
@@ -96,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
@@ -107,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
+18 -1
View File
@@ -1,4 +1,21 @@
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
const path = require('node:path');
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
const config = getDefaultConfig(__dirname);
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
// Gradle собирает нативные модули прямо в node_modules/<пакет>/android/{build,.cxx,.gradle},
// а корневой node_modules тоже в watchFolders.
const nativeBuildOutputs = /[\\/]android[\\/](build|\.cxx|\.gradle)[\\/].*/;
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
if (process.env.CI) config.maxWorkers = 2;
module.exports = config;
+3
View File
@@ -4,6 +4,9 @@
# PIPU_VERSION проставляет update.sh по git-тегу — вручную не менять
PIPU_VERSION=dev
PIPU_DATA=/srv/pipu
# Папка с музыкой на локальном диске сервера (источники type=local), например смонтированный диск.
# Пусто — $PIPU_DATA/library-host. NFS-хранилища сюда не относятся: их подключает админка.
LIBRARY_HOST=
# --- Домен и HTTPS (docs/install-guide.md) ---
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
+14
View File
@@ -17,6 +17,14 @@ x-library-ro: &library-ro
read_only: true
bind: { propagation: rslave }
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Пишет в него только storage-agent (импорт одобренных загрузок), остальным — только чтение.
x-library-host-ro: &library-host-ro
type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
read_only: true
x-restart: &restart
restart: unless-stopped
@@ -31,6 +39,7 @@ services:
ports: ["${HTTP_PORT:-80}:80"]
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
depends_on: [api]
@@ -48,6 +57,7 @@ services:
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
@@ -75,6 +85,9 @@ services:
source: ${PIPU_DATA:-/srv/pipu}/library
target: /library
bind: { propagation: rshared }
- type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
@@ -102,6 +115,7 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/models:/models
deploy: { resources: { limits: { cpus: "4" } } }
depends_on:
+20 -1
View File
@@ -17,6 +17,16 @@ mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,mei
chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads}
chown 10001:10001 "$DATA/models"
# Папка с локальной музыкой: sudo LIBRARY_HOST=/mnt/music ./install.sh (или LIBRARY_HOST в .env)
LIBRARY_HOST="${LIBRARY_HOST:-$( [ -f .env ] && sed -n 's/^LIBRARY_HOST=//p' .env || true )}"
if [ -n "$LIBRARY_HOST" ]; then
if [ ! -d "$LIBRARY_HOST" ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST: каталога нет — смонтируйте диск с музыкой (docs/install-guide.md, шаг 4)" >&2
exit 1
fi
findmnt -n --target "$LIBRARY_HOST" -o TARGET,SOURCE | sed 's/^/→ музыка: /'
fi
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
# каталог должен быть отдельной точкой монтирования с shared propagation.
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
@@ -50,10 +60,19 @@ secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
if [ ! -f .env ]; then
echo "→ .env из .env.example"
cp .env.example .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|; s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
chmod 600 .env
chown "$OWNER" .env
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
fi
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then
sed -i "s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
else
echo "LIBRARY_HOST=$LIBRARY_HOST" >> .env
fi
fi
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
+1
View File
@@ -48,6 +48,7 @@ server {
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
location /_internal/library-host/ { internal; alias /library-host/; sendfile on; aio threads; }
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
+18 -13
View File
@@ -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>` прикладывает 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` собирается с отладочной подписью. |
## Деплой
+5 -1
View File
@@ -15,7 +15,7 @@
```
/srv/pipu/
├── library/ # точки монтирования источников (storage-agent, rshared)
├── library-host/ # опционально: заранее смонтированные папки для type=local
├── library-host/ # папка для type=local по умолчанию; обычно вместо неё LIBRARY_HOST=/mnt/music
├── cache/
│ ├── transcode/
│ └── covers/
@@ -29,6 +29,10 @@
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
```
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
каталог хоста монтируется в `api`, `nginx` и `analyzer` только на чтение, в `storage-agent` — на запись
(импорт одобренных загрузок), внутри контейнеров он виден как `/library-host`.
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
+76 -17
View File
@@ -6,8 +6,9 @@
В примерах:
- домен — `music.example.com`, замените на свой;
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`;
- каталог установки — `/opt/pipu-music`.
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`, клонируется в `/opt/pipu-music`;
- диск с музыкой смонтирован в `/mnt/music`;
- данные сервиса (БД, кэши, сертификаты) — в `/srv/pipu`.
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
@@ -22,11 +23,11 @@
| Что | Требование |
|---|---|
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на диске (система, образы, кэш сборки, данные) |
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на системном диске (система, образы, кэш сборки, данные) |
| Доступ | root или пользователь с `sudo` |
| Домен | Поддомен, которым вы управляете, например `music.example.com` |
| Сеть | Входящие **80/tcp** и **443/tcp** (+ **443/udp** для HTTP/3) из интернета на сервер |
| Музыка | NFS-сервер с библиотекой (подключается позже из админки) |
| Музыка | Отдельный диск с библиотекой, подключённый к серверу (шаг 4). NFS-хранилища можно добавить позже из админки |
| Интернет на сервере | Нужен при сборке: скачиваются базовые образы и зависимости Go / npm / Python |
| Gitea | Доступ к репозиторию на чтение (если он приватный — токен или SSH-ключ) |
@@ -50,7 +51,7 @@ dig +short music.example.com
```
> Сертификат Let's Encrypt выдаётся, только когда домен уже указывает на сервер и порт 80 доступен из интернета.
> Не запускайте шаг 6, пока эта проверка не проходит.
> Не запускайте шаг 7, пока эта проверка не проходит.
---
@@ -86,7 +87,51 @@ sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp
---
## 4. Клонирование репозитория
## 4. Диск с музыкой
Музыка лежит на отдельном диске, смонтированном в `/mnt/music`. Если диск уже смонтирован — проверьте его
командой из конца шага и переходите к шагу 5.
Найдите диск и его UUID (раздел с музыкой — например, `/dev/sdb1`):
```bash
lsblk -f
```
Создайте точку монтирования:
```bash
sudo mkdir -p /mnt/music
```
Добавьте в `/etc/fstab` строку (подставьте свой UUID и файловую систему из вывода `lsblk -f`):
```ini
UUID=1234abcd-0000-0000-0000-000000000000 /mnt/music ext4 defaults,nofail,noatime 0 2
```
`nofail` нужен, чтобы сервер загружался, даже если диск не подключён. Смонтируйте и проверьте:
```bash
sudo mount -a && findmnt /mnt/music && ls /mnt/music | head
```
Сервисы Pipu работают в контейнерах не от root, поэтому музыка должна быть доступна на чтение всем:
файлы — `644`, каталоги — `755`. Если раньше права были закрытыми:
```bash
sudo chmod -R a+rX /mnt/music
```
> Для дисков с NTFS / exFAT права задаются опциями монтирования, а не `chmod`: добавьте в fstab
> `uid=0,gid=0,umask=022` вместо `defaults`.
Pipu только читает этот диск. Записывать в него будет лишь импорт одобренных треков из очереди на добавление,
и только если в админке разрешить это для хранилища ([uploads.md](uploads.md)).
---
## 5. Клонирование репозитория
```bash
sudo mkdir -p /opt/pipu-music && sudo chown "$USER" /opt/pipu-music
@@ -104,16 +149,18 @@ git clone https://gitea.hsrv.site/mrleo1nid/pipu-music.git /opt/pipu-music && cd
---
## 5. Подготовка сервера
## 6. Подготовка сервера
```bash
sudo ./install.sh
sudo LIBRARY_HOST=/mnt/music ./install.sh
```
Скрипт:
- создаёт каталоги данных в `/srv/pipu` (БД, кэши, загрузки, сертификаты);
- делает `/srv/pipu/library` точкой монтирования с shared propagation, чтобы NFS-хранилища, которые потом
подключит админ, были видны всем контейнерам, и ставит systemd-юнит, повторяющий это после перезагрузки;
- проверяет, что `/mnt/music` существует, и записывает его в `.env` как `LIBRARY_HOST` — эту папку получат
контейнеры `api`, `nginx` и `analyzer` (внутри они видят её как `/library-host`);
- создаёт `.env` со случайными паролями БД, ключом поиска и межсервисным токеном.
Заполните в `/opt/pipu-music/deploy/.env`:
@@ -124,6 +171,14 @@ DOMAIN=music.example.com
ACME_EMAIL=you@example.com
```
Проверьте, что там же стоит путь к музыке (его вписал `install.sh`):
```ini
LIBRARY_HOST=/mnt/music
```
Если диск смонтирован в другое место — поправьте путь здесь и перезапустите сервисы (`./update.sh`).
`ACME_EMAIL` — адрес, на который Let's Encrypt пишет, если с сертификатом что-то не так.
Остальные значения менять не нужно; `PIPU_VERSION` проставляет `update.sh`.
@@ -131,7 +186,7 @@ ACME_EMAIL=you@example.com
---
## 6. Сборка и запуск
## 7. Сборка и запуск
```bash
./update.sh
@@ -161,7 +216,7 @@ curl -s https://music.example.com/readyz
---
## 7. Веб-клиент
## 8. Веб-клиент
Откройте `https://music.example.com` в браузере.
@@ -176,12 +231,14 @@ curl -s https://music.example.com/readyz
Сразу после входа приложение попросит сменить логин и пароль администратора
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → адрес NFS-сервера и путь экспорта
→ «Проверить» → шаблоны путей → «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
---
## 8. Android-приложение
## 9. Android-приложение
1. На телефоне откройте страницу релизов
`https://gitea.hsrv.site/mrleo1nid/pipu-music/releases` и скачайте `pipu-music-<версия>.apk`.
@@ -201,7 +258,7 @@ curl -s https://music.example.com/readyz
---
## 9. Вариант: свой reverse proxy вместо Caddy
## 10. Вариант: свой reverse proxy вместо Caddy
Если на сервере уже есть Traefik, Nginx Proxy Manager или свой nginx с сертификатами:
@@ -238,14 +295,14 @@ curl -s https://music.example.com/readyz
}
```
3. Запуск и проверки — как в шагах 6–8.
3. Запуск и проверки — как в шагах 7–9.
Если прокси работает на другой машине, укажите в `HTTP_PORT` внутренний адрес сервера (`192.168.1.10:8081`) и
закройте этот порт от интернета файрволом.
---
## 10. Обновление, бэкапы, удаление
## 11. Обновление, бэкапы, удаление
Обновление до последнего релиза:
@@ -271,7 +328,7 @@ cd /opt/pipu-music/deploy && docker compose down
---
## 11. Если что-то не работает
## 12. Если что-то не работает
| Симптом | Что проверить |
|---|---|
@@ -282,6 +339,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
| Android: «Сервер не отвечает» | Тот же адрес открывается в браузере телефона; в адресе нет опечатки; телефон в сети с доступом к серверу |
| Android: «Эта версия приложения слишком старая» | Установите APK из последнего релиза |
+1 -1
View File
@@ -7,7 +7,7 @@
| Тип | Как подключается |
|---|---|
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
| `local` | Папка, уже смонтированная на хосте в `/srv/pipu/library-host` (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev. |
| `local` | Папка на диске сервера, заранее смонтированная на хосте (например, диск `/mnt/music` через fstab) и указанная в `LIBRARY_HOST`. В контейнерах видна как `/library-host`; админ выбирает подпапку. |
### Монтирование (storage-agent)
+1 -1
View File
@@ -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",
+8 -8
View File
@@ -111,13 +111,13 @@ importers:
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
jsdom:
specifier: ^26.1.0
version: 26.1.0
version: 26.1.0(supports-color@10.2.2)
vite:
specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/api-client:
dependencies:
@@ -137,7 +137,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/design-tokens: {}
@@ -5753,7 +5753,7 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
@@ -5938,13 +5938,13 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@26.1.0:
jsdom@26.1.0(supports-color@10.2.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
@@ -7079,7 +7079,7 @@ snapshots:
lightningcss: 1.27.0
terser: 5.51.2
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -7106,7 +7106,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
jsdom: 26.1.0
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less
+7
View File
@@ -5,3 +5,10 @@ packages:
# Пакеты, которым разрешено выполнять install-скрипты
allowBuilds:
esbuild: true
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
# expo.core.ExpoModulesPackage и release-сборка APK падает. Прямая зависимость не подходит: её запрещает expo-doctor.
publicHoistPattern:
- expo-modules-autolinking
+254
View File
@@ -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 <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);
});
}
+111
View File
@@ -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/);
});