Add CI
All checks were successful
release / release (push) Successful in 12m40s

This commit is contained in:
2026-10-27 17:38:55 +03:00
parent 2f38d27b3f
commit 3ef78bd49c
12 changed files with 363 additions and 10 deletions

10
.dockerignore Normal file
View File

@@ -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

View File

@@ -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<versionName> (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<versionName> 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
# "<version> <id>" 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

View File

@@ -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")
}
}

View File

@@ -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) }

View File

@@ -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` иначе получается групповая запись.

View File

@@ -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 <keystore>`;
- `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=<keystore> \
--secret id=keystore_password,src=<файл с паролем> --secret id=key_alias,src=<файл с alias> -o out .
```
## Личные настройки инструментов: `local/`
Папка `local/` целиком в `.gitignore`: там всё, что относится к конкретной машине, серверу и аккаунтам —

View File

@@ -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 <id> [--ez play true] [--el start <мс>]`; `--es series <id> [--es episode <id>]` — страница сериала. Только в debug: `--es engine exo|vlc`, `--ez tour true [--ei tour_file N]` (режим обхода). Без `--el start` фильм с `--ez play true` начинается с нуля и выпадает из «Продолжить просмотр».
### 5.2 Данные

View File

@@ -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 <pkg>/androidx.profileinstaller.ProfileInstallReceiver

View File

@@ -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

26
tools/ci/Dockerfile Normal file
View File

@@ -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

View File

@@ -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/ /

41
tools/ci/release.sh Normal file
View File

@@ -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 "<versionName> <versionCode>", 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