mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
633 lines
33 KiB
Python
633 lines
33 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 shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import tomllib
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
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")
|
|
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,
|
|
)
|
|
self.assertRegex(
|
|
manifest,
|
|
r"(?s)url: .*pdfium-linux-x64\.tgz\n"
|
|
r"\s+sha256: [0-9a-f]{64}\n"
|
|
r"\s+dest: pdfium-runtime\n"
|
|
r"\s+strip-components: 0\n",
|
|
)
|
|
|
|
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)
|
|
desktop = (destination / "usr/share/applications/com.flectar.mail.desktop").read_text()
|
|
self.assertIn(f"X-AppImage-Version={version}\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)
|
|
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)
|
|
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_linux_package_metadata(self):
|
|
# Exercise the actual shell packager 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"
|
|
"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"
|
|
)
|
|
(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)
|
|
result = subprocess.run(
|
|
["bash", str(root / "scripts/build-deb.sh")], cwd=root,
|
|
env={**os.environ, "PATH": f"{root / 'bin'}:{os.environ['PATH']}",
|
|
"FLECTAR_SKIP_BUILD": "0", "FLECTAR_APP_FEATURES": ""},
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
package = root / "target/deb/flectar-mail_0.1.0~alpha.1_amd64.deb"
|
|
self.assertTrue(package.is_file())
|
|
version = subprocess.check_output(["dpkg-deb", "--field", str(package), "Version"], text=True)
|
|
self.assertEqual(version.strip(), "0.1.0~alpha.1")
|
|
extracted = root / "extracted"
|
|
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"
|
|
self.assertIn("X-AppImage-Version=0.1.0-alpha.1\n", (extracted / desktop_path).read_text())
|
|
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.
|
|
# 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)
|
|
deploy = root / "bin/linuxdeploy"
|
|
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 = root / "bin/appimagetool"
|
|
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={**os.environ, "PATH": f"{root / 'bin'}:{os.environ['PATH']}",
|
|
"LINUXDEPLOY": str(deploy), "APPIMAGETOOL": str(pack), "VERSION": "stale",
|
|
"FLECTAR_SKIP_BUILD": "0", "FLECTAR_APP_FEATURES": ""},
|
|
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"])
|
|
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 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), 2)
|
|
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_staging_and_checksums(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source = root / "assets"
|
|
source.mkdir()
|
|
names = (
|
|
"linux/appimage/flectar-mail.AppImage",
|
|
"linux/deb/flectar-mail_0.1.0~beta.1_amd64.deb",
|
|
"linux/rpm/flectar-mail.rpm",
|
|
"linux/flatpak/flectar-mail.flatpak",
|
|
"windows/flectar-mail-windows-x64.zip",
|
|
"windows/flectar-mail-windows-x64-setup.exe",
|
|
"macos/flectar-mail-macos-arm64.zip",
|
|
"macos/flectar-mail-macos-arm64.dmg",
|
|
"macos/flectar-mail-macos-x64.zip",
|
|
"macos/flectar-mail-macos-x64.dmg",
|
|
"android/flectar-mail.apk",
|
|
)
|
|
for name in names:
|
|
path = source / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(name.encode())
|
|
(source / "benchmark.json").write_text("{}")
|
|
dist = root / "dist"
|
|
prepare(source, dist, "0.1.0-beta.1")
|
|
self.assertEqual(len(list(dist.iterdir())), 12)
|
|
self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64.apk").is_file())
|
|
# GitHub must not rewrite a download name after we checksum it.
|
|
deb = dist / "flectar-mail_0.1.0.beta.1_amd64.deb"
|
|
self.assertEqual(deb.read_bytes(), (source / names[1]).read_bytes())
|
|
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")
|
|
self.assertFalse((root / "missing").exists())
|
|
missing.write_bytes(b"")
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "empty", "0.1.0-beta.1")
|
|
missing.write_bytes(b"dmg")
|
|
(source / missing.name).write_bytes(b"duplicate")
|
|
with self.assertRaises(ValueError):
|
|
prepare(source, root / "duplicate", "0.1.0-beta.1")
|
|
|
|
# Stable releases also require exactly one signed Android APK.
|
|
(source / missing.name).unlink()
|
|
(source / "linux/deb/flectar-mail_0.1.0~beta.1_amd64.deb").rename(
|
|
source / "linux/deb/flectar-mail_0.1.0_amd64.deb"
|
|
)
|
|
stable_dist = root / "stable"
|
|
prepare(source, stable_dist, "0.1.0")
|
|
self.assertEqual(len(list(stable_dist.iterdir())), 12)
|
|
self.assertTrue((stable_dist / "flectar-mail_0.1.0_amd64.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")
|
|
|
|
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"))
|
|
|
|
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, *, existing="missing", prerelease="true", 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": "v0.1.0-beta.1" if prerelease == "true" else "v0.1.0",
|
|
"RELEASE_PRERELEASE": prerelease, "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_preview_and_stable_publication(self):
|
|
for prerelease in ("true", "false"):
|
|
with self.subTest(prerelease=prerelease):
|
|
result, calls = self.publish(prerelease=prerelease)
|
|
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.assertIn(f"--prerelease={prerelease}", calls[-1])
|
|
self.assertIn("--latest=" + str(prerelease == "false").lower(), 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()
|