# CI, releases, and OTA — decisions and how to build Status as of the Pilgrims of Katan bootstrap (WALA-118). ## Decisions 1. **The release channel is this instance's own releases.** The app ships from , and the in-app updater reads that repo's releases over Gitea's REST API. GitHub is not a channel for this project; the only GitHub URLs left are the `upstream` remote and the attribution link in the README. The repo is **public on purpose**: the updater downloads anonymously, with no token embedded in the APK. A private repo would make every check fail (the API answers 404 to anonymous callers), not leak anything — but it would also stop updates dead. 2. **CI lives in Actions-compatible workflows in `.github/workflows/`.** Gitea Actions runs workflows from `.github/workflows/` (or `.gitea/workflows/`), so these files serve both a GitHub-style runner and the Gitea instance. Publishing goes through `scripts/publish-release.sh` — Gitea's REST API, the same routes the app's updater reads — rather than the `gh` CLI, which a runner image need not carry. | Workflow | Trigger | Publishes | | --- | --- | --- | | `ci.yml` | push to `dev`, PRs into `dev`/`main`, manual | dev prerelease, from `dev` only | | `release.yml` | push to `main`, manual | production release, from `main` only | Pull requests build and check but never publish. Prereleases are marked so the "latest release" endpoint — which skips them — can never hand a dev build to a production install. 3. **A runner has to be registered before any of that executes.** As of the bootstrap the instance has **no Actions runner**, so the workflows are correct but idle: a Gitea runner (Docker or host) must be registered against the instance and able to resolve `actions/checkout`, `actions/setup-java`, `android-actions/setup-android` and `actions/upload-artifact`. Until then the manual build below and the manual publish script are the working paths. 4. **The local build is the guaranteed path.** Workflows need a runner that may not exist yet; `./gradlew assembleDebug` + `./gradlew testDebugUnitTest` work on any machine with JDK 17 and the Android SDK (compileSdk 34), and CI's green means exactly these two commands plus lint. ## Build numbers, tags, and why updates never downgrade An APK is **`.`**, the release tag is **`v.`**, and the build number is what Android sees as `versionCode`: ``` build number = CI run number + plgVersionRunOffset (gradle.properties) ``` - `plgVersionSeries` (currently `1.4`) is the human-facing series, set by hand in `gradle.properties`. - `plgVersionRunOffset` (currently `1000`) is a **permanent floor**, also set by hand in `gradle.properties`, set once at bootstrap and never changed. Why the offset exists: Gitea Actions restarts run numbers at 1 for each workflow, so without it the first release of a fresh instance would be build `1` — the number an unstamped local build already carries — and any reset of the counter would hand Android a *lower* `versionCode`, which it refuses as a downgrade. With the offset, every stamped build starts at 1001 and only ever climbs, because the run number is monotonic and the offset never moves. The gradle build and both workflows read the same two properties, so the tag and the APK inside it cannot name different versions. The series is a name for people: bumping it changes nothing about ordering, because `versionCode` never includes the series. Unstamped builds (local, or a PR build) are `versionCode 1`, below every release — the honest answer for a build nobody stamped. ## Manual build (works today, no runner needed) ```sh # JDK 17, Android SDK with platform 34 + build-tools 34 export JAVA_HOME=... # a JDK 17 install export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties) ./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev) PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan) ./gradlew testDebugUnitTest # JVM unit tests ./gradlew lintDebug # Android lint (CI runs this too) ``` The two variants install side by side: different applicationId, different launcher name (`Pro Libertate Games DEV` vs `Pro Libertate Games`), different tartan sett. ## Manual release (works today, no runner needed) ```sh GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/debug/app-debug.apk release "$(git rev-parse HEAD)" ``` The script takes ` [target-sha]`, creates the release through `POST /api/v1/repos/{owner}/{repo}/releases`, attaches the APK through `POST …/releases/{id}/assets` — which is the upload route Gitea's Swagger declares for this instance (1.27.3) — and then reads the release back and corrects its state if it did not stick. That last step is not decoration: this instance has been observed to accept `draft: false` at create time and store the release as a draft after the attachment lands, and a draft is invisible to the updater, so the script verifies rather than assumes. If the tag already exists it publishes into the existing release instead of failing, so a retry is safe. ## What the in-app updater reads `app/src/main/java/org/prolibertate/games/update/Updater.kt`: | Channel | Endpoint | | --- | --- | | production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` | | dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` | - `/releases/latest` is documented by Gitea as *the most recent non-prerelease, non-draft release* — the same rule GitHub implements, so the production channel keeps skipping dev builds. - The dev path takes the highest `versionCode` among the prereleases rather than whatever came first on the page, so it does not depend on server ordering. - The JSON shape is the one the code already parsed from GitHub: `tag_name`, `prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says `limit` where GitHub says `per_page`. - Each asset must be named so it contains its tag — `PilgrimsOfKatan-v1.4.1001.apk` for tag `v1.4.1001` — because the updater picks the versioned APK out of the release's assets by that rule. `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is `true`, and setting it `false` makes every check report that and fetch nothing. `UpdatePolicy.kt` — channel rules, tag parsing, verdicts — is pure and stays under test either way. ## Keystore `app/debug.keystore` is committed deliberately and must never be rotated: every build — local, CI, release — signs with it, so an OTA update installs over the previous build instead of being rejected for a signature mismatch.