Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9418df48 | ||
|
|
bf3c00ca71 | ||
|
|
633a8f79af | ||
|
|
a239650075 | ||
|
|
718125b719 | ||
|
|
d3b2e7bfc3 | ||
|
|
622eb84bc9 | ||
|
|
65aef3164a | ||
|
|
12fc866c26 | ||
|
|
f76b3c89b3 | ||
|
|
38edcd3e0e |
No files matched your search
@@ -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/...
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,111 +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: Проверка, что всё закоммичено
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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:
|
||||
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-release:
|
||||
name: android + release
|
||||
needs: verify
|
||||
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.verify.outputs.version }}
|
||||
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
||||
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,28 +84,38 @@ jobs:
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Свободное место
|
||||
run: df -h / /tmp
|
||||
# Диск, память и наполненность постоянных кэшей: пустые ~/.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; NODE_ENV нужен expo для выбора .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 мин).
|
||||
run: |
|
||||
npx expo prebuild --platform android --clean --no-install
|
||||
cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
|
||||
cd android
|
||||
./gradlew --no-daemon --max-workers=2 assembleRelease -PreactNativeArchitectures=arm64-v8a -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m" --build-cache -x lintVitalRelease
|
||||
|
||||
- name: Свободное место после сбоя
|
||||
- name: Диагностика сбоя (диск, память, OOM)
|
||||
if: failure()
|
||||
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||
run: |
|
||||
df -h / /tmp; free -m || true
|
||||
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
|
||||
|
||||
- name: Подпись APK
|
||||
env:
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
PRERELEASE: ${{ needs.release.outputs.prerelease }}
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
@@ -100,9 +130,11 @@ jobs:
|
||||
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
||||
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
||||
rm release.keystore
|
||||
echo "SIGNING=release" >> "$GITHUB_ENV"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
||||
cp "$src" "$out"
|
||||
echo "SIGNING=debug" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
||||
exit 1
|
||||
@@ -111,41 +143,14 @@ jobs:
|
||||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||||
echo "APK=$out" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Changelog
|
||||
env:
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
run: |
|
||||
# для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||||
exclude=()
|
||||
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
|
||||
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
||||
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
||||
{
|
||||
echo "## Pipu Music ${PIPU_VERSION}"
|
||||
echo
|
||||
for t in feat fix perf refactor docs ci chore; do
|
||||
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
|
||||
[ -n "$lines" ] || continue
|
||||
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
|
||||
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
|
||||
done
|
||||
} > CHANGELOG_RELEASE.md
|
||||
cat CHANGELOG_RELEASE.md
|
||||
- name: APK в релиз
|
||||
run: node scripts/release.mjs android success "$APK"
|
||||
|
||||
- name: Gitea Release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
|
||||
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
|
||||
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
|
||||
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$API/releases" | jq -r .id)"
|
||||
for f in "$APK" "$APK.sha256"; do
|
||||
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
|
||||
echo "uploaded $(basename "$f")"
|
||||
done
|
||||
- name: Статус в релизе — сборка не удалась
|
||||
if: failure()
|
||||
run: node scripts/release.mjs android failure
|
||||
|
||||
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: node scripts/release.mjs android cancelled
|
||||
@@ -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).
|
||||
@@ -9,7 +9,13 @@ const config = getDefaultConfig(__dirname);
|
||||
// в 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];
|
||||
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
|
||||
|
||||
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
|
||||
if (process.env.CI) config.maxWorkers = 2;
|
||||
|
||||
module.exports = config;
|
||||
+10
-1
@@ -5,9 +5,18 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,6 +17,14 @@ x-library-ro: &library-ro
|
||||
read_only: true
|
||||
bind: { propagation: rslave }
|
||||
|
||||
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||
# Пишет в него только storage-agent (импорт одобренных загрузок), остальным — только чтение.
|
||||
x-library-host-ro: &library-host-ro
|
||||
type: bind
|
||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||
target: /library-host
|
||||
read_only: true
|
||||
|
||||
x-restart: &restart
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -31,6 +39,7 @@ services:
|
||||
ports: ["${HTTP_PORT:-80}:80"]
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||
depends_on: [api]
|
||||
@@ -48,6 +57,7 @@ services:
|
||||
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
||||
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
||||
@@ -75,6 +85,9 @@ services:
|
||||
source: ${PIPU_DATA:-/srv/pipu}/library
|
||||
target: /library
|
||||
bind: { propagation: rshared }
|
||||
- type: bind
|
||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||
target: /library-host
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||
|
||||
recommender:
|
||||
@@ -102,6 +115,7 @@ services:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/models:/models
|
||||
deploy: { resources: { limits: { cpus: "4" } } }
|
||||
depends_on:
|
||||
|
||||
+20
-1
@@ -17,6 +17,16 @@ mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,mei
|
||||
chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads}
|
||||
chown 10001:10001 "$DATA/models"
|
||||
|
||||
# Папка с локальной музыкой: sudo LIBRARY_HOST=/mnt/music ./install.sh (или LIBRARY_HOST в .env)
|
||||
LIBRARY_HOST="${LIBRARY_HOST:-$( [ -f .env ] && sed -n 's/^LIBRARY_HOST=//p' .env || true )}"
|
||||
if [ -n "$LIBRARY_HOST" ]; then
|
||||
if [ ! -d "$LIBRARY_HOST" ]; then
|
||||
echo "LIBRARY_HOST=$LIBRARY_HOST: каталога нет — смонтируйте диск с музыкой (docs/install-guide.md, шаг 4)" >&2
|
||||
exit 1
|
||||
fi
|
||||
findmnt -n --target "$LIBRARY_HOST" -o TARGET,SOURCE | sed 's/^/→ музыка: /'
|
||||
fi
|
||||
|
||||
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
|
||||
# каталог должен быть отдельной точкой монтирования с shared propagation.
|
||||
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
|
||||
@@ -50,10 +60,19 @@ secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
|
||||
if [ ! -f .env ]; then
|
||||
echo "→ .env из .env.example"
|
||||
cp .env.example .env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|; s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||
chmod 600 .env
|
||||
chown "$OWNER" .env
|
||||
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
||||
fi
|
||||
|
||||
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||
if [ -n "$LIBRARY_HOST" ]; then
|
||||
if grep -q '^LIBRARY_HOST=' .env; then
|
||||
sed -i "s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||
else
|
||||
echo "LIBRARY_HOST=$LIBRARY_HOST" >> .env
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||
@@ -48,6 +48,7 @@ server {
|
||||
|
||||
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
|
||||
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
|
||||
location /_internal/library-host/ { internal; alias /library-host/; sendfile on; aio threads; }
|
||||
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
|
||||
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
|
||||
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
|
||||
|
||||
@@ -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
|
||||
|
||||
+86
-32
@@ -17,28 +17,43 @@ C++-сборка React Native). APK собирается только под `ar
|
||||
(тесты — `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` — по тегу
|
||||
|
||||
@@ -48,30 +63,69 @@ 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` | Создание релиза и загрузка ассетов |
|
||||
| `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 ведут туда же.
|
||||
|
||||
## Деплой
|
||||
|
||||
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
|
||||
|
||||
+5
-1
@@ -15,7 +15,7 @@
|
||||
```
|
||||
/srv/pipu/
|
||||
├── library/ # точки монтирования источников (storage-agent, rshared)
|
||||
├── library-host/ # опционально: заранее смонтированные папки для type=local
|
||||
├── library-host/ # папка для type=local по умолчанию; обычно вместо неё LIBRARY_HOST=/mnt/music
|
||||
├── cache/
|
||||
│ ├── transcode/
|
||||
│ └── covers/
|
||||
@@ -29,6 +29,10 @@
|
||||
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
||||
```
|
||||
|
||||
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
|
||||
каталог хоста монтируется в `api`, `nginx` и `analyzer` только на чтение, в `storage-agent` — на запись
|
||||
(импорт одобренных загрузок), внутри контейнеров он виден как `/library-host`.
|
||||
|
||||
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
||||
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
||||
|
||||
|
||||
+76
-17
@@ -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 из последнего релиза |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
-8
@@ -111,13 +111,13 @@ importers:
|
||||
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
version: 26.1.0(supports-color@10.2.2)
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
|
||||
packages/api-client:
|
||||
dependencies:
|
||||
@@ -137,7 +137,7 @@ importers:
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
|
||||
packages/design-tokens: {}
|
||||
|
||||
@@ -5753,7 +5753,7 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
http-proxy-agent@7.0.2(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -5938,13 +5938,13 @@ snapshots:
|
||||
|
||||
jsc-safe-url@0.2.4: {}
|
||||
|
||||
jsdom@26.1.0:
|
||||
jsdom@26.1.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2
|
||||
http-proxy-agent: 7.0.2(supports-color@10.2.2)
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.28
|
||||
@@ -7079,7 +7079,7 @@ snapshots:
|
||||
lightningcss: 1.27.0
|
||||
terser: 5.51.2
|
||||
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
@@ -7106,7 +7106,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
jsdom: 26.1.0
|
||||
jsdom: 26.1.0(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user