Skip to content

2026.07.6

2026.07.6 #10

Workflow file for this run

name: Release APK
# Two ways in:
#
# 1. Publish a GitHub Release (Releases > Draft a new release > Publish). The
# workflow picks the version, builds a signed APK, and attaches it.
#
# 2. Run it by hand (Actions > Run workflow). This is a DRY RUN by default: it
# builds and signs the APK and leaves it as a workflow artifact, creating no
# release and no tag. Tick "publish" to make it cut a real release instead.
on:
release:
types: [published]
workflow_dispatch:
inputs:
publish:
description: 'Cut a real release (leave unticked for a dry run: build only, no release, no tag)'
type: boolean
default: false
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
with:
# Tags are how the build number is derived, and checkout omits them by default.
fetch-depth: 0
- name: Resolve version (YYYY.MM.build)
id: version
env:
# Via env, never interpolated into the script body: a tag name is free-form text and
# would otherwise be a shell-injection vector.
TAG: ${{ github.event.release.tag_name }}
EVENT: ${{ github.event_name }}
PUBLISH_INPUT: ${{ inputs.publish }}
run: |
set -euo pipefail
PREFIX="$(date -u +%Y.%m)"
# If the release was tagged with an explicit YYYY.MM.build (optionally v-prefixed),
# honour it — the human's intent wins over the counter.
if [[ "$TAG" =~ ^v?([0-9]{4})\.([0-9]{2})\.([0-9]+)$ ]]; then
YEAR="${BASH_REMATCH[1]}"; MONTH="${BASH_REMATCH[2]}"; BUILD="${BASH_REMATCH[3]}"
else
# Otherwise take the highest build already tagged this month and add one.
# `|| true` because grep exits 1 when nothing matches, which under
# `set -euo pipefail` would abort the whole step. No matching tag is the
# normal case for the first release of any month, not an error.
YEAR="${PREFIX%.*}"; MONTH="${PREFIX#*.}"
LAST="$(git tag -l "${PREFIX}.*" \
| { grep -E "^${PREFIX}\.[0-9]+$" || true; } \
| sed "s/^${PREFIX}\.//" \
| sort -n | tail -1)"
BUILD=$(( ${LAST:-0} + 1 ))
fi
VERSION="${YEAR}.${MONTH}.${BUILD}"
# Monotonic and room for 999 builds a month. 10# forces base 10 so "07"/"08"/"09"
# are not read as octal.
VERSION_CODE=$(( (10#$YEAR * 100 + 10#$MONTH) * 1000 + 10#$BUILD ))
# Does this run cut a real release? Always for a published Release; for a manual
# run only when the "publish" box was ticked. Everything release-shaped downstream
# (tagging, creating, attaching) keys off this single answer.
if [ "$EVENT" = "release" ] || [ "$PUBLISH_INPUT" = "true" ]; then
PUBLISHING=true
else
PUBLISHING=false
fi
# Which tag the APK gets attached to. A published Release already has its own tag,
# so use that verbatim; a manual publish creates the version tag itself.
if [ "$EVENT" = "release" ]; then
RELEASE_TAG="$TAG"
else
RELEASE_TAG="$VERSION"
fi
{
echo "version=$VERSION"
echo "version_code=$VERSION_CODE"
echo "publishing=$PUBLISHING"
echo "release_tag=$RELEASE_TAG"
} >> "$GITHUB_OUTPUT"
echo "Building $VERSION (versionCode $VERSION_CODE)"
if [ "$PUBLISHING" = "true" ]; then
echo "This run WILL publish a release tagged $RELEASE_TAG."
else
echo "DRY RUN: building only. No release and no tag will be created."
fi
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install SDK packages
# android-37.1 is not a default package and is easy to miss: compileSdk = 37 is
# required by androidx.core:core-ktx's AAR metadata. Without it the build fails with a
# checkDebugAarMetadata error that names an AAR rather than the missing platform.
run: sdkmanager "platforms;android-36" "platforms;android-37.1" "build-tools;36.0.0"
- name: Run tests
run: ./gradlew test
- name: Decode signing keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "${KEYSTORE_BASE64:-}" ]; then
echo "::error::ANDROID_KEYSTORE_BASE64 is not set. See README (Releasing) for the" \
"four secrets this workflow needs; without them the APK cannot be signed."
exit 1
fi
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
- name: Build signed release APK
env:
ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/release.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
./gradlew :app:assembleRelease \
-PappVersionName="${{ steps.version.outputs.version }}" \
-PappVersionCode="${{ steps.version.outputs.version_code }}"
- name: Name the APK
id: apk
run: |
set -euo pipefail
SRC="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
if [ -z "$SRC" ]; then echo "::error::no APK produced"; exit 1; fi
DEST="sn-reader-${{ steps.version.outputs.version }}.apk"
mv "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
- name: Always remove the keystore
# Runs even if the build failed, so the decoded key never outlives the job.
if: always()
run: rm -f "$RUNNER_TEMP/release.jks"
- name: Tag this build
# Keeps the monthly counter durable: the next release reads these tags to pick its
# build number. Skipped when the tag already exists (explicitly-tagged releases).
if: steps.version.outputs.publishing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "Tag $VERSION already exists, nothing to push."
else
git tag "$VERSION"
git push origin "$VERSION"
fi
- name: Create the release
# Only for a manual publish. A `release` event already has its release by definition.
if: github.event_name == 'workflow_dispatch' && steps.version.outputs.publishing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.release_tag }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, reusing it."
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
- name: Attach APK to the release
if: steps.version.outputs.publishing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.release_tag }}
APK: ${{ steps.apk.outputs.path }}
run: gh release upload "$TAG" "$APK" --clobber
- name: Upload APK as an artifact
# Unconditional: on a dry run this is the only copy, and on a real release it is a
# convenient second one.
uses: actions/upload-artifact@v4
with:
name: sn-reader-${{ steps.version.outputs.version }}
path: ${{ steps.apk.outputs.path }}
- name: Say what this run actually did
# Written to the run summary so the outcome is unmissable. The whole reason this step
# exists: a dry run and a real release used to look identical from the Actions tab.
if: always()
env:
PUBLISHING: ${{ steps.version.outputs.publishing }}
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.release_tag }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
run: |
set -euo pipefail
if [ "${PUBLISHING:-false}" = "true" ]; then
{
echo "## Released $VERSION"
echo ""
echo "Published as **$TAG** with the signed APK attached."
echo ""
echo "[View the release]($REPO_URL/releases/tag/$TAG)"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## Dry run: no release created"
echo ""
echo "Built and signed \`sn-reader-$VERSION.apk\` and left it as a workflow"
echo "artifact below. **No GitHub Release and no tag were created.**"
echo ""
echo "To cut a real release, either publish one from the"
echo "[Releases page]($REPO_URL/releases), or re-run this workflow with the"
echo "**publish** box ticked."
} >> "$GITHUB_STEP_SUMMARY"
fi