Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8cfd64208 | ||
|
|
0d9418df48 | ||
|
|
bf3c00ca71 | ||
|
|
633a8f79af | ||
|
|
a239650075 |
No files matched your search
@@ -39,5 +39,13 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: "22", cache: pnpm }
|
with: { node-version: "22", cache: pnpm }
|
||||||
- run: pnpm install --frozen-lockfile
|
- 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-doctor
|
||||||
- run: npx expo export --platform android --output-dir dist
|
- run: npx expo export --platform android --output-dir dist
|
||||||
@@ -40,6 +40,12 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: "22", cache: pnpm }
|
with: { node-version: "22", cache: pnpm }
|
||||||
- run: pnpm install --frozen-lockfile
|
- 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 i18n:check
|
||||||
- run: pnpm test:scripts
|
- run: pnpm test:scripts
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
|
# 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
|
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
|
||||||
name: release
|
name: release
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
|
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
|
||||||
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||||||
@@ -60,6 +63,7 @@ jobs:
|
|||||||
# для scripts/release.mjs
|
# для scripts/release.mjs
|
||||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||||||
@@ -80,8 +84,11 @@ jobs:
|
|||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Свободное место
|
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
|
||||||
run: df -h / /tmp
|
- name: Ресурсы раннера и кэши
|
||||||
|
run: |
|
||||||
|
df -h / /tmp; free -m
|
||||||
|
du -sh ~/.gradle ~/.android 2>/dev/null || true
|
||||||
|
|
||||||
- name: Сборка APK
|
- name: Сборка APK
|
||||||
working-directory: apps/mobile
|
working-directory: apps/mobile
|
||||||
@@ -93,12 +100,20 @@ jobs:
|
|||||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
|
# 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: |
|
run: |
|
||||||
npx expo prebuild --platform android --clean --no-install
|
npx expo prebuild --platform android --clean --no-install
|
||||||
cd android
|
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
|
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()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
df -h / /tmp; free -m || true
|
df -h / /tmp; free -m || true
|
||||||
@@ -141,3 +156,8 @@ jobs:
|
|||||||
- name: Статус в релизе — сборка не удалась
|
- name: Статус в релизе — сборка не удалась
|
||||||
if: failure()
|
if: failure()
|
||||||
run: node scripts/release.mjs android failure
|
run: node scripts/release.mjs android failure
|
||||||
|
|
||||||
|
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: node scripts/release.mjs android cancelled
|
||||||
+10
-1
@@ -5,9 +5,18 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
RUN npm install -g pnpm@11.9.0
|
RUN npm install -g pnpm@11.9.0
|
||||||
WORKDIR /repo
|
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 \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile --filter @pipu/web...
|
pnpm install --frozen-lockfile --filter @pipu/web...
|
||||||
|
COPY . .
|
||||||
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
|
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
|
||||||
RUN pnpm --filter @pipu/web build
|
RUN pnpm --filter @pipu/web build
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ C++-сборка React Native). APK собирается только под `ar
|
|||||||
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
|
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
|
||||||
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
|
`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` — по тегу
|
## `release.yml` — по тегу
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -106,6 +122,10 @@ container:
|
|||||||
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
|
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
|
||||||
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
|
| `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 тега,
|
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
|
||||||
|
|||||||
+16
-5
@@ -2,10 +2,12 @@
|
|||||||
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
|
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
|
||||||
// докладывает в него APK и переписывает блок статуса в тексте.
|
// докладывает в него APK и переписывает блок статуса в тексте.
|
||||||
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
// 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, статус «собран»
|
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
||||||
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||||||
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||||||
|
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
|
||||||
|
// из ответа указывает на него же).
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
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}).`,
|
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
|
||||||
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
|
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
|
||||||
@@ -182,9 +189,11 @@ function commitsBetween(prevTag, tag) {
|
|||||||
|
|
||||||
async function context() {
|
async function context() {
|
||||||
const repo = await api('GET', '');
|
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 tag = env('TAG');
|
||||||
const release = await api('GET', `/releases/tags/${encodeURIComponent(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() {
|
async function create() {
|
||||||
@@ -242,8 +251,10 @@ async function android(status, apkPath) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const [cmd, ...args] = process.argv.slice(2);
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
if (cmd === 'create') return create();
|
if (cmd === 'create') return create();
|
||||||
if (cmd === 'android' && ['building', 'success', 'failure'].includes(args[0])) return android(args[0], args[1]);
|
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||||||
throw new Error('использование: release.mjs create | android building|failure | android success <file.apk>');
|
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])) {
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test('android status blocks', () => {
|
|||||||
assert.match(debug, /отладочным ключом/);
|
assert.match(debug, /отладочным ключом/);
|
||||||
|
|
||||||
assert.match(renderAndroidStatus({ status: 'failure', runUrl: 'https://run' }), /не удалась — \[лог сборки\]/);
|
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', () => {
|
test('replaces only the status block and is idempotent', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user