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 run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" else VERSION="${GITHUB_REF#refs/tags/}" fi echo "version=$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) 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 # Clean docker images sudo docker image prune --all --force # Show available space df -h - name: Checkout repository uses: actions/checkout@v4 - name: Setup Java uses: actions/setup-java@v4 with: distribution: "temurin" java-version: "17" - name: Setup Go uses: actions/setup-go@v5 with: go-version: "1.21" cache-dependency-path: go_backend/go.sum # Cache Gradle for faster builds - name: Cache Gradle uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- - 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 r27d LTS (required for 16KB page size support on Android 15+) # Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16) $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0" # Set NDK path echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV - name: Install gomobile run: | go install golang.org/x/mobile/cmd/gomobile@latest gomobile init - name: Build Go backend for Android working-directory: go_backend run: | mkdir -p ../android/app/libs gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . env: CGO_ENABLED: 1 - name: Setup Flutter uses: subosito/flutter-action@v2 with: channel: "stable" cache: true - name: Get Flutter dependencies run: flutter pub get - name: Generate app icons run: dart run flutter_launcher_icons - name: Build APK (Release - unsigned) run: | flutter build apk --release --split-per-abi || true # Verify APKs were created ls -la build/app/outputs/flutter-apk/ if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then echo "ERROR: APK not found!" exit 1 fi - name: Sign APKs uses: r0adkll/sign-android-release@v1 id: sign_arm64 with: releaseDirectory: build/app/outputs/flutter-apk signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: BUILD_TOOLS_VERSION: "36.0.0" - name: Rename APKs run: | VERSION=${{ needs.get-version.outputs.version }} cd build/app/outputs/flutter-apk # Signed files have -signed suffix mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true ls -la - name: Upload APK artifact uses: actions/upload-artifact@v4 with: name: android-apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk build-ios: runs-on: macos-latest needs: get-version # Only depends on version, NOT android build! steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version: "1.21" cache-dependency-path: go_backend/go.sum # Cache CocoaPods - name: Cache CocoaPods uses: actions/cache@v4 with: path: ios/Pods key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} restore-keys: pods-${{ runner.os }}- - name: Install gomobile run: | go install golang.org/x/mobile/cmd/gomobile@latest gomobile init - name: Build Go backend for iOS working-directory: go_backend run: | mkdir -p ../ios/Frameworks gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . env: CGO_ENABLED: 1 - name: Verify XCFramework created run: | ls -la ios/Frameworks/ ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1) - name: Add XCFramework to Xcode project run: | # Install xcodeproj gem for modifying Xcode project sudo gem install xcodeproj # Create Ruby script to add framework cat > add_framework.rb << 'EOF' require 'xcodeproj' project_path = 'ios/Runner.xcodeproj' project = Xcodeproj::Project.open(project_path) # Get the main target target = project.targets.find { |t| t.name == 'Runner' } # Get or create Frameworks group frameworks_group = project.main_group.find_subpath('Frameworks', true) frameworks_group ||= project.main_group.new_group('Frameworks') # Add XCFramework reference framework_path = 'Frameworks/Gobackend.xcframework' framework_ref = frameworks_group.new_file(framework_path, :project) # Add to frameworks build phase frameworks_build_phase = target.frameworks_build_phase frameworks_build_phase.add_file_reference(framework_ref) # Add to embed frameworks build phase embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' } if embed_phase build_file = embed_phase.add_file_reference(framework_ref) build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] } end project.save puts "Successfully added Gobackend.xcframework to Xcode project" EOF ruby add_framework.rb - name: Setup Flutter uses: subosito/flutter-action@v2 with: channel: "stable" cache: true # Swap pubspec for iOS build (includes ffmpeg_kit_flutter) - name: Use iOS pubspec with FFmpeg plugin run: | cp pubspec.yaml pubspec_android_backup.yaml cp pubspec_ios.yaml pubspec.yaml echo "Swapped to iOS pubspec with ffmpeg_kit_flutter" # Swap FFmpeg service for iOS - name: Use iOS FFmpeg service run: | cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart # Update class name in the swapped file sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart echo "Swapped to iOS FFmpeg service" - name: Get Flutter dependencies run: flutter pub get - name: Generate app icons run: dart run flutter_launcher_icons - name: Build iOS (unsigned) run: | # Build Flutter iOS without codesigning flutter build ios --release --no-codesign --config-only # 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: Create IPA run: | VERSION=${{ needs.get-version.outputs.version }} 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 run: | ls -la build/ios/ipa/ VERSION=${{ needs.get-version.outputs.version }} 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@v4 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@v4 - name: Extract changelog for version id: changelog run: | VERSION=${{ needs.get-version.outputs.version }} VERSION_NUM=${VERSION#v} # Remove 'v' prefix echo "Looking for version: $VERSION_NUM" # Extract changelog section for this version using sed # Find the line with version, then print until next version header or end CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md) # If no changelog found, use default message if [ -z "$CHANGELOG" ]; then echo "No changelog found for version $VERSION_NUM" CHANGELOG="See CHANGELOG.md for details." else echo "Found changelog content" # Remove trailing --- separator if present (CHANGELOG uses --- between versions) CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d') fi # Save to file for multiline support echo "$CHANGELOG" > /tmp/changelog.txt echo "Extracted changelog:" cat /tmp/changelog.txt - name: Download Android APK uses: actions/download-artifact@v4 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@v4 with: name: ios-ipa path: ./release - name: Prepare release body run: | VERSION=${{ needs.get-version.outputs.version }} cat > /tmp/release_body.txt << 'HEADER' ### What's New HEADER cat /tmp/changelog.txt >> /tmp/release_body.txt REPO_OWNER="${{ github.repository_owner }}" REPO_NAME="${{ github.event.repository.name }}" 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 ![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6) FOOTER echo "Release body:" cat /tmp/release_body.txt - name: Create Release uses: softprops/action-gh-release@v1 with: tag_name: ${{ needs.get-version.outputs.version }} name: SpotiFLAC ${{ 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 }} 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@v4 - name: Download Android APK uses: actions/download-artifact@v4 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@v4 with: name: ios-ipa path: ./release - name: Extract changelog for version id: changelog run: | VERSION=${{ needs.get-version.outputs.version }} VERSION_NUM=${VERSION#v} # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) # Use tr -d '\r' to handle CRLF line endings from Windows FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d') echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}" echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}" if [ -z "$FULL_CHANGELOG" ]; then CHANGELOG="See release notes on GitHub for details." else # Convert GitHub Markdown to Telegram HTML: # - **text** → text # - `code` → code # - ### Header → Header # - Escape HTML special chars first # - Remove > blockquote prefix CHANGELOG=$(echo "$FULL_CHANGELOG" | \ sed 's/^> //' | \ sed 's/&/\&/g' | \ sed 's//\>/g' | \ sed 's/`\([^`]*\)`/\1<\/code>/g' | \ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ sed 's/^### \(.*\)$/\1<\/b>/g' | \ sed 's/^## \(.*\)$/\1<\/b>/g' | \ sed 's/^- /• /g' | \ sed 's/^ - / ◦ /g') # Take first 2500 characters, then cut at last complete line CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d') # Check if truncated FULL_LEN=${#FULL_CHANGELOG} if [ $FULL_LEN -gt 2500 ]; then CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)' fi fi echo "$CHANGELOG" > /tmp/changelog.txt echo "DEBUG: Final 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 }} run: | VERSION=${{ needs.get-version.outputs.version }} 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 ${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 ${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 ${VERSION} - iOS (unsigned, sideload required)" fi echo "Telegram notification sent!"