Skip to content
Bitcoin Roots

v31.1-roots.1 · Commit 8: [PATCH 08/17] chore(roots): integrate v31 identity and maintenance tooling

ci/test/test_prepare_release.py

Before: /dev/null · After: ci/test/test_prepare_release.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..eefc00275c41480c3e1b45768881746ec3d59b15

Download original patch · Release notes

Diff rows 1–400 of 457. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. The two line-number columns refer to before and after.

ci/test/test_prepare_release.py ADDED
@@ -0,0 +1,457 @@
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 hashlib
7
+ import io
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ import subprocess
12
+ import tarfile
13
+ import tempfile
14
+ import unittest
15
+ import zipfile
16
+
17
+ import test_release_notices as notice_fixtures
18
+
19
+
20
+ ROOT = Path(__file__).resolve().parents[2]
21
+ SCRIPT = ROOT / "ci/release/prepare-release.sh"
22
+ ARCHIVE_TOOL = ROOT / "ci/release/archive.py"
23
+ PUBLIC_KEY = ROOT / "contrib/release/bitcoin-roots-release-key.asc"
24
+
25
+
26
+ class PrepareReleaseTest(unittest.TestCase):
27
+ def write_package(self, path, root, content, copying=None, notices=True):
28
+ member = f"{root}/bin/bitcoind"
29
+ copying = (ROOT / "COPYING").read_bytes() if copying is None else copying
30
+ notice_members = {}
31
+ platform = path.name.removeprefix("bitcoin-roots-").removesuffix(".tar.gz").removesuffix(".zip")
32
+ if notices and platform in notice_fixtures.NOTICES.PLATFORMS:
33
+ with tempfile.TemporaryDirectory() as temporary_dir:
34
+ work = Path(temporary_dir)
35
+ descriptor = notice_fixtures.ReleaseNoticesTest().descriptor(work, platform)
36
+ notice_fixtures.NOTICES.collect(platform, descriptor, work / "collected")
37
+ notice_members = {f"{root}/notices/{file.relative_to(work / 'collected').as_posix()}": file.read_bytes()
38
+ for file in (work / "collected").rglob("*") if file.is_file()}
39
+ if path.name.endswith(".tar.gz"):
40
+ with tarfile.open(path, mode="w:gz") as package:
41
+ info = tarfile.TarInfo(member)
42
+ info.size = len(content)
43
+ package.addfile(info, io.BytesIO(content))
44
+ if copying:
45
+ info = tarfile.TarInfo(f"{root}/COPYING")
46
+ info.size = len(copying)
47
+ package.addfile(info, io.BytesIO(copying))
48
+ for name, data in notice_members.items():
49
+ info = tarfile.TarInfo(name)
50
+ info.size = len(data)
51
+ package.addfile(info, io.BytesIO(data))
52
+ else:
53
+ with zipfile.ZipFile(path, mode="w") as package:
54
+ package.writestr(member, content)
55
+ if copying:
56
+ package.writestr(f"{root}/COPYING", copying)
57
+ for name, data in notice_members.items():
58
+ package.writestr(name, data)
59
+
60
+ def test_final_gate_accepts_canonical_copying_without_dependency_index(self):
61
+ with tempfile.TemporaryDirectory() as temporary_dir:
62
+ downloads = Path(temporary_dir) / "downloads"
63
+ downloads.mkdir()
64
+ archive = downloads / "bitcoin-roots-darwin-arm64.zip"
65
+ self.write_package(archive, "bitcoin-roots-30.3-roots.1", b"binary", notices=False)
66
+ (downloads / "series.patch").write_text("patch\n")
67
+ output = Path(temporary_dir) / "output"
68
+ result = subprocess.run([SCRIPT, downloads, output, PUBLIC_KEY, "v30.3-roots.1", "1"],
69
+ capture_output=True, text=True)
70
+ self.assertEqual(result.returncode, 0, result.stderr)
71
+ self.assertTrue((output / "SHA512SUMS").exists())
72
+
73
+ def test_explicit_assembly_rejects_intermediate_package(self):
74
+ with tempfile.TemporaryDirectory() as temporary_dir:
75
+ work = Path(temporary_dir)
76
+ downloads = work / "downloaded-artifacts"
77
+ downloads.mkdir()
78
+ self.write_package(downloads / "bitcoin-roots-linux-x86_64.tar.gz",
79
+ "bitcoin-roots-30.3-roots.1", b"binary", copying=b"", notices=False)
80
+ (downloads / "series.patch").write_text("patch\n")
81
+ result = subprocess.run([SCRIPT, downloads, work / "release-assets", PUBLIC_KEY,
82
+ "v30.3-roots.1", "1"], capture_output=True, text=True)
83
+ self.assertNotEqual(result.returncode, 0)
84
+ self.assertFalse((work / "release-assets/SHA512SUMS").exists())
85
+
86
+ def test_stages_copying_for_every_platform_archive(self):
87
+ workflow = (ROOT / ".github/workflows/release.yml").read_text()
88
+ # Both Linux and both macOS matrix jobs share their platform's block.
89
+ self.assertEqual(workflow.count("ci/release/archive.py stage-copying"), 3)
90
+ with tempfile.TemporaryDirectory() as temporary_dir:
91
+ work = Path(temporary_dir)
92
+ for platform in ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64", "windows-x86_64"):
93
+ with self.subTest(platform=platform):
94
+ root = work / platform / "bitcoin-roots-30.3-roots.1"
95
+ root.mkdir(parents=True)
96
+ (root / "payload").write_bytes(b"executable fixture")
97
+ subprocess.run(["python3", ARCHIVE_TOOL, "stage-copying", root], check=True)
98
+ self.assertEqual((root / "COPYING").read_bytes(), (ROOT / "COPYING").read_bytes())
99
+ archive = root.parent / ("package.tar.gz" if platform.startswith("linux") else "package.zip")
100
+ if platform.startswith("linux"):
101
+ with tarfile.open(archive, mode="w:gz") as package:
102
+ package.add(root, arcname=root.name)
103
+ else:
104
+ with zipfile.ZipFile(archive, mode="w") as package:
105
+ for path in root.iterdir():
106
+ package.write(path, f"{root.name}/{path.name}")
107
+ subprocess.run(["python3", ARCHIVE_TOOL, "validate", archive, root.name,
108
+ "--copying", ROOT / "COPYING"], check=True)
109
+
110
+ def test_actual_unix_workflow_stages_copying_without_dependency_index_and_prepares(self):
111
+ lines = (ROOT / ".github/workflows/release.yml").read_text().splitlines()
112
+ def packaging_block(name):
113
+ start = lines.index(f" - name: {name}")
114
+ start = lines.index(" run: |", start) + 1
115
+ end = start
116
+ while end < len(lines) and (lines[end].startswith(" ") or not lines[end]):
117
+ end += 1
118
+ return "\n".join(line[10:] for line in lines[start:end])
119
+
120
+ with tempfile.TemporaryDirectory() as temporary_dir:
121
+ for platform in ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64"):
122
+ with self.subTest(platform=platform):
123
+ work = Path(temporary_dir) / platform
124
+ (work / "runner").mkdir(parents=True)
125
+ out = work / "out"
126
+ out.mkdir()
127
+ environment = {**os.environ, "RUNNER_TEMP": str(work / "runner"),
128
+ "BASE_OUTDIR": str(out), "BASE_BUILD_DIR": str(work / "runner"), "ARTIFACT_NAME": f"bitcoin-roots-{platform}",
129
+ "RELEASE_TAG": "v30.3-roots.1", "GITHUB_WORKSPACE": str(work)}
130
+ if platform.startswith("linux"):
131
+ (out / "payload").write_bytes(b"binary fixture")
132
+ # Model the non-writable Docker-owned mount. Privilege
133
+ # escalation is unavailable locally; this narrow sudo
134
+ # fixture grants write access only around the command.
135
+ out.chmod(0o555)
136
+ if os.geteuid() != 0:
137
+ denied = subprocess.run(["python3", ARCHIVE_TOOL, "stage-copying", out],
138
+ capture_output=True, text=True)
139
+ self.assertNotEqual(denied.returncode, 0)
140
+ self.assertIn("Permission denied", denied.stderr)
141
+ tools = work / "tools"
142
+ tools.mkdir()
143
+ sudo = tools / "sudo"
144
+ sudo.write_text('#!/usr/bin/env bash\nset -Eeuo pipefail\n'
145
+ 'printf "%s\\n" "$@" > "$SUDO_RECORD"\n'
146
+ 'test "$#" -eq 4\n'
147
+ 'test "$1" = python3\n'
148
+ 'test "$2" = ci/release/archive.py\n'
149
+ 'test "$3" = stage-copying\n'
150
+ 'chmod u+w "$4"\n'
151
+ "trap 'chmod u-w \"$4\"' EXIT\n"
152
+ '"$@"\n')
153
+ sudo.chmod(0o755)
154
+ environment.update(PATH=f"{tools}:{os.environ['PATH']}",
155
+ SUDO_RECORD=str(work / "sudo-record"))
156
+ block = packaging_block("Package release artifacts")
157
+ else:
158
+ deploy = work / "ci/scratch/build-fixture"
159
+ deploy.mkdir(parents=True)
160
+ with zipfile.ZipFile(deploy / "Bitcoin-Roots.zip", mode="w") as package:
161
+ package.writestr("Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt", b"binary fixture")
162
+ block = packaging_block("Stage release package")
163
+ subprocess.run(["bash", "-c", block], cwd=ROOT, env=environment, check=True)
164
+ archive = next((work / "runner/release-packages").iterdir())
165
+ member = "bitcoin-roots-30.3-roots.1/COPYING"
166
+ if platform.startswith("linux"):
167
+ with tarfile.open(archive) as package:
168
+ notice = package.extractfile(member).read()
169
+ self.assertEqual(package.extractfile("bitcoin-roots-30.3-roots.1/payload").read(), b"binary fixture")
170
+ else:
171
+ with zipfile.ZipFile(archive) as package:
172
+ notice = package.read(member)
173
+ self.assertEqual(package.read("bitcoin-roots-30.3-roots.1/Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt"), b"binary fixture")
174
+ self.assertEqual(notice, (ROOT / "COPYING").read_bytes())
175
+ if platform.startswith("linux"):
176
+ self.assertEqual((work / "sudo-record").read_text().splitlines(),
177
+ ["python3", "ci/release/archive.py", "stage-copying", str(out)])
178
+ self.assertEqual(out.stat().st_mode & 0o777, 0o555)
179
+ # Permit fixture cleanup, preserving the archived mode.
180
+ out.chmod(0o755)
181
+ downloads = work / "assembled-downloads"
182
+ downloads.mkdir()
183
+ assembled = downloads / archive.name
184
+ shutil.copyfile(archive, assembled)
185
+ (downloads / "series.patch").write_text("patch\n")
186
+ subprocess.run([SCRIPT, downloads, work / "release-assets", PUBLIC_KEY,
187
+ "v30.3-roots.1", "1"], check=True)
188
+ self.assertTrue((work / "release-assets/SHA512SUMS").exists())
189
+
190
+ def test_release_preparation_rejects_missing_or_mismatched_copying(self):
191
+ with tempfile.TemporaryDirectory() as temporary_dir:
192
+ work = Path(temporary_dir)
193
+ for suffix in ("tar.gz", "zip"):
194
+ for content in (b"", b"wrong notice"):
195
+ with self.subTest(suffix=suffix, copying=content):
196
+ downloads = work / f"{suffix}-{len(content)}"
197
+ downloads.mkdir()
198
+ self.write_package(downloads / f"package.{suffix}", "bitcoin-roots-30.3-roots.1", b"payload", copying=content)
199
+ (downloads / "series.patch").write_text("patch\n")
200
+ result = subprocess.run([SCRIPT, downloads, downloads / "output", PUBLIC_KEY,
201
+ "v30.3-roots.1", "1"], capture_output=True, text=True)
202
+ self.assertNotEqual(result.returncode, 0)
203
+ self.assertIn("COPYING", result.stderr)
204
+
205
+ def test_copying_cannot_be_a_symlink_or_directory(self):
206
+ root = "bitcoin-roots-30.3-roots.1"
207
+ with tempfile.TemporaryDirectory() as temporary_dir:
208
+ for kind in ("symlink", "directory", "directory_without_slash"):
209
+ with self.subTest(kind=kind):
210
+ archive = Path(temporary_dir) / f"{kind}.zip"
211
+ with zipfile.ZipFile(archive, mode="w") as package:
212
+ package.writestr(f"{root}/notice", (ROOT / "COPYING").read_bytes())
213
+ info = zipfile.ZipInfo(f"{root}/COPYING" + ("/" if kind == "directory" else ""))
214
+ info.create_system = 3
215
+ info.external_attr = (0o120777 if kind == "symlink" else 0o40755) << 16
216
+ package.writestr(info, "notice" if kind == "symlink" else (ROOT / "COPYING").read_bytes())
217
+ result = subprocess.run(["python3", ARCHIVE_TOOL, "validate", archive, root,
218
+ "--copying", ROOT / "COPYING"], capture_output=True, text=True)
219
+ self.assertNotEqual(result.returncode, 0)
220
+ self.assertIn("COPYING", result.stderr)
221
+
222
+ def test_prepares_sorted_manifest(self):
223
+ with tempfile.TemporaryDirectory() as temporary_dir:
224
+ work = Path(temporary_dir)
225
+ downloads = work / "downloads"
226
+ output = work / "output"
227
+ (downloads / "linux").mkdir(parents=True)
228
+ (downloads / "darwin").mkdir()
229
+ packages = {
230
+ downloads / "linux/bitcoin-roots-linux-x86_64.tar.gz": b"linux package",
231
+ downloads / "darwin/bitcoin-roots-darwin-arm64.zip": b"macOS package",
232
+ }
233
+ archive_root = "bitcoin-roots-29.4-roots.1"
234
+ for path, content in packages.items():
235
+ self.write_package(path, archive_root, content)
236
+ patch = downloads / "bitcoin-roots-29.4-roots.1.patch"
237
+ patch.write_text("From patch-series\n", encoding="utf-8")
238
+
239
+ subprocess.run([SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "2"], check=True)
240
+
241
+ manifest = (output / "SHA512SUMS").read_text(encoding="utf-8")
242
+ self.assertIn("# Bitcoin Roots release: v29.4-roots.1\n", manifest)
243
+ checksum_lines = [line for line in manifest.splitlines() if not line.startswith("#")]
244
+ expected = [
245
+ f"{hashlib.sha512(path.read_bytes()).hexdigest()} {path.name}"
246
+ for path in sorted([*packages, patch])
247
+ ]
248
+ self.assertEqual(checksum_lines, expected)
249
+ subprocess.run(["sha512sum", "--check", "SHA512SUMS"], cwd=output, check=True)
250
+
251
+ def test_rejects_duplicate_package_names(self):
252
+ with tempfile.TemporaryDirectory() as temporary_dir:
253
+ work = Path(temporary_dir)
254
+ downloads = work / "downloads"
255
+ output = work / "output"
256
+ (downloads / "one").mkdir(parents=True)
257
+ (downloads / "two").mkdir()
258
+ (downloads / "one/package.zip").write_bytes(b"one")
259
+ (downloads / "two/package.zip").write_bytes(b"two")
260
+ result = subprocess.run(
261
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "2"],
262
+ capture_output=True, text=True,
263
+ )
264
+ self.assertNotEqual(result.returncode, 0)
265
+ self.assertIn("Duplicate release package name: package.zip", result.stderr)
266
+
267
+ def test_requires_exactly_one_nonempty_patch_series(self):
268
+ with tempfile.TemporaryDirectory() as temporary_dir:
269
+ work = Path(temporary_dir)
270
+ downloads = work / "downloads"
271
+ output = work / "output"
272
+ downloads.mkdir()
273
+ archive_root = "bitcoin-roots-29.4-roots.1"
274
+ self.write_package(downloads / "package.tar.gz", archive_root, b"package")
275
+
276
+ missing = subprocess.run(
277
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
278
+ capture_output=True, text=True,
279
+ )
280
+ self.assertNotEqual(missing.returncode, 0)
281
+ self.assertIn("Expected one release patch series, found 0", missing.stderr)
282
+
283
+ (downloads / "one.patch").write_text("one\n", encoding="utf-8")
284
+ (downloads / "two.patch").write_text("two\n", encoding="utf-8")
285
+ duplicate = subprocess.run(
286
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
287
+ capture_output=True, text=True,
288
+ )
289
+ self.assertNotEqual(duplicate.returncode, 0)
290
+ self.assertIn("Expected one release patch series, found 2", duplicate.stderr)
291
+
292
+ (downloads / "two.patch").unlink()
293
+ (downloads / "one.patch").write_bytes(b"")
294
+ empty = subprocess.run(
295
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
296
+ capture_output=True, text=True,
297
+ )
298
+ self.assertNotEqual(empty.returncode, 0)
299
+ self.assertIn("Release patch series is empty", empty.stderr)
300
+
301
+ def test_archive_root_uses_roots_tag_or_commit(self):
302
+ cases = [
303
+ ({"RELEASE_TAG": "v29.4-roots.1"}, "bitcoin-roots-29.4-roots.1"),
304
+ ({"RELEASE_TAG": "v29.4rc1-roots.1"}, "bitcoin-roots-29.4rc1-roots.1"),
305
+ ({"GITHUB_SHA": "0123456789abcdef"}, "bitcoin-roots-git-0123456789ab"),
306
+ ]
307
+ for environment, expected in cases:
308
+ with self.subTest(environment=environment):
309
+ result = subprocess.run(
310
+ ["python3", ARCHIVE_TOOL, "root-name"],
311
+ env={"RELEASE_TAG": "", "GITHUB_SHA": "", **environment},
312
+ capture_output=True, text=True, check=True,
313
+ )
314
+ self.assertEqual(result.stdout.strip(), expected)
315
+
316
+ def test_rejects_unsafe_archive_members(self):
317
+ with tempfile.TemporaryDirectory() as temporary_dir:
318
+ work = Path(temporary_dir)
319
+ archive = work / "unsafe.tar.gz"
320
+ with tarfile.open(archive, mode="w:gz") as package:
321
+ for name, kind in [
322
+ ("bitcoin-roots-29.4-roots.1/../escape", tarfile.REGTYPE),
323
+ ("bitcoin-roots-29.4-roots.1/link", tarfile.SYMTYPE),
324
+ ("bitcoin-roots-29.4-roots.1/hard-link", tarfile.LNKTYPE),
325
+ ("bitcoin-roots-29.4-roots.1/fifo", tarfile.FIFOTYPE),
326
+ ]:
327
+ info = tarfile.TarInfo(name)
328
+ info.type = kind
329
+ if kind in {tarfile.SYMTYPE, tarfile.LNKTYPE}:
330
+ info.linkname = "outside"
331
+ else:
332
+ info.size = 1
333
+ package.addfile(info, io.BytesIO(b"x") if kind == tarfile.REGTYPE else None)
334
+ result = subprocess.run(
335
+ ["python3", ARCHIVE_TOOL, "validate", archive, "bitcoin-roots-29.4-roots.1"],
336
+ capture_output=True, text=True,
337
+ )
338
+ self.assertNotEqual(result.returncode, 0)
339
+
340
+ zip_archive = work / "symlink.zip"
341
+ with zipfile.ZipFile(zip_archive, mode="w") as package:
342
+ info = zipfile.ZipInfo("bitcoin-roots-29.4-roots.1/link")
343
+ info.external_attr = 0o120777 << 16
344
+ package.writestr(info, "outside")
345
+ result = subprocess.run(
346
+ ["python3", ARCHIVE_TOOL, "validate", zip_archive, "bitcoin-roots-29.4-roots.1"],
347
+ capture_output=True, text=True,
348
+ )
349
+ self.assertNotEqual(result.returncode, 0)
350
+
351
+ def test_rejects_wrong_root_empty_payload_and_backslashes(self):
352
+ with tempfile.TemporaryDirectory() as temporary_dir:
353
+ work = Path(temporary_dir)
354
+ cases = {
355
+ "wrong.zip": ["bitcoin-roots-other/bin/bitcoind"],
356
+ "empty.zip": ["bitcoin-roots-29.4-roots.1/"],
357
+ "nested-directory.zip": ["bitcoin-roots-29.4-roots.1/bin/"],
358
+ "backslash.zip": ["bitcoin-roots-29.4-roots.1\\bin\\bitcoind"],
359
+ }
360
+ for name, members in cases.items():
361
+ archive = work / name
362
+ with zipfile.ZipFile(archive, mode="w") as package:
363
+ for member in members:
364
+ package.writestr(member, b"x")
365
+ with self.subTest(archive=name):
366
+ result = subprocess.run(
367
+ ["python3", ARCHIVE_TOOL, "validate", archive, "bitcoin-roots-29.4-roots.1"],
368
+ capture_output=True, text=True,
369
+ )
370
+ self.assertNotEqual(result.returncode, 0)
371
+
372
+ def validate_zip_links(self, links, files):
373
+ root = "bitcoin-roots-30.3-roots.1"
374
+ with tempfile.TemporaryDirectory() as temporary_dir:
375
+ archive = Path(temporary_dir) / "framework.zip"
376
+ with zipfile.ZipFile(archive, mode="w") as package:
377
+ for name in files:
378
+ info = zipfile.ZipInfo(f"{root}/{name}")
379
+ info.create_system = 3
380
+ info.external_attr = (0o40755 << 16) | 0x10 if name.endswith("/") else 0o100755 << 16
381
+ package.writestr(info, b"" if name.endswith("/") else b"payload")
382
+ for name, target in links.items():
383
+ info = zipfile.ZipInfo(f"{root}/{name}")
384
+ info.create_system = 3
385
+ info.external_attr = 0o120777 << 16
386
+ package.writestr(info, target)
387
+ return subprocess.run(
388
+ ["python3", ARCHIVE_TOOL, "validate", archive, root],
389
+ capture_output=True, text=True,
390
+ )
391
+
392
+ def test_accepts_internal_macos_framework_symlink_chains(self):
393
+ framework = "Bitcoin-Qt.app/Contents/Frameworks/QtGui.framework"
394
+ result = self.validate_zip_links(
395
+ {f"{framework}/Versions/Current": "A",
396
+ f"{framework}/Resources": "Versions/Current/Resources",
397
+ f"{framework}/QtGui": "Versions/Current/QtGui"},
398
+ ["Bitcoin-Qt.app/", "Bitcoin-Qt.app/Contents/", "Bitcoin-Qt.app/Contents/Frameworks/",
399
+ f"{framework}/", f"{framework}/Versions/", f"{framework}/Versions/A/",
400
+ f"{framework}/Versions/A/Resources/", f"{framework}/Versions/A/Resources/Info.plist",