# Releases. The usual way is Actions › Release › Run workflow (on main), which # 1. bumps versionName to the next patch (0.0.22-beta → 0.0.23-beta, or the version typed in) and versionCode by 1, # 2. writes fastlane/metadata/android/en-US/changelogs/.txt from the commits since the last release, # 3. commits both as "chore: version bump to v" and pushes it to main, # 4. builds the APK signed with the release key and publishes the GitHub release, which creates the tag # Equalizer314-v that F-Droid picks the release up from (UpdateCheckMode: Tags). # If versionName has no tag yet (bumped by hand), that version is released as it is, and a changelog already # written by hand for the new versionCode is used instead of the generated one. # # Pushing a tag Equalizer314-v by hand also works: that commit is built and released as it is. # # The notes come from semantic commit subjects, like Launcher314's: "feat: x" → "Added x", "fix: x; issue #12" → # "Fixed x - issue #12"; fixes without an issue, "perf: …" and merged pull requests go after a blank line. # chore/docs/ci/refactor/… and subjects without a type are left out (the run's summary lists the untyped ones). # # F-Droid rebuilds every release from source and only publishes it when its build is byte-identical to the APK # attached here (Equalizer314-v.apk), so the APK must be signed with the key in AllowedAPKSigningKeys. name: Release on: workflow_dispatch: inputs: version: description: Version (leave blank for the next patch, e.g. 0.0.22-beta → 0.0.23-beta) type: string default: '' draft: description: Keep it as a draft instead of publishing type: boolean default: false dry_run: description: Dry run (only shows the version and notes and builds the APK; changes and releases nothing) type: boolean default: false push: tags: ['Equalizer314-v*'] permissions: contents: read concurrency: # one release at a time, and never cancel one halfway group: release cancel-in-progress: false defaults: run: shell: bash env: TAG_PREFIX: Equalizer314-v # SHA-256 of the release certificate, as in F-Droid's AllowedAPKSigningKeys RELEASE_CERT_SHA256: 7a8368d18ad64294f9aadf4b736adcd15cb0cb88c6b9dc2e0bd5f1e461b83e52 # same versions as app/build.gradle.kts and F-Droid's recipe (ndk: r27c) NDK_VERSION: 27.2.12479018 CMAKE_VERSION: 3.22.1 jobs: prepare: name: Version and notes runs-on: ubuntu-latest permissions: contents: write # pushes the version bump commit outputs: skip: ${{ steps.v.outputs.skip }} release: ${{ steps.v.outputs.release }} sha: ${{ steps.v.outputs.sha }} tag: ${{ steps.v.outputs.tag }} notes: ${{ steps.v.outputs.notes }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # every tag and commit, for the notes - id: v name: Version, changelog and bump commit env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry_run || false }} run: | set -euo pipefail gradle=app/build.gradle.kts name="$(sed -n 's/^ *versionName = "\(.*\)"$/\1/p' "$gradle")" code="$(sed -n 's/^ *versionCode = \([0-9]*\)$/\1/p' "$gradle")" if [ -z "$name" ] || [ -z "$code" ]; then echo "::error::versionName / versionCode not found in $gradle" exit 1 fi has_tag() { git rev-parse -q --verify "refs/tags/$TAG_PREFIX$1" >/dev/null; } if [ "$GITHUB_REF_TYPE" = tag ]; then version="${GITHUB_REF_NAME#"$TAG_PREFIX"}" if [ "$version" != "$name" ]; then echo "::error::tag $GITHUB_REF_NAME doesn't match versionName $name" exit 1 fi if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then # the tag came from publishing a release (a draft, say), so there's nothing left to do echo "$GITHUB_REF_NAME is already released." >>"$GITHUB_STEP_SUMMARY" echo "skip=true" >>"$GITHUB_OUTPUT" exit 0 fi prev="$(git describe --tags --abbrev=0 --match "$TAG_PREFIX*" HEAD^ 2>/dev/null || true)" release=true else if [ "$DRY_RUN" != true ] && [ "$GITHUB_REF_NAME" != main ]; then echo "::error::releases are made from main (this run is on $GITHUB_REF_NAME)" exit 1 fi prev="$(git describe --tags --abbrev=0 --match "$TAG_PREFIX*" 2>/dev/null || true)" if has_tag "$name"; then # released already, so this is the next one: next patch, keeping a -beta style label core="$(grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' <<<"$name" || true)" if [ -z "$core" ] && [ -z "$INPUT_VERSION" ]; then echo "::error::can't work out the version after $name: type it in when running the workflow" exit 1 fi label="${name#"$core"}" label="${label%%_*}" IFS=. read -r major minor patch <<<"$core" version="${INPUT_VERSION:-$major.$minor.$((patch + 1))$label}" code=$((code + 1)) else version="${INPUT_VERSION:-$name}" fi version="${version#v}" if ! [[ $version =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then echo "::error::\"$version\" isn't a version name like 0.0.23-beta" exit 1 fi if has_tag "$version"; then echo "::error::$TAG_PREFIX$version is already released" exit 1 fi if [ "$DRY_RUN" != true ] && [ -z "$(git rev-list "${prev:+$prev..}HEAD")" ]; then echo "::error::no commits since $prev, nothing to release" exit 1 fi release="$([ "$DRY_RUN" = true ] && echo false || echo true)" fi tag="$TAG_PREFIX$version" range="${prev:+$prev..}HEAD" # Notes from the commits on main's own line (a merged pull request counts once, as its merge), in Launcher314's style. tidy() { # trailing "; issue #12" → " - issue #12", the word eq → EQ sed -E 's/[;,] *(issues? #[0-9].*)$/ - \1/; s/\beq\b/EQ/g' <<<"$1" } new=() fixes=() rest=() untyped=() type_re='^([A-Za-z]+)(\([^)]*\))?!?: *(.+)$' issue_re=' - issues? #[0-9]' while IFS= read -r subject; do if [[ $subject =~ $type_re ]]; then type="${BASH_REMATCH[1],,}" t="$(tidy "${BASH_REMATCH[3]}")" case "$type" in feat) new+=("Added $t") ;; fix) if [[ $t =~ $issue_re ]]; then fixes+=("Fixed $t"); else rest+=("Fixed $t"); fi ;; perf) rest+=("${t^}") ;; esac else untyped+=("$subject") fi done < <(git log --reverse --first-parent --no-merges --format=%s "$range") pr_re='^Merge pull request #([0-9]+) from ([^/]+)/' for c in $(git rev-list --reverse --first-parent --merges "$range"); do [[ $(git log -1 --format=%s "$c") =~ $pr_re ]] || continue num="${BASH_REMATCH[1]}" who="${BASH_REMATCH[2]}" title="$(git log -1 --format=%b "$c" | sed -n '/[^[:space:]]/{p;q}')" t="$(tidy "${title:-Pull request #$num}")" rest+=("${t^} - PR #$num by $who") done changelog="fastlane/metadata/android/en-US/changelogs/$code.txt" notes="$RUNNER_TEMP/notes.md" # a changelog written by hand is used as is if [ ! -f "$changelog" ]; then if [ $((${#new[@]} + ${#fixes[@]} + ${#rest[@]})) -eq 0 ]; then msg="no feat: or fix: commits since ${prev:-the start} for the notes; use those types, or write $changelog by hand" # a tag pushed by hand is out already, so it's released anyway if [ "$DRY_RUN" = true ] || [ "$GITHUB_REF_TYPE" = tag ]; then echo "::warning::$msg"; else echo "::error::$msg"; exit 1; fi fi mkdir -p "$(dirname "$changelog")" # features and fixed issues first, then a blank line and the rest main=("${new[@]}" "${fixes[@]}") { [ ${#main[@]} -eq 0 ] || printf '%s\n' "${main[@]}" if [ ${#rest[@]} -gt 0 ]; then [ ${#main[@]} -eq 0 ] || echo printf '%s\n' "${rest[@]}" fi } >"$changelog" fi { echo "## What's New"; echo; cat "$changelog"; } >"$notes" if [ "$GITHUB_REF_TYPE" != tag ]; then sed -i "s/^\( *versionName = \)\".*\"$/\1\"$version\"/; s/^\( *versionCode = \)[0-9]*$/\1$code/" "$gradle" if [ "$(sed -n 's/^ *versionName = "\(.*\)"$/\1/p' "$gradle")" != "$version" ] || [ "$(sed -n 's/^ *versionCode = \([0-9]*\)$/\1/p' "$gradle")" != "$code" ]; then echo "::error::couldn't set the version in $gradle" exit 1 fi if [ "$DRY_RUN" != true ] && [ -n "$(git status --porcelain)" ]; then git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" git add "$gradle" "$changelog" git commit -q -m "chore: version bump to v$version" # fails, changing nothing, if main moved since this run started git push -q origin "HEAD:refs/heads/$GITHUB_REF_NAME" fi fi { echo "sha=$(git rev-parse HEAD)" echo "tag=$tag" echo "release=$release" delim="NOTES_$(openssl rand -hex 12)" echo "notes<<$delim" cat "$notes" echo "$delim" } >>"$GITHUB_OUTPUT" { echo "## $tag ($code)" if [ "$DRY_RUN" = true ]; then echo "Dry run: nothing was committed or released, and the APK is built from the code as it is." fi echo cat "$notes" if [ ${#untyped[@]} -gt 0 ]; then echo echo "
Left out of the notes (no feat:/fix: type)" echo printf -- '- %s\n' "${untyped[@]}" echo "
" fi } >>"$GITHUB_STEP_SUMMARY" apk: name: APK needs: prepare if: needs.prepare.outputs.skip != 'true' runs-on: ubuntu-latest env: RELEASING: ${{ needs.prepare.outputs.release }} steps: - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.sha }} # Java 21, like local builds and F-Droid's build server - uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Install the Android NDK and CMake # yes dies of a broken pipe once sdkmanager is done, which pipefail would count as a failure run: (yes 2>/dev/null || true) | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION" "cmake;$CMAKE_VERSION" >/dev/null - name: Release key env: KEYSTORE_BASE64: ${{ secrets.EQUALIZER314_KEYSTORE_BASE64 }} STORE_PASSWORD: ${{ secrets.EQUALIZER314_STORE_PASSWORD }} KEY_ALIAS: ${{ secrets.EQUALIZER314_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.EQUALIZER314_KEY_PASSWORD }} run: | # Gradle reads ORG_GRADLE_PROJECT_* as the RELEASE_* properties app/build.gradle.kts signs with. if [ -n "$KEYSTORE_BASE64" ]; then printf '%s' "$KEYSTORE_BASE64" | base64 -d >"$RUNNER_TEMP/release.jks" { echo "ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=$RUNNER_TEMP/release.jks" echo "ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=$STORE_PASSWORD" echo "ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=$KEY_ALIAS" echo "ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=$KEY_PASSWORD" } >>"$GITHUB_ENV" elif [ "$RELEASING" = true ]; then echo "::error::No release key: a release is never signed with a throwaway key" exit 1 else echo "::warning::No release key: this dry run is signed with a throwaway key" keytool -genkeypair -keystore "$RUNNER_TEMP/release.jks" -storepass android -keypass android \ -alias dryrun -keyalg RSA -keysize 2048 -validity 1 -dname "CN=Dry run" >/dev/null { echo "ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=$RUNNER_TEMP/release.jks" echo "ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=android" echo "ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=dryrun" echo "ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=android" } >>"$GITHUB_ENV" fi - name: Build run: | version="$(sed -n 's/^ *versionName = "\(.*\)"$/\1/p' app/build.gradle.kts)" chmod +x gradlew ./gradlew assembleRelease --no-daemon mkdir -p dist cp app/build/outputs/apk/release/app-release.apk "dist/$TAG_PREFIX$version.apk" - name: Check the signature run: | apksigner="$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1)apksigner" apk="$(ls dist/*.apk)" certs="$("$apksigner" verify --print-certs "$apk")" sha="$(sed -n 's/.*certificate SHA-256 digest: //p' <<<"$certs" | head -n 1)" if [ "$RELEASING" = true ] && [ "$sha" != "$RELEASE_CERT_SHA256" ]; then echo "::error::$apk is signed with $sha, not the release key F-Droid expects" exit 1 fi { echo echo " $(basename "$apk")" echo " certificate SHA-256: $sha" echo " APK SHA-256: $(sha256sum "$apk" | cut -d' ' -f1)" } >>"$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v7 with: name: release-apk path: dist/* if-no-files-found: error - name: Remove the release key if: always() run: rm -f "$RUNNER_TEMP/release.jks" release: name: GitHub release needs: [prepare, apk] if: needs.prepare.outputs.release == 'true' runs-on: ubuntu-latest permissions: contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - uses: actions/download-artifact@v8 with: name: release-apk path: dist - name: Publish env: TAG: ${{ needs.prepare.outputs.tag }} SHA: ${{ needs.prepare.outputs.sha }} NOTES: ${{ needs.prepare.outputs.notes }} DRAFT: ${{ inputs.draft || false }} run: | printf '%s\n' "$NOTES" >"$RUNNER_TEMP/notes.md" args=(--title "$TAG" --notes-file "$RUNNER_TEMP/notes.md") # a tag pushed by hand is used as is; otherwise the release creates the tag on the bump commit if [ "$GITHUB_REF_TYPE" = tag ]; then args+=(--verify-tag); else args+=(--target "$SHA"); fi if [ "$DRAFT" = true ]; then args+=(--draft); fi url="$(gh release create "$TAG" "${args[@]}" -- dist/*)" if [ "$DRAFT" = true ]; then echo "Draft [$TAG]($url) is ready: check it and publish it (that creates the tag)." >>"$GITHUB_STEP_SUMMARY" else echo "Published [$TAG]($url)." >>"$GITHUB_STEP_SUMMARY" fi