All checks were successful
PR Checks / android-build (pull_request) Successful in 10m19s
Restore proper YAML content (the previous commit on this branch accidentally stored the base64 string as the literal file body).
269 lines
13 KiB
YAML
269 lines
13 KiB
YAML
# 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() { # <x.y.z> <major|minor|patch> -> 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:-<none>}"
|
|
|
|
# ── 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
|