musllvm

musl and LLVM cross-toolchain build system
git clone https://git.daat.foo/repos/musllvm.git
Log | Files | Refs | README | LICENSE

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()