Compare commits

..
6 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
14 changed files with 442 additions and 189 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
-111
View File
@@ -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
+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
+51 -44
View File
@@ -1,10 +1,12 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → Gitea Release с заметками → APK.
# Релиз создаётся сразу после проверок (статус Android — «собирается»); сборка APK докладывает в него файлы
# Релиз по тегу 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
@@ -13,69 +15,58 @@ 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 }}
version_code: ${{ steps.v.outputs.PIPU_VERSION_CODE }}
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
- 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
release:
name: release (заметки)
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# вся история и теги: заметки строятся по коммитам с прошлого релиза
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- 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 }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.verify.outputs.version }}
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
run: node scripts/release.mjs create
android:
name: android (APK в релиз)
needs: [verify, release]
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 }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.verify.outputs.version }}
VERSION_CODE: ${{ needs.verify.outputs.version_code }}
VERSION: ${{ needs.release.outputs.version }}
VERSION_CODE: ${{ needs.release.outputs.version_code }}
steps:
- uses: actions/checkout@v4
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
@@ -93,8 +84,11 @@ 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
@@ -106,12 +100,20 @@ jobs:
# вместе с 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 --max-workers=2 assembleRelease -PreactNativeArchitectures=arm64-v8a -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m"
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: Свободное место после сбоя
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
run: |
df -h / /tmp; free -m || true
@@ -120,7 +122,7 @@ jobs:
- 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 }}
@@ -154,3 +156,8 @@ jobs:
- 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).
+10 -1
View File
@@ -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
+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
+74 -25
View File
@@ -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,12 +63,14 @@ on:
tags: ['v*.*.*']
jobs:
verify: # все проверки ci.yml
release: # needs: verify — сразу создаёт Gitea Release (scripts/release.mjs create)
android: # needs: verify, release — собирает APK и докладывает его в релиз
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
android: # needs: release — собирает APK и докладывает его в релиз
```
Релиз появляется сразу после проверок, не дожидаясь APK. Текст строит `scripts/release.mjs`:
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
после зелёного CI** — релиз сам этого не проверяет.
Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
@@ -62,7 +79,7 @@ jobs:
финального, а не от последнего `-rc`; ссылка на полный diff;
- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease` (только arm64-v8a), подпись `apksigner`,
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 обновляет текст существующего релиза, а не создаёт второй.
@@ -70,13 +87,45 @@ Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease` (тол
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` | Создание и редактирование релиза, загрузка ассетов (scope `write:repository`) |
| `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 тега,
+16 -5
View File
@@ -2,10 +2,12 @@
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
// докладывает в него APK и переписывает блок статуса в тексте.
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure — статус сборки APK в тексте релиза
// node scripts/release.mjs android 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';
@@ -57,7 +59,12 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
: []),
]
: status === 'failure'
: status === 'cancelled'
? [
`⏹ **Android:** сборка APK отменена — [лог сборки](${runUrl}).`,
'APK появится здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
@@ -182,9 +189,11 @@ function commitsBetween(prevTag, tag) {
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: repo.html_url, tag, release, runUrl: `${repo.html_url}/actions/runs/${env('GITHUB_RUN_ID')}` };
return { repoUrl, tag, release, runUrl: `${repoUrl}/actions/runs/${env('GITHUB_RUN_ID')}` };
}
async function create() {
@@ -242,8 +251,10 @@ async function android(status, apkPath) {
async function main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure'].includes(args[0])) return android(args[0], args[1]);
throw new Error('использование: release.mjs create | android building|failure | android success <file.apk>');
if (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])) {
+1
View File
@@ -59,6 +59,7 @@ test('android status blocks', () => {
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', () => {