A hard reboot of the runner VM mid-build left broken transform metadata
in the persistent Gradle cache ("Could not read workspace metadata").
The APK step now detects it, drops the transforms cache and retries the
build once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
12 KiB
YAML
190 lines
12 KiB
YAML
# Релиз по тегу 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 (необязательно) — токен для релиза; без него — встроенный токен 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
|
||
|
||
on:
|
||
push:
|
||
tags: ["v*.*.*"]
|
||
|
||
env:
|
||
NODE_VERSION: "22"
|
||
|
||
jobs:
|
||
# Тесты здесь не повторяются: тот же коммит уже проверен 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
|
||
# вся история и теги: заметки строятся по коммитам с прошлого релиза
|
||
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 || secrets.GITHUB_TOKEN }}
|
||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||
TAG: ${{ github.ref_name }}
|
||
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
|
||
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||
run: node scripts/release.mjs create
|
||
|
||
android:
|
||
name: android (APK в релиз)
|
||
needs: release
|
||
runs-on: ubuntu-latest
|
||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||
defaults:
|
||
run:
|
||
shell: bash
|
||
container:
|
||
image: docker.gitea.com/runner-images:ubuntu-latest
|
||
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||
options: --cpus=3 --memory=6g --memory-swap=6g
|
||
volumes:
|
||
- /opt/ci-cache/gradle:/root/.gradle
|
||
- /opt/ci-cache/android:/root/.android
|
||
env:
|
||
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||
# для scripts/release.mjs
|
||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||
TAG: ${{ github.ref_name }}
|
||
VERSION: ${{ needs.release.outputs.version }}
|
||
VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
|
||
- 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
|
||
with:
|
||
# пакета `tools` (значение по умолчанию) в репозитории SDK больше нет — sdkmanager падает;
|
||
# build-tools нужны шагу подписи (apksigner), версии — как у React Native 0.79
|
||
packages: platform-tools platforms;android-35 build-tools;35.0.0
|
||
- uses: pnpm/action-setup@v4
|
||
- uses: actions/setup-node@v4
|
||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||
- run: pnpm install --frozen-lockfile
|
||
|
||
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
|
||
- name: Ресурсы раннера и кэши
|
||
run: |
|
||
df -h / /tmp; free -m
|
||
du -sh ~/.gradle ~/.android 2>/dev/null || true
|
||
|
||
- name: Сборка APK
|
||
working-directory: apps/mobile
|
||
env:
|
||
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
||
CI: "1"
|
||
NODE_ENV: production
|
||
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||
NINJAFLAGS: "-j2"
|
||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
|
||
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
|
||
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
|
||
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
|
||
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
|
||
run: |
|
||
set -o pipefail
|
||
npx expo prebuild --platform android --clean --no-install
|
||
cd android
|
||
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
||
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||
fi
|
||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||
./gradlew --stop || true
|
||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||
else
|
||
exit 1
|
||
fi
|
||
fi
|
||
./gradlew --stop
|
||
|
||
- name: Диагностика сбоя (диск, память, OOM)
|
||
if: failure()
|
||
run: |
|
||
df -h / /tmp; free -m || true
|
||
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
|
||
|
||
- name: Подпись APK
|
||
env:
|
||
PRERELEASE: ${{ needs.release.outputs.prerelease }}
|
||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||
run: |
|
||
set -euo pipefail
|
||
src=apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||
out="pipu-music-${PIPU_VERSION}.apk"
|
||
bt="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)"
|
||
if [ -n "${KEYSTORE_B64}" ]; then
|
||
echo "$KEYSTORE_B64" | base64 -d > release.keystore
|
||
"$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
|
||
fi
|
||
"$bt/apksigner" verify --print-certs "$out" | head -3
|
||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||
echo "APK=$out" >> "$GITHUB_ENV"
|
||
|
||
- name: APK в релиз
|
||
run: node scripts/release.mjs android success "$APK"
|
||
|
||
- name: Статус в релизе — сборка не удалась
|
||
if: failure()
|
||
run: node scripts/release.mjs android failure
|
||
|
||
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
|
||
- name: Статус в релизе — сборка отменена
|
||
if: cancelled()
|
||
run: node scripts/release.mjs android cancelled
|