name: Release on: push: tags: - "v*" workflow_dispatch: inputs: version: description: "Version tag (e.g., v1.0.0)" required: true default: "v1.0.0" jobs: # Get version first (quick job) get-version: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - name: Get version id: version env: EVENT_NAME: ${{ github.event_name }} INPUT_VERSION: ${{ github.event.inputs.version }} run: | if [ "$EVENT_NAME" = "workflow_dispatch" ]; then VERSION="$INPUT_VERSION" else VERSION="${GITHUB_REF#refs/tags/}" fi if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "Invalid release version" >&2 exit 1 fi printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" # Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix) VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]') if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "Detected pre-release version: $VERSION" else echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "Detected stable version: $VERSION" fi # Android and iOS build in PARALLEL build-android: runs-on: ubuntu-latest needs: get-version steps: - name: Free disk space run: | # Remove large unused tools (~15GB total). Docker prune was dropped: # it took 1-2 minutes and the rm below already frees enough. sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf /opt/hostedtoolcache/CodeQL sudo rm -rf /usr/local/share/boost sudo rm -rf /usr/share/swift sudo rm -rf /usr/local/.ghcup # Show available space df -h - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup Java uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: "temurin" java-version: "25" - name: Activate pinned Rust toolchain working-directory: rust_backend run: rustup show - name: Install Rust binding generator dependencies run: | sudo apt-get update sudo apt-get install -y libclang-dev # Cache Gradle for faster builds - name: Cache Gradle uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- - name: Cache Android NDK uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /usr/local/lib/android/sdk/ndk/29.0.14206865 key: ndk-29.0.14206865 - name: Install Android SDK & NDK run: | # Use pre-installed Android SDK on GitHub runners echo "ANDROID_HOME=$ANDROID_HOME" echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" # Accept licenses yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true # Install NDK r29 (supports 16KB page size for Android 15+) # Keep the installed platform aligned with compileSdk/targetSdk. $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0" # Set NDK path echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV - name: Setup Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version-file: .fvmrc cache: true - name: Cache pub dependencies uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} restore-keys: pub-${{ runner.os }}- - name: Get Flutter dependencies run: flutter pub get - name: Generate app icons run: dart run flutter_launcher_icons - name: Build release APKs run: | bash scripts/build_android.sh --target lib/main.dart ls -la build/app/outputs/flutter-apk/ - name: Sign and verify release APKs (V1/V2/V3) env: VERSION: ${{ needs.get-version.outputs.version }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: | set -euo pipefail : "${KEYSTORE_BASE64:?Missing KEYSTORE_BASE64}" : "${KEY_ALIAS:?Missing KEY_ALIAS}" : "${KEYSTORE_PASSWORD:?Missing KEYSTORE_PASSWORD}" export KEY_PASSWORD="${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" umask 077 signing_dir="$(mktemp -d "$RUNNER_TEMP/spotiflac-signing.XXXXXX")" trap 'rm -rf "$signing_dir"' EXIT printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$signing_dir/release.jks" apksigner="$ANDROID_HOME/build-tools/37.0.0/apksigner" apk_dir=build/app/outputs/flutter-apk for target in arm64-v8a:arm64 armeabi-v7a:arm32; do abi="${target%%:*}" label="${target##*:}" apk="$apk_dir/SpotiFLAC-${VERSION}-${label}.apk" "$apksigner" sign \ --ks "$signing_dir/release.jks" \ --ks-key-alias "$KEY_ALIAS" \ --ks-pass env:KEYSTORE_PASSWORD \ --key-pass env:KEY_PASSWORD \ --v1-signing-enabled true \ --v2-signing-enabled true \ --v3-signing-enabled true \ --v4-signing-enabled false \ --out "$apk" "$apk_dir/app-${abi}-release.apk" # Verify the app's supported Android versions, then explicitly # exercise V1 too: the manifest's minSdk 24 normally skips it. "$apksigner" verify "$apk" report="$("$apksigner" verify --verbose --print-certs --min-sdk-version 21 "$apk")" for scheme in 1 2 3; do if ! grep -Eq "Verified using v${scheme} scheme.*: true" <<< "$report"; then echo "ERROR: APK signature V${scheme} did not verify: $apk" >&2 exit 1 fi done if grep -qi "Android Debug" <<< "$report"; then echo "ERROR: Refusing to publish a debug-signed APK" >&2 exit 1 fi printf '%s\n' "$report" | grep -E '^Verifies$|^Verified using v[123] scheme|certificate SHA-256 digest:' done - name: Audit signed Rust APKs before upload env: VERSION: ${{ needs.get-version.outputs.version }} run: | apk_dir=build/app/outputs/flutter-apk python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm64.apk" \ --backend rust --abis arm64-v8a python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm32.apk" \ --backend rust --abis armeabi-v7a for abi in arm64 arm32; do "$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 4 \ "$apk_dir/SpotiFLAC-${VERSION}-${abi}.apk" done - name: Upload APK artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: android-apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk build-ios: runs-on: macos-15 needs: get-version # Only depends on version, NOT android build! steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Select Xcode 26.1.1 run: | sudo xcode-select -s /Applications/Xcode_26.1.1.app xcodebuild -version - name: Build Rust backend for iOS run: bash scripts/build_ios.sh # Cache CocoaPods - name: Cache CocoaPods uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ios/Pods key: pods-rust-${{ runner.os }}-${{ hashFiles('ios/Podfile', 'ios/Podfile.lock', 'rust_backend/SpotiFLACBackend.podspec', 'rust_backend/Cargo.lock') }} restore-keys: pods-rust-${{ runner.os }}- - name: Setup Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version-file: .fvmrc cache: true - name: Cache pub dependencies uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} restore-keys: pub-${{ runner.os }}- - name: Get Flutter dependencies run: flutter pub get - name: Normalize ffmpeg plugin shell scripts (strip CRLF) run: | find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 | while IFS= read -r -d '' f; do perl -pi -e 's/\r$//' "$f" chmod +x "$f" echo "Normalized line endings: $f" done - name: Generate app icons run: dart run flutter_launcher_icons - name: Configure Rust application pods working-directory: ios run: pod install - name: Build iOS (unsigned) run: | # Build Flutter iOS without codesigning flutter build ios --release --no-codesign --config-only --target lib/main.dart \ --dart-define="GIT_COMMIT=$(git rev-parse --short=8 HEAD)" # Use xcodebuild with code signing disabled cd ios xcodebuild -workspace Runner.xcworkspace \ -scheme Runner \ -configuration Release \ -sdk iphoneos \ -destination 'generic/platform=iOS' \ -archivePath build/Runner.xcarchive \ archive \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" \ DEVELOPMENT_TEAM="" - name: Audit Rust iOS archive before packaging run: | python3 -m unittest discover -s scripts -p 'test_check_backend_ios.py' python3 scripts/check_backend_ios.py \ ios/build/Runner.xcarchive/Products/Applications/Runner.app \ --dsym ios/build/Runner.xcarchive/dSYMs/Runner.app.dSYM \ --backend rust --archs arm64 --platform ios --release - name: Create IPA env: VERSION: ${{ needs.get-version.outputs.version }} run: | mkdir -p build/ios/ipa cd ios/build/Runner.xcarchive/Products/Applications mkdir Payload cp -r Runner.app Payload/ # Use absolute path to avoid relative path issues zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload rm -rf Payload - name: Verify IPA created env: VERSION: ${{ needs.get-version.outputs.version }} run: | ls -la build/ios/ipa/ if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then echo "ERROR: IPA not created!" exit 1 fi - name: Upload IPA artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: ios-ipa path: build/ios/ipa/SpotiFLAC-*.ipa create-release: runs-on: ubuntu-latest needs: [get-version, build-android, build-ios] permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 # Full history needed for git-cliff - name: Generate changelog with git-cliff id: changelog uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4 with: config: cliff.toml args: --latest --strip header env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OUTPUT: /tmp/changelog.txt - name: Show generated changelog run: | echo "Generated changelog:" cat /tmp/changelog.txt - name: Download Android APK uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: ios-ipa path: ./release - name: Prepare release body env: VERSION: ${{ needs.get-version.outputs.version }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} run: | CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD) PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true) # Start with git-cliff changelog, but replace its compare footer with a # deterministic previous-tag lookup from git. sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt if [ -n "$PREVIOUS_TAG" ]; then printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \ "$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \ >> /tmp/release_body.txt fi # Append download section cat >> /tmp/release_body.txt << FOOTER --- ### Downloads #### Android - **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices) - **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices) #### iOS - **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required) ### Installation **Android**: Enable "Install from unknown sources" and install the APK **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA FOOTER echo "Release body:" cat /tmp/release_body.txt - name: Create Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ needs.get-version.outputs.version }} name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }} body_path: /tmp/release_body.txt files: ./release/* draft: false prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} update-altstore: runs-on: ubuntu-latest needs: [get-version, build-ios, create-release] if: ${{ needs.get-version.outputs.is_prerelease != 'true' }} permissions: contents: write steps: - name: Checkout main branch uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: main - name: Download iOS IPA uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: ios-ipa path: ./release - name: Update apps.json env: VERSION: ${{ needs.get-version.outputs.version }} REPOSITORY: ${{ github.repository }} run: | VERSION_NUM="${VERSION#v}" DATE=$(date -u +%Y-%m-%d) IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1) if [ -z "$IPA_FILE" ]; then echo "WARNING: IPA file not found, skipping apps.json update" exit 0 fi IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE") BUNDLE_ID=$(python3 - "$IPA_FILE" <<'PY' import plistlib import sys import zipfile with zipfile.ZipFile(sys.argv[1]) as ipa: info_path = next(name for name in ipa.namelist() if name.startswith("Payload/") and name.count("/") == 2 and name.endswith(".app/Info.plist")) print(plistlib.loads(ipa.read(info_path))["CFBundleIdentifier"]) PY ) if [ ! -f apps.json ]; then echo "WARNING: apps.json not found on main, skipping" exit 0 fi jq --arg ver "$VERSION_NUM" \ --arg bundle "$BUNDLE_ID" \ --arg date "$DATE" \ --arg url "https://github.com/${REPOSITORY}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \ --argjson size "$IPA_SIZE" \ '.apps[0].bundleIdentifier = $bundle | .apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \ apps.json > apps.json.tmp && mv apps.json.tmp apps.json echo "Updated apps.json:" cat apps.json - name: Commit and push env: VERSION: ${{ needs.get-version.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps.json git diff --cached --quiet && echo "No changes to commit" || \ (git commit -m "chore: update AltStore source to ${VERSION}" && git push) notify-telegram: runs-on: ubuntu-latest needs: [get-version, create-release] if: ${{ needs.get-version.outputs.is_prerelease != 'true' }} steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Download Android APK uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: ios-ipa path: ./release - name: Generate changelog with git-cliff for Telegram uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4 with: config: cliff.toml args: --latest --strip all env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OUTPUT: /tmp/cliff_tg.txt - name: Convert changelog for Telegram id: changelog run: | if [ ! -s /tmp/cliff_tg.txt ]; then echo "See release notes on GitHub for details." > /tmp/changelog.txt else # Convert Markdown to Telegram HTML CHANGELOG=$(cat /tmp/cliff_tg.txt | \ sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \ sed '/^\*\*Full Changelog\*\*/d' | \ sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \ sed 's/ by @[A-Za-z0-9_-]\+//g' | \ sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \ sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \ sed 's/&/\&/g' | \ sed 's//\>/g' | \ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ sed 's/^### \(.*\)$/\1<\/b>/g' | \ sed 's/^## \(.*\)$/\1<\/b>/g' | \ sed 's/^- /• /g') # Truncate for Telegram 4096 char limit CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d') echo "$CHANGELOG" > /tmp/changelog.txt fi echo "Telegram changelog:" cat /tmp/changelog.txt - name: Send to Telegram Channel env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }} VERSION: ${{ needs.get-version.outputs.version }} REPOSITORY: ${{ github.repository }} run: | CHANGELOG=$(cat /tmp/changelog.txt) # Find APK files ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1) ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1) # Prepare message with changelog (HTML format) printf '%s\n' \ "SpotiFLAC Mobile ${VERSION} Released!" \ "" \ "What's New:" \ "${CHANGELOG}" \ "" \ "View Release Notes" \ > /tmp/telegram_message.txt MESSAGE=$(cat /tmp/telegram_message.txt) # Send message first (using HTML parse mode) # Use --data-urlencode for proper encoding of special chars (+, &, etc.) # Use || true to ensure file uploads continue even if message fails curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ --data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \ --data-urlencode "text=${MESSAGE}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" || true # Upload arm64 APK to channel if [ -f "$ARM64_APK" ]; then echo "Uploading arm64 APK to Telegram..." curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ -F chat_id="${TELEGRAM_CHANNEL_ID}" \ -F document=@"${ARM64_APK}" \ -F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)" fi # Upload arm32 APK to channel if [ -f "$ARM32_APK" ]; then echo "Uploading arm32 APK to Telegram..." curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ -F chat_id="${TELEGRAM_CHANNEL_ID}" \ -F document=@"${ARM32_APK}" \ -F caption="SpotiFLAC Mobile ${VERSION} - arm32" fi # Upload iOS IPA to channel IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1) if [ -f "$IOS_IPA" ]; then echo "Uploading iOS IPA to Telegram..." curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ -F chat_id="${TELEGRAM_CHANNEL_ID}" \ -F document=@"${IOS_IPA}" \ -F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)" fi echo "Telegram notification sent!"