mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
1196 lines
67 KiB
Python
1196 lines
67 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise release validation and publishing without contacting GitHub."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import plistlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import tomllib
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
SCRIPTS = Path(__file__).resolve().parent
|
|
sys.dont_write_bytecode = True
|
|
|
|
|
|
def load_script(name):
|
|
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
metadata = load_script("release-metadata").metadata
|
|
release_assets = load_script("prepare-release")
|
|
prepare = release_assets.prepare
|
|
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
|
|
|
|
|
|
class ReleaseTests(unittest.TestCase):
|
|
def test_android_ci_never_generates_a_release_key(self):
|
|
for ci_variable in ("CI", "GITHUB_ACTIONS"):
|
|
with self.subTest(ci_variable=ci_variable), tempfile.TemporaryDirectory() as directory:
|
|
environment = {
|
|
**os.environ,
|
|
"CI": "", "GITHUB_ACTIONS": "", ci_variable: "true",
|
|
"FLECTAR_ANDROID_KEYSTORE": "",
|
|
"FLECTAR_ANDROID_VERSION_NAME": "0.1.0-alpha.8",
|
|
"XDG_DATA_HOME": directory,
|
|
}
|
|
result = subprocess.run(
|
|
["bash", str(SCRIPTS / "build-android-test-apk.sh")],
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("requires FLECTAR_ANDROID_KEYSTORE", result.stderr)
|
|
self.assertEqual(list(Path(directory).rglob("*")), [])
|
|
|
|
def test_android_persistent_key_restore_and_pin(self):
|
|
# Exercise the workflow's actual restore command and its verifier.
|
|
# Stand in for keytool's public DER export; no private keys in fixtures.
|
|
certificate = b"original public certificate"
|
|
digest = hashlib.sha256(certificate).hexdigest()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "scripts").mkdir()
|
|
(root / "platform/android").mkdir(parents=True)
|
|
pin = root / "platform/android/app-signing-certificate.sha256"
|
|
pin.write_text(digest + "\n")
|
|
for name in ("verify-android-keystore.sh", "restore-android-keystore.sh"):
|
|
shutil.copyfile(SCRIPTS / name, root / "scripts" / name)
|
|
keytool = root / "keytool"
|
|
keytool.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import os, pathlib, sys\n"
|
|
"if sys.argv[1] == '-list':\n"
|
|
" print('Keystore type: JKS')\n"
|
|
" sys.exit(0)\n"
|
|
"assert sys.argv[1] in ('-exportcert', '-certreq')\n"
|
|
"assert sys.argv[2] == '-keystore'\n"
|
|
"assert sys.argv[4:8] == ['-alias', 'release-key', '-storepass:env', 'FLECTAR_ANDROID_KEYSTORE_PASSWORD']\n"
|
|
"if os.environ['FLECTAR_ANDROID_KEYSTORE_PASSWORD'] != 'fixture store password': sys.exit(1)\n"
|
|
"if sys.argv[1] == '-certreq':\n"
|
|
" assert sys.argv[8:] == ['-keypass:env', 'FLECTAR_ANDROID_KEY_PASSWORD']\n"
|
|
" sys.exit(0 if os.environ['FLECTAR_ANDROID_KEY_PASSWORD'] == 'fixture key password' else 1)\n"
|
|
"data = pathlib.Path(sys.argv[3]).read_bytes()\n"
|
|
"if data == b'unreadable keystore': sys.exit(1)\n"
|
|
"sys.stdout.buffer.write(data)\n"
|
|
)
|
|
keytool.chmod(0o755)
|
|
output = root / "github-env"
|
|
environment = {
|
|
**os.environ, "PATH": f"{root}{os.pathsep}{os.environ['PATH']}",
|
|
"RUNNER_TEMP": str(root), "GITHUB_ENV": str(output),
|
|
"FLECTAR_ANDROID_KEY_ALIAS": "release-key",
|
|
"FLECTAR_ANDROID_KEYSTORE_PASSWORD": "fixture store password",
|
|
"FLECTAR_ANDROID_KEY_PASSWORD": "fixture key password",
|
|
}
|
|
for encoded, success, message in (
|
|
("", False, "FLECTAR_ANDROID_KEYSTORE_BASE64 is required"),
|
|
("not base64!", False, ""),
|
|
(base64.b64encode(b"unreadable keystore").decode(), False, ""),
|
|
(base64.b64encode(b"replacement public certificate").decode(), False, "differs from the pinned"),
|
|
(base64.b64encode(certificate).decode(), True, digest),
|
|
):
|
|
with self.subTest(encoded=encoded):
|
|
output.unlink(missing_ok=True)
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/restore-android-keystore.sh")], cwd=root,
|
|
env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": encoded},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode == 0, success, result.stderr)
|
|
self.assertIn(message, result.stdout + result.stderr)
|
|
if success:
|
|
keystore = Path(output.read_text().strip().split("=", 1)[1])
|
|
self.assertEqual(keystore.stat().st_mode & 0o777, 0o600)
|
|
else:
|
|
self.assertFalse(output.exists())
|
|
self.assertFalse(list(root.glob("flectar-app-signing.*.keystore")))
|
|
output.unlink()
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/restore-android-keystore.sh")],
|
|
env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": base64.b64encode(certificate).decode(),
|
|
"FLECTAR_ANDROID_KEY_PASSWORD": "wrong password"},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertFalse(output.exists())
|
|
self.assertEqual(list(root.glob("flectar-app-signing.*.keystore")), [keystore])
|
|
for missing in ("FLECTAR_ANDROID_KEYSTORE_PASSWORD", "FLECTAR_ANDROID_KEY_ALIAS", "FLECTAR_ANDROID_KEY_PASSWORD"):
|
|
with self.subTest(missing=missing):
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/restore-android-keystore.sh")],
|
|
env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": base64.b64encode(certificate).decode(),
|
|
missing: ""},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(f"requires {missing}", result.stderr)
|
|
self.assertFalse(output.exists())
|
|
self.assertEqual(list(root.glob("flectar-app-signing.*.keystore")), [keystore])
|
|
for value in ("", "not-a-digest"):
|
|
pin.write_text(value)
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/verify-android-keystore.sh")],
|
|
env={**environment, "FLECTAR_ANDROID_KEYSTORE": str(keystore)},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("pinned Android app signing certificate SHA-256 is invalid", result.stderr)
|
|
|
|
def test_android_version_codes_order_all_release_channels(self):
|
|
versions = (
|
|
"0.1.0-alpha.8", "0.1.0-alpha.999", "0.1.0-beta.1", "0.1.0-beta.999",
|
|
"0.1.0-rc.1", "0.1.0-rc.999", "0.1.0", "0.1.1-alpha.1", "0.1.1",
|
|
"0.2.0-alpha.1", "0.99.99", "1.0.0-alpha.1", "20.99.99",
|
|
)
|
|
codes = [android_version_code(version) for version in versions]
|
|
self.assertEqual(codes, sorted(set(codes)))
|
|
self.assertEqual(android_version_code("0.1.0-alpha.8"), 1_000_008)
|
|
self.assertEqual(android_version_code("0.1.0"), 1_009_999)
|
|
self.assertLessEqual(max(codes), 2_100_000_000)
|
|
for version in ("21.0.0", "0.100.0", "0.0.100", "0.1.0-alpha.1000", "0.1.0-alpha.0",
|
|
"01.1.0", "0.1.0-dev.1", "0.1.0+build", "0.1.0-alpha.01"):
|
|
with self.subTest(version=version), self.assertRaises(ValueError):
|
|
android_version_code(version)
|
|
|
|
def test_flatpak_pdfium_archive_keeps_expected_layout(self):
|
|
manifest = (
|
|
SCRIPTS.parent / "packaging/flatpak/com.flectar.mail.yml"
|
|
).read_text()
|
|
self.assertIn(
|
|
"install -Dm0755 pdfium-runtime/lib/libpdfium.so "
|
|
"/app/lib/flectar-mail/libpdfium.so",
|
|
manifest,
|
|
)
|
|
# Each Flatpak architecture gets the archive the other packages pin.
|
|
pinned = load_script("stage-pdfium")
|
|
for target, arch in (("linux-x64", "x86_64"), ("linux-arm64", "aarch64")):
|
|
with self.subTest(target=target):
|
|
checksum = pinned.ASSETS[target][0]
|
|
self.assertIn(
|
|
f"/{pinned.RELEASE.replace('/', '%2F')}/pdfium-{target}.tgz\n"
|
|
f" sha256: {checksum}\n"
|
|
" dest: pdfium-runtime\n"
|
|
" strip-components: 0\n"
|
|
" only-arches:\n"
|
|
f" - {arch}\n",
|
|
manifest,
|
|
)
|
|
|
|
def test_flatpak_cargo_sources_match_lockfile(self):
|
|
project = SCRIPTS.parent
|
|
packages = tomllib.loads((project / "Cargo.lock").read_text())["package"]
|
|
expected = {
|
|
f"cargo/vendor/{package['name']}-{package['version']}": package
|
|
for package in packages
|
|
if package.get("source", "").startswith("registry+")
|
|
}
|
|
sources = json.loads(
|
|
(project / "packaging/flatpak/cargo-sources.json").read_text()
|
|
)
|
|
self.assertEqual(len(sources), len(expected) * 2 + 1)
|
|
|
|
archives = {source["dest"]: source for source in sources if source["type"] == "archive"}
|
|
checksums = {
|
|
source["dest"]: json.loads(source["contents"])["package"]
|
|
for source in sources
|
|
if source["type"] == "inline" and source.get("dest-filename") == ".cargo-checksum.json"
|
|
}
|
|
self.assertEqual(set(archives), set(expected))
|
|
self.assertEqual(
|
|
checksums,
|
|
{destination: package["checksum"] for destination, package in expected.items()},
|
|
)
|
|
for destination, package in expected.items():
|
|
name = package["name"]
|
|
version = package["version"]
|
|
source = archives[destination]
|
|
self.assertEqual(source["archive-type"], "tar-gzip")
|
|
self.assertEqual(source["sha256"], package["checksum"])
|
|
self.assertEqual(
|
|
source["url"],
|
|
f"https://static.crates.io/crates/{name}/{name}-{version}.crate",
|
|
)
|
|
self.assertEqual(
|
|
sources[-1],
|
|
{
|
|
"type": "inline",
|
|
"contents": (
|
|
'[source.vendored-sources]\n'
|
|
'directory = "cargo/vendor"\n\n'
|
|
'[source.crates-io]\n'
|
|
'replace-with = "vendored-sources"\n'
|
|
),
|
|
"dest": "cargo",
|
|
"dest-filename": "config",
|
|
},
|
|
)
|
|
|
|
def test_android_ci_output_and_oauth_fingerprints(self):
|
|
signing = (SCRIPTS / "fixtures/apksigner-v2.txt").read_text()
|
|
expected = "6f269ab047ca5d5eae7317758a265251e3d5645c5307633a5b92d192b8646280"
|
|
for output in (signing, signing.replace("V2 Signer:", "Signer #1")):
|
|
verify_android_signing(output, "test", expected)
|
|
verify_android_signing(output, "production", expected)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(output, "test", "00" * 32)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(output, "production")
|
|
self.assertEqual(android_signing.certificate_digest(output, "SHA-1"), "ce48c46e4aa046ef007c1f965f8797691ba40e9f")
|
|
with self.assertRaisesRegex(ValueError, "unsupported output format"):
|
|
verify_android_signing(signing.replace("V2 Signer:", "Unknown Signer:"), "test", expected)
|
|
|
|
# The subsequent OAuth identity-export step must support the same SDK
|
|
# format as APK verification, or a valid package would still fail CI.
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
build_tools = root / "build-tools/36.0.0"
|
|
build_tools.mkdir(parents=True)
|
|
signer = build_tools / "apksigner"
|
|
signer.write_text(f"#!/usr/bin/env python3\nprint({signing!r})\n")
|
|
signer.chmod(0o755)
|
|
apk = root / "app.apk"
|
|
apk.write_bytes(b"fixture")
|
|
output = subprocess.check_output(
|
|
["bash", str(SCRIPTS / "android-signing-identities.sh"), str(apk)],
|
|
env={**os.environ, "ANDROID_HOME": str(root)}, text=True,
|
|
)
|
|
self.assertIn("Google Android OAuth SHA-1: CE:48:C4:6E:4A:A0:46:EF:00:7C:1F:96:5F:87:97:69:1B:A4:0E:9F", output)
|
|
self.assertIn("Microsoft redirect URI: msauth://com.flectar.mail/", output)
|
|
|
|
def test_android_signing_uses_certificate_not_display_name(self):
|
|
digest = "ab" * 32
|
|
for subject in ("CN=Flectar Mail Test, O=Android, C=US", "C=US,O=Android,CN=Flectar Mail Test"):
|
|
signing = (
|
|
"Verified using v2 scheme (APK Signature Scheme v2): true\n"
|
|
"Number of signers: 1\n"
|
|
f"Signer #1 certificate DN: {subject}\n"
|
|
f"Signer #1 certificate SHA-256 digest: {digest}\n"
|
|
)
|
|
for mode in ("test", "production"):
|
|
with self.subTest(subject=subject, mode=mode):
|
|
verify_android_signing(signing, mode, digest.upper())
|
|
for expected in (None, "", "invalid", "cd" * 32):
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing, mode, expected)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing.replace("signers: 1", "signers: 2"), mode, digest)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing.replace(": true", ": false"), mode, digest)
|
|
# A plausible release subject cannot make a replacement key trusted.
|
|
production = signing.replace("CN=Flectar Mail Test", "CN=Flectar").replace(digest, "cd" * 32)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(production, "production", digest)
|
|
|
|
def test_linux_metadata_versions(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "resources").mkdir()
|
|
for name in ("com.flectar.mail.desktop", "com.flectar.mail.metainfo.xml"):
|
|
shutil.copyfile(SCRIPTS.parent / "resources" / name, root / "resources" / name)
|
|
for version in ("0.2.0-alpha.1", "0.2.0-beta.2", "0.2.0-rc.3", "0.2.0"):
|
|
with self.subTest(version=version):
|
|
(root / "Cargo.toml").write_text(f'[package]\nversion = "{version}"\n')
|
|
destination = root / version
|
|
with patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1767225600"}):
|
|
stage_linux_metadata(root, destination, arch="aarch64")
|
|
desktop = (destination / "usr/share/applications/com.flectar.mail.desktop").read_text()
|
|
self.assertIn(f"X-AppImage-Version={version}\n", desktop)
|
|
self.assertIn("X-AppImage-Arch=aarch64\n", desktop)
|
|
release = ET.parse(destination / "usr/share/metainfo/com.flectar.mail.metainfo.xml").find("./releases/release")
|
|
self.assertEqual(release.get("version"), version)
|
|
self.assertEqual(release.get("date"), "2026-01-01")
|
|
self.assertEqual(release.get("type"), "development" if "-" in version else "stable")
|
|
# Repackaging the same source must produce identical metadata
|
|
# without consulting the current date.
|
|
repeated = root / f"{version}-repeated"
|
|
with patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1767225600"}):
|
|
with patch.object(linux_metadata, "datetime", wraps=linux_metadata.datetime) as clock:
|
|
clock.now.side_effect = AssertionError("Metadata must use the source date")
|
|
stage_linux_metadata(root, repeated, arch="aarch64")
|
|
for path in destination.rglob("*"):
|
|
if path.is_file():
|
|
self.assertEqual(path.read_bytes(), (repeated / path.relative_to(destination)).read_bytes())
|
|
flatpak = root / f"{version}-flatpak"
|
|
with patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1767225600"}):
|
|
stage_linux_metadata(root, flatpak, flatpak=True)
|
|
desktop = (flatpak / "usr/share/applications/com.flectar.mail.desktop").read_text()
|
|
self.assertNotIn("X-AppImage-", desktop)
|
|
# Every other package names the architecture it was built for.
|
|
for arch in (None, "amd64", "arm64"):
|
|
with self.subTest(arch=arch), self.assertRaisesRegex(ValueError, "architecture"):
|
|
stage_linux_metadata(root, root / "unnamed", arch=arch)
|
|
for name in ("com.flectar.mail.desktop", "com.flectar.mail.metainfo.xml"):
|
|
self.assertEqual((root / "resources" / name).read_bytes(), (SCRIPTS.parent / "resources" / name).read_bytes())
|
|
|
|
def test_appimage_integration_keeps_mail_link_arguments(self):
|
|
# The desktop entry receives mail links through %U and its New email
|
|
# action passes an empty one; integration only replaces the program.
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
appdir = root / "Flectar Mail.AppDir"
|
|
stage_linux_metadata(SCRIPTS.parent, appdir, arch="x86_64")
|
|
for icon in (
|
|
"usr/share/icons/hicolor/512x512/apps/com.flectar.mail.png",
|
|
"usr/share/icons/hicolor/scalable/apps/com.flectar.mail.svg",
|
|
):
|
|
(appdir / icon).parent.mkdir(parents=True, exist_ok=True)
|
|
(appdir / icon).write_text("icon")
|
|
shutil.copyfile(SCRIPTS.parent / "resources/AppRun", appdir / "AppRun")
|
|
appimage = root / "downloads/Flectar Mail.AppImage"
|
|
appimage.parent.mkdir()
|
|
appimage.write_text("appimage")
|
|
home = root / "home"
|
|
result = subprocess.run(
|
|
["bash", str(appdir / "AppRun"), "--integrate"],
|
|
env={"PATH": os.environ["PATH"], "HOME": str(home), "APPIMAGE": str(appimage)},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
installed = home / "Applications/flectar-mail.AppImage"
|
|
desktop = (home / ".local/share/applications/com.flectar.mail.desktop").read_text()
|
|
exec_lines = [line for line in desktop.splitlines() if line.startswith("Exec=")]
|
|
self.assertEqual(exec_lines, [f'Exec="{installed}" %U', f'Exec="{installed}" mailto:'])
|
|
self.assertIn("MimeType=x-scheme-handler/mailto;\n", desktop)
|
|
|
|
def test_windows_installer_registers_the_mail_link_handler_the_app_knows(self):
|
|
# The installer registers the installed copy; the app finds that
|
|
# registration, opens its Default apps page and writes the same
|
|
# layout for a portable copy.
|
|
root = SCRIPTS.parent
|
|
installer = (root / "platform/windows/installer.iss").read_text()
|
|
windows = (root / "src/default_email_app/windows.rs").read_text()
|
|
option = re.search(
|
|
r'MAILTO_OPTION: &str = "([^"]+)"', (root / "src/mailto_links.rs").read_text(),
|
|
).group(1)
|
|
name, prog_id = re.search(
|
|
r'const INSTALLED: Registration = Registration \{\s*name: "([^"]+)",\s*prog_id: "([^"]+)"',
|
|
windows,
|
|
).groups()
|
|
description = re.search(r'const DESCRIPTION: &str = "([^"]+)"', windows).group(1)
|
|
self.assertIn("\nChangesAssociations=yes\n", installer)
|
|
entries = {}
|
|
for line in installer.splitlines():
|
|
if not line.startswith("Root: HKA;"):
|
|
continue
|
|
fields = dict(
|
|
re.match(r'\s*(\w+): (?:"((?:[^"]|"")*)"|(\S+))', field).group(1, 2)
|
|
for field in line.split(";")[1:] if field.strip()
|
|
)
|
|
if "ValueName" in fields:
|
|
entries[(fields["Subkey"], fields["ValueName"])] = fields["ValueData"]
|
|
capabilities = rf"Software\Clients\Mail\{name}\Capabilities"
|
|
self.assertEqual(entries[(rf"Software\RegisteredApplications", name)], capabilities)
|
|
self.assertEqual(entries[(capabilities, "ApplicationName")], name)
|
|
self.assertEqual(entries[(capabilities, "ApplicationDescription")], description)
|
|
self.assertEqual(entries[(rf"{capabilities}\URLAssociations", "mailto")], prog_id)
|
|
self.assertEqual(
|
|
entries[(rf"Software\Classes\{prog_id}\shell\open\command", "")],
|
|
rf'""{{app}}\flectar-mail.exe"" {option} ""%1""',
|
|
)
|
|
|
|
def test_linux_package_metadata(self):
|
|
# Exercise the actual shell packagers and dpkg with a tiny existing ELF,
|
|
# so shell expansion bugs cannot hide behind Python-only asset tests.
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
paths = (
|
|
"scripts/build-deb.sh", "scripts/build-appimage.sh",
|
|
"scripts/stage-linux-metadata.py", "resources/AppRun",
|
|
"LICENSE", "THIRD_PARTY_NOTICES.md",
|
|
"resources/com.flectar.mail.desktop", "resources/com.flectar.mail.metainfo.xml",
|
|
"resources/app-icon/flectar-mail-masked-512.png",
|
|
"resources/app-icon/flectar-mail-masked.svg",
|
|
"resources/fonts/google-sans-flex/OFL.txt",
|
|
"resources/fonts/google-sans-flex/README.md",
|
|
"resources/debian/source-control.in", "resources/debian/control.in",
|
|
)
|
|
for name in paths:
|
|
destination = root / name
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(SCRIPTS.parent / name, destination)
|
|
shutil.copytree(SCRIPTS.parent / "LICENSES", root / "LICENSES")
|
|
(root / "Cargo.toml").write_text('[package]\nversion = "0.1.0-alpha.1"\n')
|
|
(root / "target/release").mkdir(parents=True)
|
|
shutil.copyfile("/bin/true", root / "target/release/flectar-mail")
|
|
# This test checks package metadata using fixture ELFs. Stub only
|
|
# the download/renderer helpers in the disposable checkout; actual
|
|
# native PDF rendering is exercised by the dedicated smoke tests.
|
|
(root / "scripts/stage-pdfium.py").write_text(
|
|
"import pathlib, shutil, sys\n"
|
|
"with open('pdfium-targets.txt', 'a') as output:\n"
|
|
" output.write(sys.argv[1] + '\\n')\n"
|
|
"destination = pathlib.Path(sys.argv[2])\n"
|
|
"destination.mkdir(parents=True, exist_ok=True)\n"
|
|
"shutil.copyfile('/bin/true', destination / 'libpdfium.so')\n"
|
|
)
|
|
(root / "scripts/test-pdf-preview.py").write_text(
|
|
"import pathlib, sys\n"
|
|
"assert pathlib.Path(sys.argv[1]).is_file()\n"
|
|
)
|
|
# The host's /bin/true stands in for each architecture's executable;
|
|
# test-pdf-packaging.py covers the real ELF helper.
|
|
(root / "scripts/linux-elf.py").write_text(
|
|
"import json, pathlib, sys\n"
|
|
"with open('linux-elf-calls.jsonl', 'a') as output:\n"
|
|
" output.write(json.dumps(sys.argv[1:]) + '\\n')\n"
|
|
"if sys.argv[1] == 'arch':\n"
|
|
" print(pathlib.Path('fixture-architecture').read_text())\n"
|
|
)
|
|
(root / "bin").mkdir()
|
|
cargo = root / "bin/cargo"
|
|
cargo.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import json, pathlib, sys\n"
|
|
"with pathlib.Path('cargo-args.jsonl').open('a') as output:\n"
|
|
" output.write(json.dumps(sys.argv[1:]) + '\\n')\n"
|
|
)
|
|
cargo.chmod(0o755)
|
|
# Model the explicitly bundled X11 libraries without depending on
|
|
# the host's development packages or invoking real deployment tools.
|
|
libraries = root / "fixture-libs"
|
|
libraries.mkdir()
|
|
for name in ("libxkbcommon-x11.so.0", "libxkbcommon.so.0"):
|
|
shutil.copyfile("/bin/true", libraries / name)
|
|
pkg_config = root / "bin/pkg-config"
|
|
pkg_config.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import sys\n"
|
|
"assert sys.argv[1:] == ['--variable=libdir', 'xkbcommon-x11']\n"
|
|
f"print({str(libraries)!r})\n"
|
|
)
|
|
pkg_config.chmod(0o755)
|
|
# The fixture ELFs carry no RUNPATH; report the one linuxdeploy sets.
|
|
readelf = root / "bin/readelf"
|
|
readelf.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import sys\n"
|
|
"assert sys.argv[1] == '-d'\n"
|
|
"runpath = '$ORIGIN/../lib' if sys.argv[2].endswith('/usr/bin/flectar-mail') else '$ORIGIN'\n"
|
|
"print(f' 0x000000000000001d (RUNPATH) Library runpath: [{runpath}]')\n"
|
|
)
|
|
readelf.chmod(0o755)
|
|
environment = {
|
|
**os.environ, "PATH": f"{root / 'bin'}:{os.environ['PATH']}", "VERSION": "stale",
|
|
"FLECTAR_SKIP_BUILD": "0", "FLECTAR_APP_FEATURES": "",
|
|
}
|
|
for machine, debian, pdfium in (
|
|
("x86_64", "amd64", "linux-x64"), ("aarch64", "arm64", "linux-arm64"),
|
|
):
|
|
with self.subTest(machine=machine):
|
|
(root / "fixture-architecture").write_text(f"{machine} {debian} {pdfium}")
|
|
for name in ("pdfium-targets.txt", "linux-elf-calls.jsonl"):
|
|
(root / name).unlink(missing_ok=True)
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/build-deb.sh")], cwd=root,
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
package = root / f"target/deb/flectar-mail_0.1.0~alpha.1_{debian}.deb"
|
|
self.assertTrue(package.is_file())
|
|
for field, expected in (("Version", "0.1.0~alpha.1"), ("Architecture", debian)):
|
|
value = subprocess.check_output(["dpkg-deb", "--field", str(package), field], text=True)
|
|
self.assertEqual(value.strip(), expected)
|
|
extracted = root / f"extracted-{machine}"
|
|
subprocess.run(["dpkg-deb", "--extract", str(package), str(extracted)], check=True)
|
|
desktop_path = "usr/share/applications/com.flectar.mail.desktop"
|
|
metainfo_path = "usr/share/metainfo/com.flectar.mail.metainfo.xml"
|
|
desktop = (extracted / desktop_path).read_text()
|
|
self.assertIn("X-AppImage-Version=0.1.0-alpha.1\n", desktop)
|
|
self.assertIn(f"X-AppImage-Arch={machine}\n", desktop)
|
|
release = ET.parse(extracted / metainfo_path).find("./releases/release")
|
|
self.assertEqual(release.get("version"), "0.1.0-alpha.1")
|
|
self.assertEqual(release.get("type"), "development")
|
|
|
|
# Exercise the AppImage shell packager up to its tool boundary,
|
|
# without downloading deployment tools or compiling the
|
|
# application. The tools sit where the packager caches the
|
|
# downloads for this architecture.
|
|
tools = root / "target/appimage/tools"
|
|
tools.mkdir(parents=True, exist_ok=True)
|
|
deploy = tools / f"linuxdeploy-{machine}.AppImage"
|
|
deploy.write_text('''#!/usr/bin/env python3
|
|
import pathlib, shutil, sys
|
|
appdir = pathlib.Path(sys.argv[sys.argv.index("--appdir") + 1])
|
|
library = pathlib.Path(sys.argv[sys.argv.index("--library") + 1])
|
|
assert library.name == "libxkbcommon-x11.so.0"
|
|
for name in ("libxkbcommon-x11.so.0", "libxkbcommon.so.0"):
|
|
shutil.copyfile(library.parent / name, appdir / "usr/lib" / name)
|
|
(appdir / "com.flectar.mail.desktop").symlink_to("usr/share/applications/com.flectar.mail.desktop")
|
|
''')
|
|
pack = tools / f"appimagetool-{machine}.AppImage"
|
|
pack.write_text('''#!/usr/bin/env python3
|
|
import json, os, pathlib, sys
|
|
appdir = pathlib.Path(sys.argv[1])
|
|
pathlib.Path(sys.argv[2]).write_text(json.dumps({
|
|
"version": os.environ.get("VERSION"),
|
|
"desktop": (appdir / "com.flectar.mail.desktop").read_text(),
|
|
"metainfo": (appdir / "usr/share/metainfo/com.flectar.mail.metainfo.xml").read_text(),
|
|
}))
|
|
''')
|
|
deploy.chmod(0o755)
|
|
pack.chmod(0o755)
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/build-appimage.sh")], cwd=root,
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
packaged = json.loads((root / "target/appimage/flectar-mail.AppImage").read_text())
|
|
self.assertEqual(packaged["version"], "0.1.0-alpha.1")
|
|
self.assertIn("X-AppImage-Version=0.1.0-alpha.1\n", packaged["desktop"])
|
|
self.assertIn(f"X-AppImage-Arch={machine}\n", packaged["desktop"])
|
|
release = ET.fromstring(packaged["metainfo"]).find("./releases/release")
|
|
self.assertEqual(release.get("version"), "0.1.0-alpha.1")
|
|
self.assertEqual(release.get("type"), "development")
|
|
|
|
# Both packages carry this architecture's PDFium, and every
|
|
# staged ELF file is checked for the machine's page sizes.
|
|
self.assertEqual((root / "pdfium-targets.txt").read_text().split(), [pdfium, pdfium])
|
|
calls = [
|
|
json.loads(line)
|
|
for line in (root / "linux-elf-calls.jsonl").read_text().splitlines()
|
|
]
|
|
executable = str(root / "target/release/flectar-mail")
|
|
self.assertEqual(calls, [
|
|
["arch", executable],
|
|
["verify", str(root / "target/deb/debian/flectar-mail")],
|
|
["arch", executable],
|
|
["verify", str(root / "target/appimage/flectar-mail.AppDir")],
|
|
["verify", str(root / "target/appimage/.flectar-mail.AppImage.new")],
|
|
])
|
|
|
|
# Both normal package commands must include the optional runtime
|
|
# choice, even though their builds disable Cargo's default features.
|
|
invocations = [
|
|
json.loads(line)
|
|
for line in (root / "cargo-args.jsonl").read_text().splitlines()
|
|
]
|
|
self.assertEqual(len(invocations), 4)
|
|
for args in invocations:
|
|
enabled = {
|
|
feature
|
|
for index, arg in enumerate(args)
|
|
if arg == "--features"
|
|
for feature in args[index + 1].split(",")
|
|
}
|
|
self.assertTrue({"remote-content", "gpu-renderer"} <= enabled)
|
|
|
|
def test_versions_and_tags(self):
|
|
for version in ("0.1.0", "1.2.3-alpha.1", "1.2.3-beta.2", "1.2.3-rc.3"):
|
|
with self.subTest(version=version):
|
|
result = metadata(version, f"v{version}")
|
|
self.assertEqual(result["prerelease"], str("-" in version).lower())
|
|
self.assertEqual(result["native_version"], version.split("-")[0])
|
|
for version, tag in (
|
|
("0.1.0", "v0.2.0"), ("0.1.0-beta.1", "v0.1.0"),
|
|
("0.1.0-beta.01", None), ("0.1.0-beta.0", None),
|
|
("01.2.3", None), ("1.2.3-nightly", None), ("1.2.3+build", None),
|
|
):
|
|
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
|
|
metadata(version, tag)
|
|
|
|
def test_store_versions_order_like_android_version_codes(self):
|
|
versions = (
|
|
"0.1.0-alpha.8", "0.1.0-alpha.999", "0.1.0-beta.1", "0.1.0-beta.999",
|
|
"0.1.0-rc.1", "0.1.0-rc.999", "0.1.0", "0.1.1-alpha.1", "0.1.1",
|
|
"0.2.0-alpha.1", "0.99.99", "1.0.0-alpha.1", "20.99.99",
|
|
)
|
|
store = [tuple(map(int, metadata(version)["msix_version"].split("."))) for version in versions]
|
|
self.assertEqual(store, sorted(set(store)))
|
|
self.assertEqual(
|
|
sorted(versions, key=android_version_code),
|
|
sorted(versions, key=lambda version: tuple(map(int, metadata(version)["msix_version"].split(".")))),
|
|
)
|
|
# The Store reserves the last part and rejects a zero first part.
|
|
for parts in store:
|
|
self.assertEqual(len(parts), 4)
|
|
self.assertGreater(parts[0], 0)
|
|
self.assertEqual(parts[3], 0)
|
|
self.assertTrue(all(part <= 65535 for part in parts))
|
|
self.assertEqual(metadata("0.1.0-alpha.8")["msix_version"], "1.100.8.0")
|
|
self.assertEqual(metadata("0.1.0")["msix_version"], "1.100.9999.0")
|
|
for version in ("21.0.0", "0.100.0", "0.0.100", "0.1.0-alpha.1000"):
|
|
with self.subTest(version=version), self.assertRaises(ValueError):
|
|
metadata(version)
|
|
|
|
def test_store_package_layout_and_manifest(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
layout = root / "package/windows"
|
|
(layout / "LICENSES").mkdir(parents=True)
|
|
for name in ("flectar-mail.exe", "pdfium.dll", "LICENSE", "LICENSES/MIT.txt"):
|
|
(layout / name).write_text(name)
|
|
# Stand-ins for the Windows SDK tools record their arguments and
|
|
# keep what they would have packaged.
|
|
tools = root / "tools"
|
|
tools.mkdir()
|
|
calls = root / "calls.jsonl"
|
|
for name, body in (
|
|
("makepri", """
|
|
if sys.argv[1] == "createconfig":
|
|
path = Path(sys.argv[sys.argv.index("/cf") + 1])
|
|
path.write_text('<resources targetOsVersion="10.0.0" majorVersion="1"><index root="\\\\" startIndexAt="\\\\"/>'
|
|
'<packaging><autoResourcePackage qualifier="Scale"/></packaging></resources>')
|
|
else:
|
|
indexed = Path(sys.argv[sys.argv.index("/pr") + 1])
|
|
config = Path(sys.argv[sys.argv.index("/cf") + 1]).read_text()
|
|
Path(sys.argv[sys.argv.index("/of") + 1]).write_text(json.dumps({
|
|
"indexed": sorted(str(path.relative_to(indexed)) for path in indexed.rglob("*") if path.is_file()),
|
|
"config": config,
|
|
}))
|
|
"""),
|
|
("makeappx", """
|
|
if sys.argv[1] == "pack":
|
|
source = Path(sys.argv[sys.argv.index("/d") + 1])
|
|
keep = Path(os.environ["KEEP"]) / Path(sys.argv[sys.argv.index("/p") + 1]).name
|
|
shutil.copytree(source, keep)
|
|
Path(sys.argv[sys.argv.index("/p") + 1]).write_text("msix")
|
|
else:
|
|
source = Path(sys.argv[sys.argv.index("/d") + 1])
|
|
Path(sys.argv[sys.argv.index("/p") + 1]).write_text(json.dumps(sorted(path.name for path in source.iterdir())))
|
|
"""),
|
|
):
|
|
tool = tools / name
|
|
tool.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import json, os, shutil, sys\nfrom pathlib import Path\n"
|
|
f"with open({str(calls)!r}, 'a') as output:\n"
|
|
f" output.write(json.dumps([{name!r}, *sys.argv[1:]]) + '\\n')\n" + body
|
|
)
|
|
tool.chmod(0o755)
|
|
keep = root / "kept"
|
|
keep.mkdir()
|
|
environment = {**os.environ, "MAKEAPPX": str(tools / "makeappx"),
|
|
"MAKEPRI": str(tools / "makepri"), "KEEP": str(keep), "GITHUB_ACTIONS": ""}
|
|
build = [sys.executable, str(SCRIPTS / "build-msix.py")]
|
|
for arch in ("x64", "arm64"):
|
|
result = subprocess.run(
|
|
[*build, "pack", "--arch", arch, "--version", "1.100.8.0", str(layout),
|
|
str(root / f"out/flectar-mail-{arch}.msix")],
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("placeholder identity", result.stdout)
|
|
packaged = keep / f"flectar-mail-{arch}.msix"
|
|
self.assertEqual((packaged / "flectar-mail.exe").read_text(), "flectar-mail.exe")
|
|
self.assertTrue((packaged / "LICENSES/MIT.txt").is_file())
|
|
manifest = ET.parse(packaged / "AppxManifest.xml").getroot()
|
|
ns = {
|
|
"m": "http://schemas.microsoft.com/appx/manifest/foundation/windows10",
|
|
"uap": "http://schemas.microsoft.com/appx/manifest/uap/windows10",
|
|
"uap3": "http://schemas.microsoft.com/appx/manifest/uap/windows10/3",
|
|
"uap5": "http://schemas.microsoft.com/appx/manifest/uap/windows10/5",
|
|
}
|
|
identity = manifest.find("m:Identity", ns)
|
|
self.assertEqual(identity.get("Version"), "1.100.8.0")
|
|
self.assertEqual(identity.get("ProcessorArchitecture"), arch)
|
|
# Every logo the manifest names resolves to packaged variants.
|
|
visuals = manifest.find("m:Applications/m:Application/uap:VisualElements", ns)
|
|
logos = [manifest.find("m:Properties/m:Logo", ns).text,
|
|
visuals.get("Square150x150Logo"), visuals.get("Square44x44Logo")]
|
|
for logo in logos:
|
|
stem = Path(logo.replace("\\", "/"))
|
|
variants = list((packaged / stem.parent).glob(f"{stem.stem}.*.png"))
|
|
self.assertTrue(variants, logo)
|
|
# resources.pri indexes only the logos, without split resource packages.
|
|
pri = json.loads((packaged / "resources.pri").read_text())
|
|
self.assertTrue(pri["indexed"])
|
|
self.assertTrue(all(name.startswith("Assets/") for name in pri["indexed"]))
|
|
self.assertNotIn("packaging", pri["config"])
|
|
# Mail links arrive the way the app accepts them, as the
|
|
# whole command line after its option, with a packaged logo.
|
|
protocol = manifest.find(
|
|
"m:Applications/m:Application/m:Extensions/uap3:Extension/uap3:Protocol", ns,
|
|
)
|
|
self.assertEqual(protocol.get("Name"), "mailto")
|
|
option, link = protocol.get("Parameters").split(" ")
|
|
self.assertIn(
|
|
f'MAILTO_OPTION: &str = "{option}"',
|
|
(SCRIPTS.parent / "src/mailto_links.rs").read_text(),
|
|
)
|
|
self.assertEqual(link, '"%1"')
|
|
logo = Path(protocol.find("uap:Logo", ns).text.replace("\\", "/"))
|
|
self.assertTrue(list((packaged / logo.parent).glob(f"{logo.stem}.*.png")), logo)
|
|
# The startup task is the one the app enables, and its launch
|
|
# starts in the background like the other sign-in entries.
|
|
autostart = (SCRIPTS.parent / "src/autostart.rs").read_text()
|
|
extension = manifest.find("m:Applications/m:Application/m:Extensions/uap5:Extension", ns)
|
|
self.assertEqual(extension.get("Category"), "windows.startupTask")
|
|
self.assertEqual(extension.get("Executable"), "flectar-mail.exe")
|
|
task = extension.find("uap5:StartupTask", ns).get("TaskId")
|
|
self.assertIn(f'h!("{task}")', autostart)
|
|
parameters = extension.get("{http://schemas.microsoft.com/appx/manifest/uap/windows10/10}Parameters")
|
|
self.assertIn(f'BACKGROUND_ARGUMENT: &str = "{parameters}"', autostart)
|
|
|
|
result = subprocess.run(
|
|
[*build, "bundle", "--version", "1.100.8.0", str(root / "out/flectar-mail.msixbundle"),
|
|
str(root / "out/flectar-mail-x64.msix"), str(root / "out/flectar-mail-arm64.msix")],
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertEqual(json.loads((root / "out/flectar-mail.msixbundle").read_text()),
|
|
["flectar-mail-arm64.msix", "flectar-mail-x64.msix"])
|
|
bundled = [json.loads(line) for line in calls.read_text().splitlines()][-1]
|
|
self.assertEqual(bundled[:5], ["makeappx", "bundle", "/o", "/bv", "1.100.8.0"])
|
|
|
|
# The Store rejects a zero first part, a nonzero last part and parts over 65535.
|
|
for version in ("0.100.8.0", "1.100.8.1", "1.70000.8.0", "1.100.8"):
|
|
with self.subTest(version=version):
|
|
result = subprocess.run(
|
|
[*build, "pack", "--arch", "x64", "--version", version, str(layout),
|
|
str(root / "out/rejected.msix")],
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("Major.Minor.Build.0", result.stderr)
|
|
(layout / "pdfium.dll").unlink()
|
|
result = subprocess.run(
|
|
[*build, "pack", "--arch", "x64", "--version", "1.100.8.0", str(layout),
|
|
str(root / "out/incomplete.msix")],
|
|
env=environment, capture_output=True, text=True,
|
|
)
|
|
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())
|
|
|
|
@staticmethod
|
|
def write_ios_ipa(path, version, **changes):
|
|
"""A minimal IPA of `version`, as scripts/package-ios.sh stamps it."""
|
|
info = {
|
|
"CFBundleExecutable": "FlectarMail",
|
|
"CFBundleIdentifier": "com.flectar.mail",
|
|
"CFBundleShortVersionString": version.split("-")[0],
|
|
"CFBundleVersion": str(android_version_code(version)),
|
|
"MinimumOSVersion": "17.0",
|
|
"NSLocalNetworkUsageDescription": "Flectar Mail connects to mail servers on your local network.",
|
|
"UILaunchScreen": {},
|
|
**changes,
|
|
}
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(path, "w") as archive:
|
|
archive.writestr("Payload/FlectarMail.app/Info.plist", plistlib.dumps(info, fmt=plistlib.FMT_BINARY))
|
|
# A framework's Info.plist is not the app's.
|
|
archive.writestr(
|
|
"Payload/FlectarMail.app/Frameworks/PDFium.framework/Info.plist",
|
|
plistlib.dumps({"CFBundleIdentifier": "org.pdfium"}),
|
|
)
|
|
return path
|
|
|
|
def test_ios_source_describes_the_release_ipa(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
ipa = self.write_ios_ipa(Path(directory) / "app.ipa", "0.1.0-beta.1")
|
|
document = ios_release.source(ipa, "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
app = document["apps"][0]
|
|
self.assertEqual(app["versions"], [{
|
|
"version": "0.1.0",
|
|
"buildVersion": str(android_version_code("0.1.0-beta.1")),
|
|
"marketingVersion": "0.1.0-beta.1",
|
|
"date": "2026-11-20T10:00:00Z",
|
|
"localizedDescription": "Release notes: https://github.com/flectar/mail/releases/tag/v0.1.0-beta.1",
|
|
"downloadURL": "https://github.com/flectar/mail/releases/download/v0.1.0-beta.1/flectar-mail-0.1.0-beta.1-ios-arm64.ipa",
|
|
"size": ipa.stat().st_size,
|
|
"sha256": hashlib.sha256(ipa.read_bytes()).hexdigest(),
|
|
"minOSVersion": "17.0",
|
|
}])
|
|
self.assertEqual(app["bundleIdentifier"], "com.flectar.mail")
|
|
# The address people add once; the latest release always has it.
|
|
self.assertEqual(
|
|
document["sourceURL"],
|
|
"https://github.com/flectar/mail/releases/latest/download/flectar-mail-altstore-source.json",
|
|
)
|
|
# AltStore and SideStore refuse apps that ask for a permission
|
|
# their source does not list.
|
|
self.assertEqual(app["appPermissions"], {
|
|
"entitlements": [],
|
|
"privacy": {"NSLocalNetworkUsageDescription": "Flectar Mail connects to mail servers on your local network."},
|
|
})
|
|
# Releases that predate version lists read the newest version here.
|
|
latest = app["versions"][0]
|
|
for key, field in (("version", "version"), ("versionDate", "date"), ("downloadURL", "downloadURL"), ("size", "size")):
|
|
self.assertEqual(app[key], latest[field])
|
|
# Images come from the release's tag, and exist there.
|
|
prefix = "https://raw.githubusercontent.com/flectar/mail/v0.1.0-beta.1/"
|
|
shots = [shot for device in app["screenshots"].values() for shot in device]
|
|
self.assertTrue(app["screenshots"]["ipad"])
|
|
for address in (document["iconURL"], app["iconURL"], *(shot["imageURL"] for shot in shots), *app["screenshotURLs"]):
|
|
self.assertTrue(address.startswith(prefix), address)
|
|
self.assertTrue((SCRIPTS.parent / address.removeprefix(prefix)).is_file(), address)
|
|
for shot in shots:
|
|
self.assertGreater(shot["width"], 0)
|
|
self.assertGreater(shot["height"], 0)
|
|
|
|
# The IPA must belong to the release it is published with.
|
|
for change in ({"CFBundleVersion": "1"}, {"CFBundleShortVersionString": "0.2.0"}, {"CFBundleIdentifier": "com.example"}):
|
|
with self.subTest(change=change):
|
|
self.write_ios_ipa(ipa, "0.1.0-beta.1", **change)
|
|
with self.assertRaises(ValueError):
|
|
ios_release.source(ipa, "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
|
|
def test_staging_and_checksums(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source = root / "assets"
|
|
source.mkdir()
|
|
# Workflow artifacts unpack into folders named after the artifact.
|
|
names = (
|
|
"flectar-mail-linux-x64/appimage/flectar-mail.AppImage",
|
|
"flectar-mail-linux-x64/deb/flectar-mail_0.1.0~beta.1_amd64.deb",
|
|
"flectar-mail-linux-arm64/appimage/flectar-mail.AppImage",
|
|
"flectar-mail-linux-arm64/deb/flectar-mail_0.1.0~beta.1_arm64.deb",
|
|
"flectar-mail-rpm-x64/flectar-mail.rpm",
|
|
"flectar-mail-rpm-arm64/flectar-mail.rpm",
|
|
"flectar-mail-flatpak-x64/flectar-mail.flatpak",
|
|
"flectar-mail-flatpak-arm64/flectar-mail.flatpak",
|
|
"flectar-mail-windows-x64/flectar-mail-windows-x64.zip",
|
|
"flectar-mail-windows-x64/flectar-mail-windows-x64-setup.exe",
|
|
"flectar-mail-windows-arm64/flectar-mail-windows-arm64.zip",
|
|
"flectar-mail-windows-arm64/flectar-mail-windows-arm64-setup.exe",
|
|
"flectar-mail-macos-arm64/flectar-mail-macos-arm64.zip",
|
|
"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:
|
|
path = source / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(name.encode())
|
|
ipa = self.write_ios_ipa(source / "flectar-mail-ios-arm64/flectar-mail-ios-arm64.ipa", "0.1.0-beta.1")
|
|
(source / "flectar-mail-linux-x64/startup-benchmark/benchmark.json").parent.mkdir()
|
|
(source / "flectar-mail-linux-x64/startup-benchmark/benchmark.json").write_text("{}")
|
|
dist = root / "dist"
|
|
prepare(source, dist, "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
self.assertEqual(len(list(dist.iterdir())), 20)
|
|
self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64.apk").is_file())
|
|
self.assertEqual((dist / ios_release.ipa_name("0.1.0-beta.1")).read_bytes(), ipa.read_bytes())
|
|
# The AltStore source describes the staged IPA and is checksummed
|
|
# like every other download.
|
|
altstore = json.loads((dist / ios_release.SOURCE_NAME).read_text(encoding="utf-8"))
|
|
self.assertEqual(altstore["apps"][0]["versions"][0]["sha256"], hashlib.sha256(ipa.read_bytes()).hexdigest())
|
|
self.assertIn(f" {ios_release.SOURCE_NAME}\n", (dist / "SHA256SUMS").read_text())
|
|
# 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]),
|
|
("flectar-mail-0.1.0-beta.1-linux-arm64.AppImage", names[2]),
|
|
("flectar-mail-0.1.0-beta.1-linux-x64.rpm", names[4]),
|
|
("flectar-mail-0.1.0-beta.1-linux-arm64.rpm", names[5]),
|
|
("flectar-mail-0.1.0-beta.1-linux-x64.flatpak", names[6]),
|
|
("flectar-mail-0.1.0-beta.1-linux-arm64.flatpak", names[7]),
|
|
# GitHub must not rewrite a download name after we checksum it.
|
|
("flectar-mail_0.1.0.beta.1_amd64.deb", names[1]),
|
|
("flectar-mail_0.1.0.beta.1_arm64.deb", names[3]),
|
|
("flectar-mail-0.1.0-beta.1-windows-arm64.zip", names[10]),
|
|
("flectar-mail-0.1.0-beta.1-windows-arm64-setup.exe", names[11]),
|
|
):
|
|
self.assertEqual((dist / download).read_bytes(), name.encode())
|
|
for path in dist.iterdir():
|
|
self.assertRegex(path.name, r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
result = subprocess.run(
|
|
["sha256sum", "--check", "SHA256SUMS"], cwd=dist,
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
missing = source / names[-1]
|
|
missing.unlink()
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "missing", "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
self.assertFalse((root / "missing").exists())
|
|
missing.write_bytes(b"")
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "empty", "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
missing.write_bytes(b"apk")
|
|
(missing.parent / "copy").mkdir()
|
|
(missing.parent / "copy" / missing.name).write_bytes(b"duplicate")
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "duplicate", "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
shutil.rmtree(missing.parent / "copy")
|
|
|
|
# A package in another architecture's artifact does not count.
|
|
arm_rpm = source / "flectar-mail-rpm-arm64/flectar-mail.rpm"
|
|
arm_rpm.rename(source / "flectar-mail-rpm-x64/arm64.rpm")
|
|
with self.assertRaisesRegex(ValueError, "flectar-mail-rpm-arm64/flectar-mail.rpm"):
|
|
prepare(source, root / "wrong-artifact", "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
(source / "flectar-mail-rpm-x64/arm64.rpm").rename(arm_rpm)
|
|
|
|
# An IPA built for another version stops the release before
|
|
# anything is staged.
|
|
self.write_ios_ipa(ipa, "0.1.0-beta.2")
|
|
with self.assertRaisesRegex(ValueError, "CFBundleVersion"):
|
|
prepare(source, root / "other-ipa", "0.1.0-beta.1", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
self.assertFalse((root / "other-ipa").exists())
|
|
|
|
# Stable releases also require exactly one signed Android APK.
|
|
for artifact, arch in (("flectar-mail-linux-x64", "amd64"), ("flectar-mail-linux-arm64", "arm64")):
|
|
folder = source / artifact / "deb"
|
|
(folder / f"flectar-mail_0.1.0~beta.1_{arch}.deb").rename(folder / f"flectar-mail_0.1.0_{arch}.deb")
|
|
self.write_ios_ipa(source / "flectar-mail-ios-arm64/flectar-mail-ios-arm64.ipa", "0.1.0")
|
|
stable_dist = root / "stable"
|
|
prepare(source, stable_dist, "0.1.0", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
self.assertEqual(len(list(stable_dist.iterdir())), 20)
|
|
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())
|
|
missing.unlink()
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "missing-stable", "0.1.0", "flectar/mail", "2026-11-20T10:00:00Z")
|
|
|
|
def test_notes_distinguish_android_preview(self):
|
|
for version in ("0.1.0-beta.1", "0.1.0"):
|
|
self.assertIn("previews and stable releases share one signing", release_assets.release_notes(version))
|
|
self.assertIn("does not receive Flathub updates", release_assets.release_notes("0.1.0-beta.1"))
|
|
self.assertIn("build-provenance attestations", release_assets.release_notes("0.1.0-beta.1"))
|
|
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"))
|
|
self.assertIn(ios_release.source_address("flectar/mail"), release_assets.release_notes("0.1.0-beta.1"))
|
|
|
|
def test_metadata_rejects_stale_android_lockfile(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "platform/android").mkdir(parents=True)
|
|
(root / "Cargo.toml").write_text('[package]\nversion = "0.2.0-beta.1"\n')
|
|
lock = '[[package]]\nname = "flectar-mail"\nversion = "0.2.0-beta.1"\n'
|
|
(root / "Cargo.lock").write_text(lock)
|
|
android_lock = root / "platform/android/Cargo.lock"
|
|
android_lock.write_text(lock.replace("0.2.0-beta.1", "0.1.0"))
|
|
command = [sys.executable, str(SCRIPTS / "release-metadata.py"), "--tag", "v0.2.0-beta.1"]
|
|
result = subprocess.run(command, cwd=root, capture_output=True, text=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("platform/android/Cargo.lock", result.stderr)
|
|
android_lock.write_text(lock)
|
|
result = subprocess.run(command, cwd=root, capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("prerelease=true", result.stdout)
|
|
|
|
def publish(self, *, tag="v0.1.0-beta.1", existing="missing", fail_upload=False):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "dist").mkdir()
|
|
(root / "dist" / "package.zip").write_bytes(b"package")
|
|
(root / "release-notes.md").write_text("Notes")
|
|
gh = root / "gh"
|
|
gh.write_text('''#!/usr/bin/env python3
|
|
import json, os, sys
|
|
with open("calls.jsonl", "a") as stream:
|
|
stream.write(json.dumps(sys.argv[1:]) + "\\n")
|
|
command = sys.argv[2]
|
|
if command == "view":
|
|
existing = os.environ["EXISTING"]
|
|
if existing == "missing":
|
|
sys.exit(1)
|
|
print(existing)
|
|
if command == "upload" and os.environ["FAIL_UPLOAD"] == "true":
|
|
sys.exit(1)
|
|
''')
|
|
gh.chmod(0o755)
|
|
result = subprocess.run(
|
|
["bash", str(SCRIPTS / "publish-release.sh")], cwd=root,
|
|
env={**os.environ, "PATH": f"{root}:{os.environ['PATH']}",
|
|
"GITHUB_REF_NAME": tag, "EXISTING": existing,
|
|
"FAIL_UPLOAD": str(fail_upload).lower()},
|
|
capture_output=True, text=True,
|
|
)
|
|
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
|
|
return result, calls
|
|
|
|
def test_previews_and_stable_releases_publish_as_the_latest(self):
|
|
# The latest-download links, such as the AltStore source's address,
|
|
# must reach every new release.
|
|
for tag in ("v0.1.0-beta.1", "v0.1.0"):
|
|
with self.subTest(tag=tag):
|
|
result, calls = self.publish(tag=tag)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual([c[1] for c in calls], ["view", "create", "upload", "edit"])
|
|
self.assertIn("--draft", calls[1])
|
|
self.assertFalse(any(argument.startswith("--prerelease") for argument in calls[1]))
|
|
self.assertIn("--prerelease=false", calls[-1])
|
|
self.assertIn("--latest=true", calls[-1])
|
|
self.assertIn("--draft=false", calls[-1])
|
|
|
|
def test_failed_upload_does_not_publish(self):
|
|
result, calls = self.publish(fail_upload=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertNotIn("edit", [c[1] for c in calls])
|
|
|
|
def test_resume_draft_and_reject_published_replacement(self):
|
|
result, calls = self.publish(existing="true")
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual([c[1] for c in calls], ["view", "upload", "edit"])
|
|
result, calls = self.publish(existing="false")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual([c[1] for c in calls], ["view"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|