Files
shoes/scripts/test-release-notices.py
cfal abf24533a9 remove unused mobile SDK wrappers (#207)
* build: remove distributable mobile SDK wrappers

Remove the Android Kotlin/Gradle wrapper, AAR and XCFramework packaging, and mobile SDK release publishing.

Preserve Rust JNI/C embedding and native cross-compilation CI, with checks for library outputs and the generated C header. Document app-owned integration and retain desktop release-notice verification.

* ci: verify mobile header after locked native builds

cbindgen resolves Cargo metadata without --locked and can update Cargo.lock. Run it after the native builds so it cannot hide a stale lockfile from their checks.

* docs: remove obsolete mobile wrapper examples

Remove examples referencing nonexistent JNI wrapper methods and clarify the app-owned callback contract. Keep native mobile support unchanged.

* docs: clarify native mobile CI coverage
2026-10-22 03:08:57 -04:00

150 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Exercises dependency-notice generation and release tarball verification."""
import importlib.util
import io
import json
from pathlib import Path
import re
import subprocess
import tarfile
import tempfile
import unittest
from unittest import mock
SPEC = importlib.util.spec_from_file_location(
"notices", Path(__file__).with_name("check-release-notices.py")
)
NOTICES = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(NOTICES)
GENERATOR_SPEC = importlib.util.spec_from_file_location(
"generate_notices", Path(__file__).with_name("generate-release-notices.py")
)
GENERATOR = importlib.util.module_from_spec(GENERATOR_SPEC)
GENERATOR_SPEC.loader.exec_module(GENERATOR)
# SPDX's ISC fallback names its publisher rather than the dependency's authors.
PLACEHOLDER_ATTRIBUTION = re.compile(
r"<(?:year|copyright holders?|owner)>|YEAR by AUTHOR EMAIL"
r"|Copyright \(c\) 2004-2010 by Internet Systems Consortium, Inc\.",
re.IGNORECASE,
)
class ReleaseNoticesTests(unittest.TestCase):
def test_generation_preserves_bundle_on_warning_or_failure(self):
for status, stderr in [(0, ""), (0, "WARN checksum mismatch"), (1, ""), (1, "failed")]:
with self.subTest(status=status, stderr=stderr), tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "dependencies.txt"
output.write_text("original")
def run(command, **kwargs):
Path(command[-1]).write_text("generated")
return subprocess.CompletedProcess(command, status, stderr=stderr)
with mock.patch.object(GENERATOR.subprocess, "run", side_effect=run):
if status or stderr:
with self.assertRaises(RuntimeError):
GENERATOR.generate(output)
self.assertEqual(output.read_text(), "original")
else:
GENERATOR.generate(output)
self.assertEqual(output.read_text(), "generated")
def test_bundle_has_no_placeholder_attributions(self):
bundle = (NOTICES.ROOT / "licenses/dependencies.txt").read_text()
for block in bundle.split("\nLicense: ")[1:]:
heading = block.splitlines()[0]
if heading.endswith(("(MIT)", "(BSD-2-Clause)", "(BSD-3-Clause)", "(ISC)", "(0BSD)")):
with self.subTest(license=heading):
text = block.partition("\n\n")[2]
self.assertNotRegex(text, PLACEHOLDER_ATTRIBUTION)
def test_placeholder_detection_includes_spdx_isc_publisher(self):
for text in [
"Copyright (c) <year> <copyright holders>",
"Copyright (c) <year> <owner>",
"Copyright (C) YEAR by AUTHOR EMAIL",
'Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")\n'
"Copyright (c) 1995-2003 by Internet Software Consortium",
]:
with self.subTest(text=text):
self.assertRegex(text, PLACEHOLDER_ATTRIBUTION)
def test_bundle_covers_production_dependency_graph(self):
cargo_options = [
"--locked", "--offline", "--all-features",
"--manifest-path", str(NOTICES.ROOT / "Cargo.toml"),
]
# Cargo tree excludes features enabled only by dev dependencies, unlike metadata.
tree = subprocess.check_output([
"cargo", "tree", *cargo_options, "--target", "all",
"--edges", "normal,build", "--prefix", "none", "--format", "{p}",
], text=True)
production = {tuple(line.split()[:2]) for line in tree.splitlines()}
metadata = json.loads(subprocess.check_output([
"cargo", "metadata", *cargo_options, "--format-version", "1",
]))
gotatun_versions = [p["version"] for p in metadata["packages"] if p["name"] == "gotatun"]
self.assertEqual(len(gotatun_versions), 1)
version = gotatun_versions[0]
source_notice = (NOTICES.ROOT / "THIRD_PARTY_NOTICES.md").read_text()
for required in [
f"GotaTun {version},",
f"https://crates.io/api/v1/crates/gotatun/{version}/download",
f"https://github.com/mullvad/gotatun/tree/v{version}",
]:
self.assertIn(required, source_notice)
bundle = (NOTICES.ROOT / "licenses/dependencies.txt").read_text()
original_notices = {
"curve25519-dalek": ["LICENSE"], "x25519-dalek": ["LICENSE"],
"ip_network": ["LICENSE"], "ip_network_table": ["LICENSE"],
"aws-lc-sys": ["LICENSE"], "aws-lc-rs": ["LICENSE"],
"tikv-jemalloc-sys": ["jemalloc/COPYING"], "moka": ["NOTICE"],
"ring": [
"LICENSE-BoringSSL", "LICENSE-other-bits",
"src/polyfill/once_cell/LICENSE-MIT", "third_party/fiat/LICENSE",
],
"h3": ["../LICENSE"], "h3-quinn": ["../LICENSE"],
}
for package in metadata["packages"]:
if (package["name"], "v" + package["version"]) not in production:
continue
if package["source"] is not None:
entry = f"Package: {package['name']} {package['version']}\n"
self.assertTrue(entry in bundle, f"Missing {entry.strip()}")
for notice in original_notices.get(package["name"], []):
source_license = Path(package["manifest_path"]).parent / notice
self.assertTrue(
source_license.read_text().strip() in bundle,
f"Missing original {notice} for {package['name']}",
)
def test_artifacts_reject_missing_and_modified_notices(self):
expected = NOTICES.expected_notices()
for kind, mode in [("tar", "w"), ("tar.gz", "w:gz")]:
for mutation in [None, "missing", "modified"]:
with self.subTest(kind=kind, mutation=mutation), tempfile.TemporaryDirectory() as tmp:
entries = expected.copy()
name = "licenses/dependencies.txt"
if mutation == "missing":
del entries[name]
elif mutation == "modified":
entries[name] = b"incomplete notice"
artifact = Path(tmp) / ("release." + kind)
with tarfile.open(artifact, mode) as archive:
for name, contents in entries.items():
info = tarfile.TarInfo(name)
info.size = len(contents)
archive.addfile(info, io.BytesIO(contents))
if mutation is None:
NOTICES.check(artifact)
else:
with self.assertRaises((KeyError, ValueError)):
NOTICES.check(artifact)
if __name__ == "__main__":
unittest.main()