diff --git a/docs/development.md b/docs/development.md index 29969a3..45f2593 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,3 +50,10 @@ are system-wide while the test runs: use a disposable Windows runner with admin rights, not a workstation with an active VPN. A failed baseline loopback check is an error, not a skip. The configuration suite covers all 24 combinations of route mode, profile/system permissions and the blocking option, including DNS policy. + +Release assets after 0.6.0 contain the installer, the complete application ZIP, +the source ZIP and verification metadata. Both native cores are already in the +application ZIP and installer; no separate core ZIPs are published. The signed +manifest retains `sing-box` and `sing-box-h3` component inventories for older +reboot-update workers, referencing the same application ZIP. Release retries and +changelog baseline selection still recognize the old asset layout through 0.6.0. diff --git a/scripts/package.py b/scripts/package.py index 9723038..aaf83f2 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -118,6 +118,23 @@ def validate_native_payload(payload): raise ValueError("Legacy sing-box binaries must not be packaged.") +def payload_components(payload, staging, app_version, runtime_files, legacy_runtime=False): + app_name = f"WindowsMegaProxy-{app_version}-win-x64-app.zip" + app_files = archive(payload, staging / app_name, exclude=("dependencies",) if legacy_runtime else ()) + components = {"app": {"version": app_version, "file": app_name, "files": app_files + runtime_files}} + for name, core_version in (("sing-box", CORE_VERSION), ("sing-box-h3", H3_CORE_VERSION)): + prefix = "dependencies/" + name + "/" + if legacy_runtime: + filename = f"WindowsMegaProxy-{app_version}-win-x64-{name}.zip" + files = archive(payload / "dependencies" / name, staging / filename, prefix=prefix) + else: + # Older reboot workers still require both core file inventories. + filename = app_name + files = [entry for entry in app_files if entry["path"].startswith(prefix)] + components[name] = {"version": core_version, "file": filename, "files": files} + return components + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--preview", action="store_true", help="Use a temporary signing key; never publish as a production update.") @@ -224,18 +241,7 @@ def main(): with zipfile.ZipFile(wintun) as upstream: license_file = next(name for name in upstream.namelist() if name.endswith("/LICENSE.txt")) (payload / "licenses/Wintun-LICENSE.txt").write_bytes(upstream.read(license_file)) - components = {} - for component, directory, prefix, exclude, component_version in ( - ("app", payload, "", ("dependencies",) if legacy_runtime else (), app_version), - ("sing-box", payload / "dependencies/sing-box", "dependencies/sing-box/", (), CORE_VERSION), - ("sing-box-h3", payload / "dependencies/sing-box-h3", "dependencies/sing-box-h3/", (), H3_CORE_VERSION), - ): - filename = f"WindowsMegaProxy-{app_version}-win-x64-{component}.zip" - files = archive(directory, staging / filename, prefix=prefix, exclude=exclude) - if component == "app": - # Installed file inventory includes downloaded runtime files for old boot-update workers. - files.extend(runtime_files) - components[component] = {"version": component_version, "file": filename, "files": files} + components = payload_components(payload, staging, app_version, runtime_files, legacy_runtime) definitions = { "AppVersion": app_version, "VersionDirectory": f"{app_version}-{commit[:12]}", "RepositoryRoot": str(ROOT), "PayloadDirectory": str(payload), "ReleaseDirectory": str(staging), @@ -319,7 +325,7 @@ def main(): "authenticodeCertificateSha256": signer.fingerprint, "runtime": {"version": runtime["version"], "url": runtime_file["url"], "sha512": runtime_file["hash"]} if legacy_runtime else runtime} signed_manifest(manifest, staging, key) - names = [item["file"] for item in components.values()] + ["update-manifest.json", "update-manifest.sig", "update-public-key.txt"] + names = sorted({item["file"] for item in components.values()}) + ["update-manifest.json", "update-manifest.sig", "update-public-key.txt"] checksums(staging, names) for name in (*names, "SHA256SUMS"): shutil.copy2(staging / name, output / name) diff --git a/scripts/publish_release.py b/scripts/publish_release.py index 37a8cb7..170b6ed 100644 --- a/scripts/publish_release.py +++ b/scripts/publish_release.py @@ -58,11 +58,16 @@ def release_state(repo, tag): return state -def package_names(tag): +def package_names(tag, *, bundled=None): version = tag[1:] prefix = f"WindowsMegaProxy-{version}" + # Published releases through 0.6.0 have separate core assets and remain immutable. + if bundled is None: + bundled = tuple(map(int, version.split("."))) > (0, 6, 0) + app = prefix + "-win-x64-app.zip" return {"app": prefix + "-win-x64-app.zip", "installer": prefix + "-win-x64-setup.exe", - "sing-box": prefix + "-win-x64-sing-box.zip", "sing-box-h3": prefix + "-win-x64-sing-box-h3.zip", + "sing-box": app if bundled else prefix + "-win-x64-sing-box.zip", + "sing-box-h3": app if bundled else prefix + "-win-x64-sing-box-h3.zip", "source": prefix + "-source.zip"} @@ -81,7 +86,8 @@ def verify(directory, tag, commit): if manifest.get("signingKeyId") != hashlib.sha256(public).hexdigest(): raise RuntimeError("Release signing key ID differs from the public key") Ed25519PublicKey.from_public_bytes(public).verify((directory / "update-manifest.sig").read_bytes(), manifest_bytes) - names = package_names(tag) + components = manifest["components"] + names = package_names(tag, bundled=components["sing-box"]["file"] == components["app"]["file"]) if set(manifest["components"]) != set(names): raise RuntimeError("Unexpected release components") for component, name in names.items(): diff --git a/scripts/test_package.py b/scripts/test_package.py index 590228e..3a9f202 100644 --- a/scripts/test_package.py +++ b/scripts/test_package.py @@ -134,6 +134,23 @@ def test_app_and_core_archives_have_independent_root_relative_paths(self): with zipfile.ZipFile(root / "core.zip") as output: self.assertEqual(output.namelist(), ["dependencies/sing-box/libcronet.dll"]) + def test_bundled_cores_keep_inventories_without_duplicate_archives(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + payload = root / "payload" + for name in ("sing-box", "sing-box-h3"): + directory = payload / "dependencies" / name + directory.mkdir(parents=True) + (directory / "core.dll").write_bytes(name.encode()) + (payload / "app.exe").write_bytes(b"app") + components = package.payload_components(payload, root, "0.6.1", []) + self.assertEqual(len(list(root.glob("*.zip"))), 1) + for name in ("sing-box", "sing-box-h3"): + self.assertEqual(components[name]["file"], components["app"]["file"]) + self.assertEqual(components[name]["files"], [entry for entry in components["app"]["files"] + if entry["path"].startswith("dependencies/" + name + "/")]) + self.assertEqual(len(components[name]["files"]), 1) + def test_signature_authenticates_exact_manifest_bytes(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/scripts/test_release.py b/scripts/test_release.py index a7bac78..a507946 100644 --- a/scripts/test_release.py +++ b/scripts/test_release.py @@ -46,6 +46,22 @@ def row(path, malicious=0): @patch.dict("os.environ", {"GITHUB_STEP_SUMMARY": ""}) class VirusTotalTests(unittest.TestCase): + def test_compact_manifest_and_legacy_assets(self): + self.assertEqual(len(set(release.package_names("v0.6.0").values())), 5) + self.assertEqual(len(set(release.package_names("v0.6.1").values())), 3) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fixture(root) + manifest = json.loads((root / "update-manifest.json").read_bytes()) + for name in ("sing-box", "sing-box-h3"): + (root / manifest["components"][name]["file"]).unlink() + manifest["components"][name] = dict(manifest["components"]["app"], version="core-version") + signed_manifest(manifest, root, Ed25519PrivateKey.generate()) + names = set(release.package_names(TAG, bundled=True).values()) | release.METADATA + checksums(root, sorted(names - {"SHA256SUMS"})) + self.assertEqual(release.verify(root, TAG, COMMIT), names) + + def test_release_limit_counts_malicious_and_suspicious_per_file(self): for malicious, suspicious in ((0, 0), (10, 0), (9, 1), (0, 10), (10, 1), (11, 0), (0, 11)): with self.subTest(malicious=malicious, suspicious=suspicious), tempfile.TemporaryDirectory() as temporary: diff --git a/scripts/test_release_notes.py b/scripts/test_release_notes.py index 7cc08df..ac5cc8b 100644 --- a/scripts/test_release_notes.py +++ b/scripts/test_release_notes.py @@ -14,6 +14,12 @@ def published(tag, date="2026-11-01T12:00:00Z"): class ReleaseNotesTests(unittest.TestCase): + def test_compact_release_is_a_valid_baseline(self): + release = published("v0.6.1") + self.assertFalse(any("sing-box" in asset["name"] for asset in release["assets"])) + with patch.object(notes, "gh", return_value=json.dumps([[release]])), patch.object(notes.sp, "run", return_value=Mock(returncode=0)): + self.assertEqual(notes.previous_release("0.7.0"), "v0.6.1") + def test_no_releases_is_the_first_release(self): with patch.object(notes, "gh", return_value="[[]]"), patch.object(notes.sp, "run") as run: self.assertIsNone(notes.previous_release("0.0.1"))