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:
+51
-12
@@ -86,7 +86,7 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
|
||||
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
|
||||
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
|
||||
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
|
||||
`UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps
|
||||
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
|
||||
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
|
||||
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
|
||||
@@ -135,7 +135,35 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
|
||||
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
|
||||
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
|
||||
- **E–G — pending.**
|
||||
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
|
||||
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master` —
|
||||
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
|
||||
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
|
||||
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
|
||||
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
|
||||
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
|
||||
As-built:
|
||||
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
|
||||
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
|
||||
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
|
||||
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
|
||||
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
|
||||
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
|
||||
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
|
||||
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
|
||||
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
|
||||
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
|
||||
the existing `unit`/`ui` jobs).
|
||||
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
|
||||
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
|
||||
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
|
||||
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
|
||||
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
|
||||
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
|
||||
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
|
||||
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||||
- **F–G — pending.**
|
||||
|
||||
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||||
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||||
@@ -334,7 +362,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
||||
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||||
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||||
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||||
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||||
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`,
|
||||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||
4. **Native env matrix** — see Build & env.
|
||||
|
||||
@@ -426,7 +454,7 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||||
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||
`window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||
`update.title/body/action` (add siblings to the maintenance keys).
|
||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||||
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||||
@@ -585,7 +613,7 @@ silently establishes a server guest (Profile + online play light up). Emulator s
|
||||
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
|
||||
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
|
||||
|
||||
### E. CI — signed APK artifact
|
||||
### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G)
|
||||
|
||||
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
||||
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
||||
@@ -603,6 +631,9 @@ New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-depl
|
||||
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
|
||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
|
||||
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||||
*As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned**
|
||||
(`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a
|
||||
missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`.
|
||||
7. `npx cap sync android`.
|
||||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||||
tag (scheme below).
|
||||
@@ -617,9 +648,12 @@ for the MVP.
|
||||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
||||
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
||||
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
||||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
||||
`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||
`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
|
||||
`signingConfigs.release` reading env/props. **Use `=` assignment, not the command form**
|
||||
`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return
|
||||
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
|
||||
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
|
||||
|
||||
### F. Docs (bake in the same PR)
|
||||
|
||||
@@ -634,8 +668,11 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
||||
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
|
||||
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
||||
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
||||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
|
||||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
|
||||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`,
|
||||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets
|
||||
for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the
|
||||
gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` /
|
||||
`ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`.
|
||||
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
|
||||
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||||
ships an incompatible wire change.
|
||||
@@ -675,8 +712,10 @@ target, built later.
|
||||
## Build & env matrix
|
||||
|
||||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
||||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
||||
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
|
||||
(`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
|
||||
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
|
||||
Gitea variable once published); the gate is dormant so the button never fires yet.
|
||||
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
||||
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
||||
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||||
|
||||
Reference in New Issue
Block a user