Files
SpotiFLAC-Mobile/.github/workflows/release.yml
T

630 lines
23 KiB
YAML

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/modules-2
~/.gradle/wrapper
# Do not publish transforms of the decrypted SDK in shared caches.
key: gradle-public-dependencies-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-public-dependencies-${{ 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" \
"cmake;3.22.1"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
- name: Prepare pinned Discord SDK
uses: ./.github/actions/discord-sdk
with:
passphrase: ${{ secrets.DISCORD_SDK_PASSPHRASE }}
- 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 --require-discord
python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm32.apk" \
--backend rust --abis armeabi-v7a --require-discord
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/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\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' \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"<a href=\"https://github.com/${REPOSITORY}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /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!"