test_release.py (11490B)
1 import hashlib 2 import importlib.util 3 import json 4 import os 5 from pathlib import Path 6 import platform 7 import shutil 8 import subprocess 9 import tarfile 10 import tempfile 11 import unittest 12 from unittest.mock import patch 13 14 15 ROOT = Path(__file__).resolve().parent.parent 16 SPEC = importlib.util.spec_from_file_location("package", ROOT / "scripts/package.py") 17 PACKAGE = importlib.util.module_from_spec(SPEC) 18 SPEC.loader.exec_module(PACKAGE) 19 ARCHIVES = [f"musllvm-{arch}-linux-musl-host-x86_64-linux.tar.xz" for arch in ("x86_64", "aarch64")] 20 FAKE_GH = r'''#!/usr/bin/env python3 21 import json 22 import os 23 import sys 24 from pathlib import Path 25 26 args = sys.argv[1:] 27 with open(os.environ["FAKE_GH_LOG"], "a") as stream: 28 stream.write(json.dumps(args) + "\n") 29 action = args[1] 30 if action == os.environ.get("FAKE_GH_FAIL"): 31 sys.exit(1) 32 if action == "view": 33 state = os.environ.get("FAKE_RELEASE", "missing") 34 if state == "missing": 35 sys.exit("release not found") 36 print("true" if state == "draft" else "false") 37 elif action in ("create", "upload"): 38 for name in ("SHA256SUMS", "sources.list"): 39 if not Path(name).is_file(): 40 sys.exit(2) 41 ''' 42 FAKE_GIT = r'''#!/usr/bin/env python3 43 import os 44 import sys 45 if "rev-parse" in sys.argv: 46 print(os.environ["FAKE_GIT_SHA"]) 47 else: 48 print("2026-10-12") 49 ''' 50 51 52 class PackageTests(unittest.TestCase): 53 def setUp(self): 54 self.temporary = tempfile.TemporaryDirectory(prefix="musllvm-package-test-") 55 self.addCleanup(self.temporary.cleanup) 56 self.root = Path(self.temporary.name) 57 self.prefix = self.root / "original" 58 self.output = self.root / "releases" 59 self.target = "x86_64-linux-musl" 60 for relative in ("host/bin/clang", "host/bin/ld.lld", "bin/" + self.target + "-cc", 61 "sysroot/usr/lib/libc.so", "share/musllvm/resource-version", ".musllvm-config"): 62 path = self.prefix / relative 63 path.parent.mkdir(parents=True, exist_ok=True) 64 path.write_text("fixture\n") 65 (self.prefix / "host/bin/clang").chmod(0o755) 66 (self.prefix / "libexec").mkdir() 67 (self.prefix / "libexec/clang").symlink_to("../host/bin/clang") 68 (self.prefix / "sysroot/lib").mkdir() 69 (self.prefix / "sysroot/lib/ld-musl-x86_64.so.1").symlink_to("../usr/lib/libc.so") 70 self.sources = self.root / "sources.list" 71 self.sources.write_text("llvm llvm-test.tar.xz " + "0" * 64 + " https://example.invalid/llvm\n") 72 self.source_root = self.root / "src" 73 for relative in ("llvm-test/LICENSE.TXT", "llvm-test/libcxx/LICENSE.TXT", 74 "llvm-test/LICENSES/exceptions/test-exception"): 75 path = self.source_root / relative 76 path.parent.mkdir(parents=True, exist_ok=True) 77 path.write_text(relative + "\n") 78 79 def package(self, target=None): 80 return PACKAGE.package(self.prefix, target or self.target, self.sources, self.output, self.source_root) 81 82 def test_archive_preserves_links_modes_and_source_metadata(self): 83 archive = self.package() 84 digest, filename = archive.with_name(archive.name + ".sha256").read_text().split() 85 self.assertEqual(digest, hashlib.sha256(archive.read_bytes()).hexdigest()) 86 self.assertEqual(filename, archive.name) 87 self.assertIn("host-" + platform.machine() + "-linux", filename) 88 root = "musllvm-" + self.target 89 with tarfile.open(archive) as stream: 90 self.assertEqual(stream.getmember(root + "/libexec/clang").linkname, "../host/bin/clang") 91 self.assertEqual(stream.getmember(root + "/host/bin/clang").mode, 0o755) 92 self.assertNotIn(root + "/.musllvm-config", stream.getnames()) 93 self.assertEqual(stream.extractfile(root + "/share/musllvm/sources.list").read(), self.sources.read_bytes()) 94 metadata = json.load(stream.extractfile(root + "/share/musllvm/build-info.json")) 95 self.assertEqual(metadata["target"], self.target) 96 self.assertEqual(metadata["llvm_mode"], "source") 97 self.assertEqual(metadata["source_manifest_sha256"], hashlib.sha256(self.sources.read_bytes()).hexdigest()) 98 for relative in ("LICENSE.TXT", "libcxx/LICENSE.TXT", "LICENSES/exceptions/test-exception"): 99 self.assertEqual(stream.extractfile(root + "/share/musllvm/licenses/llvm/" + relative).read(), 100 (self.source_root / "llvm-test" / relative).read_bytes()) 101 self.assertEqual(len({member.mtime for member in stream}), 1) 102 extracted = self.root / "unpacked" 103 stream.extractall(extracted, filter="data") 104 relocated = extracted / root 105 self.assertEqual((relocated / "libexec/clang").resolve(), relocated / "host/bin/clang") 106 self.assertEqual((relocated / "sysroot/lib/ld-musl-x86_64.so.1").read_bytes(), b"fixture\n") 107 108 def test_archive_is_independent_of_file_timestamps(self): 109 archive = self.package() 110 first = archive.read_bytes() 111 os.utime(self.prefix / "host/bin/clang", (1, 1)) 112 self.assertEqual(self.package().read_bytes(), first) 113 114 def test_absolute_or_escaping_links_are_rejected(self): 115 external = self.root / "external" 116 external.write_text("fixture") 117 link = self.prefix / "libexec/bad" 118 for destination in (str(external), str(self.prefix / "host/bin/clang"), "../../external", "missing"): 119 with self.subTest(destination=destination): 120 link.symlink_to(destination) 121 with self.assertRaises(ValueError): 122 self.package() 123 link.unlink() 124 self.assertFalse(self.output.exists()) 125 126 def test_incomplete_toolchains_and_unsafe_targets_are_rejected(self): 127 (self.prefix / "host/bin/ld.lld").unlink() 128 with self.assertRaisesRegex(ValueError, "incomplete toolchain"): 129 self.package() 130 with self.assertRaisesRegex(ValueError, "invalid target"): 131 self.package("../../escape") 132 133 def test_failed_packaging_keeps_the_existing_archive(self): 134 archive = self.package() 135 first = archive.read_bytes() 136 with patch.object(PACKAGE.tarfile, "open", side_effect=OSError("fixture write failure")): 137 with self.assertRaises(OSError): 138 self.package() 139 self.assertEqual(archive.read_bytes(), first) 140 self.assertFalse(list(self.output.glob(".package-*"))) 141 142 def test_new_core_component_keeps_its_license_and_requires_its_source(self): 143 with self.sources.open("a") as stream: 144 stream.write("pkgconf pkgconf-test.tar.gz " + "1" * 64 + " https://example.invalid/pkgconf\n") 145 with self.assertRaisesRegex(ValueError, "missing extracted source"): 146 self.package() 147 directory = self.source_root / "pkgconf-test" 148 directory.mkdir() 149 (directory / "COPYING").write_text("pkgconf license fixture\n") 150 with tarfile.open(self.package()) as stream: 151 self.assertEqual(stream.extractfile("musllvm-" + self.target + "/share/musllvm/licenses/pkgconf/COPYING").read(), 152 b"pkgconf license fixture\n") 153 154 155 class PublishTests(unittest.TestCase): 156 def setUp(self): 157 self.temporary = tempfile.TemporaryDirectory(prefix="musllvm-publish-test-") 158 self.addCleanup(self.temporary.cleanup) 159 self.root = Path(self.temporary.name) 160 (self.root / "scripts").mkdir() 161 shutil.copyfile(ROOT / "scripts/publish.sh", self.root / "scripts/publish.sh") 162 (self.root / "sources.list").write_text("# component archive sha256 url\nllvm llvm-test.tar.xz digest url\npkgconf pkgconf-test.tar.xz digest url\n") 163 self.output = self.root / "build/releases" 164 self.output.mkdir(parents=True) 165 for name in ARCHIVES: 166 content = name.encode() 167 (self.output / name).write_bytes(content) 168 (self.output / (name + ".sha256")).write_text(hashlib.sha256(content).hexdigest() + " " + name + "\n") 169 tools = self.root / "tools" 170 tools.mkdir() 171 for name, content in (("gh", FAKE_GH), ("git", FAKE_GIT)): 172 path = tools / name 173 path.write_text(content) 174 path.chmod(0o755) 175 self.log = self.root / "gh.jsonl" 176 self.env = {**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"], 177 "GITHUB_SHA": "1" * 40, "FAKE_GIT_SHA": "1" * 40, 178 "FAKE_GH_LOG": str(self.log), "FAKE_RELEASE": "missing", "FAKE_GH_FAIL": ""} 179 180 def publish(self, success=True, **environment): 181 result = subprocess.run(["sh", str(self.root / "scripts/publish.sh")], 182 env={**self.env, **environment}, text=True, 183 stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 184 self.assertEqual(result.returncode == 0, success, result.stdout) 185 return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] 186 187 def test_new_release_uploads_both_targets_before_publication(self): 188 calls = self.publish() 189 self.assertEqual([call[1] for call in calls], ["view", "create", "edit"]) 190 self.assertEqual(calls[1][2], "musllvm-2026-10-12-" + "1" * 12) 191 self.assertIn("--draft", calls[1]) 192 self.assertEqual(calls[1][calls[1].index("--target") + 1], self.env["GITHUB_SHA"]) 193 for archive in ARCHIVES: 194 self.assertIn(archive, calls[1]) 195 self.assertIn("--draft=false", calls[2]) 196 self.assertIn("pkgconf-test.tar.xz", (self.output / "notes.md").read_text()) 197 self.assertEqual(len((self.output / "SHA256SUMS").read_text().splitlines()), 2) 198 199 def test_published_release_is_not_overwritten(self): 200 calls = self.publish(FAKE_RELEASE="published") 201 self.assertEqual([call[1] for call in calls], ["view"]) 202 203 def test_draft_release_resumes_its_upload(self): 204 calls = self.publish(FAKE_RELEASE="draft") 205 self.assertEqual([call[1] for call in calls], ["view", "upload", "edit"]) 206 self.assertIn("--clobber", calls[1]) 207 208 def test_missing_or_corrupt_archives_prevent_publication(self): 209 archive = self.output / ARCHIVES[0] 210 archive.write_text("corrupt") 211 self.assertEqual(self.publish(success=False), []) 212 archive.unlink() 213 self.assertEqual(self.publish(success=False), []) 214 215 def test_checksum_for_another_archive_prevents_publication(self): 216 shutil.copyfile(self.output / (ARCHIVES[0] + ".sha256"), self.output / (ARCHIVES[1] + ".sha256")) 217 self.assertEqual(self.publish(success=False), []) 218 219 def test_upload_failure_leaves_the_release_unpublished(self): 220 for state, action in (("missing", "create"), ("draft", "upload")): 221 with self.subTest(state=state): 222 if self.log.exists(): 223 self.log.unlink() 224 calls = self.publish(success=False, FAKE_RELEASE=state, FAKE_GH_FAIL=action) 225 self.assertNotIn("edit", [call[1] for call in calls]) 226 227 def test_failed_release_lookup_creates_nothing(self): 228 calls = self.publish(success=False, FAKE_GH_FAIL="view") 229 self.assertEqual([call[1] for call in calls], ["view"]) 230 231 def test_wrong_checkout_prevents_publication(self): 232 self.assertEqual(self.publish(success=False, FAKE_GIT_SHA="2" * 40), []) 233 234 235 if __name__ == "__main__": 236 unittest.main()