diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9112a9a..129fa31 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,47 +1,66 @@ -# Build a SIGNED release APK and attach it to a Gitea release (PLAN.md §10, §12). +# Automated build + release for the Runic Gateway Android app. # -# Trigger: pushing a semver tag `v*` (e.g. `v1.0.0`). Cutting an APK is a -# deliberate act — we do NOT release on every merge to main — so the tag is the -# source of truth for the version. `workflow_dispatch` builds the signed APK too -# but skips publishing (a dry run to smoke-test signing without cutting a release). +# Trigger: every push to `main` (i.e. every merged PR). # -# Version: the tag drives versionName (`v1.2.3` -> `1.2.3`); the workflow run -# number is the monotonic versionCode. Both are passed to Gradle as -P overrides. +# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine: # -# Signing (Settings -> Actions -> Secrets on RunicGateway/Android-app). The -# keystore never lives in the repo — it is a base64 secret decoded at build time. -# Secret names deliberately avoid the reserved GITEA_/GITHUB_ prefixes: +# ┌── 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 (equals the store password for a -# PKCS12 keystore) -# The release itself is created with the runner's built-in ${{ github.token }}, -# so no extra API token secret is required. +# 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 notes are identical to 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 rather than using actions/setup-java, install the exact -# SDK packages, and `chmod +x ./gradlew` in-step (checkout drops the exec bit). +# 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: - tags: ['v*'] + branches: [main] workflow_dispatch: {} concurrency: - group: release-apk-${{ github.ref }} + group: release-apk cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/Android-app + GRADLE_MODULE: app jobs: - release-apk: + release: runs-on: ubuntu-latest + # 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: | @@ -49,17 +68,96 @@ jobs: apt-get install -y git curl unzip jq openjdk-17-jdk-headless echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - - uses: actions/checkout@v4 + - 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 - 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: Cache Gradle + if: ${{ steps.plan.outputs.release == 'true' }} uses: actions/cache@v4 with: path: | @@ -69,25 +167,8 @@ jobs: restore-keys: | gradle-${{ runner.os }}- - # Derive versionName from the tag (dispatch runs get a 0.0.0-dev placeholder, - # since they don't publish) and a monotonic versionCode from the run number. - - name: Resolve version - id: ver - run: | - set -euo pipefail - if [ "${{ github.ref_type }}" = "tag" ]; then - VN="${GITHUB_REF_NAME#v}" - else - VN="0.0.0-dev" - fi - echo "versionName=${VN}" >> "$GITHUB_OUTPUT" - echo "versionCode=${{ github.run_number }}" >> "$GITHUB_OUTPUT" - echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - echo "==> versionName=${VN} versionCode=${{ github.run_number }} ref=${GITHUB_REF_NAME}" - - # Decode the keystore secret to a file the build reads via ANDROID_KEYSTORE_FILE. - # `base64 -d` tolerates the trailing newline a pasted secret may carry. - name: Decode signing keystore + if: ${{ steps.plan.outputs.release == 'true' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -99,7 +180,20 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks" echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV" - - name: Build signed release APK + # ── 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 }} @@ -107,44 +201,69 @@ jobs: run: | set -euo pipefail chmod +x ./gradlew - ./gradlew --no-daemon :app:assembleRelease \ - -PversionName="${{ steps.ver.outputs.versionName }}" \ - -PversionCode="${{ steps.ver.outputs.versionCode }}" + ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease - - name: Stage APK - id: stage + - name: Package APK + SHA256SUMS + if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail - SRC="app/build/outputs/apk/release/app-release.apk" + SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; } - mkdir -p dist - OUT="dist/runic-gateway-${{ steps.ver.outputs.versionName }}.apk" - cp "$SRC" "$OUT" - ( cd dist && sha256sum "$(basename "$OUT")" > SHA256SUMS ) - echo "apk=${OUT}" >> "$GITHUB_OUTPUT" + 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 - # Publish only for a real tag push; a manual dispatch stops after the signed - # build above (dry run). - - name: Create Gitea release and upload APK - if: ${{ github.ref_type == 'tag' }} + # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── + - name: Commit version bump and push tag + if: ${{ steps.plan.outputs.release == 'true' }} env: - RELEASE_TOKEN: ${{ github.token }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail - TAG="${{ steps.ver.outputs.tag }}" + 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}" - TOKEN="$(printf '%s' "${RELEASE_TOKEN}" | tr -d '\r\n')" + 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 ${TOKEN}" \ + -H "Authorization: token ${CI_TOKEN}" \ -H "Content-Type: application/json" \ - -d "$(jq -n --arg tag "$TAG" \ - '{tag_name:$tag, name:$tag, body:("Signed release APK for " + $tag + ". Sideload on Android 10+ (§10); the app self-configures its shard site on first run."), draft:false, prerelease:false}')" \ + -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 "$(basename "${{ steps.stage.outputs.apk }}")" SHA256SUMS; do + + 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 ${TOKEN}" \ + -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null echo " uploaded ${f}" done diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee78cb1..202213f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,10 +40,10 @@ android { applicationId = "com.runicgateway.app" minSdk = 29 targetSdk = 35 - // The release workflow drives these from the git tag (versionName) and a - // monotonic CI run number (versionCode) via -P overrides; local/PR builds - // fall back to the committed defaults. Keep the tag as the release's source - // of truth (PLAN.md §10: semantic versionName + monotonic versionCode). + // These committed defaults are the version source of truth (PLAN.md §10). + // release.yml's conventional-commit engine bumps versionName here and commits + // it on release; versionCode is derived from it (major*10000+minor*100+patch) + // so it stays monotonic. Both remain overridable via -P for local/manual builds. versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1 versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"