8 Commits

Author SHA1 Message Date
d0fb6bbdfc 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>
2026-07-20 19:11:40 +00:00
9268579c5f ci(release): make the release tag-driven (no push to protected main)
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m23s
The push-to-main release model kept failing: the job builds the signed APK
fine, but the final `git push origin HEAD:main` (version-bump commit) is
rejected by main's branch protection — "pre-receive hook declined / Internal
Server Error" — across runs #187, #194. main is deliberately protected
(allowlist push, required approvals, required status checks), which is
fundamentally incompatible with a CI job pushing a fresh commit to it.

Flip the trigger: the workflow now runs on pushing a `v*` tag (or via
workflow_dispatch with a tag input). The tag *is* the release input, so:

- version/versionCode are derived from the tag name (no version-planning engine);
- app/build.gradle.kts is set for the build only, never committed back;
- no `git push` to main, no tag creation, no REGISTRY_USER needed —
  only REGISTRY_TOKEN, to create the Gitea release + upload the APK/SHA256SUMS.

To cut a release now: `git tag v0.1.0 && git push origin v0.1.0`.

Keeps the speed fixes from #13 (trimmed setup-android, no Gradle cache,
timeout-minutes). Changelog is still generated from conventional-commit
subjects since the previous tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 19:00:04 +00:00
0c395b2527 Merge pull request 'ci(release): trim setup-android, drop broken Gradle cache, add job timeout' (#13) from ci/release-workflow-speedup into main
Some checks failed
Release APK / release (push) Failing after 9m23s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 17:50:25 +00:00
2d84a930e5 ci(release): fix workflow file mangled by prior base64 double-encode
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).
2026-07-20 17:39:29 +00:00
b6a0fa1f5d ci(release): trim setup-android, drop broken Gradle cache, add job timeout
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m43s
The release workflow was slow and sometimes appeared to hang mid-build:

- android-actions/setup-android@v3 pulled the entire Android emulator and the
  legacy `tools` package (hundreds of MB, network-bound on the self-hosted
  runner) that a headless APK build never uses. Pin `packages: ''` so it only
  puts cmdline-tools on PATH; the next step installs exactly what we need.
- The `actions/cache@v4` Gradle step timed out every run against the Gitea
  artifact-cache backend (`getCacheEntry failed: Request timeout`), adding
  latency with no benefit. Dropped it.
- The job had no timeout, and with `concurrency.cancel-in-progress: false` a
  genuinely wedged run would hang forever and block every later release behind
  it. Added `timeout-minutes: 30` so a hang fails fast.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:37:15 +00:00
bc09593550 Merge pull request 'ci(release): conventional-commit auto-release engine for the APK' (#12) from ci/android-release-engine into main
Some checks failed
Release APK / release (push) Has been cancelled
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 09:35:19 +00:00
9514172b71 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>
2026-07-20 04:31:05 -05:00
de79bf547c Merge pull request 'feat(release): m6 release mechanics — signed APK, R8, version guard, icons' (#11) from feat/m6-release into main
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 09:17:23 +00:00
2 changed files with 123 additions and 79 deletions

View File

@@ -1,47 +1,57 @@
# Build a SIGNED release APK and attach it to a Gitea release (PLAN.md §10, §12).
# Automated 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: 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.
#
# 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.
# 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.
#
# 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:
# versionName = the tag without its leading `v`; versionCode = major*10000 +
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
# into app/build.gradle.kts for the build only — nothing is committed back to main.
#
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
# REGISTRY_TOKEN — Gitea access token with `write:repository` (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)
#
# 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*']
workflow_dispatch: {}
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Existing v* tag to (re)build and release'
required: true
concurrency:
group: release-apk-${{ github.ref }}
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
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
# 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
steps:
- name: Install base tools + JDK 17
run: |
@@ -49,44 +59,72 @@ 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 the release tag (full history for the changelog)
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-depth: 0
# ── Derive version + changelog straight from the tag ─────────────────
- name: Plan the release (version + changelog from the tag)
id: plan
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
TAG="${{ github.event.inputs.tag || github.ref_name }}"
case "$TAG" in
v[0-9]*) : ;;
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
esac
VERSION="${TAG#v}"
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
IFS=. read -r MA MI PA <<< "$VERSION"
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
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 ${TAG}"
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 "$PREV_TAG" ]; then echo "Since ${PREV_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=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
# ── SDK + signing keystore ───────────────────────────────────────────
- name: Set up Android SDK
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
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
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
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -99,7 +137,18 @@ 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
# ── Set the version, build the signed APK ────────────────────────────
- name: Set the app version to match the tag
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
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -107,44 +156,39 @@ 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
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' }}
# ── Create the Gitea release + upload assets (no push to main) ───────
- name: Create Gitea release and upload assets
env:
RELEASE_TOKEN: ${{ github.token }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
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

View File

@@ -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"