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: