# Automated build + release for the Runic Gateway Android app. # # Trigger: every push to `main` (i.e. every merged PR). # # Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine: # # ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ # │ reads: latest v* git tag + conventional-commit subjects │ # │ produces: next version, changelog, and (at the end) the release │ # └───────────────────────────────────────────────────────────────────┘ # ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐ # │ consumes: the version │ # │ produces: the artifact (a signed release APK + SHA256SUMS) │ # └───────────────────────────────────────────────────────────────────┘ # # Version bump (conventional commits since the last v* tag): # feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch # nothing releasable -> no release is cut # (first ever run, no tag) -> releases the current build.gradle.kts version as-is # versionName is the semver; versionCode is derived major*10000+minor*100+patch so # it is deterministic + monotonic (PLAN.md §10). The bump is committed back to # app/build.gradle.kts, then tagged. # # Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app): # REGISTRY_USER — Gitea username the token below belongs to # REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump # commit + tag and create the release) # ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line) # ANDROID_KEYSTORE_PASSWORD — keystore password # ANDROID_KEY_ALIAS — key alias (e.g. runicgateway) # ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore) # Also: `main` must accept a direct push from the REGISTRY_USER account (disable # branch protection for it, or add it as an exception) — the bump commit lands on # main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow. # # Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container # lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base # tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and # `chmod +x ./gradlew` in-step (checkout drops the exec bit). name: Release APK on: push: branches: [main] workflow_dispatch: {} concurrency: group: release-apk cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/Android-app GRADLE_MODULE: app jobs: release: runs-on: ubuntu-latest # Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on # the self-hosted runner) instead of hanging forever and — because concurrency # is `cancel-in-progress: false` — blocking every later release behind it. timeout-minutes: 30 # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]). # Quoted because the expression contains a colon (`chore(release):`), which an # unquoted YAML scalar would misparse as a mapping value. if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}" steps: - name: Install base tools + JDK 17 run: | apt-get update apt-get install -y git curl unzip jq openjdk-17-jdk-headless echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - name: Check out full history (need tags + commit log for the bump) uses: actions/checkout@v4 with: fetch-depth: 0 # ── RELEASE ENGINE: decide the next version + changelog ────────────── - name: Plan the release (version + changelog) id: plan run: | set -euo pipefail mkdir -p dist git fetch --tags --force >/dev/null 2>&1 || true # Current committed version (the `?: "x.y.z"` default in build.gradle.kts). MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)" LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" BODIES="$(git log --no-merges --format='%B' $RANGE || true)" BUMP=none if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi bump() { # -> bumped IFS=. read -r MA MI PA <<< "$1" case "$2" in major) echo "$((MA+1)).0.0" ;; minor) echo "${MA}.$((MI+1)).0" ;; patch) echo "${MA}.${MI}.$((PA+1))" ;; esac } RELEASE=true if [ -z "$LAST_TAG" ]; then VERSION="$MANIFEST_VERSION" # first release: ship what's committed elif [ "$BUMP" = none ]; then RELEASE=false # no feat/fix/breaking since last tag VERSION="${LAST_TAG#v}" else VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" fi if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then echo "Tag v${VERSION} already exists — nothing to release." RELEASE=false fi # Derive a deterministic, monotonic versionCode from the semver. IFS=. read -r MA MI PA <<< "$VERSION" VERSION_CODE=$(( MA*10000 + MI*100 + PA )) { echo "## Runic Gateway Android v${VERSION}" echo FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } echo "### All changes" if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi echo "$SUBJECTS" | sed 's/^/- /' echo echo "---" echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run." } > dist/CHANGELOG.md echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-}" # ── ANDROID ADAPTER: SDK + signing keystore ────────────────────────── - name: Set up Android SDK if: ${{ steps.plan.outputs.release == 'true' }} uses: android-actions/setup-android@v3 with: # Only put cmdline-tools on PATH. The action's default package set drags in # the whole emulator + the legacy `tools` package (hundreds of MB, network- # bound on this runner) that a headless APK build never uses. The next step # installs exactly the packages we need. packages: '' - name: Install Android SDK packages if: ${{ steps.plan.outputs.release == 'true' }} run: | set +o pipefail yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" - name: Decode signing keystore if: ${{ steps.plan.outputs.release == 'true' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | set -euo pipefail if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release." exit 1 fi printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks" echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV" # ── ANDROID ADAPTER: set the version, gate, build the signed APK ───── - name: Set the app version to match the release if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" VERSION_CODE="${{ steps.plan.outputs.versionCode }}" # Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks). sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts" sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts" grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts" - name: Unit tests + signed release APK if: ${{ steps.plan.outputs.release == 'true' }} env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | set -euo pipefail chmod +x ./gradlew ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease - name: Package APK + SHA256SUMS if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; } cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk" ( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS ) ls -l dist && cat dist/SHA256SUMS # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── - name: Commit version bump and push tag if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" # Secrets can arrive with a trailing newline; a stray CR/LF corrupts the # remote URL / auth header. Strip line breaks before use. CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" git config user.name "android-app-ci" git config user.email "ci@whitlocktech.com" git remote set-url origin \ "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" git add "${GRADLE_MODULE}/build.gradle.kts" if ! git diff --cached --quiet; then git commit -m "chore(release): bump version to ${TAG} [skip ci]" git push origin "HEAD:main" else echo "Version unchanged (first release) — no bump commit needed." fi git tag "${TAG}" git push origin "${TAG}" # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── - name: Create Gitea release and upload assets if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" API="https://${GITEA_HOST}/api/v1/repos/${REPO}" BODY="$(cat dist/CHANGELOG.md)" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" REL_ID="$(curl -sSf -X POST "${API}/releases" \ -H "Authorization: token ${CI_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ | jq -r '.id')" echo "Created release ${TAG} (id=${REL_ID})" for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null echo " uploaded ${f}" done