mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
369 lines
19 KiB
Python
369 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise release validation and publishing without contacting GitHub."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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
|
|
linux_metadata = load_script("stage-linux-metadata")
|
|
stage_linux_metadata = linux_metadata.stage
|
|
|
|
|
|
class ReleaseTests(unittest.TestCase):
|
|
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)
|
|
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"
|
|
)
|
|
with self.subTest(subject=subject):
|
|
verify_android_signing(signing, "test", digest.upper())
|
|
for expected in (None, "", "invalid", "cd" * 32):
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing, "test", expected)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing, "production")
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing.replace("signers: 1", "signers: 2"), "test", digest)
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(signing.replace(": true", ": false"), "test", digest)
|
|
production = signing.replace("CN=Flectar Mail Test", "CN=Flectar")
|
|
verify_android_signing(production, "production")
|
|
with self.assertRaises(ValueError):
|
|
verify_android_signing(production.replace("CN=Flectar", "CN=Android Debug"), "production")
|
|
|
|
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())
|
|
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("#!/bin/sh\nexit 0\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']}"},
|
|
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.
|
|
deploy = root / "bin/linuxdeploy"
|
|
deploy.write_text('''#!/usr/bin/env python3
|
|
import pathlib, sys
|
|
appdir = pathlib.Path(sys.argv[sys.argv.index("--appdir") + 1])
|
|
(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"},
|
|
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")
|
|
|
|
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",
|
|
"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",
|
|
"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())), 8)
|
|
self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64-test.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 keep the six desktop downloads, even if the
|
|
# input folder happens to contain test APKs from another step.
|
|
(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())), 7)
|
|
self.assertTrue((stable_dist / "flectar-mail_0.1.0_amd64.deb").is_file())
|
|
self.assertFalse(list(stable_dist.glob("*.apk")))
|
|
|
|
def test_notes_distinguish_android_preview(self):
|
|
self.assertIn("test-signed", release_assets.release_notes("0.1.0-beta.1"))
|
|
self.assertIn("prereleases only", release_assets.release_notes("0.1.0"))
|
|
|
|
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()
|