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:
@@ -0,0 +1,172 @@
|
|||||||
|
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
||||||
|
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
||||||
|
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||||
|
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||||
|
#
|
||||||
|
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||||
|
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||||
|
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
|
||||||
|
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
|
||||||
|
# runner host needs nothing pre-installed.
|
||||||
|
name: android-build
|
||||||
|
run-name: "android build ${{ github.sha }}"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
confirm:
|
||||||
|
description: 'Type "build" to confirm an APK build from master.'
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NO_COLOR: "1"
|
||||||
|
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
||||||
|
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
||||||
|
VITE_PAYMENTS_DISABLED: "1"
|
||||||
|
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
||||||
|
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
||||||
|
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
||||||
|
# lives elsewhere (the default matches deploy/README.md's install path).
|
||||||
|
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
||||||
|
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
||||||
|
# missing — this doubles as the runner-access check (deploy/README.md).
|
||||||
|
- name: Verify the host Android SDK
|
||||||
|
run: |
|
||||||
|
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [ ! -x "$sm" ]; then
|
||||||
|
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
||||||
|
fi
|
||||||
|
for pkg in "platforms/android-36" "build-tools"; do
|
||||||
|
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
||||||
|
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
||||||
|
|
||||||
|
- name: Compute version + native build env
|
||||||
|
id: prep
|
||||||
|
env:
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
||||||
|
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
||||||
|
# rather than derive a versionCode from a "-N-gSHA" describe.
|
||||||
|
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||||||
|
case "$desc" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
v="${desc#v}"
|
||||||
|
IFS=. read -r MA MI PA <<< "$v"
|
||||||
|
# 10# forces base-10 so a zero-padded part is never read as octal.
|
||||||
|
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
||||||
|
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
||||||
|
# trailing slash so the Connect endpoint never doubles it.
|
||||||
|
gateway="${PUBLIC_BASE_URL%/}"
|
||||||
|
{
|
||||||
|
echo "tag=$desc"
|
||||||
|
echo "name=$v"
|
||||||
|
echo "code=$code"
|
||||||
|
echo "gateway=$gateway"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
||||||
|
|
||||||
|
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
||||||
|
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.0.9
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build the SPA (native flavour)
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
||||||
|
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
||||||
|
# (after the build, before cap sync copies dist/ into the native assets).
|
||||||
|
- name: Bundle the dictionaries
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: node scripts/bundle-dicts.mjs
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
||||||
|
- name: Sync the native project
|
||||||
|
working-directory: ui
|
||||||
|
run: node_modules/.bin/cap sync android
|
||||||
|
|
||||||
|
- name: Decode the release keystore
|
||||||
|
id: keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||||
|
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "keystore decoded -> signed release build"
|
||||||
|
else
|
||||||
|
echo "file=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Assemble the release APK
|
||||||
|
working-directory: ui/android
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||||
|
|
||||||
|
- name: Upload the APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: erudit-${{ steps.prep.outputs.name }}-apk
|
||||||
|
path: ui/android/app/build/outputs/apk/release/*.apk
|
||||||
|
if-no-files-found: error
|
||||||
+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,
|
gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
|
||||||
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
|
`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 +
|
`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
|
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
|
||||||
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
|
`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
|
`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
|
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
|
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.
|
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
|
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
|
*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
|
`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.
|
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
|
`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`.
|
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||||
4. **Native env matrix** — see Build & env.
|
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);`.
|
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
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).
|
`update.title/body/action` (add siblings to the maintenance keys).
|
||||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
- **`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 }`.
|
- **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
|
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
|
||||||
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
|
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
|
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
||||||
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
`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).
|
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
|
||||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
|
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.
|
`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`.
|
7. `npx cap sync android`.
|
||||||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||||||
tag (scheme below).
|
tag (scheme below).
|
||||||
@@ -617,9 +648,12 @@ for the MVP.
|
|||||||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
**`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}"`.
|
`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`:
|
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
|
||||||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
`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)
|
### 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
|
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;
|
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).
|
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`,
|
- 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.
|
`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
|
- `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
|
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||||||
ships an incompatible wire change.
|
ships an incompatible wire change.
|
||||||
@@ -675,8 +712,10 @@ target, built later.
|
|||||||
## Build & env matrix
|
## Build & env matrix
|
||||||
|
|
||||||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||||||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
|
||||||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
(`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_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_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.)
|
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ apply plugin: 'com.android.application'
|
|||||||
android {
|
android {
|
||||||
namespace = "ru.eruditgame.app"
|
namespace = "ru.eruditgame.app"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
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 {
|
defaultConfig {
|
||||||
applicationId "ru.eruditgame.app"
|
applicationId "ru.eruditgame.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
|
||||||
versionName "1.0"
|
// 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"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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:!*~'
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
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,7 +2,7 @@
|
|||||||
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
|
// 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
|
// 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
|
// 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
|
// in the system browser / store app) or a plain reload on the web (which fetches the current
|
||||||
// client). Mirrors MaintenanceOverlay.svelte's look.
|
// client). Mirrors MaintenanceOverlay.svelte's look.
|
||||||
import { updateRequired } from '../lib/update.svelte';
|
import { updateRequired } from '../lib/update.svelte';
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
const ch = clientChannel();
|
const ch = clientChannel();
|
||||||
if (ch === 'android' || ch === 'ios') {
|
if (ch === 'android' || ch === 'ios') {
|
||||||
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
|
// '_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');
|
if (url) window.open(url, '_system');
|
||||||
} else {
|
} else {
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|||||||
Vendored
+3
-2
@@ -7,8 +7,9 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_GATEWAY_URL?: string;
|
readonly VITE_GATEWAY_URL?: string;
|
||||||
/** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */
|
/** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */
|
||||||
readonly VITE_PAYMENTS_DISABLED?: string;
|
readonly VITE_PAYMENTS_DISABLED?: string;
|
||||||
/** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */
|
/** RuStore listing URL the native update overlay opens for its "update" action. Empty until the app
|
||||||
readonly VITE_STORE_URL?: string;
|
* 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. */
|
/** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */
|
||||||
readonly VITE_DICT_VERSION?: string;
|
readonly VITE_DICT_VERSION?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user