Skip to content
Bitcoin Roots

v29.4-roots.2 · Commit 7: [PATCH 07/21] release: establish Roots maintenance and assurance

ci/test/test_prepare_release.py

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

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..07a61705d3d554d35869bcc7bca987d9ae0d148a

Download original patch · Release notes

Diff rows 1–186 of 186. 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_prepare_release.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from pathlib import Path
9
+ import subprocess
10
+ import tarfile
11
+ import tempfile
12
+ import unittest
13
+ import zipfile
14
+
15
+
16
+ ROOT = Path(__file__).resolve().parents[2]
17
+ SCRIPT = ROOT / "ci/release/prepare-release.sh"
18
+ ARCHIVE_TOOL = ROOT / "ci/release/archive.py"
19
+ PUBLIC_KEY = ROOT / "contrib/release/bitcoin-roots-release-key.asc"
20
+
21
+
22
+ class PrepareReleaseTest(unittest.TestCase):
23
+ def write_package(self, path, root, content):
24
+ member = f"{root}/bin/bitcoind"
25
+ if path.name.endswith(".tar.gz"):
26
+ with tarfile.open(path, mode="w:gz") as package:
27
+ info = tarfile.TarInfo(member)
28
+ info.size = len(content)
29
+ package.addfile(info, io.BytesIO(content))
30
+ else:
31
+ with zipfile.ZipFile(path, mode="w") as package:
32
+ package.writestr(member, content)
33
+
34
+ def test_prepares_sorted_manifest(self):
35
+ with tempfile.TemporaryDirectory() as temporary_dir:
36
+ work = Path(temporary_dir)
37
+ downloads = work / "downloads"
38
+ output = work / "output"
39
+ (downloads / "linux").mkdir(parents=True)
40
+ (downloads / "darwin").mkdir()
41
+ packages = {
42
+ downloads / "linux/bitcoin-roots-linux-x86_64.tar.gz": b"linux package",
43
+ downloads / "darwin/bitcoin-roots-darwin-arm64.zip": b"macOS package",
44
+ }
45
+ archive_root = "bitcoin-roots-29.4-roots.1"
46
+ for path, content in packages.items():
47
+ self.write_package(path, archive_root, content)
48
+ patch = downloads / "bitcoin-roots-29.4-roots.1.patch"
49
+ patch.write_text("From patch-series\n", encoding="utf-8")
50
+
51
+ subprocess.run([SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "2"], check=True)
52
+
53
+ manifest = (output / "SHA512SUMS").read_text(encoding="utf-8")
54
+ self.assertIn("# Bitcoin Roots release: v29.4-roots.1\n", manifest)
55
+ checksum_lines = [line for line in manifest.splitlines() if not line.startswith("#")]
56
+ expected = [
57
+ f"{hashlib.sha512(path.read_bytes()).hexdigest()} {path.name}"
58
+ for path in sorted([*packages, patch])
59
+ ]
60
+ self.assertEqual(checksum_lines, expected)
61
+ subprocess.run(["sha512sum", "--check", "SHA512SUMS"], cwd=output, check=True)
62
+
63
+ def test_rejects_duplicate_package_names(self):
64
+ with tempfile.TemporaryDirectory() as temporary_dir:
65
+ work = Path(temporary_dir)
66
+ downloads = work / "downloads"
67
+ output = work / "output"
68
+ (downloads / "one").mkdir(parents=True)
69
+ (downloads / "two").mkdir()
70
+ (downloads / "one/package.zip").write_bytes(b"one")
71
+ (downloads / "two/package.zip").write_bytes(b"two")
72
+ result = subprocess.run(
73
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "2"],
74
+ capture_output=True, text=True,
75
+ )
76
+ self.assertNotEqual(result.returncode, 0)
77
+ self.assertIn("Duplicate release package name: package.zip", result.stderr)
78
+
79
+ def test_requires_exactly_one_nonempty_patch_series(self):
80
+ with tempfile.TemporaryDirectory() as temporary_dir:
81
+ work = Path(temporary_dir)
82
+ downloads = work / "downloads"
83
+ output = work / "output"
84
+ downloads.mkdir()
85
+ archive_root = "bitcoin-roots-29.4-roots.1"
86
+ self.write_package(downloads / "package.tar.gz", archive_root, b"package")
87
+
88
+ missing = subprocess.run(
89
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
90
+ capture_output=True, text=True,
91
+ )
92
+ self.assertNotEqual(missing.returncode, 0)
93
+ self.assertIn("Expected one release patch series, found 0", missing.stderr)
94
+
95
+ (downloads / "one.patch").write_text("one\n", encoding="utf-8")
96
+ (downloads / "two.patch").write_text("two\n", encoding="utf-8")
97
+ duplicate = subprocess.run(
98
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
99
+ capture_output=True, text=True,
100
+ )
101
+ self.assertNotEqual(duplicate.returncode, 0)
102
+ self.assertIn("Expected one release patch series, found 2", duplicate.stderr)
103
+
104
+ (downloads / "two.patch").unlink()
105
+ (downloads / "one.patch").write_bytes(b"")
106
+ empty = subprocess.run(
107
+ [SCRIPT, downloads, output, PUBLIC_KEY, "v29.4-roots.1", "1"],
108
+ capture_output=True, text=True,
109
+ )
110
+ self.assertNotEqual(empty.returncode, 0)
111
+ self.assertIn("Release patch series is empty", empty.stderr)
112
+
113
+ def test_archive_root_uses_roots_tag_or_commit(self):
114
+ cases = [
115
+ ({"RELEASE_TAG": "v29.4-roots.1"}, "bitcoin-roots-29.4-roots.1"),
116
+ ({"RELEASE_TAG": "v29.4rc1-roots.1"}, "bitcoin-roots-29.4rc1-roots.1"),
117
+ ({"GITHUB_SHA": "0123456789abcdef"}, "bitcoin-roots-git-0123456789ab"),
118
+ ]
119
+ for environment, expected in cases:
120
+ with self.subTest(environment=environment):
121
+ result = subprocess.run(
122
+ ["python3", ARCHIVE_TOOL, "root-name"],
123
+ env={"RELEASE_TAG": "", "GITHUB_SHA": "", **environment},
124
+ capture_output=True, text=True, check=True,
125
+ )
126
+ self.assertEqual(result.stdout.strip(), expected)
127
+
128
+ def test_rejects_unsafe_archive_members(self):
129
+ with tempfile.TemporaryDirectory() as temporary_dir:
130
+ work = Path(temporary_dir)
131
+ archive = work / "unsafe.tar.gz"
132
+ with tarfile.open(archive, mode="w:gz") as package:
133
+ for name, kind in [
134
+ ("bitcoin-roots-29.4-roots.1/../escape", tarfile.REGTYPE),
135
+ ("bitcoin-roots-29.4-roots.1/link", tarfile.SYMTYPE),
136
+ ("bitcoin-roots-29.4-roots.1/hard-link", tarfile.LNKTYPE),
137
+ ("bitcoin-roots-29.4-roots.1/fifo", tarfile.FIFOTYPE),
138
+ ]:
139
+ info = tarfile.TarInfo(name)
140
+ info.type = kind
141
+ if kind in {tarfile.SYMTYPE, tarfile.LNKTYPE}:
142
+ info.linkname = "outside"
143
+ else:
144
+ info.size = 1
145
+ package.addfile(info, io.BytesIO(b"x") if kind == tarfile.REGTYPE else None)
146
+ result = subprocess.run(
147
+ ["python3", ARCHIVE_TOOL, "validate", archive, "bitcoin-roots-29.4-roots.1"],
148
+ capture_output=True, text=True,
149
+ )
150
+ self.assertNotEqual(result.returncode, 0)
151
+
152
+ zip_archive = work / "symlink.zip"
153
+ with zipfile.ZipFile(zip_archive, mode="w") as package:
154
+ info = zipfile.ZipInfo("bitcoin-roots-29.4-roots.1/link")
155
+ info.external_attr = 0o120777 << 16
156
+ package.writestr(info, "outside")
157
+ result = subprocess.run(
158
+ ["python3", ARCHIVE_TOOL, "validate", zip_archive, "bitcoin-roots-29.4-roots.1"],
159
+ capture_output=True, text=True,
160
+ )
161
+ self.assertNotEqual(result.returncode, 0)
162
+
163
+ def test_rejects_wrong_root_empty_payload_and_backslashes(self):
164
+ with tempfile.TemporaryDirectory() as temporary_dir:
165
+ work = Path(temporary_dir)
166
+ cases = {
167
+ "wrong.zip": ["bitcoin-roots-other/bin/bitcoind"],
168
+ "empty.zip": ["bitcoin-roots-29.4-roots.1/"],
169
+ "nested-directory.zip": ["bitcoin-roots-29.4-roots.1/bin/"],
170
+ "backslash.zip": ["bitcoin-roots-29.4-roots.1\\bin\\bitcoind"],
171
+ }
172
+ for name, members in cases.items():
173
+ archive = work / name
174
+ with zipfile.ZipFile(archive, mode="w") as package:
175
+ for member in members:
176
+ package.writestr(member, b"x")
177
+ with self.subTest(archive=name):
178
+ result = subprocess.run(
179
+ ["python3", ARCHIVE_TOOL, "validate", archive, "bitcoin-roots-29.4-roots.1"],
180
+ capture_output=True, text=True,
181
+ )
182
+ self.assertNotEqual(result.returncode, 0)
183
+
184
+
185
+ if __name__ == "__main__":
186
+ unittest.main()