ci: provision pinned Discord SDK for Android and iOS

This commit is contained in:
zarzet committed 2026-10-04 02:12:44 +07:00
1 parent 5bcbb029c9
commit 3191ff926f
8 files changed
+412 -15

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: Prepare Discord Social SDK
description: Decrypt, verify and stage the pinned Android and iOS SDK.
inputs:
passphrase:
description: Decryption key supplied from an Actions repository secret.
required: true
runs:
using: composite
steps:
- name: Ensure GnuPG is available
shell: bash
run: |
if ! command -v gpg >/dev/null 2>&1; then
if [[ "$RUNNER_OS" == "macOS" ]]; then
brew install gnupg
else
echo "GnuPG is required to prepare the Discord SDK." >&2
exit 1
fi
fi
- name: Decrypt, verify and stage SDK
shell: bash
env:
DISCORD_MOBILE_SDK_PASSPHRASE: ${{ inputs.passphrase }}
run: |
set -euo pipefail
umask 077
sdk_dir="$(python3 scripts/prepare_discord_sdk.py)"
bash scripts/setup_discord_sdk.sh "$sdk_dir"
printf 'SPOTIFLAC_DISCORD_SDK=1\n' >> "$GITHUB_ENV"
+45 -5
View File
@@ -53,6 +53,10 @@ jobs:
- 'third_party/audio_service/**'
- 'third_party/spotiflac_discord/**'
- 'scripts/setup_discord_sdk.sh'
- 'scripts/prepare_discord_sdk.py'
- 'scripts/test_prepare_discord_sdk.py'
- '.github/actions/discord-sdk/**'
- 'third_party/discord/*.zip.gpg'
- 'ios/**'
- 'scripts/build_ios.sh'
- 'scripts/check_backend_ios.py'
@@ -68,6 +72,10 @@ jobs:
- 'third_party/audio_service/**'
- 'third_party/spotiflac_discord/**'
- 'scripts/setup_discord_sdk.sh'
- 'scripts/prepare_discord_sdk.py'
- 'scripts/test_prepare_discord_sdk.py'
- '.github/actions/discord-sdk/**'
- 'third_party/discord/*.zip.gpg'
- 'android/**'
- 'rust_backend/**'
- 'scripts/build_android.sh'
@@ -151,7 +159,7 @@ jobs:
run: bash scripts/check_rust_backend.sh
- name: Install Android NDK
run: $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865"
run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;29.0.14206865"'
- name: Build Android ARM32 and ARM64 libraries
run: ANDROID_NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" bash scripts/build_rust_backend.sh android
@@ -161,6 +169,8 @@ jobs:
runs-on: macos-latest
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ios == 'true'
env:
SPOTIFLAC_DISCORD_SDK: "0"
steps:
- name: Checkout repository
@@ -180,6 +190,12 @@ jobs:
flutter-version-file: .fvmrc
cache: true
- name: Prepare pinned Discord SDK
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/actions/discord-sdk
with:
passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }}
- name: Configure Rust application pods
run: |
flutter pub get
@@ -190,6 +206,10 @@ jobs:
run: |
python3 -m unittest discover -s scripts -p 'test_check_backend_ios.py'
flutter build ios --release --no-codesign --target lib/main.dart
if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then
test -f build/ios/iphoneos/Runner.app/Frameworks/discord_partner_sdk.framework/discord_partner_sdk
test -f build/ios/iphoneos/Runner.app/Discord-License-Notices.txt
fi
python3 scripts/check_backend_ios.py build/ios/iphoneos/Runner.app \
--backend rust --archs arm64 --platform ios --release
@@ -198,6 +218,8 @@ jobs:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.android == 'true'
env:
SPOTIFLAC_DISCORD_SDK: "0"
steps:
- name: Checkout repository
@@ -235,10 +257,19 @@ jobs:
"cmake;3.22.1"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
- name: Test APK audits
- name: Test SDK preparation, release selection and APK audits
run: |
python3 -m unittest discover -s scripts -p 'test_prepare_discord_sdk.py'
python3 -m unittest discover -s scripts -p 'test_build_android.py'
python3 -m unittest discover -s scripts -p 'test_check_backend_apk.py'
# Fork PRs cannot access repository secrets and exercise the Lite build.
- name: Prepare pinned Discord SDK
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/actions/discord-sdk
with:
passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }}
- name: Get Flutter dependencies
run: flutter pub get
@@ -258,16 +289,25 @@ jobs:
run: gradle -p android :app:assembleDebug :app:testDebugUnitTest
- name: Build Rust release application
run: bash scripts/build_android.sh --target lib/main.dart
run: |
if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then
bash scripts/build_android.sh --production --target lib/main.dart
else
bash scripts/build_android.sh --lite --target lib/main.dart
fi
- name: Verify Rust release APK payloads
run: |
audit_flags=()
if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then
audit_flags+=(--discord-sdk)
fi
python3 scripts/check_backend_apk.py \
build/app/outputs/flutter-apk/app-arm64-v8a-release.apk \
--backend rust --abis arm64-v8a
--backend rust --abis arm64-v8a "${audit_flags[@]}"
python3 scripts/check_backend_apk.py \
build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk \
--backend rust --abis armeabi-v7a
--backend rust --abis armeabi-v7a "${audit_flags[@]}"
# PR builds use Gradle's test key; production signing stays in Release.
for abi in arm64-v8a armeabi-v7a; do
"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose \
+17 -3
View File
@@ -70,6 +70,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Prepare pinned Discord SDK
uses: ./.github/actions/discord-sdk
with:
passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }}
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
@@ -240,6 +245,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Prepare pinned Discord SDK
uses: ./.github/actions/discord-sdk
with:
passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }}
- name: Select Xcode 26.1.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.1.app
@@ -334,7 +344,7 @@ jobs:
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
zip -r "$GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" Payload
rm -rf Payload
- name: Verify IPA created
@@ -517,8 +527,12 @@ jobs:
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)
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: update AltStore source to ${VERSION}"
git push
fi
notify-telegram:
runs-on: ubuntu-latest
+2 -1
View File
@@ -53,8 +53,9 @@ AGENTS.md
/android/*.keystore
/android/app/*.jks
# Keep obsolete local SDK copies out of the checkout.
# Only the pinned encrypted CI payload belongs in the checkout.
/third_party/discord/*
!/third_party/discord/discord-mobile-1.10.19337.zip.gpg
# iOS generated state
/ios/build/
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Verify and prepare the pinned official Discord mobile SDK for CI."""
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
VERSION = "1.10.19337"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OUTPUT = PROJECT_ROOT / ".dart_tool" / "discord-social-sdk" / VERSION
ENCRYPTED_ARCHIVE = PROJECT_ROOT / "third_party" / "discord" / f"discord-mobile-{VERSION}.zip.gpg"
XCFRAMEWORK = "lib/release/discord_partner_sdk.xcframework"
FILES = {
"License-Notices.txt":
"e8afa66340c225431e69768543cc34a7240f3494a9d759189fe118620ea8eebf",
"include/discordpp.h":
"822e71509bf11b7155becca5fb00d215a7db7b305f357c0f3b5f6e27ff45b6ab",
"lib/release/discord_partner_sdk.aar":
"b1b2491f1e1848c79fd6f1986d5aa1e0c8019e88a6e62c7be06b89e8e4870933",
f"{XCFRAMEWORK}/Info.plist":
"bb227b40a5adb4e4a02447b46a91b7a0fe28c9d238fbb80e19b7215c7ae3f431",
}
for slice_name, plist, binary in (
("ios-arm64",
"41bc76fed4b9d3adb7d711e5b44848b87f775598ed24173abfd1f4a6a0656a90",
"8d86063b1a6c5c562b822378d34f55d335b37358e20f3425d446cc335ffae8bd"),
("ios-arm64-simulator",
"a3e162d5587897f9435e7897dbcde4d5b32c6dd445a0c8ff897157f5f340e4a1",
"eddc98dcb0e5614d64bef4c17476e5bb1ad53b3a7e8d6d8fc550b3abfc067f98"),
):
framework = f"{XCFRAMEWORK}/{slice_name}/discord_partner_sdk.framework"
FILES.update({
f"{framework}/Info.plist": plist,
f"{framework}/discord_partner_sdk": binary,
f"{framework}/Headers/cdiscord.h":
"86ef9280ab8432493952bb974bd18c5a25d7725011393b5dba05162bcfdb3371",
f"{framework}/Headers/discord_partner_sdk.h":
"b049099dab197146011b3ceecc823ec25fdbbda7a37ea1584be8e472501a390b",
f"{framework}/Headers/discordpp.h": FILES["include/discordpp.h"],
f"{framework}/Modules/module.modulemap":
"48879037011d015ad2c9802fdc478862785ba073e7f96dfdf6c0865b6075a542",
})
MAX_FILE_BYTES = 64 * 1024 * 1024
class SdkError(Exception):
pass
def verify(directory: Path) -> None:
for relative, expected in FILES.items():
path = directory / relative
if not path.is_file() or path.is_symlink():
raise SdkError("Missing or invalid Discord SDK file: " + relative)
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() != expected:
raise SdkError(f"Discord SDK {VERSION} checksum mismatch: {relative}")
def unpack(archive: Path, destination: Path) -> None:
with zipfile.ZipFile(archive) as zf:
for relative in FILES:
matches = [info for info in zf.infolist() if not info.is_dir() and
(info.filename == relative or info.filename.endswith("/" + relative))]
if len(matches) != 1:
raise SdkError("SDK archive must contain exactly one " + relative)
if matches[0].file_size > MAX_FILE_BYTES:
raise SdkError("SDK archive entry exceeds size limit: " + relative)
path = destination / relative
path.parent.mkdir(parents=True, exist_ok=True)
# Write only pinned destinations, never paths supplied by the ZIP.
with zf.open(matches[0]) as source, path.open("wb") as target:
shutil.copyfileobj(source, target)
if relative.endswith(".framework/discord_partner_sdk"):
path.chmod(0o755)
def prepare(output: Path, source_dir: Optional[Path] = None,
archive: Optional[Path] = None, passphrase: Optional[str] = None) -> Path:
output = output.expanduser().resolve()
if source_dir is None and archive is None and output.is_dir():
verify(output)
return output
if source_dir is None and archive is None and not passphrase:
raise SdkError(
f"Discord SDK {VERSION} is required. Supply --source-dir, --archive, "
"or the Actions secret DISCORD_MOBILE_SDK_PASSPHRASE."
)
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="discord-sdk-", dir=output.parent) as temporary:
staging = Path(temporary) / "sdk"
if source_dir is not None:
source_dir = source_dir.expanduser().resolve()
verify(source_dir)
for relative in FILES:
target = staging / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_dir / relative, target)
else:
if archive is None:
archive = Path(temporary) / "sdk.zip"
try:
result = subprocess.run(
["gpg", "--batch", "--quiet", "--no-symkey-cache",
"--pinentry-mode", "loopback",
"--passphrase-fd", "0", "--output", str(archive),
"--decrypt", str(ENCRYPTED_ARCHIVE)],
input=passphrase.encode(), stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False,
)
except FileNotFoundError as exc:
raise SdkError("Install GnuPG to decrypt the Discord SDK") from exc
if result.returncode != 0:
raise SdkError("SDK decryption failed; check DISCORD_MOBILE_SDK_PASSPHRASE")
unpack(archive, staging)
verify(staging)
for relative in FILES:
target = output / relative
target.parent.mkdir(parents=True, exist_ok=True)
(staging / relative).replace(target)
verify(output)
return output
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--source-dir", type=Path)
parser.add_argument("--archive", type=Path, help="official SDK ZIP for offline setup")
args = parser.parse_args()
try:
output = prepare(args.output, args.source_dir, args.archive,
os.environ.get("DISCORD_MOBILE_SDK_PASSPHRASE"))
except (SdkError, OSError, zipfile.BadZipFile) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
# stdout contains only the verified directory, suitable for setup_discord_sdk.sh.
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+147
View File
@@ -0,0 +1,147 @@
"""Check SDK integrity, bounded extraction and encrypted CI preparation."""
import hashlib
import shutil
import subprocess
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
import prepare_discord_sdk as sdk
class PrepareDiscordSdkTest(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name).resolve()
self.output = self.root / "prepared"
self.archive = self.root / "sdk.zip"
self.contents = {
"lib/release/discord_partner_sdk.aar": b"test Android library",
f"{sdk.XCFRAMEWORK}/ios-arm64/discord_partner_sdk.framework/discord_partner_sdk":
b"test iOS device library",
f"{sdk.XCFRAMEWORK}/ios-arm64-simulator/discord_partner_sdk.framework/discord_partner_sdk":
b"test iOS simulator library",
"License-Notices.txt": b"test license notices",
"include/discordpp.h": b"test C++ header",
}
checksums = {name: hashlib.sha256(data).hexdigest()
for name, data in self.contents.items()}
patch = mock.patch.object(sdk, "FILES", checksums)
patch.start()
self.addCleanup(patch.stop)
def make_archive(self, prefix="", contents=None):
with zipfile.ZipFile(self.archive, "w") as zf:
for name, data in (self.contents if contents is None else contents).items():
zf.writestr(prefix + name, data)
zf.writestr("../../outside.txt", b"must not be extracted")
zf.writestr("lib/release/desktop.dll", b"unused")
def assert_prepared(self):
files = {str(path.relative_to(self.output)): path.read_bytes()
for path in self.output.rglob("*") if path.is_file()}
self.assertEqual(files, self.contents)
def test_root_and_prefixed_archives_extract_only_mobile_files(self):
for prefix in ("", "discord_social_sdk/"):
with self.subTest(prefix=prefix):
self.make_archive(prefix)
sdk.prepare(self.output, archive=self.archive)
self.assert_prepared()
self.assertFalse((self.root / "outside.txt").exists())
def test_source_directory_and_verified_cache(self):
source = self.root / "download"
for name, data in self.contents.items():
path = source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
sdk.prepare(self.output, source_dir=source)
shutil.rmtree(source)
self.assertEqual(sdk.prepare(self.output), self.output)
self.assert_prepared()
def test_corrupt_cache_is_rejected(self):
self.make_archive()
sdk.prepare(self.output, archive=self.archive)
(self.output / "License-Notices.txt").write_bytes(b"tampered")
with self.assertRaisesRegex(sdk.SdkError, "checksum mismatch"):
sdk.prepare(self.output)
def test_bad_archive_does_not_replace_verified_sdk(self):
self.make_archive()
sdk.prepare(self.output, archive=self.archive)
contents = self.contents.copy()
contents["include/discordpp.h"] = b"wrong SDK version"
self.make_archive(contents=contents)
with self.assertRaisesRegex(sdk.SdkError, "checksum mismatch"):
sdk.prepare(self.output, archive=self.archive)
self.assert_prepared()
def test_each_missing_mobile_component_is_rejected(self):
for name in self.contents:
with self.subTest(name=name):
self.make_archive(contents={key: value for key, value in self.contents.items()
if key != name})
with self.assertRaisesRegex(sdk.SdkError, "exactly one"):
sdk.prepare(self.output, archive=self.archive)
self.assertFalse(self.output.exists())
def test_duplicate_file_is_rejected(self):
self.make_archive()
with zipfile.ZipFile(self.archive, "a") as zf:
zf.writestr("other/License-Notices.txt", b"duplicate")
with self.assertRaisesRegex(sdk.SdkError, "exactly one"):
sdk.prepare(self.output, archive=self.archive)
def test_oversized_file_is_rejected_before_writing_output(self):
self.make_archive()
with mock.patch.object(sdk, "MAX_FILE_BYTES", 1):
with self.assertRaisesRegex(sdk.SdkError, "size limit"):
sdk.prepare(self.output, archive=self.archive)
self.assertFalse(self.output.exists())
def test_missing_configuration_names_the_required_secret(self):
with self.assertRaisesRegex(sdk.SdkError, "DISCORD_MOBILE_SDK_PASSPHRASE"):
sdk.prepare(self.output)
def test_decryption_failure_does_not_echo_key(self):
key = "private-test-passphrase"
with mock.patch.object(sdk.subprocess, "run", return_value=mock.Mock(returncode=2)):
with self.assertRaises(sdk.SdkError) as error:
sdk.prepare(self.output, passphrase=key)
self.assertNotIn(key, str(error.exception))
self.assertFalse(self.output.exists())
def test_missing_gpg_is_actionable(self):
with mock.patch.object(sdk.subprocess, "run", side_effect=FileNotFoundError):
with self.assertRaisesRegex(sdk.SdkError, "Install GnuPG"):
sdk.prepare(self.output, passphrase="test")
@unittest.skipUnless(shutil.which("gpg"), "GnuPG is required for encrypted archive test")
def test_encrypted_archive_roundtrip_and_wrong_key(self):
self.make_archive()
encrypted = self.root / "sdk.zip.gpg"
key = "test-fixture-passphrase"
gnupg_home = self.root / "gnupg"
gnupg_home.mkdir(mode=0o700)
with mock.patch.dict(sdk.os.environ, {"GNUPGHOME": str(gnupg_home)}):
subprocess.run(
["gpg", "--batch", "--quiet", "--pinentry-mode", "loopback",
"--passphrase-fd", "0", "--cipher-algo", "AES256", "--symmetric",
"--output", str(encrypted), str(self.archive)],
input=key.encode(), check=True, capture_output=True,
)
with mock.patch.object(sdk, "ENCRYPTED_ARCHIVE", encrypted):
with self.assertRaisesRegex(sdk.SdkError, "decryption failed"):
sdk.prepare(self.output, passphrase="incorrect")
sdk.prepare(self.output, passphrase=key)
self.assert_prepared()
if __name__ == "__main__":
unittest.main()
Binary file not shown.
+16 -6
View File
@@ -14,10 +14,19 @@ bash scripts/setup_discord_sdk.sh /path/to/discord_social_sdk
flutter pub get
```
On CI, provide the official SDK through the build environment and run the same
script before building. Vendor binaries and their license notices stay under
the ignored `sdk/` and `ios/Frameworks/` directories; do not commit developer credentials or rehost SDK
downloads. Distributions containing the SDK must include its license notices
CI prepares the pinned mobile SDK with `.github/actions/discord-sdk` before
building. Its AES-256 encrypted payload contains only the Android AAR, iOS
XCFramework, C++ header and license notices from the official download. Set the
Actions repository secret `DISCORD_MOBILE_SDK_PASSPHRASE` to the payload's key.
The helper verifies SHA-256 checksums for all 16 required files before staging
them. A missing key, failed decryption or changed SDK stops the build.
Decrypted vendor binaries and notices stay under the ignored `.dart_tool/`,
`sdk/` and `ios/Frameworks/` directories. Do not commit the encryption key,
developer credentials or unencrypted SDK downloads. This uses GitHub's
[large-secret storage pattern](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#storing-large-secrets);
the portal's signed download link expires after one hour and is unsuitable for
release automation. Distributions containing the SDK must include its license notices
and follow Discord's SDK terms. The setup script stages notices for inclusion
in Android assets and iOS resources. Without these files the app still builds, but
the presence setting reports that the SDK is unavailable. A clean clone alone
@@ -31,8 +40,9 @@ as unavailable; the staged SDK files are retained for subsequent full builds.
Production Android builds use `bash scripts/build_android.sh --production`,
which forces Discord on, checks the staged SDK and audits it in every APK ABI.
`--production --lite` is rejected. Release CI requires the SDK on both platforms
and stops before publication if the SDK is absent. Provision the official SDK
in the build environment before running that workflow.
and stops before publication if the SDK is absent. CI on trusted branches also
builds with Discord; fork PRs run native/debug checks and explicit Lite builds
without receiving the decryption key.
Android uses the installed Discord app's signed-in session (Social SDK 1.10+).
The AAR and CMake use the same staged native library; the host packages one copy