Skip to content

Release Build

Release Build #156

Workflow file for this run

name: Build Android APK
on:
push:
tags:
- '*.*.*'
- '!*-*'
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 1.0.0). Leave empty for auto-increment."
required: false
default: ""
type: string
create_release:
description: "Create a GitHub Release with the APK?"
required: false
default: true
type: boolean
keystore_base64:
description: "Base64-encoded keystore (from SAVE-THESE-SECRETS.txt). Overrides ANDROID_KEYSTORE_BASE64 secret."
required: false
default: ""
type: string
key_alias:
description: "Signing key alias (from SAVE-THESE-SECRETS.txt). Overrides ANDROID_SIGNING_KEY_ALIAS secret."
required: false
default: ""
type: string
key_password:
description: "Signing key password (from SAVE-THESE-SECRETS.txt). Overrides ANDROID_SIGNING_KEY_PASSWORD secret."
required: false
default: ""
type: string
jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
packages: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: microsoft
java-version: 17
- name: Install .NET 10.0.x
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Install .NET Android workload
run: dotnet workload install android
- name: Authenticate to GitHub Packages
run: dotnet nuget update source winnerspiros-github --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text
- name: Ensure Android NDK and CMake are available
run: |
# The ubuntu-latest runner has $ANDROID_HOME pre-installed.
# sdkmanager is not on PATH; use the full path.
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
# Install latest stable NDK (r29) + CMake.
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
"$SDKMANAGER" --install "ndk;29.0.14206865" "cmake;3.22.1" > /dev/null 2>&1
- name: Build native library (libosu_native.so)
run: |
NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
CMAKE_BIN="$ANDROID_HOME/cmake/3.22.1/bin/cmake"
# arm64 only β€” matches RuntimeIdentifiers in osu.Android.props.
for ABI in arm64-v8a; do
echo "::group::Building osu_native for $ABI"
"$CMAKE_BIN" -B "build-native/$ABI" -S osu.Android/Native \
-DCMAKE_TOOLCHAIN_FILE="$NDK_HOME/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="$ABI" \
-DANDROID_PLATFORM=android-33 \
-DCMAKE_BUILD_TYPE=Release
"$CMAKE_BIN" --build "build-native/$ABI" --config Release -j "$(nproc)"
mkdir -p "osu.Android/libs/$ABI"
cp "build-native/$ABI/libosu_native.so" "osu.Android/libs/$ABI/"
echo "::endgroup::"
done
echo "Native libraries built successfully:"
find osu.Android/libs -name "*.so" -exec ls -lh {} \;
- name: Decode or generate keystore
id: keystore
run: |
KS_PATH="${{ github.workspace }}/osu.Android/osu.keystore"
# Priority: workflow_dispatch inputs > repository secrets > auto-generate.
# This lets users paste values from SAVE-THESE-SECRETS.txt directly into
# the "Run workflow" form so they don't need to set up repo secrets.
# Note: the auto-generated keystore uses the same password for both key and
# store, so a single key_password input covers both. Users with separate
# passwords should use repository secrets instead of workflow inputs.
EFFECTIVE_KS="${INPUT_KEYSTORE_BASE64:-$KEYSTORE_BASE64}"
EFFECTIVE_ALIAS="${INPUT_KEY_ALIAS:-$KEY_ALIAS_SECRET}"
EFFECTIVE_PASS="${INPUT_KEY_PASSWORD:-$KEY_PASS_SECRET}"
EFFECTIVE_STORE_PASS="${INPUT_KEY_PASSWORD:-${STORE_PASS_SECRET:-$EFFECTIVE_PASS}}"
# Mask passwords so they never appear in logs.
if [ -n "$EFFECTIVE_PASS" ]; then echo "::add-mask::$EFFECTIVE_PASS"; fi
if [ -n "$EFFECTIVE_STORE_PASS" ]; then echo "::add-mask::$EFFECTIVE_STORE_PASS"; fi
if [ -n "$EFFECTIVE_KS" ]; then
# ── User provided a keystore (via input or secret) ──────────────
echo "$EFFECTIVE_KS" | base64 --decode > "$KS_PATH"
echo "has_keystore=true" >> "$GITHUB_OUTPUT"
echo "generated=false" >> "$GITHUB_OUTPUT"
echo "key_alias=${EFFECTIVE_ALIAS:-osu-release}" >> "$GITHUB_OUTPUT"
echo "key_pass=${EFFECTIVE_PASS}" >> "$GITHUB_OUTPUT"
echo "store_pass=${EFFECTIVE_STORE_PASS}" >> "$GITHUB_OUTPUT"
if [ -n "$INPUT_KEYSTORE_BASE64" ]; then
echo "βœ… Using keystore from workflow dispatch inputs."
else
echo "βœ… Using saved keystore from repository secrets."
fi
else
# ── No keystore β†’ auto-generate one for this build ──────────────
# The APK will install fine on any device, but UPDATING from a
# previous build signed with a DIFFERENT key will fail.
# To avoid that, save the generated keystore as a secret
# (instructions are printed at the end of the build).
AUTO_PASS="osu-$(openssl rand -hex 12)"
echo "::add-mask::$AUTO_PASS"
keytool -genkeypair \
-keystore "$KS_PATH" \
-storepass "$AUTO_PASS" \
-keypass "$AUTO_PASS" \
-alias osu-release \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=osu! Android,O=osu,C=US" 2>/dev/null
echo "has_keystore=true" >> "$GITHUB_OUTPUT"
echo "generated=true" >> "$GITHUB_OUTPUT"
echo "key_alias=osu-release" >> "$GITHUB_OUTPUT"
echo "key_pass=$AUTO_PASS" >> "$GITHUB_OUTPUT"
echo "store_pass=$AUTO_PASS" >> "$GITHUB_OUTPUT"
# Export base64 and password into a single instructions file
# so the user only needs to download one artifact.
{
echo "=== osu! Android Signing Keystore ==="
echo ""
echo "ANDROID_KEYSTORE_BASE64 value (copy everything on the next line):"
base64 -w 0 "$KS_PATH"
echo ""
echo ""
echo "ANDROID_SIGNING_KEY_ALIAS value:"
echo "osu-release"
echo ""
echo "ANDROID_SIGNING_KEY_PASSWORD value:"
echo "$AUTO_PASS"
echo ""
echo "ANDROID_SIGNING_STORE_PASSWORD value:"
echo "$AUTO_PASS"
} > "${{ github.workspace }}/SAVE-THESE-SECRETS.txt"
echo "::warning::No signing keystore secret found β€” auto-generated one for this build."
echo "::warning::See the end of this job for instructions to save it for future builds."
fi
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEY_ALIAS_SECRET: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }}
KEY_PASS_SECRET: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }}
STORE_PASS_SECRET: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }}
INPUT_KEYSTORE_BASE64: ${{ inputs.keystore_base64 }}
INPUT_KEY_ALIAS: ${{ inputs.key_alias }}
INPUT_KEY_PASSWORD: ${{ inputs.key_password }}
- name: Determine version
id: version
run: |
# Priority: manual input > tag name > auto-increment from run number
INPUT_VERSION="${{ inputs.version }}"
REF="${{ github.ref_name }}"
if [[ -n "$INPUT_VERSION" && "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION="$INPUT_VERSION"
echo "πŸ“¦ Using manually specified version: $VERSION"
elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION="$REF"
echo "πŸ“¦ Using version from tag: $VERSION"
else
# Auto-generate: YYYY.MMDD.run_number
VERSION="$(date -u +%Y).$(date -u +%-m%d).${{ github.run_number }}"
echo "πŸ“¦ Auto-generated version: $VERSION"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Build Android APK
run: >
dotnet publish -c Release
osu.Android/osu.Android.csproj
-f net10.0-android
-p:Version="${{ steps.version.outputs.version }}"
-p:ApplicationDisplayVersion="${{ steps.version.outputs.version }}"
-p:ApplicationVersion="${{ github.run_number }}"
-p:AndroidKeyStore=true
-p:AndroidSigningKeyStore="${{ github.workspace }}/osu.Android/osu.keystore"
-p:AndroidSigningKeyAlias="${{ steps.keystore.outputs.key_alias }}"
-p:AndroidSigningKeyPass="${{ steps.keystore.outputs.key_pass }}"
-p:AndroidSigningStorePass="${{ steps.keystore.outputs.store_pass }}"
- name: Find and rename APK
id: find_apk
run: |
APK=$(find "osu.Android/bin/Release/net10.0-android/publish" -maxdepth 1 -name "*-Signed.apk" 2>/dev/null | head -1)
if [ -z "$APK" ]; then
APK=$(find "osu.Android/bin/Release" -name "*-Signed.apk" | head -1)
fi
if [ -z "$APK" ]; then
echo "::error::Failed to locate signed APK. Listing bin directory:"
find osu.Android/bin -name "*.apk" -o -name "*.aab" 2>/dev/null || true
exit 1
fi
# Rename to a clean versioned filename for the release
VERSION="${{ steps.version.outputs.version }}"
FINAL_APK="$(dirname "$APK")/osu-lazer-$VERSION.apk"
cp "$APK" "$FINAL_APK"
APK_SIZE=$(stat -c %s "$FINAL_APK" 2>/dev/null || stat -f %z "$FINAL_APK" 2>/dev/null || wc -c < "$FINAL_APK")
APK_SIZE_MB=$((APK_SIZE / 1048576))
echo "Found APK: $FINAL_APK ($APK_SIZE_MB MB)"
echo "apk_path=$FINAL_APK" >> "$GITHUB_OUTPUT"
- name: Verify APK signature
run: |
APK="${{ steps.find_apk.outputs.apk_path }}"
APKSIGNER="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/apksigner"
if "$APKSIGNER" verify --print-certs "$APK"; then
echo "APK signature verified βœ“"
else
echo "::error::APK signature verification failed. The APK may not install correctly."
exit 1
fi
- name: Verify native libraries in APK
run: |
APK="${{ steps.find_apk.outputs.apk_path }}"
echo "Checking APK for required native libraries..."
NATIVE_LIBS=$(unzip -l "$APK" | grep "lib/arm64-v8a/.*\.so" || true)
echo "$NATIVE_LIBS"
echo ""
MISSING=0
for LIB in libbass.so libbass_fx.so libbassmix.so; do
if echo "$NATIVE_LIBS" | grep -q "$LIB"; then
echo "βœ… $LIB found"
else
echo "::error::$LIB is MISSING from the APK β€” the app will crash at startup (DllNotFoundException)."
MISSING=1
fi
done
if [ "$MISSING" -ne 0 ]; then
echo ""
echo "::error::One or more required native libraries are missing from the APK. Ensure ppy.osu.Framework.Android was properly restored from the winnerspiros GitHub Packages feed."
exit 1
fi
# Architecture sanity check: a previous build packaged the Linux-glibc
# libbass.so from ppy.osu.Framework.NativeLibs into lib/arm64-v8a/, which
# passed the name check above but failed at runtime with
# System.DllNotFoundException: bass because Android's bionic linker cannot
# resolve glibc-only symbols. Both Linux and Android builds report
# identically as "ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV)"
# via file(1), so we rely on the presence of GLIBC_ versioned symbols
# (which exist only in glibc-linked binaries) to distinguish them.
echo ""
echo "Verifying native library architectures (must be Android arm64, not Linux glibc)..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
BAD=0
for LIB in libbass.so libbass_fx.so libbassmix.so; do
unzip -p "$APK" "lib/arm64-v8a/$LIB" > "$TMPDIR/$LIB"
FILE_INFO=$(file "$TMPDIR/$LIB")
echo " $LIB: $FILE_INFO"
# Must be a 64-bit aarch64 ELF shared object.
if ! echo "$FILE_INFO" | grep -qE "ELF 64-bit.*aarch64|ELF 64-bit.*ARM aarch64"; then
echo "::error::$LIB is not a 64-bit aarch64 ELF β€” runtime DllNotFoundException will occur."
BAD=1
continue
fi
# Reliable Linux-vs-Android distinguisher: GLIBC_ versioned symbols
# (e.g. memcpy@@GLIBC_2.17) appear only in glibc-linked Linux binaries.
# Android's bionic libc uses no symbol versioning.
if strings "$TMPDIR/$LIB" | grep -q "^GLIBC_"; then
echo "::error::$LIB references GLIBC_ versioned symbols β€” this is the Linux ELF from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/, not the Android ELF from ppy.osu.Framework.Android. Android's bionic linker cannot load it; the app will crash at startup with System.DllNotFoundException: bass."
BAD=1
continue
fi
echo " βœ… $LIB is a valid Android arm64 ELF"
done
if [ "$BAD" -ne 0 ]; then
echo ""
echo "::error::One or more native libraries in the APK are NOT valid Android arm64 binaries. This usually means desktop runtime .so files (e.g. from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/) leaked into lib/arm64-v8a/ during packaging. See osu.Android.props FixRuntimePackAssetTypes target."
exit 1
fi
echo ""
echo "All required native libraries present and valid βœ“"
- name: Upload APK artifact
uses: actions/upload-artifact@v7
with:
name: osu-android-${{ steps.version.outputs.version }}
path: ${{ steps.find_apk.outputs.apk_path }}
if-no-files-found: error
# When the keystore was auto-generated, upload it so the user can save it
# as a repository secret for consistent signing across builds.
- name: Upload generated keystore
if: steps.keystore.outputs.generated == 'true'
uses: actions/upload-artifact@v7
with:
name: osu-signing-keystore
path: |
${{ github.workspace }}/osu.Android/osu.keystore
${{ github.workspace }}/SAVE-THESE-SECRETS.txt
retention-days: 7
- name: Print keystore setup instructions
if: steps.keystore.outputs.generated == 'true'
run: |
echo ""
echo "=================================================================="
echo " ⚠️ YOUR APK WAS SIGNED WITH AN AUTO-GENERATED KEYSTORE"
echo "=================================================================="
echo ""
echo " βœ… The APK will install fine on any device."
echo ""
echo " ⚠️ BUT β€” if you build again without saving this keystore,"
echo " Android will REFUSE to update (different signing certificate)."
echo ""
echo " To keep your APK updatable across builds:"
echo ""
echo " 1. Download the 'osu-signing-keystore' artifact from this run"
echo " 2. Open 'SAVE-THESE-SECRETS.txt' β€” it contains all 4 values"
echo " 3. Go to: Settings β†’ Secrets and variables β†’ Actions"
echo " 4. Create these 4 secrets with the values from the file:"
echo ""
echo " β€’ ANDROID_KEYSTORE_BASE64"
echo " β€’ ANDROID_SIGNING_KEY_ALIAS"
echo " β€’ ANDROID_SIGNING_KEY_PASSWORD"
echo " β€’ ANDROID_SIGNING_STORE_PASSWORD"
echo ""
echo " ⚑ After saving the secrets, all future builds will use the"
echo " same keystore automatically β€” no more setup needed."
echo ""
echo " πŸ’‘ QUICK OPTION: You can also paste the values from"
echo " SAVE-THESE-SECRETS.txt directly into the workflow"
echo " dispatch inputs (keystore_base64, key_alias, key_password)"
echo " when running the 'Build Android APK' workflow manually."
echo "=================================================================="
# Create a GitHub Release with the APK attached.
# Works for both tag pushes and manual workflow_dispatch runs.
# For manual runs: creates a tag automatically.
- name: Create GitHub Release
if: inputs.create_release != false
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: "osu! Android ${{ steps.version.outputs.version }}"
files: ${{ steps.find_apk.outputs.apk_path }}
generate_release_notes: true
body: |
## osu! Android v${{ steps.version.outputs.version }}
### Download
Grab the APK below and install it on your Android device.
> **Minimum Android version:** 13 (API 33)
> **Architecture:** arm64-v8a
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}