diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6041e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# The build context of tools/ci/release.Dockerfile: the sources, not what builds, local runs and personal files leave. +.git/ +**/build/ +.gradle/ +.kotlin/ +.idea/ +local/ +local.properties +**/__pycache__/ +*.apk diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..96ad5d1 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,181 @@ +# CI — on push to master: the TV's APK and the desktop's .deb, built in Docker, go onto the repo's Releases page as +# tag v (docs/development.md, «CI»). +# +# A push that changed the apps since the last release (docs, tools, tests and the baseline profile generator aside — +# they never reach the APK or the .deb) builds both one patch up (v0.1.1 → 0.1.2). A sleepyowl.versionName raised by +# hand in gradle.properties past the last release (0.2.0) is taken as is; with no release yet, that one goes out +# first. Any other push stops in a few seconds. +# +# Secrets (Settings → Actions → Secrets): +# ANDROID_KEYSTORE_BASE64 base64 of the keystore that signs the APK +# ANDROID_KEYSTORE_PASSWORD its password (also the key's: PKCS12 has one) +# ANDROID_KEY_ALIAS the key's alias; «sleepyowl» if not set +# RELEASE_TOKEN optional: a token with write:repository, if the automatic one may not create releases +# Without the keystore the job fails: an APK on a key made up in the container installs over nothing. +# +# Actions variables, all with defaults: RELEASES_KEEP (10) — how many releases stay on the page (older ones go with +# their files, their tags stay); BUILD_CACHE_KEEP (10GB) — the cap of the runner's build cache. + +name: release + +on: + push: + branches: [master] + +jobs: + release: + runs-on: ubuntu-latest + env: + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }} + # RUN --mount and --output need BuildKit; newer CLIs use it anyway. + DOCKER_BUILDKIT: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # The whole history with its tags: the last release and what changed since. + fetch-depth: 0 + + - name: Version, and whether it is out already + id: version + run: | + base=$(sed -n 's/^sleepyowl\.versionName=//p' gradle.properties) + # The last release: Gitea tags its commit v when it goes out. + last=$(git tag -l 'v*' | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true) + if [ -z "$last" ]; then + name=$base + elif [ "$base" != "$last" ] && [ "$(printf '%s\n%s\n' "$last" "$base" | sort -V | tail -n 1)" = "$base" ]; then + # Raised by hand past the last release. + name=$base + # What never reaches the APK or the .deb makes no release on its own. + elif git diff --quiet "v$last" HEAD -- . ':(exclude)docs' ':(exclude)tools' ':(exclude).gitea' \ + ':(exclude)baselineprofile' ':(exclude,glob)**/src/test/**' ':(exclude,glob)**/*.md' \ + ':(exclude).gitignore' ':(exclude).dockerignore'; then + echo "Nothing in the apps changed since v$last (docs, tools and tests aside): no new release." + echo "build=false" >> "$GITHUB_OUTPUT" + exit 0 + else + name=$(printf '%s' "$last" | awk -F . '{ printf "%d.%d.%d", $1, $2, $3 + 1 }') + fi + tag="v$name" + echo "Next release: $tag (last: ${last:-none}, gradle.properties: $base)" + { echo "name=$name"; echo "tag=$tag"; } >> "$GITHUB_OUTPUT" + # Without a token the private repo answers 404 to everything, which would read as "not released" and build + # for nothing on every push. + [ -n "$TOKEN" ] || { echo "No token for the releases API (RELEASE_TOKEN or the automatic one)." >&2; exit 1; } + status=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$tag") + if [ "$status" = 200 ]; then + echo "$tag is released already: nothing to build." + echo "build=false" >> "$GITHUB_OUTPUT" + elif [ "$status" != 404 ]; then + echo "The releases API answered $status." >&2 + exit 1 + else + echo "build=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build the APK and the .deb + if: steps.version.outputs.build == 'true' + env: + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + NAME: ${{ steps.version.outputs.name }} + run: | + if [ -z "$KEYSTORE" ]; then + echo "::error::ANDROID_KEYSTORE_BASE64 is not set: no release without the signing key (docs/development.md, «CI»)." + exit 1 + fi + keys=$(mktemp -d) + trap 'rm -rf "$keys"' EXIT + printf '%s' "$KEYSTORE" | base64 -d > "$keys/release.jks" + printf '%s' "$KEYSTORE_PASSWORD" > "$keys/password" + printf '%s' "$KEY_ALIAS" > "$keys/alias" + docker build --label ru.stenfredd.ci=sleepyowl-ci -t sleepyowl-ci tools/ci + # The previous toolchain image, when its Dockerfile changed. + docker image prune -f --filter label=ru.stenfredd.ci=sleepyowl-ci + # --no-cache: secrets are not part of the layer cache key, so a cached layer could hand back an APK signed + # with whatever key built it first. Gradle's cache mount stays. + docker build --no-cache -f tools/ci/release.Dockerfile \ + --secret id=keystore,src="$keys/release.jks" \ + --secret id=keystore_password,src="$keys/password" \ + --secret id=key_alias,src="$keys/alias" \ + --build-arg VERSION_NAME="$NAME" \ + -o out . + ls -l out + cat out/version.txt + + - name: Publish on the Releases page + if: steps.version.outputs.build == 'true' + env: + NAME: ${{ steps.version.outputs.name }} + TAG: ${{ steps.version.outputs.tag }} + run: | + set -eu + auth="Authorization: token $TOKEN" + # What the APK came out as; versionCode follows versionName (app/build.gradle.kts). + read -r built CODE < out/version.txt + [ "$built" = "$NAME" ] || { echo "The APK is $built, not $NAME." >&2; exit 1; } + deb=$(ls out/sleepyowl_*_amd64.deb) + apk_sha=$(sha256sum out/sleepyowl.apk | cut -d ' ' -f 1) + deb_sha=$(sha256sum "$deb" | cut -d ' ' -f 1) + cert=$(cat out/cert-sha256.txt) + body=$(printf 'Коммит %s.\\n\\n**Android TV:** `sleepyowl-%s.apk`, versionCode %s.\\nSHA-256: `%s`\\nСертификат подписи, SHA-256: `%s`\\n\\n**Linux:** `%s` — `sudo apt install ./%s` (нужен libmpv2).\\nSHA-256: `%s`' \ + "${{ github.sha }}" "$NAME" "$CODE" "$apk_sha" "$cert" "${deb#out/}" "${deb#out/}" "$deb_sha") + release=$(curl -sS --fail-with-body -X POST -H "$auth" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$TAG\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"SleepyOwl $NAME\",\"body\":\"$body\",\"draft\":true}" \ + "$API/releases") + id=$(printf '%s' "$release" | grep -o '"id":[0-9]*' | head -n 1 | cut -d : -f 2) + # A draft until both files are in: a failed upload leaves no half release behind, and the next push tries again. + trap 'curl -sS -X DELETE -H "$auth" "$API/releases/$id" > /dev/null' EXIT + curl -sS --fail-with-body -H "$auth" -F "attachment=@out/sleepyowl.apk" \ + "$API/releases/$id/assets?name=sleepyowl-$NAME.apk" > /dev/null + curl -sS --fail-with-body -H "$auth" -F "attachment=@$deb" \ + "$API/releases/$id/assets?name=${deb#out/}" > /dev/null + curl -sS --fail-with-body -X PATCH -H "$auth" -H 'Content-Type: application/json' \ + -d '{"draft":false}' "$API/releases/$id" > /dev/null + trap - EXIT + echo "Released $TAG: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG" + + - name: Keep the newest releases only + if: steps.version.outputs.build == 'true' + # Housekeeping: the release above is out whatever happens here. + continue-on-error: true + env: + KEEP: ${{ vars.RELEASES_KEEP || '10' }} + run: | + set -eu + auth="Authorization: token $TOKEN" + list=$(mktemp) + page=1 + while [ "$page" -le 100 ]; do + resp=$(curl -sS --fail-with-body -H "$auth" "$API/releases?limit=50&page=$page") + [ "$resp" != "[]" ] || break + # " " of each release (Gitea writes id, then tag_name). + printf '%s' "$resp" | grep -o '"id":[0-9]*,"tag_name":"v[0-9.]*"' \ + | sed 's/"id":\([0-9]*\),"tag_name":"v\(.*\)"/\2 \1/' >> "$list" || true + page=$((page + 1)) + done + echo "Releases: $(wc -l < "$list"), keeping the newest $KEEP." + sort -rV "$list" | tail -n +"$((KEEP + 1))" | while read -r version id; do + curl -sS --fail-with-body -X DELETE -H "$auth" "$API/releases/$id" > /dev/null + echo "Deleted the release v$version (its tag stays)." + done + + # Docker's build cache stays for the next build — the toolchain image's layers, Gradle's cache mount — but no + # bigger than BUILD_CACHE_KEEP: the least recently used goes first. It is the runner daemon's cache, shared by + # everything that builds there. + - name: Cap the build cache + if: always() && steps.version.outputs.build == 'true' + continue-on-error: true + env: + KEEP: ${{ vars.BUILD_CACHE_KEEP || '10GB' }} + run: | + # --max-used-space is the cap (Docker 28+, API 1.48); older daemons know --keep-storage. + if printf '1.48\n%s\n' "$(docker version --format '{{.Server.APIVersion}}')" | sort -V -C; then + docker builder prune -af --max-used-space "$KEEP" + else + docker builder prune -af --keep-storage "$KEEP" + fi + docker system df diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2abf959..326ba91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,20 @@ plugins { alias(libs.plugins.androidx.baselineprofile) } +// Версия из gradle.properties или `-Psleepyowl.versionName=…` (CI поднимает patch). versionCode следует за ней: +// 1.2.3 → 1_002_003, так что более новая версия всегда ставится поверх старой. +val appVersion = providers.gradleProperty("sleepyowl.versionName").get() +val appVersionCode = appVersion.split('.').mapNotNull(String::toIntOrNull).let { parts -> + require(parts.size == 3 && parts[1] < 1000 && parts[2] < 1000) { "sleepyowl.versionName must be major.minor.patch (minor, patch < 1000): $appVersion" } + parts[0] * 1_000_000 + parts[1] * 1_000 + parts[2] +} + +// Ключ подписи release вне репозитория: ANDROID_KEYSTORE (+ пароль, alias). Без него — debug-ключ этой машины. +val releaseKeystore = providers.environmentVariable("ANDROID_KEYSTORE").orNull?.let(::file)?.also { + require(it.isFile) { "ANDROID_KEYSTORE is not a file: $it" } +} +val releaseStorePassword = providers.environmentVariable("ANDROID_KEYSTORE_PASSWORD").orNull.orEmpty() + android { namespace = "ru.stenfredd.sleepyowl" compileSdk = 37 @@ -12,19 +26,31 @@ android { applicationId = "ru.stenfredd.sleepyowl" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" + versionCode = appVersionCode + versionName = appVersion ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") } } + signingConfigs { + if (releaseKeystore != null) { + create("release") { + storeFile = releaseKeystore + storePassword = releaseStorePassword + keyAlias = providers.environmentVariable("ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotEmpty() } ?: "sleepyowl" + // У PKCS12 (по умолчанию в keytool) один пароль на хранилище и ключ. + keyPassword = providers.environmentVariable("ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotEmpty() } ?: releaseStorePassword + } + } + } + buildTypes { release { // Скорость интерфейса проверяем только на этой сборке: R8 + baseline profile. - // Пока своего ключа нет, подписываем debug-ключом, чтобы ставилась на ТВ. isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - signingConfig = signingConfigs.getByName("debug") + // Без ANDROID_KEYSTORE — debug-ключом, чтобы локальная сборка ставилась на ТВ. + signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug") } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index d3c8402..c59bd6e 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -13,8 +13,8 @@ plugins { alias(libs.plugins.compose.multiplatform) } -/** The desktop app's version: the About screen, the log, the .deb. */ -val appVersion = "0.1.0" +/** The desktop app's version (gradle.properties, or `-Psleepyowl.versionName=…` from CI): the About screen, the log, the .deb. */ +val appVersion = providers.gradleProperty("sleepyowl.versionName").get() kotlin { jvmToolchain(21) } diff --git a/docs/desktop.md b/docs/desktop.md index 73767a3..2a232dd 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -372,7 +372,7 @@ sudo apt remove sleepyowl # ~/.config/sleepyowl остаётс - **.deb собирается своей задачей** (`PackageDeb` в `desktop/build.gradle.kts`, через `dpkg-deb`), а не `packageDeb` jpackage: его пакет не умеет зависеть от `libmpv2` — libmpv загружается через JNA, и зависимость сама не находится. Свой пакет даёт ещё короткое имя в `/usr/bin` и меню без лишних скриптов. - **libmpv — системная** `libmpv2 (>= 0.37)`: на проверенных файлах 0.37 не хуже 0.41 (§6), своя libmpv не нужна. Остальные зависимости — то, что `dpkg-shlibdeps` нашёл у библиотек JRE, Skiko и JNA (имена Mint 22 / Ubuntu 24.04: `libc6 (>= 2.34)`, `libstdc++6`, `libfontconfig1`, `libx11-6`, `libxext6`, `libxi6`, `libxrender1`, `libxtst6`, `libasound2t64 | libasound2`), плюс то, что грузится через JNA: `libegl1`, `libopengl0`, `libgl1`. -- **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии приложения — одно место, `appVersion` в `desktop/build.gradle.kts`; `BuildInfo.VERSION` генерируется из него. +- **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии — `sleepyowl.versionName` в `gradle.properties`, общий с ТВ (CI поднимает его на релизе); `BuildInfo.VERSION` генерируется из него. - **Модули JRE:** `suggestRuntimeModules` дал `java.instrument`, `jdk.security.auth` (dbus-java), `jdk.unsupported`. Добавлены два, которых jdeps не видит: `jdk.crypto.ec` (TLS к публичному адресу сервера по https) и `jdk.localedata` — без него `Locale.getDisplayLanguage(ru)` даёт названия языков дорожек по-английски. - **Права файлов в пакете** выставляются явно (755/644): при `umask 002` иначе получается групповая запись. diff --git a/docs/development.md b/docs/development.md index 8b4a63d..72100d2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,7 +7,7 @@ | Что | Команда | |---|---| | ТВ, debug | `./gradlew :app:assembleDebug` → `app/build/outputs/apk/debug/app-debug.apk` | -| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, пока подписан debug-ключом; ABI `armeabi-v7a` + `arm64-v8a` | +| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, ключ — ниже; ABI `armeabi-v7a` + `arm64-v8a` | | ПК, запуск | `./gradlew :desktop:run` или `tools/linux/desktop.sh` (без Gradle вокруг процесса) | | ПК, пакет | `./gradlew :desktop:packageDeb` → `desktop/build/deb/sleepyowl_<версия>_amd64.deb` (зависит от `libmpv2`) | | Тесты | `./gradlew test` (в т.ч. рендер-тесты экранов ПК и `ui:shared` без дисплея) | @@ -15,6 +15,47 @@ Производительность интерфейса на ТВ мерить только на release-сборке (см. [ui-performance.md](ui-performance.md)). Baseline profile лежит в `app/src/release/generated/baselineProfiles/` и снимается заново на ТВ (`gen_profile`, ниже). +Версия обоих приложений — `sleepyowl.versionName` в `gradle.properties` (или `-Psleepyowl.versionName=…`); +`versionCode` APK следует за ней (`1.2.3` → `1002003`). Release-APK подписывается ключом из `ANDROID_KEYSTORE` +(+ `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, по умолчанию `sleepyowl`), без него — debug-ключом машины. + +## CI: релизы в Gitea + +`.gitea/workflows/release.yaml`: на пуш в `master` APK и .deb собираются в Docker и выкладываются на страницу +Releases репозитория как тег `v<версия>` (APK `sleepyowl-<версия>.apk`, `.deb` как его назвал `packageDeb`). + +- **Версия.** Каждый релиз — patch на единицу выше прошлого тега (`v0.1.1` → `0.1.2`). Чтобы начать новую minor или + major, поднять `sleepyowl.versionName` в `gradle.properties` выше последнего релиза — следующий релиз возьмёт её. + Первый релиз — версия из `gradle.properties`. Пуш, который не менял приложения (только `docs/`, `tools/`, + `.gitea/`, `baselineprofile/`, тесты, `*.md`), релиза не делает. +- **Сборка.** `tools/ci/Dockerfile` — образ с JDK 21 и Android SDK; `tools/ci/release.Dockerfile` собирает в нём + `:app:assembleRelease :desktop:packageDeb` (`tools/ci/release.sh`) целиком внутри BuildKit: исходники — контекст + сборки, ключ — BuildKit-секреты (в слои не попадает), кэш Gradle — cache mount, который живёт между сборками, + результат — `--output`. Так раннеру не нужны bind mount'ы. +- **Раннер.** Нужен Gitea Actions runner с меткой `ubuntu-latest`, видящий этот репозиторий (на уровне репозитория, + организации `StenFreddSoft` или всего сервера) и с доступом к Docker. +- **Секреты** (*Settings → Actions → Secrets*): + - `ANDROID_KEYSTORE_BASE64` — `base64 -w0 `; + - `ANDROID_KEYSTORE_PASSWORD` — его пароль (у PKCS12 он же пароль ключа); + - `ANDROID_KEY_ALIAS` — alias ключа, если не `sleepyowl`; + - `RELEASE_TOKEN` — только если автоматический токен Actions не может создавать релизы: токен с `write:repository`. + + Без ключа джоба падает: APK на ключе, придуманном в контейнере, не встанет поверх ни одной установленной версии. + Ключ должен быть один и тот же навсегда — APK на другом ключе ставится только после удаления приложения (с его + настройками и входом). Свой ключ: `keytool -genkeypair -keystore sleepyowl.jks -alias sleepyowl -keyalg RSA + -keysize 4096 -validity 36500`, хранить вне репозитория и с резервной копией. +- **Переменные** (*Settings → Actions → Variables*, необязательные): `RELEASES_KEEP` (10) — сколько релизов остаётся + на странице (старые удаляются вместе с файлами, теги остаются); `BUILD_CACHE_KEEP` (`10GB`) — потолок кэша + сборки Docker на раннере. + +Локально то же самое (команды — в шапке `tools/ci/release.Dockerfile`): + +```sh +docker build -t sleepyowl-ci tools/ci +docker build --no-cache -f tools/ci/release.Dockerfile --secret id=keystore,src= \ + --secret id=keystore_password,src=<файл с паролем> --secret id=key_alias,src=<файл с alias> -o out . +``` + ## Личные настройки инструментов: `local/` Папка `local/` целиком в `.gitignore`: там всё, что относится к конкретной машине, серверу и аккаунтам — diff --git a/docs/tv-app.md b/docs/tv-app.md index 0a516dd..94d722a 100644 --- a/docs/tv-app.md +++ b/docs/tv-app.md @@ -150,7 +150,7 @@ SleepyOwl — open-source клиент Jellyfin на Kotlin (GPL-3.0-or-later). - `ui/theme` (`Tokens`): цвета, типографика, отступы, радиусы, свечение фокуса из макета. `ui/components`: `FocusCard`/`focusCard` (фокус-узел со свечением), `GlowCache`, `Cards` (постер, широкая, большая), `Buttons` (основная с градиентом, круглые, чип; `Chip(swatch = …)` — цветная точка), `Icons`, `RemoteImage` + `CardImageTransformation`, `BlurHash`, `Avatar`, `StatusDot`, `Brand`, `Grid`, `Section`, `Scrolling`, `TextInput`. - **Оболочка** (`ui/shell/Shell.kt`): «Назад» на вкладке → фокус в рейл, «Назад» в рейле → выход из приложения. - **Упрощения макета ради скорости:** нет сетки точек на фоне; у постеров нет затемнения снизу — значок «4K» на маленькой тёмной плашке; полоска прогресса на постере — со скруглёнными концами и отступами, а не во всю ширину по краю; тень раскрытого рейла — узкая полоса 40 dp вместо размытия 80 px. Длинное название в большой карточке обрезается многоточием. -- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись пока debug-ключом. Воспроизведение Exo и VLC в release проверено. +- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись ключом из `ANDROID_KEYSTORE` (в CI — секрет, см. development.md), локально без него — debug-ключом. Воспроизведение Exo и VLC в release проверено. - **Открытие из adb** (`am start -n ru.stenfredd.sleepyowl/.MainActivity …`), работает и в release: `--es item [--ez play true] [--el start <мс>]`; `--es series [--es episode ]` — страница сериала. Только в debug: `--es engine exo|vlc`, `--ez tour true [--ei tour_file N]` (режим обхода). Без `--el start` фильм с `--ez play true` начинается с нуля и выпадает из «Продолжить просмотр». ### 5.2 Данные diff --git a/docs/ui-performance.md b/docs/ui-performance.md index c946969..9eca3d8 100644 --- a/docs/ui-performance.md +++ b/docs/ui-performance.md @@ -43,7 +43,7 @@ ### 1.2 Правила замеров -- **Только release**: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), подпись debug-ключом, `profileinstaller`. +- **Только release**: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), `profileinstaller`. - После установки — сразу компиляция по baseline profile: ``` adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE /androidx.profileinstaller.ProfileInstallReceiver diff --git a/gradle.properties b/gradle.properties index b605a62..105b65a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,5 @@ org.gradle.configuration-cache=true android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official +# Версия приложений (ТВ и ПК). CI поднимает patch на каждый релиз (docs/development.md, «CI»). +sleepyowl.versionName=0.1.0 diff --git a/tools/ci/Dockerfile b/tools/ci/Dockerfile new file mode 100644 index 0000000..6402567 --- /dev/null +++ b/tools/ci/Dockerfile @@ -0,0 +1,26 @@ +# Build toolchain of CI (.gitea/workflows/release.yaml, docs/development.md «CI»): JDK 21 (Gradle, the desktop app's +# toolchain and its jpackage) and the Android SDK the TV app compiles against. dpkg-deb for the .deb comes with the base +# image. Gradle itself comes through the wrapper and, like every dependency, lands in /gradle — a BuildKit cache mount of +# release.Dockerfile. Bump the platform together with compileSdk in app/build.gradle.kts. +FROM eclipse-temurin:21-jdk-noble + +ARG CMDLINE_TOOLS=16111833 +ARG SDK_PACKAGES="platforms;android-37.0 build-tools;36.0.0" + +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl unzip \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL -o /tmp/tools.zip https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip \ + && mkdir -p $ANDROID_HOME/cmdline-tools \ + && unzip -q /tmp/tools.zip -d $ANDROID_HOME/cmdline-tools \ + && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ + && rm /tmp/tools.zip \ + && (yes | sdkmanager --licenses > /dev/null) \ + && sdkmanager $SDK_PACKAGES + +ENV GRADLE_USER_HOME=/gradle \ + ANDROID_USER_HOME=/gradle/android \ + GRADLE_OPTS=-Dorg.gradle.daemon=false diff --git a/tools/ci/release.Dockerfile b/tools/ci/release.Dockerfile new file mode 100644 index 0000000..bc5eb51 --- /dev/null +++ b/tools/ci/release.Dockerfile @@ -0,0 +1,26 @@ +# The release build of CI (.gitea/workflows/release.yaml) on the toolchain of ./Dockerfile. BuildKit does it all, so +# the job needs no bind mounts (act_runner's docker resolves those on the host, not in the job): the sources go in as +# the build context, the signing key as secrets that never land in a layer, Gradle's caches in a cache mount that +# outlives the build, and the result comes out with --output. +# +# docker build -t sleepyowl-ci tools/ci +# docker build --no-cache -f tools/ci/release.Dockerfile \ +# --secret id=keystore,src=release.jks --secret id=keystore_password,src=password.txt \ +# --secret id=key_alias,src=alias.txt --build-arg VERSION_NAME=0.1.1 -o out . +# +# out/ then holds what tools/ci/release.sh lists. --no-cache because secrets are not in the layer cache key: a cached +# layer would return an APK signed with whatever key built it; the Gradle cache mount survives it. +ARG CI_IMAGE=sleepyowl-ci + +FROM ${CI_IMAGE} AS build +ARG VERSION_NAME= +WORKDIR /work +COPY . . +RUN --mount=type=cache,id=sleepyowl-gradle,target=/gradle,sharing=locked \ + --mount=type=secret,id=keystore \ + --mount=type=secret,id=keystore_password \ + --mount=type=secret,id=key_alias \ + sh tools/ci/release.sh /out + +FROM scratch +COPY --from=build /out/ / diff --git a/tools/ci/release.sh b/tools/ci/release.sh new file mode 100644 index 0000000..89003d5 --- /dev/null +++ b/tools/ci/release.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# The release build of CI (release.Dockerfile): the TV's APK on the release key, which BuildKit hands in as secrets, +# and the desktop's .deb. +# $1/sleepyowl.apk the APK (R8 without obfuscation: no mapping needed for stack traces) +# $1/sleepyowl_*_amd64.deb the .deb +# $1/cert-sha256.txt the APK's signing certificate, SHA-256 (as apksigner prints it) +# $1/version.txt " ", as the APK has them +# VERSION_NAME overrides gradle.properties' sleepyowl.versionName. +set -eu + +OUT=$1 +APK=app/build/outputs/apk/release/app-release.apk + +# Without the key Gradle signs with a debug key made up in the container: such an APK installs over nothing. +[ -s /run/secrets/keystore ] || { echo "No keystore secret: the release key is required." >&2; exit 1; } +ANDROID_KEYSTORE=/run/secrets/keystore +ANDROID_KEYSTORE_PASSWORD=$(cat /run/secrets/keystore_password) +ANDROID_KEY_ALIAS=$(cat /run/secrets/key_alias 2>/dev/null || true) +export ANDROID_KEYSTORE ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS + +./gradlew :app:assembleRelease :desktop:packageDeb \ + ${VERSION_NAME:+"-Psleepyowl.versionName=$VERSION_NAME"} + +buildtools=$(ls -d "$ANDROID_HOME"/build-tools/* | tail -n 1) +digest=$("$buildtools/apksigner" verify --print-certs "$APK" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p') +[ -n "$digest" ] || { echo "apksigner printed no certificate digest." >&2; exit 1; } +line=$("$buildtools/aapt2" dump badging "$APK" | sed -n '1p') +name=$(printf '%s' "$line" | sed -n "s/.* versionName='\([^']*\)'.*/\1/p") +code=$(printf '%s' "$line" | sed -n "s/.* versionCode='\([0-9]*\)'.*/\1/p") +[ -n "$name" ] && [ -n "$code" ] || { echo "No version in the APK's manifest: $line" >&2; exit 1; } + +mkdir -p "$OUT" +cp "$APK" "$OUT/sleepyowl.apk" +cp desktop/build/deb/sleepyowl_*_amd64.deb "$OUT/" +printf '%s\n' "$digest" > "$OUT/cert-sha256.txt" +printf '%s %s\n' "$name" "$code" > "$OUT/version.txt" + +# What the build left in /work would land in this RUN's layer, and BuildKit keeps such a layer from every run +# (--no-cache): only the sources stay. (.dockerignore keeps every build/ out of the context, so these are all Gradle's.) +find . -type d -name build -prune -exec rm -rf {} + +rm -rf .gradle .kotlin