ci(release): auto-release engine (conventional commits) for the APK
Replace the tag-triggered release.yml with link/'s language-agnostic release engine, adapted for Android. On every push to main it derives the next version from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major, feat -> minor, fix|perf -> patch; nothing releasable -> no release), generates a grouped changelog, bumps versionName in build.gradle.kts (versionCode derived major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the notes + APK + SHA256SUMS. Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create the release, matching link/. main must allow that account to push (bump lands on main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged. Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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() { # <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
|
||||
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user