Merge pull request 'ci(release): make the release tag-driven (stop pushing to protected main)' (#14) from ci/tag-driven-release into main
All checks were successful
Release APK / release (push) Successful in 8m44s

Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-20 19:11:40 +00:00

View File

@@ -1,37 +1,23 @@
# Automated build + release for the Runic Gateway Android app. # Automated release for the Runic Gateway Android app.
# #
# Trigger: every push to `main` (i.e. every merged PR). # Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
# build never has to push to protected `main`; the tag *is* the release input.
# #
# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine: # To cut a release:
# git tag v0.1.0 && git push origin v0.1.0
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
# the workflow_dispatch input below.
# #
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ # versionName = the tag without its leading `v`; versionCode = major*10000 +
# │ reads: latest v* git tag + conventional-commit subjects │ # minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
# │ produces: next version, changelog, and (at the end) the release │ # into app/build.gradle.kts for the build only — nothing is committed back to main.
# └───────────────────────────────────────────────────────────────────┘
# ┌── 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): # 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` (create the release)
# 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_BASE64 — base64 of the release .jks (single line)
# ANDROID_KEYSTORE_PASSWORD — keystore password # ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway) # ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore) # 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 # 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 # lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
@@ -42,11 +28,16 @@ name: Release APK
on: on:
push: push:
branches: [main] tags:
workflow_dispatch: {} - 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Existing v* tag to (re)build and release'
required: true
concurrency: concurrency:
group: release-apk group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false cancel-in-progress: false
env: env:
@@ -61,10 +52,6 @@ jobs:
# the self-hosted runner) instead of hanging forever and — because concurrency # the self-hosted runner) instead of hanging forever and — because concurrency
# is `cancel-in-progress: false` — blocking every later release behind it. # is `cancel-in-progress: false` — blocking every later release behind it.
timeout-minutes: 30 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: steps:
- name: Install base tools + JDK 17 - name: Install base tools + JDK 17
run: | run: |
@@ -72,70 +59,46 @@ jobs:
apt-get install -y git curl unzip jq openjdk-17-jdk-headless apt-get install -y git curl unzip jq openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out full history (need tags + commit log for the bump) - name: Check out the release tag (full history for the changelog)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-depth: 0 fetch-depth: 0
# ── RELEASE ENGINE: decide the next version + changelog ────────────── # ── Derive version + changelog straight from the tag ─────────────────
- name: Plan the release (version + changelog) - name: Plan the release (version + changelog from the tag)
id: plan id: plan
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p dist mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true git fetch --tags --force >/dev/null 2>&1 || true
# Current committed version (the `?: "x.y.z"` default in build.gradle.kts). TAG="${{ github.event.inputs.tag || github.ref_name }}"
MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)" case "$TAG" in
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" v[0-9]*) : ;;
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi *) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
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 esac
} VERSION="${TAG#v}"
RELEASE=true # versionCode: deterministic + monotonic from the semver (PLAN.md §10).
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" IFS=. read -r MA MI PA <<< "$VERSION"
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
VERSION_CODE=$(( MA*10000 + MI*100 + PA )) VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
# Changelog: conventional-commit subjects since the previous v* tag.
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
{ {
echo "## Runic Gateway Android v${VERSION}" echo "## Runic Gateway Android ${TAG}"
echo echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes" echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /' echo "$SUBJECTS" | sed 's/^/- /'
echo echo
echo "---" echo "---"
@@ -144,14 +107,11 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT" echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
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 ────────────────────────── # ── SDK + signing keystore ───────────────────────────────────────────
- name: Set up Android SDK - name: Set up Android SDK
if: ${{ steps.plan.outputs.release == 'true' }}
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
# Only put cmdline-tools on PATH. The action's default package set drags in # Only put cmdline-tools on PATH. The action's default package set drags in
@@ -161,13 +121,11 @@ jobs:
packages: '' packages: ''
- name: Install Android SDK packages - name: Install Android SDK packages
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set +o pipefail set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Decode signing keystore - name: Decode signing keystore
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: | run: |
@@ -179,9 +137,8 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks" printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV" echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
# ── ANDROID ADAPTER: set the version, gate, build the signed APK ───── # ── Set the version, build the signed APK ────────────────────────────
- name: Set the app version to match the release - name: Set the app version to match the tag
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}" VERSION="${{ steps.plan.outputs.version }}"
@@ -192,7 +149,6 @@ jobs:
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts" grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
- name: Unit tests + signed release APK - name: Unit tests + signed release APK
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -203,7 +159,6 @@ jobs:
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
- name: Package APK + SHA256SUMS - name: Package APK + SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
@@ -212,37 +167,8 @@ jobs:
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS ) ( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
ls -l dist && cat dist/SHA256SUMS ls -l dist && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── # ── Create the Gitea release + upload assets (no push to main) ───────
- 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 - name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |