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@v6 - name: Setup Java uses: actions/setup-java@v5 with: distribution: "temurin" java-version: "17" - name: Setup Go uses: actions/setup-go@v6 with: go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache Gradle for faster builds - name: Cache Gradle uses: actions/cache@v5 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@v6 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@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache CocoaPods - name: Cache CocoaPods uses: actions/cache@v5 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 -tags 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 - 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@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@v6 with: fetch-depth: 0 # Full history needed for git-cliff - name: Generate changelog with git-cliff id: changelog uses: orhun/git-cliff-action@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@v7 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@v7 with: name: ios-ipa path: ./release - name: Prepare release body run: | VERSION=${{ needs.get-version.outputs.version }} REPO_OWNER="${{ github.repository_owner }}" REPO_NAME="${{ github.event.repository.name }}" 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 ![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@v2 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 }} 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@v6 with: ref: main - name: Download iOS IPA uses: actions/download-artifact@v7 with: name: ios-ipa path: ./release - name: Update apps.json run: | VERSION="${{ needs.get-version.outputs.version }}" 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") if [ ! -f apps.json ]; then echo "WARNING: apps.json not found on main, skipping" exit 0 fi jq --arg ver "$VERSION_NUM" \ --arg date "$DATE" \ --arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \ --argjson size "$IPA_SIZE" \ '.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 run: | VERSION="${{ needs.get-version.outputs.version }}" 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@v6 with: fetch-depth: 0 - name: Download Android APK uses: actions/download-artifact@v7 with: name: android-apk path: ./release - name: Download iOS IPA uses: actions/download-artifact@v7 with: name: ios-ipa path: ./release - name: Generate changelog with git-cliff for Telegram uses: orhun/git-cliff-action@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 }} 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!"