build: publish the iOS app for sideloading

This commit is contained in:
Tomás Romero committed 2026-11-03 23:06:00 +01:00
1 parent 9b7853df9e
commit dc37fbc375
7 files changed
+326 -35

No files matched your search

+15 -28
View File
@@ -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
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
+6
View File
@@ -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
+92
View File
@@ -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()
+45
View File
@@ -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"
+6 -3
View File
@@ -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
)
+160 -2
View File
@@ -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: