feat(android): manual signed-APK CI workflow

Add .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build,
master-only; mirrors prod-deploy): builds the native SPA, bundles the offline
dicts, assembles a release APK as a run artifact. JDK 21 via setup-java; the
Android SDK is host-provisioned (host-executor runner) with a fail-fast verify
that also checks the runner user's access. Signing degrades gracefully — no
keystore => unsigned release APK, not a failure.

Wire ui/android/app/build.gradle: versionCode/versionName from the release tag
(-P props; '=' assignment, not the command form that binds .toInteger() to the
DSL setter's null return), plus a guarded signingConfigs.release from env.

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

Bake the E as-built into ANDROID_PLAN.md.
This commit is contained in:
Ilia Denisov
2026-07-12 20:27:07 +02:00
parent e7cb60c996
commit ca2c6487cf
5 changed files with 257 additions and 18 deletions
+29 -2
View File
@@ -3,12 +3,22 @@ apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,10 +26,27 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened
// action takes the user to the fix — the store listing on a native build (VITE_RUSTORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
@@ -13,7 +13,7 @@
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_STORE_URL;
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
+3 -2
View File
@@ -7,8 +7,9 @@ interface ImportMetaEnv {
readonly VITE_GATEWAY_URL?: string;
/** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */
readonly VITE_PAYMENTS_DISABLED?: string;
/** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */
readonly VITE_STORE_URL?: string;
/** RuStore listing URL the native update overlay opens for its "update" action. Empty until the app
* is published (the button then no-ops); the version gate is dormant in the MVP so it never fires yet. */
readonly VITE_RUSTORE_URL?: string;
/** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */
readonly VITE_DICT_VERSION?: string;
}