Skip to content

Build & Release

Build & Release #235

Workflow file for this run

name: Build & Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v1.2.8)'
required: true
type: string
release:
types: [published]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
env:
FLUTTER_VERSION: "3.44.1"
CORE_VERSION: "v1.1.15"
VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}
jobs:
build-windows:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
flutter_channel: stable
flutter_version: "3.44.1"
# Flutter stable has no windows-arm64 host archive. Following the
# approach used by gopeed: run on the native windows-11-arm runner
# with a Flutter main-channel snapshot that ships arm64 host tools,
# plus llvm-mingw for the native C/C++ toolchain.
- arch: arm64
runner: windows-11-arm
flutter_channel: main
flutter_version: "a3222d4a"
llvm_ver: "20251202"
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Enable long paths
run: git config --global core.longpaths true
# Install llvm-mingw (ARM64 native C/C++ toolchain) — only needed on ARM64
# where the runner's VS toolchain targets arm64 but Flutter plugins like
# local_notifier pull in jni which needs a working C compiler.
- name: Install llvm-mingw (ARM64 only)
if: matrix.arch == 'arm64'
run: |
$ver = "${{ matrix.llvm_ver }}"
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
$zip = "$env:RUNNER_TEMP\llvm.zip"
$extract = "$env:RUNNER_TEMP\extract"
$target = "C:\clangarm64"
curl -L $url -o $zip
rm -r -fo $extract,$target -ea Ignore
mkdir $extract | Out-Null
tar -xf $zip -C $extract
mv (Get-ChildItem $extract)[0].FullName $target
$b = "$target\bin"
"CC=$b\clang.exe" >> $env:GITHUB_ENV
"CXX=$b\clang++.exe" >> $env:GITHUB_ENV
$b >> $env:GITHUB_PATH
# local_notifier -> jni needs a JDK at build time for jvm.lib.
# Must match the target architecture (ARM64), otherwise jvm.lib
# machine type conflicts with the build target and linking fails.
- name: Setup ARM64 JDK for JNI linking
if: matrix.arch == 'arm64'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
architecture: arm64
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter_version }}
channel: ${{ matrix.flutter_channel }}
architecture: ${{ matrix.flutter_arch || 'x64' }}
cache: true
- name: Sync version
run: |
$v = "${{ env.VERSION }}".TrimStart('v')
(Get-Content pubspec.yaml) -replace '^version:.*', "version: $v" | Set-Content pubspec.yaml
- name: Download core
run: |
curl -L "https://github.com/mapleafgo/singcast-cli/releases/download/$env:CORE_VERSION/singcast-$env:CORE_VERSION-windows-${{ matrix.arch }}.tar.gz" -o "singcast-core.tar.gz"
tar -zxf singcast-core.tar.gz
Move-Item -Force singcast-windows-${{ matrix.arch }}.exe windows/core/singcast-core.exe
- name: Setup Chinese language for Inno Setup
run: |
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
- name: Build & package (x64)
if: matrix.arch == 'amd64'
run: |
dart pub global activate fastforge
fastforge package --platform=windows --targets=exe,zip
- name: Rename artifacts (x64)
if: matrix.arch == 'amd64'
run: |
$ver = "${{ env.VERSION }}".TrimStart('v')
$distDir = "dist\$ver"
$exe = (Get-ChildItem "$distDir\*.exe")[0]
$zip = (Get-ChildItem "$distDir\*.zip")[0]
Move-Item -Force $exe.FullName "$distDir\singcast-$ver-windows-amd64.exe"
Move-Item -Force $zip.FullName "$distDir\singcast-$ver-windows-amd64-portable.zip"
# ARM64: Flutter main channel builds natively to build/windows/arm64/.
# fastforge does not reliably support main-channel ARM64, so build with
# flutter directly and package with Inno Setup + zip manually.
- name: Build (ARM64)
if: matrix.arch == 'arm64'
run: flutter build windows --release
- name: Package ARM64 portable zip
if: matrix.arch == 'arm64'
run: |
$ver = "${{ env.VERSION }}".TrimStart('v')
$release = "build\windows\arm64\runner\Release"
Compress-Archive -Path "$release\*" -DestinationPath "build\singcast-$ver-windows-arm64-portable.zip"
- name: Package ARM64 installer
if: matrix.arch == 'arm64'
shell: pwsh
run: |
$ver = "${{ env.VERSION }}".TrimStart('v')
$release = "build\windows\arm64\runner\Release"
$content = @"
[Setup]
AppId={{B3039CF4-E394-42B6-971C-4BE97F322DE8}
AppVersion=$ver
AppName=Singcast
AppPublisher=mapleafgo
AppPublisherURL=https://github.com/mapleafgo/singcast
DefaultDirName={autopf}\Singcast
DisableProgramGroupPage=yes
OutputDir=Output
OutputBaseFilename=singcast-$ver-windows-arm64
Compression=lzma
SolidCompression=yes
SetupIconFile=..\..\windows\runner\resources\app_icon.ico
WizardStyle=modern
PrivilegesRequired=lowest
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "arm64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\Singcast"; Filename: "{app}\singcast.exe"
Name: "{autodesktop}\Singcast"; Filename: "{app}\singcast.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\singcast.exe"; Description: "{cm:LaunchProgram,Singcast}"; Flags: nowait postinstall skipifsilent
[Registry]
Root: HKCU; Subkey: "Software\Classes\clash"; ValueType: string; ValueName: ""; ValueData: "URL:Singcast Protocol"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\clash"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\clash\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\singcast.exe,0"
Root: HKCU; Subkey: "Software\Classes\clash\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\singcast.exe"" ""%1"""
[UninstallRun]
Filename: "{app}\singcast-core.exe"; Parameters: "service uninstall"; Flags: runhidden
"@
New-Item -Path "build\windows" -ItemType Directory -Force | Out-Null
$content | Out-File -FilePath "build\windows\setup.iss" -Encoding UTF8
iscc.exe /Q "build\windows\setup.iss"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: |
dist/*/*.exe
dist/*/*.zip
build/*.zip
build/windows/Output/*.exe
overwrite_files: true
build-linux:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
architecture: ${{ matrix.flutter_arch || 'x64' }}
cache: true
- name: Sync version
run: |
v="${VERSION#v}"
sed -i "s/^version:.*/version: $v/" pubspec.yaml
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libnotify-dev
wget -O /tmp/appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x /tmp/appimagetool
sudo mv /tmp/appimagetool /usr/local/bin/appimagetool
- name: Download core
run: |
curl -L "https://github.com/mapleafgo/singcast-cli/releases/download/$CORE_VERSION/singcast-$CORE_VERSION-linux-amd64.tar.gz" -o singcast-core.tar.gz
tar -zxf singcast-core.tar.gz
chmod +x singcast-linux-amd64
mv singcast-linux-amd64 linux/core/singcast-core
- name: Build & package
run: |
dart pub global activate fastforge
fastforge package --platform=linux --targets=appimage,deb,zip
- name: Rename artifacts
run: |
v="${VERSION#v}"
d="dist/$v"
mv "$d/singcast-$v-linux.AppImage" "$d/singcast-$v-linux-amd64.AppImage"
mv "$d/singcast-$v-linux.deb" "$d/singcast-$v-linux-amd64.deb"
mv "$d/singcast-$v-linux.zip" "$d/singcast-$v-linux-amd64.zip"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: dist/*/*
overwrite_files: true
build-macos:
runs-on: macos-latest
timeout-minutes: 25
strategy:
matrix:
include:
- arch: amd64
core_arch: amd64
- arch: arm64
core_arch: arm64
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
architecture: ${{ matrix.flutter_arch || 'x64' }}
cache: true
- name: Sync version
run: |
v="${VERSION#v}"
sed -i "" "s/^version:.*/version: $v/" pubspec.yaml
- name: Download core
run: |
mkdir -p macos/Frameworks
curl -L "https://github.com/mapleafgo/singcast-cli/releases/download/$CORE_VERSION/singcast-$CORE_VERSION-darwin-${{ matrix.core_arch }}.tar.gz" -o singcast-core.tar.gz
tar -zxf singcast-core.tar.gz
chmod +x singcast-darwin-${{ matrix.core_arch }}
mv singcast-darwin-${{ matrix.core_arch }} macos/Frameworks/singcast-core
- name: Build
env:
ARCHS: ${{ matrix.arch == 'amd64' && 'x86_64' || '' }}
run: flutter build macos --release
- name: Ad-hoc sign
run: codesign --force --deep --sign - build/macos/Build/Products/Release/singcast.app
- name: Create DMG
run: |
v="${VERSION#v}"
APP_PATH="build/macos/Build/Products/Release/singcast.app"
hdiutil create -volname "Singcast" -srcfolder "$APP_PATH" -ov -format UDZO "build/macos/Build/Products/Release/singcast-$v-macos-${{ matrix.arch }}.dmg"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: build/macos/Build/Products/Release/*.dmg
overwrite_files: true
build-android:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
include:
- target: android-arm64
arch: arm64
core_arch: arm64
- target: android-x64
arch: x64
core_arch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: liberica
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
architecture: ${{ matrix.flutter_arch || 'x64' }}
cache: true
- name: Sync version
run: |
v="${VERSION#v}"
ver="${v%%-*}"
IFS='.' read -r major minor patch <<< "$ver"
base=$((major * 10000000 + minor * 100000 + patch * 1000))
pre="${v#*-}"
if [[ "$pre" == "$v" ]]; then
vc=$((base + 999))
elif [[ "$pre" == beta* ]]; then
n="${pre//[!0-9]/}"
vc=$((base + 500 + ${n:-0}))
elif [[ "$pre" == rc* ]]; then
n="${pre//[!0-9]/}"
vc=$((base + ${n:-0}))
else
n="${pre//[!0-9]/}"
vc=$((base + ${n:-0}))
fi
sed -i "s/^version:.*/version: $v+$vc/" pubspec.yaml
- name: Setup Android signing
run: |
if [ -n "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" ]; then
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/release.jks
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=release.jks
EOF
fi
- name: Download core
run: |
curl -L "https://github.com/mapleafgo/singcast-cli/releases/download/$CORE_VERSION/libsingcast-$CORE_VERSION-android-${{ matrix.core_arch }}.tar.gz" -o libsingcast.tar.gz
tar -zxf libsingcast.tar.gz
mv libsingcast-android-${{ matrix.core_arch }}.aar android/app/libs/libsingcast.aar
- name: Build APK
run: flutter build apk --release --target-platform ${{ matrix.target }}
- name: Rename artifact
run: |
v="${VERSION#v}"
mv build/app/outputs/flutter-apk/app-release.apk "build/app/outputs/flutter-apk/singcast-$v-android-${{ matrix.arch }}.apk"
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: build/app/outputs/flutter-apk/*.apk
overwrite_files: true
# iOS build. Produces an unsigned sideload .ipa (gopeed-style).
# NOTE: This is a VPN app with a Packet Tunnel Extension. The committed
# Runner.xcodeproj does NOT contain the ExtensionProvider target, so xcodegen
# regenerates the project from project.yml before building. The resulting ipa
# is unsigned: it can be sideloaded via TrollStore or re-signed, but the
# tunnel extension only runs after re-signing with a paid Apple Developer
# account + NetworkExtension provisioning profiles.
build-ios:
runs-on: macos-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
architecture: ${{ matrix.flutter_arch || 'x64' }}
cache: true
- name: Sync version
run: |
v="${VERSION#v}"
sed -i "" "s/^version:.*/version: $v/" pubspec.yaml
- name: Download core
run: |
rm -rf ios/Frameworks/singcast.xcframework ios/Frameworks/libsingcast-darwin.xcframework
curl -L "https://github.com/mapleafgo/singcast-cli/releases/download/$CORE_VERSION/libsingcast-$CORE_VERSION-ios.tar.gz" -o ios-core.tar.gz
tar -zxf ios-core.tar.gz -C ios/Frameworks
mv ios/Frameworks/singcast.xcframework ios/Frameworks/libsingcast-darwin.xcframework
- name: Mark core framework extension-safe
# The Go-built static library inside the xcframework lacks the
# MH_APP_EXTENSION_SAFE Mach-O flag (0x02000000). Xcode requires this
# for any binary linked into an app-extension target. The binary is
# a Mach-O fat wrapper whose slices are BSD ar archives containing
# Mach-O object files. We patch the flag on every object in place.
run: |
python3 - <<'PY'
import struct, pathlib
MH_APP_EXTENSION_SAFE = 0x02000000
MH_MAGICS_LE = {0xFEEDFACE, 0xFEEDFACF}
MH_MAGICS_BE = {0xCEFAEDFE, 0xCFFAEDFE}
def is_macho(data, off):
if off + 4 > len(data):
return False
be = struct.unpack(">I", data[off:off+4])[0]
le = struct.unpack("<I", data[off:off+4])[0]
return be in MH_MAGICS_BE or le in MH_MAGICS_LE
def patch_macho(data, off):
flags_off = off + 24
flags = struct.unpack("<I", data[flags_off:flags_off+4])[0]
if not (flags & MH_APP_EXTENSION_SAFE):
flags |= MH_APP_EXTENSION_SAFE
struct.pack_into("<I", data, flags_off, flags)
return True
return False
def patch_ar_archive(data, base, length):
"""Scan a BSD ar archive and patch Mach-O objects inside."""
patched = 0
end = min(base + length, len(data)) if length else len(data)
pos = base + 8 # skip "!<arch>\n"
while pos + 60 <= end:
hdr = data[pos:pos+60]
raw_name = hdr[0:16]
size_str = hdr[48:58].rstrip()
try:
size = int(size_str)
except ValueError:
break
member_start = pos + 60
name_len = 0
name = raw_name.rstrip()
if name.startswith(b"#1/"):
try:
name_len = int(name[3:])
except ValueError:
name_len = 0
obj_off = member_start + name_len
if is_macho(data, obj_off):
if patch_macho(data, obj_off):
patched += 1
pos = member_start + size
if pos % 2 == 1:
pos += 1
return patched
def patch_file(data):
patched = 0
magic = struct.unpack(">I", data[:4])[0]
if magic == 0xCAFEBABE: # FAT_MAGIC (big-endian header)
n = struct.unpack(">I", data[4:8])[0]
for i in range(n):
off = 8 + i * 20
sl_off = struct.unpack(">I", data[off+8:off+12])[0]
sl_size = struct.unpack(">I", data[off+12:off+16])[0]
if data[sl_off:sl_off+8] == b"!<arch>\n":
patched += patch_ar_archive(data, sl_off, sl_size)
elif is_macho(data, sl_off):
if patch_macho(data, sl_off):
patched += 1
elif data[:8] == b"!<arch>\n":
patched += patch_ar_archive(data, 0, 0)
elif is_macho(data, 0):
if patch_macho(data, 0):
patched += 1
return patched
root = pathlib.Path("ios/Frameworks/libsingcast-darwin.xcframework")
total = 0
for bin in root.rglob("Singcast"):
if not bin.is_file():
continue
with open(bin, "rb") as f:
data = bytearray(f.read())
n = patch_file(data)
if n > 0:
with open(bin, "wb") as f:
f.write(data)
print(f" {bin}: patched {n} Mach-O object(s)")
total += n
print(f"Total: {total} objects patched")
if total == 0:
raise SystemExit("ERROR: no Mach-O objects found to patch")
PY
# Re-sign ad-hoc after patching binaries.
XCFW="ios/Frameworks/libsingcast-darwin.xcframework"
# Sign each .framework, then the xcframework itself
find "$XCFW" -name "*.framework" -type d -print0 | while IFS= read -r -d '' fw; do
codesign --force --deep --all-architectures --sign - "$fw"
codesign --verify --verbose "$fw" 2>&1 || true
done
codesign --force --deep --all-architectures --sign - "$XCFW"
codesign --verify --verbose "$XCFW" 2>&1 || true
- name: Regenerate Xcode project
run: |
flutter pub get
brew install xcodegen
cd ios && xcodegen generate
# Disable Swift Package Manager integration: it needs a shared
# Runner.xcscheme that xcodegen's output + Flutter's project patching
# don't reliably provide. This project uses CocoaPods (Podfile), so
# SPM integration is unnecessary.
flutter config --no-enable-swift-package-manager
- name: Build (no codesign)
run: flutter build ios --no-codesign --release
- name: Package ipa
run: |
v="${VERSION#v}"
rm -rf Payload
cp -R build/ios/iphoneos/Runner.app Payload/
zip -qry singcast-$v-ios.ipa Payload
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: singcast-*-ios.ipa
overwrite_files: true