Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 61: [PATCH 61/68] fix(release): assemble platform-specific license notices

ci/test/test_prepare_release.py

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

File metadata
index 7e3d14cf91fbb3452357231d066b4d0e788ad82c..fe6ebe020e35539a2a189a828886d53a5f06286c 100755

Download original patch · Release notes

Diff rows 1–212 of 212. 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 CHANGED
@@ -5,6 +5,7 @@
5
 
6
  import hashlib
7
  import io
 
8
  from pathlib import Path
9
  import subprocess
10
  import tarfile
@@ -12,6 +13,8 @@ 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"
@@ -20,16 +23,196 @@ 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:
 
5
 
6
  import hashlib
7
  import io
8
+ import os
9
  from pathlib import Path
10
  import subprocess
11
  import tarfile
 
13
  import unittest
14
  import zipfile
15
 
16
+ import test_release_notices as notice_fixtures
17
+
18
 
19
  ROOT = Path(__file__).resolve().parents[2]
20
  SCRIPT = ROOT / "ci/release/prepare-release.sh"
 
23
 
24
 
25
  class PrepareReleaseTest(unittest.TestCase):
26
+ def write_package(self, path, root, content, copying=None, notices=True):
27
  member = f"{root}/bin/bitcoind"
28
+ copying = (ROOT / "COPYING").read_bytes() if copying is None else copying
29
+ notice_members = {}
30
+ platform = path.name.removeprefix("bitcoin-roots-").removesuffix(".tar.gz").removesuffix(".zip")
31
+ if notices and platform in notice_fixtures.NOTICES.PLATFORMS:
32
+ with tempfile.TemporaryDirectory() as temporary_dir:
33
+ work = Path(temporary_dir)
34
+ descriptor = notice_fixtures.ReleaseNoticesTest().descriptor(work, platform)
35
+ notice_fixtures.NOTICES.collect(platform, descriptor, work / "collected")
36
+ notice_members = {f"{root}/notices/{file.relative_to(work / 'collected').as_posix()}": file.read_bytes()
37
+ for file in (work / "collected").rglob("*") if file.is_file()}
38
  if path.name.endswith(".tar.gz"):
39
  with tarfile.open(path, mode="w:gz") as package:
40
  info = tarfile.TarInfo(member)
41
  info.size = len(content)
42
  package.addfile(info, io.BytesIO(content))
43
+ if copying:
44
+ info = tarfile.TarInfo(f"{root}/COPYING")
45
+ info.size = len(copying)
46
+ package.addfile(info, io.BytesIO(copying))
47
+ for name, data in notice_members.items():
48
+ info = tarfile.TarInfo(name)
49
+ info.size = len(data)
50
+ package.addfile(info, io.BytesIO(data))
51
  else:
52
  with zipfile.ZipFile(path, mode="w") as package:
53
  package.writestr(member, content)
54
+ if copying:
55
+ package.writestr(f"{root}/COPYING", copying)
56
+ for name, data in notice_members.items():
57
+ package.writestr(name, data)
58
+
59
+ def test_final_gate_rejects_missing_faulty_or_wrong_platform_notices(self):
60
+ for fault in ("missing", "faulty-index", "wrong-platform"):
61
+ with self.subTest(fault=fault), tempfile.TemporaryDirectory() as temporary_dir:
62
+ downloads = Path(temporary_dir) / "downloads"
63
+ downloads.mkdir()
64
+ archive = downloads / "bitcoin-roots-darwin-arm64.zip"
65
+ root = "bitcoin-roots-30.3-roots.1"
66
+ self.write_package(archive, root, b"binary", notices=False)
67
+ if fault != "missing":
68
+ work = Path(temporary_dir)
69
+ platform = "darwin-x86_64" if fault == "wrong-platform" else "darwin-arm64"
70
+ descriptor = notice_fixtures.ReleaseNoticesTest().descriptor(work, platform)
71
+ notice_fixtures.NOTICES.collect(platform, descriptor, work / "collected")
72
+ with zipfile.ZipFile(archive, "a") as package:
73
+ for file in (work / "collected").rglob("*"):
74
+ if file.is_file():
75
+ content = b'{}' if fault == "faulty-index" and file.name == "index.json" else file.read_bytes()
76
+ package.writestr(f"{root}/notices/{file.relative_to(work / 'collected').as_posix()}", content)
77
+ (downloads / "series.patch").write_text("patch\n")
78
+ output = Path(temporary_dir) / "output"
79
+ result = subprocess.run([SCRIPT, downloads, output, PUBLIC_KEY, "v30.3-roots.1", "1"],
80
+ capture_output=True, text=True)
81
+ self.assertNotEqual(result.returncode, 0)
82
+ self.assertFalse((output / "SHA512SUMS").exists())
83
+ self.assertFalse((output / archive.name).exists())
84
+
85
+ def test_draft_workflow_assembly_step_rejects_intermediate_package(self):
86
+ lines = (ROOT / ".github/workflows/release.yml").read_text().splitlines()
87
+ start = lines.index(" - name: Prepare release assets and manifest")
88
+ start = lines.index(" run: |", start) + 1
89
+ end = start
90
+ while end < len(lines) and (lines[end].startswith(" ") or not lines[end]):
91
+ end += 1
92
+ block = "\n".join(line[10:] for line in lines[start:end])
93
+ with tempfile.TemporaryDirectory() as temporary_dir:
94
+ work = Path(temporary_dir)
95
+ (work / "ci").symlink_to(ROOT / "ci", target_is_directory=True)
96
+ downloads = work / "downloaded-artifacts"
97
+ downloads.mkdir()
98
+ self.write_package(downloads / "bitcoin-roots-linux-x86_64.tar.gz",
99
+ "bitcoin-roots-30.3-roots.1", b"binary", notices=False)
100
+ (downloads / "series.patch").write_text("patch\n")
101
+ environment = {**os.environ, "PUBLIC_KEY_FILE": str(PUBLIC_KEY),
102
+ "RELEASE_TAG": "v30.3-roots.1", "EXPECTED_PACKAGE_COUNT": "1"}
103
+ result = subprocess.run(["bash", "-c", block], cwd=work, env=environment,
104
+ capture_output=True, text=True)
105
+ self.assertNotEqual(result.returncode, 0)
106
+ self.assertFalse((work / "release-assets/SHA512SUMS").exists())
107
+
108
+ def test_stages_copying_for_every_platform_archive(self):
109
+ workflow = (ROOT / ".github/workflows/release.yml").read_text()
110
+ # Both Linux and both macOS matrix jobs share their platform's block.
111
+ self.assertEqual(workflow.count("ci/release/archive.py stage-copying"), 3)
112
+ with tempfile.TemporaryDirectory() as temporary_dir:
113
+ work = Path(temporary_dir)
114
+ for platform in ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64", "windows-x86_64"):
115
+ with self.subTest(platform=platform):
116
+ root = work / platform / "bitcoin-roots-30.3-roots.1"
117
+ root.mkdir(parents=True)
118
+ (root / "payload").write_bytes(b"executable fixture")
119
+ subprocess.run(["python3", ARCHIVE_TOOL, "stage-copying", root], check=True)
120
+ self.assertEqual((root / "COPYING").read_bytes(), (ROOT / "COPYING").read_bytes())
121
+ archive = root.parent / ("package.tar.gz" if platform.startswith("linux") else "package.zip")
122
+ if platform.startswith("linux"):
123
+ with tarfile.open(archive, mode="w:gz") as package:
124
+ package.add(root, arcname=root.name)
125
+ else:
126
+ with zipfile.ZipFile(archive, mode="w") as package:
127
+ for path in root.iterdir():
128
+ package.write(path, f"{root.name}/{path.name}")
129
+ subprocess.run(["python3", ARCHIVE_TOOL, "validate", archive, root.name,
130
+ "--copying", ROOT / "COPYING"], check=True)
131
+
132
+ def test_actual_unix_workflow_intermediate_repacks_and_prepares(self):
133
+ lines = (ROOT / ".github/workflows/release.yml").read_text().splitlines()
134
+ def packaging_block(name):
135
+ start = lines.index(f" - name: {name}")
136
+ start = lines.index(" run: |", start) + 1
137
+ end = start
138
+ while end < len(lines) and (lines[end].startswith(" ") or not lines[end]):
139
+ end += 1
140
+ return "\n".join(line[10:] for line in lines[start:end])
141
+
142
+ with tempfile.TemporaryDirectory() as temporary_dir:
143
+ for platform in ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64"):
144
+ with self.subTest(platform=platform):
145
+ work = Path(temporary_dir) / platform
146
+ (work / "runner").mkdir(parents=True)
147
+ out = work / "out"
148
+ out.mkdir()
149
+ environment = {**os.environ, "RUNNER_TEMP": str(work / "runner"),
150
+ "BASE_OUTDIR": str(out), "ARTIFACT_NAME": f"bitcoin-roots-{platform}",
151
+ "RELEASE_TAG": "v30.3-roots.1", "GITHUB_WORKSPACE": str(work)}
152
+ if platform.startswith("linux"):
153
+ (out / "payload").write_bytes(b"binary fixture")
154
+ block = packaging_block("Package release artifacts")
155
+ else:
156
+ deploy = work / "ci/scratch/build-fixture"
157
+ deploy.mkdir(parents=True)
158
+ with zipfile.ZipFile(deploy / "Bitcoin-Roots.zip", mode="w") as package:
159
+ package.writestr("Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt", b"binary fixture")
160
+ block = packaging_block("Stage release package")
161
+ subprocess.run(["bash", "-c", block], cwd=ROOT, env=environment, check=True)
162
+ archive = next((work / "runner/release-packages").iterdir())
163
+ member = "bitcoin-roots-30.3-roots.1/COPYING"
164
+ if platform.startswith("linux"):
165
+ with tarfile.open(archive) as package:
166
+ notice = package.extractfile(member).read()
167
+ self.assertEqual(package.extractfile("bitcoin-roots-30.3-roots.1/payload").read(), b"binary fixture")
168
+ else:
169
+ with zipfile.ZipFile(archive) as package:
170
+ notice = package.read(member)
171
+ self.assertEqual(package.read("bitcoin-roots-30.3-roots.1/Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt"), b"binary fixture")
172
+ self.assertEqual(notice, (ROOT / "COPYING").read_bytes())
173
+ descriptor = notice_fixtures.ReleaseNoticesTest().descriptor(work, platform)
174
+ notice_fixtures.NOTICES.collect(platform, descriptor, work / "notice-inputs")
175
+ downloads = work / "assembled-downloads"
176
+ downloads.mkdir()
177
+ assembled = downloads / archive.name
178
+ subprocess.run(["python3", notice_fixtures.REPACK_TOOL, archive, assembled,
179
+ "bitcoin-roots-30.3-roots.1", work / "notice-inputs/index.json", platform], check=True)
180
+ (downloads / "series.patch").write_text("patch\n")
181
+ subprocess.run([SCRIPT, downloads, work / "release-assets", PUBLIC_KEY,
182
+ "v30.3-roots.1", "1"], check=True)
183
+ self.assertTrue((work / "release-assets/SHA512SUMS").exists())
184
+
185
+ def test_release_preparation_rejects_missing_or_mismatched_copying(self):
186
+ with tempfile.TemporaryDirectory() as temporary_dir:
187
+ work = Path(temporary_dir)
188
+ for suffix in ("tar.gz", "zip"):
189
+ for content in (b"", b"wrong notice"):
190
+ with self.subTest(suffix=suffix, copying=content):
191
+ downloads = work / f"{suffix}-{len(content)}"
192
+ downloads.mkdir()
193
+ self.write_package(downloads / f"package.{suffix}", "bitcoin-roots-30.3-roots.1", b"payload", copying=content)
194
+ (downloads / "series.patch").write_text("patch\n")
195
+ result = subprocess.run([SCRIPT, downloads, downloads / "output", PUBLIC_KEY,
196
+ "v30.3-roots.1", "1"], capture_output=True, text=True)
197
+ self.assertNotEqual(result.returncode, 0)
198
+ self.assertIn("COPYING", result.stderr)
199
+
200
+ def test_copying_cannot_be_a_symlink_or_directory(self):
201
+ root = "bitcoin-roots-30.3-roots.1"
202
+ with tempfile.TemporaryDirectory() as temporary_dir:
203
+ for kind in ("symlink", "directory", "directory_without_slash"):
204
+ with self.subTest(kind=kind):
205
+ archive = Path(temporary_dir) / f"{kind}.zip"
206
+ with zipfile.ZipFile(archive, mode="w") as package:
207
+ package.writestr(f"{root}/notice", (ROOT / "COPYING").read_bytes())
208
+ info = zipfile.ZipInfo(f"{root}/COPYING" + ("/" if kind == "directory" else ""))
209
+ info.create_system = 3
210
+ info.external_attr = (0o120777 if kind == "symlink" else 0o40755) << 16
211
+ package.writestr(info, "notice" if kind == "symlink" else (ROOT / "COPYING").read_bytes())
212
+ result = subprocess.run(["python3", ARCHIVE_TOOL, "validate", archive, root,
213
+ "--copying", ROOT / "COPYING"], capture_output=True, text=True)
214
+ self.assertNotEqual(result.returncode, 0)
215
+ self.assertIn("COPYING", result.stderr)
216
 
217
  def test_prepares_sorted_manifest(self):
218
  with tempfile.TemporaryDirectory() as temporary_dir: