Compare commits

...
15 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 b8cfd64208 fix(release): run React Native codegen before the APK build
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Failing after 26s
The app's CMake configure reads JNI sources generated by library codegen
(react-native-edge-to-edge), but the RN Gradle plugin does not declare that
dependency. With a warm Gradle cache configureCMake started before codegen
and v0.1.12 failed. Run generateCodegenArtifactsFromSchema in a separate
Gradle call first; both calls share one daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:37:14 +03:00
mrleo1nidandClaude Opus 5.5 0d9418df48 ci(release): skip lintVitalRelease in the APK build
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Failing after 54s
lintVital runs Google Play pre-release checks on every module (~1 min);
the APK is only published to Gitea releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:34:39 +03:00
mrleo1nidandClaude Opus 5.5 bf3c00ca71 ci(release): rename the diagnostic steps of the Android build
"Free space" no longer described them: the pre-build step now also prints
memory and the size of the persistent Gradle/Android caches, and the
failure step reports disk, memory and OOM kills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:25:50 +03:00
mrleo1nidandClaude Opus 5.5 633a8f79af ci: cache turbo, npx and Metro; install web deps before copying code
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m41s
ci / ts / ts (packages + web) (push) Successful in 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
- ci-ts restores the local turbo cache, so unchanged packages skip
  typecheck, test and build
- ci-mobile caches the npx copy of expo-doctor and Metro's transform cache
- the web image copies only workspace manifests before pnpm install, so the
  dependency layer survives code changes (CI docker job and update.sh)
- docs: table of what is cached where

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:24:29 +03:00
mrleo1nidandClaude Opus 5.5 a239650075 fix(release): public links in release notes and a cancelled status
ci / ts / ts (packages + web) (push) Successful in 1m24s
release / release (версия и заметки) (push) Successful in 45s
release / android (APK в релиз) (push) Failing after 12m0s
The job reaches the Gitea API at its internal address, so html_url in the
response pointed at http://10.10.1.129:3000 and every link in the notes was
unreachable from outside. Build links from PUBLIC_URL (repo variable,
defaults to https://gitea.hsrv.site). A cancelled Android build now shows
"cancelled" instead of staying at "building".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:09:59 +03:00
mrleo1nidandClaude Opus 5.5 718125b719 ci: split CI by area with path filters and speed up releases
ci / api / api (push) Successful in 54s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 53s
ci / recommender / recommender (push) Successful in 38s
release / release (версия и заметки) (push) Successful in 7s
ci / ts / ts (packages + web) (push) Successful in 37s
release / android (APK в релиз) (push) Canceled after 4m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m47s
- ci.yml becomes ci-api, ci-contract, ci-recommender, ci-ts, ci-mobile and
  docker workflows, each filtered by paths; docs-only commits run nothing
- docker builds all four images in one job with plain docker build on the
  runner host, so the layer cache survives between runs
- release no longer re-runs the test suite (the tagged commit already passed
  CI on main); version and release notes are one job
- the android job mounts persistent Gradle and Android SDK caches from
  /opt/ci-cache (when the runner allows the volumes) and uses --build-cache
- RELEASE_TOKEN is optional: fall back to the built-in job token
- docs: CI layout and act_runner settings (capacity, cache, valid_volumes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:55:45 +03:00
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
mrleo1nidandClaude Opus 5.5 58fd82e52b ci(release): build the APK for arm64-v8a only and report disk usage
ci / api (push) Successful in 50s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 41s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 52s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 1m20s
release / verify (push) Successful in 1m16s
release / android + release (push) Canceled after 25m58s
The v0.1.2 release ran out of disk while Gradle unpacked the NDK; building
React Native's native code for four ABIs needs several more GB. Print df
before the build and after a failure to make the next shortage obvious.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:48:33 +03:00
mrleo1nidandClaude Opus 5.5 4fa1add80d fix: address project review findings
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / api (push) Successful in 51s
ci / recommender (push) Canceled after 2s
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
Security and robustness:
- storage-agent rejects an empty INTERNAL_TOKEN (config notEmpty + middleware)
- api takes the client IP only from X-Real-IP (chi RealIP trusted True-Client-IP)
- /readyz reports ok/fail and logs the cause; checks run in parallel with a timeout
- storage-agent shuts down gracefully; shared httpx.NewServer/Serve with IdleTimeout
- goose migrations take a Postgres advisory lock
- recommender readyz: per-check timeout, redis socket timeouts

Clients:
- Android release allows cleartext HTTP for LAN servers (expo-build-properties)
- checkServer times out after 10s; connect button is disabled while checking

Deploy and CI:
- install.sh hands cache/uploads/updates/models to the non-root service users
- update.sh refuses downgrades unless PIPU_ALLOW_DOWNGRADE=1
- recommender/analyzer get only the env they need; required secrets fail fast
- nginx security headers and CSP; strip True-Client-IP
- dev compose binds ports to 127.0.0.1; postgres/redis images pinned
- version.mjs fails on a malformed explicit tag instead of releasing 0.0.0-dev
- contract check catches untracked generated files
- final releases fail without a release keystore; changelog skips rc tags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:46:46 +03:00
53 changed files with 1484 additions and 411 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
name: ci / api
on:
push:
branches: [main]
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
pull_request:
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
+59
View File
@@ -0,0 +1,59 @@
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
name: ci / contract
on:
push:
branches: [main]
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
pull_request:
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
# status, а не diff: diff не видит новые неотслеживаемые файлы
run: |
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
+51
View File
@@ -0,0 +1,51 @@
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
name: ci / mobile
on:
push:
branches: [main]
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
pull_request:
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# npx expo-doctor качает пакет в кэш npm; Metro хранит трансформации в /tmp/metro-cache
- uses: actions/cache@v4
with:
path: |
~/.npm/_npx
/tmp/metro-cache
key: mobile-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: mobile-${{ hashFiles('pnpm-lock.yaml') }}-
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+31
View File
@@ -0,0 +1,31 @@
# Python: recommender и analyzer (docs/ci-cd.md).
name: ci / recommender
on:
push:
branches: [main]
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
pull_request:
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
+53
View File
@@ -0,0 +1,53 @@
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
name: ci / ts
on:
push:
branches: [main]
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
pull_request:
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# Локальный кэш turbo: typecheck/test/build пропускаются для пакетов, чьи входы не менялись
- uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-ts-${{ github.sha }}
restore-keys: turbo-ts-
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
-108
View File
@@ -1,108 +0,0 @@
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
run: git diff --exit-code -- services/api/internal/api/gen packages
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { name: api, dockerfile: services/api/Dockerfile, target: api }
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
push: false
build-args: VERSION=ci
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+61
View File
@@ -0,0 +1,61 @@
# Сборка всех Docker-образов без push: сервер собирает их сам при обновлении, и здесь ловятся поломки
# Dockerfile и зависимостей. Только при изменении Dockerfile, deploy/ и манифестов зависимостей.
name: ci / docker
on:
push:
branches: [main]
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
pull_request:
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
- name: Сборка образов
env: { DOCKER_BUILDKIT: "1" }
run: |
set -euo pipefail
build() { echo "::group::$1"; docker build --build-arg VERSION=ci -t "pipu-ci/$1" "${@:2}" .; echo "::endgroup::"; }
build api -f services/api/Dockerfile --target api
build storage-agent -f services/api/Dockerfile --target storage-agent
build recommender -f services/recommender/Dockerfile
build web -f apps/web/Dockerfile
+97 -63
View File
@@ -1,8 +1,12 @@
# Релиз по тегу 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):
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
# Переменные (Settings → Actions → Variables):
# PUBLIC_URL (необязательно) — внешний адрес Gitea для ссылок в тексте релиза; по умолчанию https://gitea.hsrv.site
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
name: release
@@ -11,11 +15,12 @@ on:
tags: ["v*.*.*"]
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
verify:
# Тесты здесь не повторяются: тот же коммит уже проверен CI в main (ci-*.yml). Ставьте тег после зелёного CI.
release:
name: release (версия и заметки)
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.PIPU_VERSION }}
@@ -23,35 +28,50 @@ jobs:
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
steps:
- uses: actions/checkout@v4
- id: v
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2" }
- uses: pnpm/action-setup@v4
# вся история и теги: заметки строятся по коммитам с прошлого релиза
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Go
working-directory: services/api
run: go vet ./... && go test ./...
- name: Python
working-directory: services/recommender
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
- name: TypeScript
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
android-release:
name: android + release
needs: verify
runs-on: ubuntu-latest
with: { node-version: "${{ env.NODE_VERSION }}" }
- id: v
name: Версия из тега
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- name: Gitea Release
env:
PIPU_VERSION: ${{ needs.verify.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
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: ${{ steps.v.outputs.PIPU_VERSION }}
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
run: node scripts/release.mjs create
android:
name: android (APK в релиз)
needs: release
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
container:
image: docker.gitea.com/runner-images:ubuntu-latest
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
# для scripts/release.mjs
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 }}
VERSION_CODE: ${{ needs.release.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
@@ -64,14 +84,45 @@ jobs:
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
- name: Ресурсы раннера и кэши
run: |
df -h / /tmp; free -m
du -sh ~/.gradle ~/.android 2>/dev/null || true
- 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.
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
npx expo prebuild --platform android --clean --no-install
cd android && ./gradlew --no-daemon assembleRelease
cd android
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
"${gradle[@]}" generateCodegenArtifactsFromSchema
"${gradle[@]}" assembleRelease -x lintVitalRelease
./gradlew --stop
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
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:
PRERELEASE: ${{ needs.release.outputs.prerelease }}
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -86,44 +137,27 @@ 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
else
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
fi
"$bt/apksigner" verify --print-certs "$out" | head -3
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo "APK=$out" >> "$GITHUB_ENV"
- name: Changelog
run: |
prev="$(git describe --tags --abbrev=0 "${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
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
+2 -2
View File
@@ -20,7 +20,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
| `.gitea/workflows` | CI по областям (`ci-*.yml`, `docker.yml`) и релиз по тегу (`release.yml`) |
## Разработка
@@ -65,6 +65,6 @@ pnpm generate && (cd services/api && go generate ./...)
## Релиз
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → подписанный APK → Gitea Release.
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
+1 -1
View File
@@ -85,7 +85,7 @@ components:
description: Результат проверки каждой зависимости
additionalProperties:
type: string
description: "`ok` или текст ошибки"
description: "`ok` или `fail` (причина — только в логе сервиса)"
ServerInfo:
type: object
required: [name, version, api_version, supported_locales, min_android_version_code]
+6 -1
View File
@@ -1,6 +1,6 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=10203.
// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=1020399.
const version = process.env.PIPU_VERSION ?? '0.0.0';
const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1');
@@ -22,5 +22,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
permissions: ['REQUEST_INSTALL_PACKAGES'],
},
platforms: ['android'],
plugins: [
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
],
extra: { versionCode },
});
+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;
+1
View File
@@ -15,6 +15,7 @@
"@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*",
"expo": "~53.0.20",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.7",
"expo-status-bar": "~2.2.3",
"i18next": "^25.2.1",
+10 -1
View File
@@ -17,7 +17,10 @@ export function ConnectScreen() {
const [address, setAddress] = useState('');
const [state, setState] = useState<State>({ kind: 'idle' });
const checking = state.kind === 'checking';
async function onCheck() {
if (checking) return;
const baseUrl = normalizeServerUrl(address);
if (!baseUrl) {
setState({ kind: 'invalid' });
@@ -39,6 +42,7 @@ export function ConnectScreen() {
value={address}
onChangeText={setAddress}
onSubmitEditing={onCheck}
editable={!checking}
placeholder="https://music.home.lan"
placeholderTextColor={colors.text3}
autoCapitalize="none"
@@ -62,7 +66,12 @@ export function ConnectScreen() {
)}
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
<Pressable onPress={onCheck} style={({ pressed }) => [styles.button, pressed && { opacity: 0.85 }]}>
<Pressable
onPress={onCheck}
disabled={checking}
accessibilityState={{ disabled: checking, busy: checking }}
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
>
<Text style={styles.buttonText}>{t('server.check')}</Text>
</Pressable>
</View>
+11 -1
View File
@@ -5,14 +5,24 @@
FROM node:22-alpine AS build
RUN npm install -g pnpm@11.9.0
WORKDIR /repo
COPY . .
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/api-client/package.json packages/api-client/
COPY packages/core/package.json packages/core/
COPY packages/design-tokens/package.json packages/design-tokens/
COPY packages/i18n/package.json packages/i18n/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter @pipu/web...
COPY . .
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
RUN pnpm --filter @pipu/web build
FROM nginx:1.28-alpine
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY deploy/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
ARG VERSION=0.0.0-dev
LABEL org.opencontainers.image.version=${VERSION}
+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.
+8 -7
View File
@@ -3,16 +3,17 @@
# docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников
# Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080),
# api без docker — см. README, раздел «Разработка».
# Порты слушают только 127.0.0.1: у Redis нет пароля, а у dev-Postgres он известен всем.
name: pipu-dev
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector:0.8.7-pg17
environment:
POSTGRES_USER: pipu
POSTGRES_PASSWORD: pipu
POSTGRES_DB: pipu
ports: ["5432:5432"]
ports: ["127.0.0.1:5432:5432"]
volumes: [pg-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
@@ -20,8 +21,8 @@ services:
retries: 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
image: redis:7.4-alpine
ports: ["127.0.0.1:6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -32,7 +33,7 @@ services:
environment:
MEILI_MASTER_KEY: dev-master-key-change-me
MEILI_ENV: development
ports: ["7700:7700"]
ports: ["127.0.0.1:7700:7700"]
volumes: [meili-data:/meili_data]
api:
@@ -45,7 +46,7 @@ services:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0
LOG_LEVEL: debug
ports: ["8080:8080"]
ports: ["127.0.0.1:8080:8080"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
@@ -59,7 +60,7 @@ services:
DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu
REDIS_URL: redis://redis:6379/0
INTERNAL_TOKEN: dev-internal-token
ports: ["8000:8000"]
ports: ["127.0.0.1:8000:8000"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
+29 -8
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]
@@ -45,9 +54,10 @@ services:
args: *version
env_file: .env
environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD}@postgres:5432/pipu?sslmode=disable
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
@@ -67,13 +77,17 @@ services:
cap_add: [SYS_ADMIN]
security_opt: ["apparmor:unconfined"]
environment:
INTERNAL_TOKEN: ${INTERNAL_TOKEN}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LIBRARY_ROOT: /library
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- type: bind
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:
@@ -81,8 +95,12 @@ services:
image: pipu-music-recommender:${PIPU_VERSION:-dev}
build: *build-rec
command: ["serve"]
env_file: .env
environment: *db-env
# только нужные переменные, а не весь .env с секретами Meilisearch и Gitea
environment:
<<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on:
postgres: { condition: service_healthy }
@@ -91,10 +109,13 @@ services:
image: pipu-music-recommender:${PIPU_VERSION:-dev}
build: *build-rec
command: ["analyze", "--workers", "4"]
env_file: .env
environment: *db-env
environment:
<<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/models:/models
deploy: { resources: { limits: { cpus: "4" } } }
depends_on:
@@ -102,7 +123,7 @@ services:
postgres:
<<: *restart
image: pgvector/pgvector:pg17
image: pgvector/pgvector:0.8.7-pg17
environment:
POSTGRES_USER: pipu
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@@ -115,7 +136,7 @@ services:
redis:
<<: *restart
image: redis:7-alpine
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ]
+26 -2
View File
@@ -12,6 +12,21 @@ DATA="${PIPU_DATA:-/srv/pipu}"
echo "→ каталоги в $DATA"
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config}
# Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001).
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
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
@@ -30,7 +45,7 @@ Before=docker.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'findmnt -n $DATA/library || mount --bind $DATA/library $DATA/library; mount --make-rshared $DATA/library'
ExecStart=/bin/sh -c 'findmnt -n "$DATA/library" || mount --bind "$DATA/library" "$DATA/library"; mount --make-rshared "$DATA/library"'
RemainAfterExit=yes
[Install]
@@ -45,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"
+7
View File
@@ -26,6 +26,8 @@ server {
root /usr/share/nginx/html;
client_max_body_size 16m;
include /etc/nginx/snippets/security-headers.conf;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
@@ -37,6 +39,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $pipu_scheme;
# api берёт IP клиента только из X-Real-IP; остальные заголовки клиент может подделать
proxy_set_header True-Client-IP "";
}
location = /healthz { proxy_pass http://pipu_api; }
@@ -44,17 +48,20 @@ 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; }
location /assets/ {
expires 1y;
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
+6
View File
@@ -0,0 +1,6 @@
# Заголовки безопасности для SPA. Подключается через include: add_header внутри location
# отменяет унаследованные с уровня server, поэтому include повторяется там, где есть свои add_header.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
+17
View File
@@ -26,6 +26,23 @@ elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
REF="v$REF"
fi
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
TARGET="${REF#v}"
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
cur_core="$(core "$CURRENT" || true)"
tgt_core="$(core "$TARGET" || true)"
# сравниваем только MAJOR.MINOR.PATCH: переход 0.2.0-rc.1 → 0.2.0 откатом не считается
if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
[ "$(printf '%s\n%s\n' "$cur_core" "$tgt_core" | sort -V | head -1)" = "$tgt_core" ]; then
echo "внимание: $CURRENT → $TARGET — это откат. Миграции БД не откатываются;" >&2
echo " сделайте резервную копию (docs/deployment.md) и проверьте, что старая версия совместима со схемой." >&2
if [ "${PIPU_ALLOW_DOWNGRADE:-}" != 1 ]; then
echo " Чтобы продолжить: PIPU_ALLOW_DOWNGRADE=1 ./update.sh $REF" >&2
exit 1
fi
fi
echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
+1 -1
View File
@@ -114,7 +114,7 @@ pipu-music/
├── tests/
│ ├── e2e/ # Playwright
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
├── .gitea/workflows/ # ci.yml, release.yml
├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
├── docs/
├── turbo.json pnpm-workspace.yaml package.json
└── README.md
+91 -33
View File
@@ -3,6 +3,10 @@
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
с доступом к Docker.
Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
## Версионирование
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
@@ -13,28 +17,43 @@ Workflows лежат в `.gitea/workflows/`. Синтаксис совмести
(тесты — `pnpm test:scripts`).
- Changelog — `git-cliff` из Conventional Commits.
## `ci.yml` — на каждый push и PR
## CI — на каждый push в `main` и PR
```yaml
on:
push: { branches: [main] }
pull_request:
Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
jobs:
changes: # фильтр путей: api / recommender / web / mobile / packages / docs
contract: # spectral lint openapi.yaml; регенерация Go + TS клиентов; git diff --exit-code
api: # go vet, golangci-lint, go test -race ./... (testcontainers), покрытие
recommender: # uv sync; ruff; mypy --strict; pytest (вкл. оффлайн-оценку качества)
ts: # pnpm install --frozen-lockfile; turbo lint typecheck test; i18n:check
web-build: # vite build, проверка размера бандла
docker: # сборка всех Docker-образов без push (сервер собирает их сам — ADR-013)
mobile-check: # tsc, jest, expo prebuild + ./gradlew assembleDebug (только если менялся mobile/)
e2e: # needs: api, web-build; docker compose -f deploy/compose.test.yaml up; playwright test
```
| Workflow | Job | Что делает | Когда |
|---|---|---|---|
| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
- Кэши: Go modules / build cache, pnpm store, uv cache, Gradle, сгенерированные тестовые аудио.
- Ветка `main` защищена: обязательны `contract`, `api`, `recommender`, `ts`, `e2e`.
- Ночью (`schedule`) — Maestro E2E на эмуляторе и полный прогон без фильтров путей.
Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
ветку отменяет устаревший прогон.
`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
### Кэши
| Что | Где кэшируется | Чем |
|---|---|---|
| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
| Go-модули и build cache | `api`, `contract` | `setup-go` |
| uv | `recommender` | `setup-uv` `enable-cache` |
| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
`Cache action is only supported on GHES…`, и всё скачивается заново.
## `release.yml` — по тегу
@@ -44,29 +63,68 @@ 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
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
android: # needs: release — собирает APK и докладывает его в релиз
```
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
после зелёного CI** — релиз сам этого не проверяет.
Релиз появляется в первую же минуту, не дожидаясь 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 --build-cache` (только 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.
## Настройка раннера
Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
```yaml
runner:
capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
cache:
enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
port: 8088 # фиксированный порт; открыть его для docker-сетей
container:
valid_volumes:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
Обслуживание хоста раннера (cron, раз в сутки):
```
0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
```
Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
следующий релиз скачает всё заново.
## Секреты
| Секрет | Назначение |
|---|---|
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. |
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
адресу, и ссылки из ответа API ведут туда же.
## Деплой
+7 -3
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 для повторения после перезагрузки).
@@ -46,8 +50,8 @@
| `api` | `services/api/Dockerfile`, target `api` | Миграции при старте |
| `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` |
| `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` |
| `postgres` | `pgvector/pgvector:pg17` | Healthcheck `pg_isready` |
| `redis` | `redis:7-alpine` | AOF |
| `postgres` | `pgvector/pgvector:0.8.7-pg17` | Healthcheck `pg_isready` |
| `redis` | `redis:7.4-alpine` | AOF |
| `meilisearch` | `getmeili/meilisearch:v1.11` | |
| `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов |
+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",
+1 -1
View File
@@ -1 +1 @@
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+5
View File
@@ -52,6 +52,11 @@ describe('checkServer', () => {
expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' });
});
it('reports offline when the server does not answer in time', async () => {
const client = createApiClient({ baseUrl: 'https://music.test', fetch: () => new Promise<Response>(() => {}) });
await expect(checkServer(client, undefined, 20)).resolves.toEqual({ status: 'offline', error: 'timeout' });
});
it('reports offline with the problem code on API errors', async () => {
const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500)));
expect(res).toEqual({ status: 'offline', error: 'common.internal' });
+25 -2
View File
@@ -25,19 +25,42 @@ export type ServerCheck =
| { status: 'unsupported_app'; info: ServerInfo }
| { status: 'offline'; error: string };
/** Сколько ждать ответа сервера, прежде чем считать его недоступным. */
export const SERVER_CHECK_TIMEOUT_MS = 10_000;
/**
* Проверяет сервер через GET /api/v1/server-info.
* appVersionCode — versionCode Android-клиента; для веба не передаётся.
*/
export async function checkServer(client: ApiClient, appVersionCode?: number): Promise<ServerCheck> {
export async function checkServer(
client: ApiClient,
appVersionCode?: number,
timeoutMs = SERVER_CHECK_TIMEOUT_MS,
): Promise<ServerCheck> {
// Гонка с таймером, а не только abort: не каждая реализация fetch (Hermes, полифилы)
// надёжно прерывает запрос по сигналу. AbortSignal.timeout не используется — его может не быть в Hermes.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error('timeout'));
}, timeoutMs);
});
try {
const { data, error } = await client.GET('/api/v1/server-info');
const { data, error } = await Promise.race([
client.GET('/api/v1/server-info', { signal: controller.signal }),
timeout,
]);
if (!data) return { status: 'offline', error: error ? error.code : 'unknown' };
if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) {
return { status: 'unsupported_app', info: data };
}
return { status: 'ok', info: data };
} catch (e) {
if (controller.signal.aborted) return { status: 'offline', error: 'timeout' };
return { status: 'offline', error: e instanceof Error ? e.message : String(e) };
} finally {
clearTimeout(timer);
}
}
+80 -51
View File
@@ -31,13 +31,16 @@ importers:
version: link:../../packages/i18n
expo:
specifier: ~53.0.20
version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
expo-build-properties:
specifier: ~0.14.8
version: 0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))
expo-constants:
specifier: ~17.1.7
version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
expo-status-bar:
specifier: ~2.2.3
version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
i18next:
specifier: ^25.2.1
version: 25.10.10(typescript@5.8.3)
@@ -46,10 +49,10 @@ importers:
version: 19.0.0
react-i18next:
specifier: ^15.5.3
version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3)
version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3)
react-native:
specifier: 0.79.6
version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
devDependencies:
'@types/react':
specifier: ~19.0.10
@@ -1370,6 +1373,9 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
anser@1.4.10:
resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
@@ -1880,6 +1886,11 @@ packages:
react: '*'
react-native: '*'
expo-build-properties@0.14.8:
resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==}
peerDependencies:
expo: '*'
expo-constants@17.1.8:
resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==}
peerDependencies:
@@ -1943,6 +1954,9 @@ packages:
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
fb-watchman@2.0.4:
resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==}
@@ -4454,11 +4468,11 @@ snapshots:
'@expo/sudo-prompt@9.3.2': {}
'@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
'@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
dependencies:
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
'@expo/ws-tunnel@1.0.6': {}
@@ -4652,13 +4666,13 @@ snapshots:
nullthrows: 1.1.1
yargs: 17.7.3
'@react-native/community-cli-plugin@0.79.6':
'@react-native/community-cli-plugin@0.79.6(supports-color@10.2.2)':
dependencies:
'@react-native/dev-middleware': 0.79.6
chalk: 4.1.2
debug: 2.6.9
invariant: 2.2.4
metro: 0.82.5
metro: 0.82.5(supports-color@10.2.2)
metro-config: 0.82.5
metro-core: 0.82.5
semver: 7.8.5
@@ -4693,12 +4707,12 @@ snapshots:
'@react-native/normalize-colors@0.79.6': {}
'@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
'@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
dependencies:
invariant: 2.2.4
nullthrows: 1.1.1
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
optionalDependencies:
'@types/react': 19.0.14
@@ -4995,6 +5009,13 @@ snapshots:
agent-base@7.1.4: {}
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.8
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
anser@1.4.10: {}
ansi-colors@4.1.3: {}
@@ -5511,39 +5532,45 @@ snapshots:
expect-type@1.4.0: {}
expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
'@expo/image-utils': 0.7.6
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)):
expo-build-properties@0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)):
dependencies:
ajv: 8.20.0
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
semver: 7.8.5
expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)):
dependencies:
'@expo/config': 11.0.13
'@expo/env': 1.0.7
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)):
expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)):
dependencies:
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
fontfaceobserver: 2.3.0
react: 19.0.0
expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
react: 19.0.0
expo-modules-autolinking@2.1.15:
@@ -5560,14 +5587,14 @@ snapshots:
dependencies:
invariant: 2.2.4
expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2):
expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2):
dependencies:
'@babel/runtime': 7.29.10
'@expo/cli': 0.24.24(supports-color@10.2.2)
@@ -5575,18 +5602,18 @@ snapshots:
'@expo/config-plugins': 10.1.2
'@expo/fingerprint': 0.13.4(supports-color@10.2.2)
'@expo/metro-config': 0.20.18
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
babel-preset-expo: 13.2.5(@babel/core@7.29.7)(supports-color@10.2.2)
expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-modules-autolinking: 2.1.15
expo-modules-core: 2.5.0
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
whatwg-url-without-unicode: 8.0.0-3
transitivePeerDependencies:
- '@babel/core'
@@ -5602,6 +5629,8 @@ snapshots:
fast-json-stable-stringify@2.1.0: {}
fast-uri@3.1.8: {}
fb-watchman@2.0.4:
dependencies:
bser: 2.1.2
@@ -6076,7 +6105,7 @@ snapshots:
cosmiconfig: 5.2.1
flow-enums-runtime: 0.0.6
jest-validate: 29.7.0
metro: 0.82.5
metro: 0.82.5(supports-color@10.2.2)
metro-cache: 0.82.5
metro-core: 0.82.5
metro-runtime: 0.82.5
@@ -6163,7 +6192,7 @@ snapshots:
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
metro: 0.82.5
metro: 0.82.5(supports-color@10.2.2)
metro-babel-transformer: 0.82.5
metro-cache: 0.82.5
metro-cache-key: 0.82.5
@@ -6176,7 +6205,7 @@ snapshots:
- supports-color
- utf-8-validate
metro@0.82.5:
metro@0.82.5(supports-color@10.2.2):
dependencies:
'@babel/code-frame': 7.29.7
'@babel/core': 7.29.7
@@ -6516,40 +6545,40 @@ snapshots:
react-dom: 19.3.1(react@19.3.1)
typescript: 5.8.3
react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3):
react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.29.10
html-parse-stringify: 3.1.0
i18next: 25.10.10(typescript@5.8.3)
react: 19.0.0
optionalDependencies:
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
typescript: 5.8.3
react-is@17.0.2: {}
react-is@18.3.1: {}
react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0):
react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2):
dependencies:
'@jest/create-cache-key-function': 29.7.0
'@react-native/assets-registry': 0.79.6
'@react-native/codegen': 0.79.6(@babel/core@7.29.7)
'@react-native/community-cli-plugin': 0.79.6
'@react-native/community-cli-plugin': 0.79.6(supports-color@10.2.2)
'@react-native/gradle-plugin': 0.79.6
'@react-native/js-polyfills': 0.79.6
'@react-native/normalize-colors': 0.79.6
'@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
'@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
abort-controller: 3.0.0
anser: 1.4.10
ansi-regex: 5.0.1
+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
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env node
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
// докладывает в него APK и переписывает блок статуса в тексте.
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
// из ответа указывает на него же).
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SECTIONS = [
['feat', 'Новое'],
['fix', 'Исправления'],
['perf', 'Производительность'],
['refactor', 'Рефакторинг'],
['docs', 'Документация'],
['build', 'Сборка и CI'],
['ci', 'Сборка и CI'],
];
const OTHER = 'Прочее';
const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
export function groupCommits(commits) {
const breaking = [];
const byTitle = new Map();
for (const { sha, subject } of commits) {
const m = COMMIT_RE.exec(subject);
const item = m ? { sha, scope: m[2] ?? null, text: m[4] } : { sha, scope: null, text: subject };
if (m?.[3]) breaking.push(item);
const title = (m && SECTIONS.find(([t]) => t === m[1])?.[1]) ?? OTHER;
if (!byTitle.has(title)) byTitle.set(title, []);
byTitle.get(title).push(item);
}
const order = [...new Set(SECTIONS.map(([, t]) => t)), OTHER];
return { breaking, sections: order.filter((t) => byTitle.has(t)).map((t) => [t, byTitle.get(t)]) };
}
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} МБ`;
/** Блок статуса сборки Android в тексте релиза. */
export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing }) {
const lines =
status === 'success'
? [
`✅ **Android:** APK собран — \`${apk.name}\` (${formatSize(apk.size)}), versionCode \`${versionCode}\`.`,
`SHA-256: \`${apk.sha256}\``,
...(signing === 'debug'
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
: []),
]
: status === 'cancelled'
? [
`⏹ **Android:** сборка APK отменена — [лог сборки](${runUrl}).`,
'APK появится здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
]
: [
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
'Файл появится в этом релизе автоматически, а этот текст обновится.',
];
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
}
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = [];
if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
}
out.push(androidStatus, '', '## Что изменилось', '');
if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
}
if (groups.sections.length) {
for (const [title, items] of groups.sections) out.push(`### ${title}`, '', ...items.map(item), '');
} else {
out.push('Изменений с прошлой версии нет.', '');
}
out.push(
prevTag
? `Все изменения: [${prevTag}...${tag}](${repoUrl}/compare/${prevTag}...${tag})`
: `Все изменения: [история ${tag}](${repoUrl}/commits/tag/${tag})`,
'',
'## Обновление сервера',
'',
'Перед обновлением сделайте бэкап базы:',
'',
'```bash',
'cd /opt/pipu-music/deploy && docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump',
'```',
'',
'Обновление:',
'',
'```bash',
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
'```',
'',
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
'',
'```bash',
'curl -s https://<ваш-домен>/api/v1/server-info',
'```',
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'',
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
'',
);
return out.join('\n');
}
// ---------------------------------------------------------------------------------------------------------
// CLI
function env(name) {
const v = process.env[name];
if (!v) throw new Error(`не задана переменная окружения ${name}`);
return v;
}
async function api(method, path, { json, form } = {}) {
const res = await fetch(`${env('GITEA_API')}${path}`, {
method,
headers: {
Authorization: `token ${env('GITEA_TOKEN')}`,
...(json ? { 'Content-Type': 'application/json' } : {}),
},
body: json ? JSON.stringify(json) : form,
});
if (res.status === 404 && method === 'GET') return null;
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
function previousTag(tag, prerelease) {
// для финального релиза — изменения с прошлого финального, а не с последнего rc
const exclude = prerelease ? [] : ['--exclude', '*-rc*'];
try {
return git('describe', '--tags', '--abbrev=0', ...exclude, `${tag}^`);
} catch {
return null;
}
}
function commitsBetween(prevTag, tag) {
const out = git('log', '--no-merges', '--format=%H%x09%s', prevTag ? `${prevTag}..${tag}` : tag);
return out
? out.split('\n').map((l) => {
const [sha, ...rest] = l.split('\t');
return { sha, subject: rest.join('\t') };
})
: [];
}
async function context() {
const repo = await api('GET', '');
const publicUrl = process.env.PUBLIC_URL?.replace(/\/+$/, '');
const repoUrl = publicUrl ? `${publicUrl}/${repo.full_name}` : repo.html_url;
const tag = env('TAG');
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
return { repoUrl, tag, release, runUrl: `${repoUrl}/actions/runs/${env('GITHUB_RUN_ID')}` };
}
async function create() {
const { repoUrl, tag, release, runUrl } = await context();
const prerelease = env('PRERELEASE') === 'true';
const prevTag = previousTag(tag, prerelease);
const body = renderNotes({
version: env('VERSION'),
tag,
prevTag,
prerelease,
repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)),
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
});
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
const saved = release
? await api('PATCH', `/releases/${release.id}`, { json: fields })
: await api('POST', '/releases', { json: { tag_name: tag, ...fields } });
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
}
async function upload(release, file) {
const name = basename(file);
for (const old of release.assets ?? []) {
if (old.name === name) await api('DELETE', `/releases/${release.id}/assets/${old.id}`);
}
const form = new FormData();
form.append('attachment', new Blob([readFileSync(file)]), name);
await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { form });
console.log(`приложен ${name}`);
}
async function android(status, apkPath) {
const { release, runUrl } = await context();
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
let apk;
if (status === 'success') {
if (!apkPath) throw new Error('android success: укажите путь к APK');
const data = readFileSync(apkPath);
apk = { name: basename(apkPath), size: data.length, sha256: createHash('sha256').update(data).digest('hex') };
for (const f of [apkPath, `${apkPath}.sha256`]) if (existsSync(f)) await upload(release, f);
}
const block = renderAndroidStatus({
status,
runUrl,
apk,
versionCode: process.env.VERSION_CODE,
signing: process.env.SIGNING,
});
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceAndroidStatus(release.body ?? '', block) } });
console.log(`статус Android: ${status}`);
}
async function main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]);
}
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main().catch((e) => {
process.stderr.write(`${e.message}\n`);
process.exit(1);
});
}
+112
View File
@@ -0,0 +1,112 @@
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' }), /не удалась — \[лог сборки\]/);
assert.match(renderAndroidStatus({ status: 'cancelled', 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/);
});
+19 -4
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование).
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT)
// node scripts/version.mjs → берёт GITHUB_REF_NAME, иначе 0.0.0-dev
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT);
// тег не по формату — ошибка (релиз не должен стать 0.0.0-dev)
// node scripts/version.mjs → берёт GITHUB_REF_NAME, если это релизный тег, иначе 0.0.0-dev
//
// Android versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N),
// поэтому 1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1.
@@ -23,9 +24,23 @@ export function parseVersion(tag) {
return { version, versionCode, prerelease: rc !== null };
}
const DEV = { version: '0.0.0-dev', versionCode: 1, prerelease: true };
export function resolveVersion(argTag, refName) {
if (argTag !== undefined) return parseVersion(argTag);
const ref = refName ?? '';
return TAG_RE.test(ref) ? parseVersion(ref) : DEV;
}
function main() {
const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? '';
const v = TAG_RE.test(tag) ? parseVersion(tag) : { version: '0.0.0-dev', versionCode: 1, prerelease: true };
let v;
try {
v = resolveVersion(process.argv[2], process.env.GITHUB_REF_NAME);
} catch (e) {
process.stderr.write(`${e.message}
`);
process.exit(1);
}
process.stdout.write(
`PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`,
);
+12 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseVersion } from './version.mjs';
import { parseVersion, resolveVersion } from './version.mjs';
test('release tag', () => {
assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false });
@@ -23,3 +23,14 @@ test('rejects malformed tags', () => {
assert.throws(() => parseVersion(tag), undefined, tag);
}
});
test('explicit tag must be a release tag', () => {
assert.throws(() => resolveVersion('v1.2.3-beta', undefined));
assert.equal(resolveVersion('v1.2.3', 'main').version, '1.2.3');
});
test('branch builds fall back to the dev version', () => {
assert.equal(resolveVersion(undefined, 'main').version, '0.0.0-dev');
assert.equal(resolveVersion(undefined, undefined).version, '0.0.0-dev');
assert.equal(resolveVersion(undefined, 'v2.0.0').version, '2.0.0');
});
+4 -32
View File
@@ -3,13 +3,10 @@ package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
@@ -63,37 +60,12 @@ func run() error {
Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
}, map[string]system.Checker{
}, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
})
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: server.NewRouter(server.Deps{Log: log, System: sys}),
ReadHeaderTimeout: 10 * time.Second,
}
return serve(ctx, log, srv, cfg.ShutdownTimeout)
}
func serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return srv.Shutdown(shutdownCtx)
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
+21 -17
View File
@@ -2,10 +2,11 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"os/signal"
"syscall"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config"
@@ -14,21 +15,24 @@ import (
)
func main() {
cfg, err := config.Load[config.StorageAgent]()
if err != nil {
slog.Error("storage-agent: config", "err", err)
os.Exit(1)
}
log := httpx.NewLogger(cfg.LogLevel)
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: storageagent.NewRouter(cfg.InternalToken, cfg.LibraryRoot),
ReadHeaderTimeout: 10 * time.Second,
}
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
if err := srv.ListenAndServe(); err != nil {
log.Error("storage-agent stopped", "err", err)
if err := run(); err != nil {
slog.Error("storage-agent stopped", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load[config.StorageAgent]()
if err != nil {
return err
}
log := httpx.NewLogger(cfg.LogLevel)
slog.SetDefault(log)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouter(log, cfg.InternalToken, cfg.LibraryRoot))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
+22 -22
View File
@@ -474,28 +474,28 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/7xWX28bRRD/KquFh1hcbIdSQf1WVUD7gBQliJc2SjZ322Sb+8feXkSILMUJapFSGqkg",
"xANq6Tdwgt0cdn35CrNfgU+CZu/OvnMuITyUJ/t2Z3fm95vfzM4+tQMvDHzuq4h29qnkURj4ETcfyzLY",
"dLmHf+3AV9xX+JeFoStspkTgt8LM4qMnUeDjXmRvc4/hvw8lf0w79IPW7P5Wthu1inu73a5FHR7ZUoR4",
"He1QeKV/hAROYQR9srDyxT1y55PbnzYoWubH8fb7nLlqe1UxFZvvUAYhl0pkcUfTde7HHu08pMEOXbOo",
"2gs57dBISeFvmSsl/zYWkjtok5+a2QWbT7itaNcqE1H1ZAcOx985EL9D38CYwEQfw18ERpDCgEA6BZcQ",
"GMBYnxCx9JlvEZhAHy70ASTwDob6gGywWG03hb/LXOGs25I73FeCudEGvYQDOVRMuDWBvIZUn+geTGAI",
"E0hgWLiFMaTwJ6RwRhZwd7p+ASlyD+fQzz7G+jmcoyH09SEMYaxfNOpiCJlkniGFOY7AAJi7XCJLyZjX",
"UDvLVb4lfMW3uMS9bAVBPWaxq2iHss0gVp1Nl/k79Ib5tLIk1aV1hTNn7yoR2dvc3rkGzzzXG8HOBoEE",
"xpAQQ9RI9/RhJef1qask7A8Ywrk+Qtb1ITJOjCyQ/qE+yISD6XkLA0hRWefQhzNIdM9IJ0WfZUd1RJeK",
"Av1vSeZw58b1YRXM1DG6yuUulw/8x8FlQlko1ne5jETWKvh3zAtdPL67VEeMJ/x15jsyEE5xbP3KckuM",
"ut9B38g1K7r80L3A4eRudtFiXmMo/rdZSegTy5SnPoTUED3MZD+AgWH8LSQofRjqQ6J7ZsmkglozBEvt",
"9p07Vo2AfebxKtZQhPGiF0fCrsMcxWEYSMWddTewmZvxNj38kMoY3fpIvVDcK5fN7JJ8gUnJ9vC7xPkc",
"by8RCCpHn1SwYetd5d43XDbKMGm7udRs/2vdGdAzt1Yl8XUYr8n1ZY2hN5Hra77Z6SM4xfrTz3IJrHy+",
"+jW5u/yALIswJl8h7U0Cv5kiSdEKEhgRrB6sGjxU9MF5OgIWikUMaYv7jUd+VoZj7KgwwcuwjwYh99EM",
"I84CazQJvDGNdmxehFxx5O+DX4gT2FGLhaLpOc1HSIwSyrA8ixVDLzFZJKBr0dwV7dBbzXbzFsX2q7aN",
"HvDO1u5SKzKluFhwtcVVnQL0MZzr40LhuodPwCn2IAJ9fWT6GOJL9A9IFfT1U0gg6RBkBk4JJEVpVfqU",
"PtEvsHWdTQX2Yo5R65Gve8Yc37uM/rx96edVbvvopb4m4Z0+hiHRJwYENscF/ROM0AFMijMjfLL0s4L8",
"RsY2diYzwzxwaId+yVWpdVnVMejjdvuaEei/jT4lL3XTzxvD62AqFEirvA2zJyN/DutdTWMvzVlYdp7H",
"5N6NnEyHgTmJo0jZVmQeg71IcY+u4dWtbTONfV+S2SV27+cm75HaykxYR+5rFKh+avTWI5mK5rn51Uhr",
"qsOL8hHoX0WAxEHiWvwrmcV7hF8eZurQv8QcXzEvYMIzzEdwgW0QZXa7fet/C+4VDHAELuYnnEh1z0zN",
"+J6PIK0PfIgT0MSIeS78alZ/zt53OIPJLLcpnOoejPVR0U+y5fN8Eke7Y7JwQ8IatdLAKEzB4/o+jaVL",
"O7RFu2tT4/18RigOdde6/wQAAP//nG+aXpMNAAA=",
"H4sIAAAAAAAC/7xW3W4bRRR+ldHARSw2tkOpoL6rKqC9QIoSxE0bJZPdSTLN/jE7GxEiS3GCWqSURioI",
"cYFa+gabYDdLHG9e4cwr8CTozO7aa2cTwkW5snf3zPn5vu+cOXvUDrww8LmvItrZo5JHYeBH3DwsymDd",
"5R7+tQNfcV/hXxaGrrCZEoHfCnOLj55GgY/fInuLewz/fSj5Bu3QD1oT/638a9Qq/Xa7XYs6PLKlCNEd",
"7VB4rX+EFE7gHBIyt/TFA3Lvk7ufNihaFsfR+0POXLW1rJiKzXMog5BLJfK8o/F77sce7TymwTZdsaja",
"DTnt0EhJ4W8al5J/GwvJHbQpTk3sgvWn3Fa0a1WBmI5kBw7H35kifofElDGCkT6CvwicQwZ9Atm4uJRA",
"H4b6mIiFz3yLwAgSuNT7kMIFDPQ+WWOx2moKf4e5wlm1JXe4rwRzozV6pQ7EUDHh1iTyBjJ9rHswggGM",
"IIVBGRaGkMGfkMEpmcOv4/eXkCH2cAZJ/jDUL+AMDSHRBzCAoX7ZqMshZJJ5BhTmOAITYO5iBSwlY14D",
"7YSr4pPwFd/kEr/lb7CoDRa7inYoWw9i1Vl3mb9Nb8mnlZNUR+sSZ87udSKyt7i9fUM9s1ivBdtrBFIY",
"QkrWNphw18hcTql+bqSQkL/3fyH6oMD0HDICpyURA6J7SDycQqp7kDTqaZ4i9w8YwJk+RG/6ANkhJh5S",
"hZ6MyJDKd9CHDFV4BknhH2WW6Z4+gJTeSEqlgTD+pmQOd27dS1aJYh36y1zucPnI3wiugs9CsbrDZSTy",
"scK/Y17o4vGdhTpgPOGvMt+RgXDKY6vXtmZqOuECEkND3qDFoQeBw8n93NF80Y/Iz7u8ffSxZVoZOTRA",
"D/IW6UPfIP4OUmwTGOiDCZ0DvU+tSQUL7fa9e1aN2H3m8elaQxHG814cCbuu5igOw0Aq7qy6gc3cHLfx",
"4cdUxhjWR+iF4l61xSZOihdMSraLzxXMZ3B7hYWgcvTxVG04ppe59w2XjWqZtN1caLb/tUdN0ZOw1hTx",
"dTXewPVVjWE0UehrdjDqQzjBXtXPCwksfb78Nbm/+IgsijAmXyHsTQK/mSbJ0ApSOCfYPdg1eKicmbNw",
"BCwU85jSJvcbT/y8DYc4fWFk2v+UzAUh99EMM84TazQJvDWzYGhuj0JxZmg4gR21WCiantN8gsAooQzK",
"k1wx9QqSJQFdixahaIfeababdyiOarVl9IA+WzsLrci04nyJ1SZXdQrQR3Cmj0qF6x5eFyc4gwgk+tBc",
"Dlhfqn9AqCDRzyCFtEMQGTghkJatNTWn9LF+iaPrdCywlzOIWk983TPmeDfm8BfjS7+YxjbBKPU9CRf6",
"CMfssSkCh+Oc/gnOMQCMyjPneL3p5yX4jRxtnExm33nk0A79kqvK6LKmV6aP2+0b1qX/tiZVotRtSm8N",
"rv2xUCCbxm2QXxnF1Vkfapx7ZSfDtvM8JndvFWS8OMxIHEXKNiNzGexGint0BV23tszm9n1FZlfQfViY",
"vEdop/bHOnDfoED1M6O3HslVNIvNr0ZaYx1eVo9Ach0AEpeOG+tfyi3eY/nVxaeu+lfI8TX7AhKe13wI",
"lzgGUWZ323f+t+ReQ99sU8Wuhdur7pkNe7xW1SU+wA1oZMQ8k/40qz/n9zucwmjCbQYnugdDfVjOk/z1",
"WbG1o90RmbslYI1aaWAWpuHx/R6NpUs7tEW7K2PjvWJHKA91V7r/BAAA//8arOezvw0AAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
@@ -24,9 +24,10 @@ type API struct {
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
type StorageAgent struct {
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
InternalToken string `env:"INTERNAL_TOKEN,required"`
InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
}
// Load заполняет структуру конфигурации T из окружения.
@@ -24,3 +24,10 @@ func TestLoadAPIRequiresDatabase(t *testing.T) {
_, err := Load[API]()
assert.ErrorContains(t, err, "DATABASE_URL")
}
func TestLoadStorageAgentRejectsEmptyToken(t *testing.T) {
t.Setenv("INTERNAL_TOKEN", "")
_, err := Load[StorageAgent]()
assert.ErrorContains(t, err, "INTERNAL_TOKEN")
}
+8 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/lock"
"pipu/api/migrations"
)
@@ -26,12 +27,17 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
return pool, nil
}
// Migrate применяет встроенные миграции goose.
// Migrate применяет встроенные миграции goose. Advisory-lock в PostgreSQL не даёт
// нескольким экземплярам api применять миграции одновременно.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
sqlDB := stdlib.OpenDBFromPool(pool)
defer func(d *sql.DB) { _ = d.Close() }(sqlDB)
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS)
locker, err := lock.NewPostgresSessionLocker()
if err != nil {
return fmt.Errorf("db: migrations lock: %w", err)
}
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS, goose.WithSessionLocker(locker))
if err != nil {
return fmt.Errorf("db: migrations: %w", err)
}
@@ -0,0 +1,57 @@
package httpx
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
)
// NewServer создаёт http.Server с общими таймаутами. WriteTimeout не задаётся:
// стриминг аудио и загрузки держат ответ открытым дольше любого разумного предела.
func NewServer(addr string, h http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
}
// Serve запускает сервер и корректно останавливает его при отмене ctx.
func Serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
// RealIP подставляет в RemoteAddr адрес клиента из X-Real-IP, который выставляет nginx.
// В отличие от middleware.RealIP из chi, не доверяет True-Client-IP и X-Forwarded-For:
// их клиент может прислать сам, а nginx пропускает их дальше без изменений.
func RealIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,41 @@
package httpx
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRealIP(t *testing.T) {
cases := []struct {
name string
headers map[string]string
want string
}{
{"no headers", nil, "192.0.2.1:1234"},
{"x-real-ip", map[string]string{"X-Real-IP": "203.0.113.7"}, "203.0.113.7"},
{"invalid x-real-ip", map[string]string{"X-Real-IP": "evil"}, "192.0.2.1:1234"},
{"spoofed true-client-ip", map[string]string{"True-Client-IP": "1.1.1.1"}, "192.0.2.1:1234"},
{"spoofed x-forwarded-for", map[string]string{"X-Forwarded-For": "1.1.1.1"}, "192.0.2.1:1234"},
{
"x-real-ip wins over spoofed headers",
map[string]string{"X-Real-IP": "203.0.113.7", "True-Client-IP": "1.1.1.1"},
"203.0.113.7",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
h := RealIP(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr }))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
for k, v := range tc.headers {
req.Header.Set(k, v)
}
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, tc.want, got)
})
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ func NewRouter(d Deps) http.Handler {
}
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, httpx.AccessLog(log), middleware.Recoverer)
r.Use(middleware.RequestID, httpx.RealIP, httpx.AccessLog(log), middleware.Recoverer)
r.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed)
+11 -5
View File
@@ -5,6 +5,7 @@ package storageagent
import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
@@ -21,8 +22,12 @@ type Mount struct {
Path string `json:"path"`
}
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN.
func NewRouter(token, libraryRoot string) http.Handler {
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN;
// при пустом token все запросы к /internal отклоняются.
func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
r := chi.NewRouter()
r.NotFound(httpx.NotFound)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
@@ -30,10 +35,11 @@ func NewRouter(token, libraryRoot string) http.Handler {
})
r.Route("/internal", func(r chi.Router) {
r.Use(requireToken(token))
r.Get("/mounts", func(w http.ResponseWriter, _ *http.Request) {
r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) {
mounts, err := listMounts(libraryRoot)
if err != nil {
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", err.Error())
log.ErrorContext(r.Context(), "list mounts", "err", err)
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
@@ -46,7 +52,7 @@ func requireToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
return
}
@@ -13,7 +13,7 @@ import (
)
func TestInternalRequiresToken(t *testing.T) {
h := NewRouter("secret", t.TempDir())
h := NewRouter(nil, "secret", t.TempDir())
for _, auth := range []string{"", "Bearer wrong", "secret"} {
rec := httptest.NewRecorder()
@@ -26,6 +26,18 @@ func TestInternalRequiresToken(t *testing.T) {
}
}
func TestInternalRejectsEverythingWithEmptyToken(t *testing.T) {
h := NewRouter(nil, "", t.TempDir())
for _, auth := range []string{"", "Bearer ", "Bearer x"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
req.Header.Set("Authorization", auth)
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code, "auth=%q", auth)
}
}
func TestListMounts(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755))
@@ -34,7 +46,7 @@ func TestListMounts(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
req.Header.Set("Authorization", "Bearer secret")
NewRouter("secret", root).ServeHTTP(rec, req)
NewRouter(nil, "secret", root).ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body struct{ Mounts []Mount }
+28 -8
View File
@@ -3,6 +3,8 @@ package system
import (
"context"
"log/slog"
"sync"
"time"
"pipu/api/internal/api/gen"
@@ -29,13 +31,17 @@ type Info struct {
// Handler реализует системную часть gen.StrictServerInterface.
type Handler struct {
info Info
log *slog.Logger
checks map[string]Checker
checkTimeout time.Duration
}
// NewHandler создаёт обработчик. checks — именованные проверки для /readyz.
func NewHandler(info Info, checks map[string]Checker) *Handler {
return &Handler{info: info, checks: checks, checkTimeout: 2 * time.Second}
func NewHandler(info Info, log *slog.Logger, checks map[string]Checker) *Handler {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
return &Handler{info: info, log: log, checks: checks, checkTimeout: 2 * time.Second}
}
// GetHealthz сообщает, что процесс жив; зависимости не проверяет.
@@ -43,21 +49,35 @@ func (h *Handler) GetHealthz(context.Context, gen.GetHealthzRequestObject) (gen.
return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil
}
// GetReadyz проверяет все зависимости; 503, если хотя бы одна недоступна.
// GetReadyz параллельно проверяет все зависимости; 503, если хотя бы одна недоступна.
// /readyz доступен снаружи, поэтому текст ошибки пишется только в лог, а в ответ — "fail".
func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) {
ctx, cancel := context.WithTimeout(ctx, h.checkTimeout)
defer cancel()
results := make(map[string]string, len(h.checks))
healthy := true
var (
mu sync.Mutex
wg sync.WaitGroup
results = make(map[string]string, len(h.checks))
healthy = true
)
for name, c := range h.checks {
if err := c.Check(ctx); err != nil {
results[name] = err.Error()
wg.Add(1)
go func() {
defer wg.Done()
err := c.Check(ctx)
mu.Lock()
defer mu.Unlock()
if err != nil {
h.log.WarnContext(ctx, "readiness check failed", "check", name, "err", err)
results[name] = "fail"
healthy = false
continue
return
}
results[name] = "ok"
}()
}
wg.Wait()
if !healthy {
return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil
+2 -2
View File
@@ -20,7 +20,7 @@ func newServer(checks map[string]system.Checker) http.Handler {
Version: "1.2.3",
SupportedLocales: []string{"ru", "en"},
MinAndroidVersionCode: 10203,
}, checks)
}, nil, checks)
return server.NewRouter(server.Deps{System: h})
}
@@ -68,7 +68,7 @@ func TestReadyz(t *testing.T) {
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, "degraded", body["status"])
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "connection refused"}, body["checks"])
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "fail"}, body["checks"])
})
}
+18 -7
View File
@@ -1,6 +1,8 @@
"""FastAPI-приложение внутреннего API рекомендаций."""
import asyncio
import hmac
import logging
from collections.abc import Awaitable, Callable
from typing import Annotated
@@ -11,6 +13,10 @@ from pipu_rec.settings import Settings
Check = Callable[[], Awaitable[None]]
log = logging.getLogger(__name__)
CHECK_TIMEOUT_S = 2.0
def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI:
app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
@@ -26,15 +32,20 @@ def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> Fa
async def healthz() -> dict[str, str]:
return {"status": "ok"}
async def run_check(name: str, check: Check) -> str:
try:
await asyncio.wait_for(check(), CHECK_TIMEOUT_S)
except Exception as exc:
log.warning("readiness check %s failed: %r", name, exc)
return "fail"
return "ok"
@app.get("/readyz")
async def readyz() -> JSONResponse:
results: dict[str, str] = {}
for name, check in checks.items():
try:
await check()
results[name] = "ok"
except Exception as exc:
results[name] = str(exc) or type(exc).__name__
# Проверки параллельные, каждая ограничена таймаутом; причину ошибки пишем только в лог.
names = list(checks)
outcomes = await asyncio.gather(*(run_check(n, checks[n]) for n in names))
results = dict(zip(names, outcomes, strict=True))
healthy = all(v == "ok" for v in results.values())
return JSONResponse(
{"status": "ok" if healthy else "degraded", "checks": results},
+1 -1
View File
@@ -15,7 +15,7 @@ log = logging.getLogger("pipu_rec")
def _serve(settings: Settings) -> None:
rdb = aioredis.Redis.from_url(settings.redis_url)
rdb = aioredis.Redis.from_url(settings.redis_url, socket_connect_timeout=2, socket_timeout=2)
async def check_postgres() -> None:
async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn:
+15 -1
View File
@@ -32,7 +32,21 @@ def test_readyz_all_up() -> None:
def test_readyz_dependency_down() -> None:
r = client({"postgres": ok, "redis": down}).get("/readyz")
assert r.status_code == 503
assert r.json()["checks"]["redis"] == "connection refused"
assert r.json()["checks"]["redis"] == "fail"
def test_readyz_dependency_hangs(monkeypatch) -> None: # type: ignore[no-untyped-def]
import asyncio
from pipu_rec.api import app as app_module
async def hang() -> None:
await asyncio.sleep(10)
monkeypatch.setattr(app_module, "CHECK_TIMEOUT_S", 0.05)
r = client({"postgres": ok, "redis": hang}).get("/readyz")
assert r.status_code == 503
assert r.json()["checks"] == {"postgres": "ok", "redis": "fail"}
def test_internal_status_requires_token() -> None: