v30.3-roots.1 · Commit 61: [PATCH 61/68] fix(release): assemble platform-specific license notices
ci/test/test_release_notices.py
Before: /dev/null · After: ci/test/test_release_notices.py
File metadata
new file mode 100644 index 0000000000000000000000000000000000000000..6178f6560d0aa77c8a9618b6b634378b2409666a
Download original patch · Release notes
Diff rows 1–275 of 275. Line numbers refer to the original before/after files.
+ Added− RemovedUnmarked lines are context.
Scroll the code area horizontally to read long lines. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
ci/test/test_release_notices.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# Copyright (c) 2026-present The Bitcoin Roots developers
|
| 3 |
+
# Distributed under the MIT software license, see the accompanying
|
| 4 |
+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
| 5 |
+
|
| 6 |
+
import importlib.util
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import subprocess
|
| 11 |
+
import sys
|
| 12 |
+
import tarfile
|
| 13 |
+
import tempfile
|
| 14 |
+
import unittest
|
| 15 |
+
import zipfile
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 18 |
+
NOTICE_TOOL = ROOT / "ci/release/notices.py"
|
| 19 |
+
REPACK_TOOL = ROOT / "ci/release/repack.py"
|
| 20 |
+
SPEC = importlib.util.spec_from_file_location("release_notices", NOTICE_TOOL)
|
| 21 |
+
NOTICES = importlib.util.module_from_spec(SPEC)
|
| 22 |
+
SPEC.loader.exec_module(NOTICES)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ReleaseNoticesTest(unittest.TestCase):
|
| 26 |
+
def descriptor(self, work, platform):
|
| 27 |
+
# These are executable provider-interface fixtures, not real release inputs.
|
| 28 |
+
receipt = work / "build-receipt.json"
|
| 29 |
+
receipt.write_text('{"version": "fixture-1", "provider": "test"}\n')
|
| 30 |
+
license_file = work / "LICENSE"
|
| 31 |
+
license_file.write_bytes(b"exact fixture dependency license\n")
|
| 32 |
+
entries = {component: {"version": "fixture-1", "source": "fixture:build-receipt.json",
|
| 33 |
+
"receipt": receipt.name, "files": [license_file.name]}
|
| 34 |
+
for component in NOTICES.required_components(platform)}
|
| 35 |
+
path = work / "descriptor.json"
|
| 36 |
+
path.write_text(json.dumps(entries))
|
| 37 |
+
return path
|
| 38 |
+
|
| 39 |
+
def collect(self, work, platform):
|
| 40 |
+
descriptor = self.descriptor(work, platform)
|
| 41 |
+
destination = work / "collected"
|
| 42 |
+
subprocess.run([sys.executable, NOTICE_TOOL, "collect", platform, descriptor, destination], check=True)
|
| 43 |
+
return destination / "index.json"
|
| 44 |
+
|
| 45 |
+
def test_collect_preserves_tracked_and_dependency_bytes(self):
|
| 46 |
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
| 47 |
+
work = Path(temporary_dir)
|
| 48 |
+
index_path = self.collect(work, "linux-x86_64")
|
| 49 |
+
index = json.loads(index_path.read_text())
|
| 50 |
+
for component, source in NOTICES.TRACKED_NOTICES.items():
|
| 51 |
+
self.assertEqual((index_path.parent / f"tracked/{component}.txt").read_bytes(), (ROOT / source).read_bytes())
|
| 52 |
+
for entry in index["components"].values():
|
| 53 |
+
self.assertEqual((index_path.parent / entry["files"][0]).read_bytes(), (work / "LICENSE").read_bytes())
|
| 54 |
+
self.assertEqual((index_path.parent / entry["evidence"]).read_bytes(), (work / "build-receipt.json").read_bytes())
|
| 55 |
+
|
| 56 |
+
def test_missing_receipt_component_or_notice_rejected_before_output(self):
|
| 57 |
+
for problem in ("component", "receipt", "notice"):
|
| 58 |
+
with self.subTest(problem=problem), tempfile.TemporaryDirectory() as temporary_dir:
|
| 59 |
+
work = Path(temporary_dir)
|
| 60 |
+
descriptor = self.descriptor(work, "darwin-arm64")
|
| 61 |
+
metadata = json.loads(descriptor.read_text())
|
| 62 |
+
if problem == "component":
|
| 63 |
+
del metadata["openssl"]
|
| 64 |
+
elif problem == "receipt":
|
| 65 |
+
metadata["qt"]["receipt"] = "absent"
|
| 66 |
+
else:
|
| 67 |
+
metadata["qt"]["files"] = []
|
| 68 |
+
descriptor.write_text(json.dumps(metadata))
|
| 69 |
+
result = subprocess.run([sys.executable, NOTICE_TOOL, "collect", "darwin-arm64", descriptor, work / "collected"], capture_output=True)
|
| 70 |
+
self.assertNotEqual(result.returncode, 0)
|
| 71 |
+
self.assertFalse((work / "collected").exists())
|
| 72 |
+
|
| 73 |
+
def test_independent_source_provenance_does_not_require_runner_receipt(self):
|
| 74 |
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
| 75 |
+
work = Path(temporary_dir)
|
| 76 |
+
descriptor = self.descriptor(work, "linux-x86_64")
|
| 77 |
+
metadata = json.loads(descriptor.read_text())
|
| 78 |
+
for entry in metadata.values():
|
| 79 |
+
entry["evidence"] = entry.pop("receipt")
|
| 80 |
+
entry["source_sha256"] = "a" * 64
|
| 81 |
+
entry["provenance_type"] = "pinned-source-archive"
|
| 82 |
+
descriptor.write_text(json.dumps(metadata))
|
| 83 |
+
NOTICES.collect("linux-x86_64", descriptor, work / "collected")
|
| 84 |
+
index = json.loads((work / "collected/index.json").read_text())
|
| 85 |
+
self.assertEqual(index["components"]["qt"]["source_sha256"], "a" * 64)
|
| 86 |
+
self.assertNotIn("receipt", index["components"]["qt"])
|
| 87 |
+
|
| 88 |
+
def test_unknown_version_requires_explicit_stability_provenance(self):
|
| 89 |
+
for stable in (False, True):
|
| 90 |
+
with self.subTest(stable=stable), tempfile.TemporaryDirectory() as temporary_dir:
|
| 91 |
+
work = Path(temporary_dir)
|
| 92 |
+
descriptor = self.descriptor(work, "linux-x86_64")
|
| 93 |
+
metadata = json.loads(descriptor.read_text())
|
| 94 |
+
metadata["qt"]["version"] = "unknown"
|
| 95 |
+
if stable:
|
| 96 |
+
metadata["qt"]["provenance_type"] = "source-version-stability"
|
| 97 |
+
metadata["qt"]["notice_stable_versions"] = ["fixture-1", "fixture-2"]
|
| 98 |
+
descriptor.write_text(json.dumps(metadata))
|
| 99 |
+
if stable:
|
| 100 |
+
NOTICES.collect("linux-x86_64", descriptor, work / "collected")
|
| 101 |
+
else:
|
| 102 |
+
with self.assertRaisesRegex(ValueError, "Unknown version"):
|
| 103 |
+
NOTICES.collect("linux-x86_64", descriptor, work / "collected")
|
| 104 |
+
|
| 105 |
+
def test_staging_rejects_altered_notice_and_symlink_escape(self):
|
| 106 |
+
for problem in ("altered", "escape"):
|
| 107 |
+
with self.subTest(problem=problem), tempfile.TemporaryDirectory() as temporary_dir:
|
| 108 |
+
work = Path(temporary_dir)
|
| 109 |
+
index_path = self.collect(work, "linux-aarch64")
|
| 110 |
+
index = json.loads(index_path.read_text())
|
| 111 |
+
path = index_path.parent / "tracked/leveldb.txt"
|
| 112 |
+
if problem == "altered":
|
| 113 |
+
path.write_bytes(b"different")
|
| 114 |
+
else:
|
| 115 |
+
outside = work / "outside"
|
| 116 |
+
path.parent.rename(outside)
|
| 117 |
+
path.parent.symlink_to(outside, target_is_directory=True)
|
| 118 |
+
root = work / "package"
|
| 119 |
+
root.mkdir()
|
| 120 |
+
with self.assertRaises(ValueError):
|
| 121 |
+
NOTICES.stage(root, index_path, "linux-aarch64")
|
| 122 |
+
self.assertFalse((root / "notices").exists())
|
| 123 |
+
self.assertIn("tracked/leveldb.txt", index["files"])
|
| 124 |
+
|
| 125 |
+
def test_unsafe_notice_paths_rejected(self):
|
| 126 |
+
for path in ("", "/absolute", "a//b", "a/../b", "./a", "a\\b", "nul\x00"):
|
| 127 |
+
with self.subTest(path=path), self.assertRaises(ValueError):
|
| 128 |
+
NOTICES.safe_path(path)
|
| 129 |
+
|
| 130 |
+
def test_archive_notice_tampering_rejected(self):
|
| 131 |
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
| 132 |
+
work = Path(temporary_dir)
|
| 133 |
+
inputs = self.collect(work, "windows-x86_64")
|
| 134 |
+
root = "bitcoin-roots-30.3-roots.1"
|
| 135 |
+
archive = work / "tampered.zip"
|
| 136 |
+
with zipfile.ZipFile(archive, "w") as package:
|
| 137 |
+
for path in inputs.parent.rglob("*"):
|
| 138 |
+
if path.is_file():
|
| 139 |
+
content = b"altered" if path.name == "leveldb.txt" else path.read_bytes()
|
| 140 |
+
package.writestr(f"{root}/notices/{path.relative_to(inputs.parent).as_posix()}", content)
|
| 141 |
+
with self.assertRaisesRegex(ValueError, "Notice bytes differ"):
|
| 142 |
+
NOTICES.validate_archive(archive, root, "windows-x86_64")
|
| 143 |
+
|
| 144 |
+
def test_wrong_platform_bundle_rejected(self):
|
| 145 |
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
| 146 |
+
work = Path(temporary_dir)
|
| 147 |
+
inputs = self.collect(work, "darwin-arm64")
|
| 148 |
+
root = work / "package"
|
| 149 |
+
root.mkdir()
|
| 150 |
+
with self.assertRaisesRegex(ValueError, "platform mismatch"):
|
| 151 |
+
NOTICES.stage(root, inputs, "darwin-x86_64")
|
| 152 |
+
self.assertFalse((root / "notices").exists())
|
| 153 |
+
|
| 154 |
+
def test_repack_refuses_dangling_destination_symlink(self):
|
| 155 |
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
| 156 |
+
work = Path(temporary_dir)
|
| 157 |
+
inputs = self.collect(work, "windows-x86_64")
|
| 158 |
+
source = work / "original.zip"
|
| 159 |
+
with zipfile.ZipFile(source, "w") as package:
|
| 160 |
+
package.writestr("bitcoin-roots-30.3-roots.1/bin/bitcoind", b"payload")
|
| 161 |
+
destination = work / "repacked.zip"
|
| 162 |
+
target = work / "must-not-be-created.zip"
|
| 163 |
+
destination.symlink_to(target)
|
| 164 |
+
result = subprocess.run([sys.executable, REPACK_TOOL, source, destination,
|
| 165 |
+
"bitcoin-roots-30.3-roots.1", inputs, "windows-x86_64"], capture_output=True)
|
| 166 |
+
self.assertNotEqual(result.returncode, 0)
|
| 167 |
+
self.assertTrue(destination.is_symlink())
|
| 168 |
+
self.assertFalse(target.exists())
|
| 169 |
+
|
| 170 |
+
def test_repack_rejects_changed_or_nonregular_existing_copying(self):
|
| 171 |
+
for kind in ("changed", "symlink", "directory"):
|
| 172 |
+
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temporary_dir:
|
| 173 |
+
work = Path(temporary_dir)
|
| 174 |
+
inputs = self.collect(work, "windows-x86_64")
|
| 175 |
+
source = work / "original.zip"
|
| 176 |
+
root = "bitcoin-roots-30.3-roots.1"
|
| 177 |
+
with zipfile.ZipFile(source, "w") as package:
|
| 178 |
+
package.writestr(f"{root}/bin/bitcoind", b"binary")
|
| 179 |
+
info = zipfile.ZipInfo(f"{root}/COPYING" + ("/" if kind == "directory" else ""))
|
| 180 |
+
info.create_system = 3
|
| 181 |
+
info.external_attr = (0o120777 if kind == "symlink" else 0o40755 if kind == "directory" else 0o100644) << 16
|
| 182 |
+
package.writestr(info, "bin/bitcoind" if kind == "symlink" else b"wrong COPYING")
|
| 183 |
+
destination = work / "assembled.zip"
|
| 184 |
+
result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root,
|
| 185 |
+
inputs, "windows-x86_64"], capture_output=True, text=True)
|
| 186 |
+
self.assertNotEqual(result.returncode, 0)
|
| 187 |
+
self.assertIn("COPYING", result.stderr)
|
| 188 |
+
self.assertFalse(destination.exists())
|
| 189 |
+
|
| 190 |
+
def test_existing_canonical_copying_is_preserved_but_notices_are_not_replaced(self):
|
| 191 |
+
for existing_notices in (False, True):
|
| 192 |
+
with self.subTest(existing_notices=existing_notices), tempfile.TemporaryDirectory() as temporary_dir:
|
| 193 |
+
work = Path(temporary_dir)
|
| 194 |
+
inputs = self.collect(work, "windows-x86_64")
|
| 195 |
+
source = work / "original.zip"
|
| 196 |
+
root = "bitcoin-roots-30.3-roots.1"
|
| 197 |
+
with zipfile.ZipFile(source, "w") as package:
|
| 198 |
+
package.writestr(f"{root}/bin/bitcoind", b"binary")
|
| 199 |
+
copying = zipfile.ZipInfo(f"{root}/COPYING", (2019, 1, 2, 3, 4, 6))
|
| 200 |
+
copying.create_system = 3
|
| 201 |
+
copying.external_attr = 0o100640 << 16
|
| 202 |
+
package.writestr(copying, (ROOT / "COPYING").read_bytes())
|
| 203 |
+
if existing_notices:
|
| 204 |
+
package.writestr(f"{root}/notices/previous.txt", b"existing notice")
|
| 205 |
+
destination = work / "assembled.zip"
|
| 206 |
+
result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root,
|
| 207 |
+
inputs, "windows-x86_64"], capture_output=True, text=True)
|
| 208 |
+
if existing_notices:
|
| 209 |
+
self.assertNotEqual(result.returncode, 0)
|
| 210 |
+
self.assertIn("already contains notices", result.stderr)
|
| 211 |
+
self.assertFalse(destination.exists())
|
| 212 |
+
else:
|
| 213 |
+
self.assertEqual(result.returncode, 0, result.stderr)
|
| 214 |
+
self.assertEqual(json.loads(result.stdout)["unchanged_members"], 2)
|
| 215 |
+
with zipfile.ZipFile(destination) as package:
|
| 216 |
+
self.assertEqual(package.read(copying.filename), (ROOT / "COPYING").read_bytes())
|
| 217 |
+
self.assertEqual(package.getinfo(copying.filename).external_attr, copying.external_attr)
|
| 218 |
+
self.assertEqual(package.getinfo(copying.filename).date_time, copying.date_time)
|
| 219 |
+
|
| 220 |
+
def test_repack_all_platforms_preserves_binary_modes_and_app_links(self):
|
| 221 |
+
for platform in sorted(NOTICES.PLATFORMS):
|
| 222 |
+
with self.subTest(platform=platform), tempfile.TemporaryDirectory() as temporary_dir:
|
| 223 |
+
work = Path(temporary_dir)
|
| 224 |
+
inputs = self.collect(work, platform)
|
| 225 |
+
root = "bitcoin-roots-30.3-roots.1"
|
| 226 |
+
source = work / ("original.tar.gz" if platform.startswith("linux") else "original.zip")
|
| 227 |
+
destination = work / ("repacked.tar.gz" if platform.startswith("linux") else "repacked.zip")
|
| 228 |
+
payload = b"binary bytes including signature fixture\x00\xff"
|
| 229 |
+
member_name = f"{root}/Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt" if platform.startswith("darwin") else f"{root}/bin/bitcoind"
|
| 230 |
+
if platform.startswith("linux"):
|
| 231 |
+
with tarfile.open(source, "w:gz") as package:
|
| 232 |
+
info = tarfile.TarInfo(member_name)
|
| 233 |
+
info.mode = 0o755
|
| 234 |
+
info.mtime = 123456
|
| 235 |
+
info.size = len(payload)
|
| 236 |
+
package.addfile(info, io.BytesIO(payload))
|
| 237 |
+
else:
|
| 238 |
+
with zipfile.ZipFile(source, "w") as package:
|
| 239 |
+
info = zipfile.ZipInfo(member_name, (2020, 1, 2, 3, 4, 6))
|
| 240 |
+
info.create_system = 3
|
| 241 |
+
info.external_attr = 0o100755 << 16
|
| 242 |
+
package.writestr(info, payload)
|
| 243 |
+
if platform.startswith("windows"):
|
| 244 |
+
# PowerShell archives may carry no Unix mode at all.
|
| 245 |
+
package.filelist[-1].external_attr = 0
|
| 246 |
+
if platform.startswith("darwin"):
|
| 247 |
+
link = zipfile.ZipInfo(f"{root}/Bitcoin-Qt.app/Contents/MacOS/current")
|
| 248 |
+
link.create_system = 3
|
| 249 |
+
link.external_attr = 0o120755 << 16
|
| 250 |
+
package.writestr(link, "Bitcoin-Qt")
|
| 251 |
+
result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root, inputs, platform], capture_output=True, text=True)
|
| 252 |
+
self.assertEqual(result.returncode, 0, result.stderr)
|
| 253 |
+
proof = json.loads(result.stdout)
|
| 254 |
+
self.assertTrue(proof["payload_bytes_modes_links_unchanged"])
|
| 255 |
+
self.assertEqual(proof["unchanged_members"], 2 if platform.startswith("darwin") else 1)
|
| 256 |
+
NOTICES.validate_archive(destination, root, platform)
|
| 257 |
+
if destination.suffix == ".zip":
|
| 258 |
+
with zipfile.ZipFile(destination) as package:
|
| 259 |
+
self.assertEqual(package.read(member_name), payload)
|
| 260 |
+
self.assertEqual(package.getinfo(member_name).external_attr >> 16,
|
| 261 |
+
0 if platform.startswith("windows") else 0o100755)
|
| 262 |
+
if platform.startswith("darwin"):
|
| 263 |
+
self.assertEqual(package.read(link.filename), b"Bitcoin-Qt")
|
| 264 |
+
added = [name for name in package.namelist() if "/COPYING" in name or "/notices/" in name]
|
| 265 |
+
self.assertTrue(all(".app/" not in name for name in added))
|
| 266 |
+
else:
|
| 267 |
+
with tarfile.open(destination) as package:
|
| 268 |
+
self.assertEqual(package.extractfile(member_name).read(), payload)
|
| 269 |
+
self.assertEqual(package.getmember(member_name).mode, 0o755)
|
| 270 |
+
retry = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root, inputs, platform], capture_output=True)
|
| 271 |
+
self.assertNotEqual(retry.returncode, 0)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
unittest.main()
|