From dc37fbc375a6ddc8791e06ef6c5f2b7dc1413bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Tue, 3 Nov 2026 23:06:00 +0100 Subject: [PATCH] build: publish the iOS app for sideloading --- .github/workflows/native-platforms.yml | 43 +++---- platform/ios/Info.plist | 4 +- platform/ios/project.yml | 6 + scripts/ios-release.py | 92 ++++++++++++++ scripts/package-ios.sh | 45 +++++++ scripts/prepare-release.py | 9 +- scripts/test-release.py | 162 ++++++++++++++++++++++++- 7 files changed, 326 insertions(+), 35 deletions(-) create mode 100644 scripts/ios-release.py create mode 100755 scripts/package-ios.sh diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index 52943814..77418440 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -24,9 +24,9 @@ on: type: boolean default: true include_ios: - description: Also build an experimental unsigned iOS archive + description: Build the unsigned iOS app type: boolean - default: false + default: true pull_request: paths: - ".github/workflows/native-platforms.yml" @@ -708,15 +708,17 @@ jobs: ios: needs: metadata - if: github.event_name == 'workflow_dispatch' && inputs.include_ios - name: iOS unsigned archive (arm64) + if: github.event_name == 'push' || inputs.include_ios + name: iOS app (arm64, unsigned) runs-on: macos-15 + env: + SOURCE_DATE_EPOCH: ${{ needs.metadata.outputs.source_date_epoch }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: stable - targets: aarch64-apple-ios,aarch64-apple-ios-sim + targets: aarch64-apple-ios - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: cache-on-failure: true @@ -725,34 +727,18 @@ jobs: - name: Install Xcode project generator run: brew install xcodegen - - name: Generate Xcode project - working-directory: platform/ios - run: xcodegen generate - - - name: Build unsigned device archive + # Unsigned, so people can sideload it with their own Apple ID. + - name: Build the IPA # iOS bundles its own OAuth registrations (platform/ios/README.md). env: FLECTAR_GOOGLE_IOS_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_IOS_CLIENT_ID }} FLECTAR_MICROSOFT_IOS_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_IOS_CLIENT_ID }} - run: | - mkdir -p target/ios - xcodebuild \ - -project platform/ios/FlectarMail.xcodeproj \ - -scheme FlectarMail \ - -configuration Release \ - -sdk iphoneos \ - -destination 'generic/platform=iOS' \ - -archivePath "$PWD/target/ios/FlectarMail.xcarchive" \ - CODE_SIGNING_ALLOWED=NO \ - archive - ditto -c -k --sequesterRsrc --keepParent \ - target/ios/FlectarMail.xcarchive \ - target/ios/flectar-mail-ios-unsigned-xcarchive.zip + run: bash scripts/package-ios.sh target/ios - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: flectar-mail-ios-arm64-unsigned - path: target/ios/flectar-mail-ios-unsigned-xcarchive.zip + name: flectar-mail-ios-arm64 + path: target/ios/flectar-mail-ios-arm64.ipa if-no-files-found: error release: @@ -765,8 +751,9 @@ jobs: needs.rpm-test.result == 'success' && needs.flatpak.result == 'success' && needs.windows.result == 'success' && needs.microsoft-store.result == 'success' && - needs.macos.result == 'success' && needs.android.result == 'success' - needs: [metadata, quality, linux, rpm, rpm-test, flatpak, windows, microsoft-store, macos, android] + needs.macos.result == 'success' && needs.android.result == 'success' && + needs.ios.result == 'success' + needs: [metadata, quality, linux, rpm, rpm-test, flatpak, windows, microsoft-store, macos, android, ios] runs-on: ubuntu-24.04 permissions: actions: read diff --git a/platform/ios/Info.plist b/platform/ios/Info.plist index 6e4e8321..21171a5e 100644 --- a/platform/ios/Info.plist +++ b/platform/ios/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS NSLocalNetworkUsageDescription diff --git a/platform/ios/project.yml b/platform/ios/project.yml index fe43351d..ae8992b8 100644 --- a/platform/ios/project.yml +++ b/platform/ios/project.yml @@ -5,6 +5,10 @@ settings: base: ENABLE_USER_SCRIPT_SANDBOXING: NO PRODUCT_BUNDLE_IDENTIFIER: com.flectar.mail + # scripts/package-ios.sh sets both from Cargo.toml; these values only + # apply to builds started from Xcode. + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Alternate icons chosen in Settings; resources/app-icon/generate.py # writes their icon sets and checks this list. @@ -18,6 +22,8 @@ targets: path: Info.plist properties: CFBundleDisplayName: Flectar Mail + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) NSLocalNetworkUsageDescription: Flectar Mail connects to mail servers on your local network. UILaunchScreen: {} UIRequiresFullScreen: false diff --git a/scripts/ios-release.py b/scripts/ios-release.py new file mode 100644 index 00000000..156561a8 --- /dev/null +++ b/scripts/ios-release.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Package the iOS app for sideloading. + +`ipa` turns the unsigned app that scripts/package-ios.sh builds into the IPA +that GitHub releases publish. +""" + +import argparse +import os +import plistlib +import shutil +import stat +import time +import zipfile +from pathlib import Path, PurePosixPath + +BUNDLE_ID = "com.flectar.mail" +# The earliest time a ZIP entry can record. +ZIP_EPOCH = 315532800 + + +def check_app(app: Path, short_version: str, build_version: str) -> list[Path]: + """Check the app's identity and that it is unsigned; return its contents.""" + info = plistlib.loads((app / "Info.plist").read_bytes()) + for key, expected in ( + ("CFBundleIdentifier", BUNDLE_ID), + ("CFBundleShortVersionString", short_version), + ("CFBundleVersion", build_version), + ): + if info.get(key) != expected: + raise ValueError(f"{key} is {info.get(key)!r}, expected {expected!r}") + if not info.get("MinimumOSVersion"): + raise ValueError("Info.plist names no MinimumOSVersion") + executable = app / info.get("CFBundleExecutable", "") + if executable == app or not executable.is_file() or not executable.stat().st_mode & stat.S_IXUSR: + raise ValueError("The app's CFBundleExecutable is missing or not executable") + contents = sorted(app.rglob("*")) + for path in contents: + if path.is_symlink(): + raise ValueError(f"{path.relative_to(app)} is a symbolic link") + # Sideloading tools sign the app with the user's Apple ID. Anything + # signed here would need its entitlements declared in the AltStore + # source. + if path.name in ("_CodeSignature", "embedded.mobileprovision"): + raise ValueError(f"{path.relative_to(app)} is signing material; the IPA must be unsigned") + return contents + + +def write_ipa(app: Path, output: Path, short_version: str, build_version: str) -> None: + contents = check_app(app, short_version, build_version) + # Entries keep their permissions but share one timestamp, so the same app + # always packages into the same IPA. + epoch = max(int(os.environ.get("SOURCE_DATE_EPOCH") or ZIP_EPOCH), ZIP_EPOCH) + date_time = time.gmtime(epoch)[:6] + partial = output.with_name(output.name + ".partial") + try: + with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as ipa: + payload = zipfile.ZipInfo("Payload/", date_time) + payload.external_attr = (stat.S_IFDIR | 0o755) << 16 | 0x10 + ipa.writestr(payload, b"") + for path in (app, *contents): + name = PurePosixPath("Payload", app.name, path.relative_to(app).as_posix()) + entry = zipfile.ZipInfo.from_file(path, str(name), strict_timestamps=False) + entry.date_time = date_time + if path.is_dir(): + ipa.writestr(entry, b"") + continue + entry.compress_type = zipfile.ZIP_DEFLATED + with path.open("rb") as source, ipa.open(entry, "w") as target: + shutil.copyfileobj(source, target) + partial.replace(output) + finally: + partial.unlink(missing_ok=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + ipa = commands.add_parser("ipa", help="package the unsigned app as an IPA") + ipa.add_argument("app", type=Path) + ipa.add_argument("output", type=Path) + ipa.add_argument("--short-version", required=True) + ipa.add_argument("--build-version", required=True) + args = parser.parse_args() + try: + write_ipa(args.app, args.output, args.short_version, args.build_version) + except (OSError, ValueError) as error: + raise SystemExit(str(error)) from error + + +if __name__ == "__main__": + main() diff --git a/scripts/package-ios.sh b/scripts/package-ios.sh new file mode 100755 index 00000000..cfe717fa --- /dev/null +++ b/scripts/package-ios.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build the unsigned iOS app and package it as an IPA for sideloading. +# AltStore, SideStore and Sideloadly sign it with the user's own Apple ID. +set -euo pipefail + +output_dir="${1:?output directory is required}" +project_dir="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +mkdir -p "$output_dir" +output_dir="$(CDPATH='' cd -- "$output_dir" && pwd)" + +version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$project_dir/Cargo.toml" | head -1)" +# CFBundleShortVersionString takes only numbers, so prereleases share it. +# CFBundleVersion follows the Android version sequence (alpha < beta < rc < +# release), which AltStore and SideStore compare to offer updates. +short_version="${version%%-*}" +build_version="$(python3 "$project_dir/scripts/android-version-code.py" "$version")" + +xcodegen generate --spec "$project_dir/platform/ios/project.yml" +archive="$output_dir/FlectarMail.xcarchive" +rm -rf "$archive" +xcodebuild \ + -project "$project_dir/platform/ios/FlectarMail.xcodeproj" \ + -scheme FlectarMail \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -archivePath "$archive" \ + CODE_SIGNING_ALLOWED=NO \ + MARKETING_VERSION="$short_version" \ + CURRENT_PROJECT_VERSION="$build_version" \ + archive + +# A signature would only be replaced, and entitlements would have to be +# declared in the AltStore source. +app="$archive/Products/Applications/FlectarMail.app" +for code in "$app" "$app"/Frameworks/*.framework; do + if codesign --display "$code" 2>/dev/null; then + printf '%s is signed; the IPA must be unsigned.\n' "$code" >&2 + exit 1 + fi +done + +python3 "$project_dir/scripts/ios-release.py" ipa "$app" "$output_dir/flectar-mail-ios-arm64.ipa" \ + --short-version "$short_version" --build-version "$build_version" +printf 'Built %s (%s, build %s)\n' "$output_dir/flectar-mail-ios-arm64.ipa" "$version" "$build_version" diff --git a/scripts/prepare-release.py b/scripts/prepare-release.py index 095e562e..40439488 100644 --- a/scripts/prepare-release.py +++ b/scripts/prepare-release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage desktop and Android downloads with checksums.""" +"""Stage desktop and mobile downloads with checksums.""" import hashlib import os @@ -31,6 +31,7 @@ def prepare(source: Path, destination: Path, version: str) -> None: ("flectar-mail-macos-arm64", "flectar-mail-macos-arm64.dmg", f"flectar-mail-{version}-macos-arm64.dmg"), ("flectar-mail-macos-x64", "flectar-mail-macos-x64.zip", f"flectar-mail-{version}-macos-x64.zip"), ("flectar-mail-macos-x64", "flectar-mail-macos-x64.dmg", f"flectar-mail-{version}-macos-x64.dmg"), + ("flectar-mail-ios-arm64", "flectar-mail-ios-arm64.ipa", f"flectar-mail-{version}-ios-arm64.ipa"), ("flectar-mail-android-signed-arm64", "flectar-mail.apk", f"flectar-mail-{version}-android-arm64.apk"), ) # Validate every input before copying anything; unrelated benchmark files @@ -81,8 +82,10 @@ def release_notes(version: str) -> str: "installation. The preview Flatpak is a sideloaded bundle and does not " "receive Flathub updates yet; close-to-tray is disabled in that package. " "SHA256SUMS covers every download, and GitHub " - "records signed build-provenance attestations. iOS archives are " - "experimental and available through manual workflow runs only.\n\n" + "records signed build-provenance attestations.\n\n" + "The iOS app (iOS 17 or later) is unsigned. Sideload it with AltStore, " + "SideStore, or Sideloadly, which sign it with your Apple ID; see " + "https://github.com/flectar/mail/blob/main/docs/install.md#ios.\n\n" + android ) diff --git a/scripts/test-release.py b/scripts/test-release.py index 22ae8153..d860ab42 100644 --- a/scripts/test-release.py +++ b/scripts/test-release.py @@ -6,6 +6,7 @@ import hashlib import importlib.util import json import os +import plistlib import shutil import subprocess import sys @@ -13,6 +14,7 @@ import tempfile import tomllib import unittest import xml.etree.ElementTree as ET +import zipfile from pathlib import Path from unittest.mock import patch @@ -34,6 +36,7 @@ android_signing = load_script("verify-android-signing") verify_android_signing = android_signing.verify android_version_code = load_script("android-version-code").version_code linux_metadata = load_script("stage-linux-metadata") +ios_release = load_script("ios-release") stage_linux_metadata = linux_metadata.stage @@ -684,6 +687,158 @@ else: self.assertNotEqual(result.returncode, 0) self.assertIn("pdfium.dll is missing", result.stderr) + def test_ios_app_packages_into_an_unsigned_ipa(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + app = root / "FlectarMail.app" + (app / "Frameworks/PDFium.framework").mkdir(parents=True) + (app / "Licenses/Flectar Mail").mkdir(parents=True) + info = { + "CFBundleExecutable": "FlectarMail", + "CFBundleIdentifier": "com.flectar.mail", + "CFBundleShortVersionString": "0.1.0", + "CFBundleVersion": "1000008", + "MinimumOSVersion": "17.0", + } + # Xcode writes the built Info.plist in binary form. + (app / "Info.plist").write_bytes(plistlib.dumps(info, fmt=plistlib.FMT_BINARY)) + for name, mode in ( + ("FlectarMail", 0o755), + ("Frameworks/PDFium.framework/PDFium", 0o755), + ("Licenses/Flectar Mail/LICENSE", 0o644), + ): + (app / name).write_bytes(name.encode()) + (app / name).chmod(mode) + ipa = root / "app.ipa" + with patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1767225600"}): + ios_release.write_ipa(app, ipa, "0.1.0", "1000008") + first = ipa.read_bytes() + (app / "FlectarMail").touch() + ios_release.write_ipa(app, ipa, "0.1.0", "1000008") + # The same app always packages into the same IPA. + self.assertEqual(ipa.read_bytes(), first) + self.assertEqual(list(root.glob("*.partial")), []) + with zipfile.ZipFile(ipa) as archive: + entries = {entry.filename: entry for entry in archive.infolist()} + self.assertEqual(plistlib.loads(archive.read("Payload/FlectarMail.app/Info.plist")), info) + self.assertEqual(sorted(entries), [ + "Payload/", + "Payload/FlectarMail.app/", + "Payload/FlectarMail.app/FlectarMail", + "Payload/FlectarMail.app/Frameworks/", + "Payload/FlectarMail.app/Frameworks/PDFium.framework/", + "Payload/FlectarMail.app/Frameworks/PDFium.framework/PDFium", + "Payload/FlectarMail.app/Info.plist", + "Payload/FlectarMail.app/Licenses/", + "Payload/FlectarMail.app/Licenses/Flectar Mail/", + "Payload/FlectarMail.app/Licenses/Flectar Mail/LICENSE", + ]) + # Executables stay executable. + for name, mode in ( + ("FlectarMail", 0o755), + ("Frameworks/PDFium.framework/PDFium", 0o755), + ("Licenses/Flectar Mail/LICENSE", 0o644), + ): + self.assertEqual(entries[f"Payload/FlectarMail.app/{name}"].external_attr >> 16 & 0o777, mode) + self.assertEqual({entry.date_time for entry in entries.values()}, {(2026, 1, 1, 0, 0, 0)}) + + # Another identity or version, or a missing executable, is refused. + for key, value in ( + ("CFBundleIdentifier", "com.example.mail"), + ("CFBundleShortVersionString", "1.0"), + ("CFBundleVersion", "1"), + ("MinimumOSVersion", None), + ("CFBundleExecutable", "Missing"), + ("CFBundleExecutable", None), + ): + with self.subTest(key=key, value=value): + changed = {**info, key: value} + (app / "Info.plist").write_bytes(plistlib.dumps({k: v for k, v in changed.items() if v is not None})) + with self.assertRaises(ValueError): + ios_release.write_ipa(app, root / "refused.ipa", "0.1.0", "1000008") + (app / "Info.plist").write_bytes(plistlib.dumps(info)) + (app / "FlectarMail").chmod(0o644) + with self.assertRaises(ValueError): + ios_release.write_ipa(app, root / "refused.ipa", "0.1.0", "1000008") + (app / "FlectarMail").chmod(0o755) + # Sideloading tools sign the app; signing material and symbolic + # links would not survive that. + for name in ("_CodeSignature", "Frameworks/PDFium.framework/_CodeSignature", "embedded.mobileprovision"): + with self.subTest(name=name): + (app / name).mkdir() + with self.assertRaisesRegex(ValueError, "unsigned"): + ios_release.write_ipa(app, root / "refused.ipa", "0.1.0", "1000008") + (app / name).rmdir() + (app / "Frameworks/link").symlink_to("../FlectarMail") + with self.assertRaisesRegex(ValueError, "symbolic link"): + ios_release.write_ipa(app, root / "refused.ipa", "0.1.0", "1000008") + (app / "Frameworks/link").unlink() + self.assertFalse((root / "refused.ipa").exists()) + ios_release.write_ipa(app, root / "accepted.ipa", "0.1.0", "1000008") + + def test_ios_packaging_stamps_the_release_version(self): + # Run the real script with stand-ins for the Xcode tools, which only + # exist on macOS. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "scripts").mkdir() + for name in ("package-ios.sh", "ios-release.py", "android-version-code.py"): + shutil.copyfile(SCRIPTS / name, root / "scripts" / name) + (root / "Cargo.toml").write_text('[package]\nname = "flectar-mail"\nversion = "0.1.0-alpha.8"\n') + tools = root / "bin" + tools.mkdir() + calls = root / "calls.log" + (tools / "xcodegen").write_text(f'#!/bin/sh\necho "xcodegen $*" >> "{calls}"\n') + # Build an app the way Xcode would with the given settings. + (tools / "xcodebuild").write_text(f"""#!/usr/bin/env python3 +import os, plistlib, sys +from pathlib import Path +with open({str(calls)!r}, "a") as log: + log.write("xcodebuild " + " ".join(sys.argv[1:]) + "\\n") +arguments = sys.argv[1:] +settings = dict(argument.split("=", 1) for argument in arguments if "=" in argument) +app = Path(arguments[arguments.index("-archivePath") + 1], "Products/Applications/FlectarMail.app") +(app / "Frameworks/PDFium.framework").mkdir(parents=True) +for name in ("FlectarMail", "Frameworks/PDFium.framework/PDFium"): + (app / name).write_bytes(b"binary") + (app / name).chmod(0o755) +(app / "Info.plist").write_bytes(plistlib.dumps({{ + "CFBundleExecutable": "FlectarMail", + "CFBundleIdentifier": "com.flectar.mail", + "CFBundleShortVersionString": settings["MARKETING_VERSION"], + "CFBundleVersion": settings["CURRENT_PROJECT_VERSION"], + "MinimumOSVersion": "17.0", +}}, fmt=plistlib.FMT_BINARY)) +""") + # `codesign --display` fails for unsigned code. + (tools / "codesign").write_text('#!/bin/sh\n[ "${FAKE_SIGNED:-}" = 1 ]\n') + for tool in tools.iterdir(): + tool.chmod(0o755) + environment = {**os.environ, "PATH": f"{tools}:{os.environ['PATH']}"} + environment.pop("SOURCE_DATE_EPOCH", None) + result = subprocess.run( + ["bash", "scripts/package-ios.sh", "out"], cwd=root, env=environment, + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + with zipfile.ZipFile(root / "out/flectar-mail-ios-arm64.ipa") as archive: + info = plistlib.loads(archive.read("Payload/FlectarMail.app/Info.plist")) + # Prereleases share the numeric version; the build number orders them. + self.assertEqual(info["CFBundleShortVersionString"], "0.1.0") + self.assertEqual(info["CFBundleVersion"], str(android_version_code("0.1.0-alpha.8"))) + log = calls.read_text() + self.assertIn(f"xcodegen generate --spec {root}/platform/ios/project.yml", log) + self.assertIn("CODE_SIGNING_ALLOWED=NO", log) + self.assertIn(f"-archivePath {root}/out/FlectarMail.xcarchive", log) + + result = subprocess.run( + ["bash", "scripts/package-ios.sh", "signed"], cwd=root, + env={**environment, "FAKE_SIGNED": "1"}, capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be unsigned", result.stderr) + self.assertFalse((root / "signed/flectar-mail-ios-arm64.ipa").exists()) + def test_staging_and_checksums(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -707,6 +862,7 @@ else: "flectar-mail-macos-arm64/flectar-mail-macos-arm64.dmg", "flectar-mail-macos-x64/flectar-mail-macos-x64.zip", "flectar-mail-macos-x64/flectar-mail-macos-x64.dmg", + "flectar-mail-ios-arm64/flectar-mail-ios-arm64.ipa", "flectar-mail-android-signed-arm64/flectar-mail.apk", ) for name in names: @@ -717,8 +873,9 @@ else: (source / "flectar-mail-linux-x64/startup-benchmark/benchmark.json").write_text("{}") dist = root / "dist" prepare(source, dist, "0.1.0-beta.1") - self.assertEqual(len(list(dist.iterdir())), 18) + self.assertEqual(len(list(dist.iterdir())), 19) self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64.apk").is_file()) + self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-ios-arm64.ipa").is_file()) # Each download comes from its own architecture's artifact. for download, name in ( ("flectar-mail-0.1.0-beta.1-linux-x64.AppImage", names[0]), @@ -769,7 +926,7 @@ else: (folder / f"flectar-mail_0.1.0~beta.1_{arch}.deb").rename(folder / f"flectar-mail_0.1.0_{arch}.deb") stable_dist = root / "stable" prepare(source, stable_dist, "0.1.0") - self.assertEqual(len(list(stable_dist.iterdir())), 18) + self.assertEqual(len(list(stable_dist.iterdir())), 19) self.assertTrue((stable_dist / "flectar-mail_0.1.0_amd64.deb").is_file()) self.assertTrue((stable_dist / "flectar-mail_0.1.0_arm64.deb").is_file()) self.assertTrue((stable_dist / "flectar-mail-0.1.0-android-arm64.apk").is_file()) @@ -785,6 +942,7 @@ else: self.assertIn("Apple silicon and Intel", release_assets.release_notes("0.1.0-beta.1")) self.assertIn("Linux and Windows on x64 and arm64", release_assets.release_notes("0.1.0-beta.1")) self.assertIn("-linux-arm64.rpm", release_assets.release_notes("0.1.0-beta.1")) + self.assertIn("Sideload it with AltStore", release_assets.release_notes("0.1.0-beta.1")) def test_metadata_rejects_stale_android_lockfile(self): with tempfile.TemporaryDirectory() as directory: