2026.07.6 #10
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |