Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 63: [PATCH 63/68] fix(release): restore signed five-platform CI releases

ci/release/runner-notices.py

Before: /dev/null · After: ci/release/runner-notices.py

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..37141288eff5f94248fe3f7e7030ac4faf40cc7f

Download original patch · Release notes

Diff rows 1–247 of 247. 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/release/runner-notices.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """Collect notices from the dependency selection of this release runner."""
7
+
8
+ import argparse
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path, PurePosixPath
13
+ import re
14
+ import subprocess
15
+ import tarfile
16
+ import urllib.request
17
+
18
+ import notices
19
+
20
+
21
+ def run(*command):
22
+ return subprocess.check_output(command, text=True).strip()
23
+
24
+
25
+ def sha256(path):
26
+ with path.open('rb') as stream:
27
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
28
+
29
+
30
+ def write_record(destination, component, record, files, provenance='build-receipt'):
31
+ directory = destination / component
32
+ directory.mkdir(parents=True, exist_ok=True)
33
+ evidence = directory / 'build-selection.json'
34
+ evidence.write_text(json.dumps(record, indent=2, sort_keys=True) + '\n')
35
+ return {'version': record['version'], 'source': record['source'],
36
+ 'source_sha256': record.get('archive_sha256', sha256(evidence)),
37
+ 'provenance_type': provenance, 'evidence': str(evidence.resolve()),
38
+ 'role': 'build-selected-dependency', 'files': [str(p.resolve()) for p in files]}
39
+
40
+
41
+ def source_notices(archive, expected_hash, destination):
42
+ if not re.fullmatch(r'[0-9a-f]{64}', expected_hash or '') or sha256(archive) != expected_hash:
43
+ raise ValueError(f'Source archive hash mismatch: {archive.name}')
44
+ files = []
45
+ with tarfile.open(archive) as package:
46
+ for member in package:
47
+ if not member.isfile():
48
+ continue
49
+ path = PurePosixPath(member.name)
50
+ if path.is_absolute() or '..' in path.parts or '\\' in member.name or ':' in member.name:
51
+ raise ValueError('Unsafe source archive member')
52
+ name = path.name.lower()
53
+ selected = bool(re.fullmatch(r'(?:copying|licen[cs]e|copyright|notice)(?:[._-].*)?', name))
54
+ selected |= 'LICENSES' in path.parts or name in {'ftl.txt', 'gplv2.txt'}
55
+ header = name == 'sqlite3.c' or path.as_posix().endswith('/keysyms/keysyms.c')
56
+ if not selected and not header:
57
+ continue
58
+ data = package.extractfile(member).read()
59
+ if header:
60
+ data = data.split(b'*/', 1)[0] + b'*/\n'
61
+ target = destination / Path(*path.parts)
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ target.write_bytes(data)
64
+ files.append(target)
65
+ if not files:
66
+ raise ValueError(f'No source license notices: {archive.name}')
67
+ return files
68
+
69
+
70
+ def acquire(url, checksum, destination):
71
+ if not url.startswith('https://'):
72
+ raise ValueError('Source acquisition requires HTTPS')
73
+ if not destination.exists():
74
+ with urllib.request.urlopen(url, timeout=120) as response, destination.open('xb') as out:
75
+ while data := response.read(1024 * 1024):
76
+ out.write(data)
77
+ if sha256(destination) != checksum:
78
+ raise ValueError('Acquired source hash differs from installed dependency selection')
79
+ return destination
80
+
81
+
82
+ def depends_notices(depends, host, destination):
83
+ def variables(names):
84
+ output = run('make', '--no-print-directory', '-C', str(depends), f'HOST={host}',
85
+ *[f'print-{name}' for name in names])
86
+ return dict(line.split('=', 1) for line in output.splitlines() if '=' in line)
87
+ selected = variables(['packages', 'host_prefix', 'SOURCES_PATH'])
88
+ if not (Path(selected['host_prefix']) / 'toolchain.cmake').is_file():
89
+ raise ValueError('Installed depends toolchain is missing')
90
+ packages = selected['packages'].split()
91
+ names = [f'{p}_{suffix}' for p in packages for suffix in
92
+ ('version', 'source', 'sha256_hash', 'download_path', 'download_file', 'extra_sources')]
93
+ values = variables(names)
94
+ result = {}
95
+ for package in packages:
96
+ source = Path(values[f'{package}_source'])
97
+ checksum = values[f'{package}_sha256_hash']
98
+ url = values[f'{package}_download_path'].rstrip('/') + '/' + values[f'{package}_download_file']
99
+ files = source_notices(source, checksum, destination / package)
100
+ # Qt translations/tools are separately fetched and bundled by the recipe.
101
+ if package == 'qt':
102
+ extras = variables(['qt_qttranslations_file_name', 'qt_qttranslations_sha256_hash',
103
+ 'qt_qttools_file_name', 'qt_qttools_sha256_hash'])
104
+ for part in ('qttranslations', 'qttools'):
105
+ filename = extras[f'qt_{part}_file_name']
106
+ extra_hash = extras[f'qt_{part}_sha256_hash']
107
+ extra = Path(selected['SOURCES_PATH']) / filename
108
+ files += source_notices(extra, extra_hash,
109
+ destination / package)
110
+ record = {'version': values[f'{package}_version'], 'source': url,
111
+ 'archive_sha256': checksum, 'host': host,
112
+ 'recipe': (depends / 'packages' / f'{package}.mk').read_text(),
113
+ 'selection': selected, 'source_commit': run('git', 'rev-parse', 'HEAD^{commit}')}
114
+ result[package] = write_record(destination, package, record, files, 'pinned-source-archive')
115
+ return result
116
+
117
+
118
+ def brew_notices(destination, bundle=None):
119
+ requested = ['qt@6', 'boost', 'libevent', 'zeromq', 'qrencode', 'miniupnpc', 'capnp']
120
+ dependencies = run('brew', 'deps', '--installed', '--recursive', *requested).splitlines()
121
+ names = sorted(set(requested + dependencies))
122
+ formulae = [formula for name in names for formula in json.loads(run('brew', 'info', '--json=v2', name))['formulae']]
123
+ required = notices.required_components('darwin-arm64') - {'sqlite'}
124
+ seen = set()
125
+ linkage = {}
126
+ qt_sources = {'qtbase'}
127
+ if bundle is not None:
128
+ for binary in sorted(bundle.rglob('*')):
129
+ if not binary.is_file() or binary.is_symlink():
130
+ continue
131
+ with binary.open('rb') as stream:
132
+ magic = stream.read(4)
133
+ if magic in {b'\xcf\xfa\xed\xfe', b'\xfe\xed\xfa\xcf', b'\xca\xfe\xba\xbe', b'\xbe\xba\xfe\xca'}:
134
+ linkage[str(binary.relative_to(bundle))] = {'sha256': sha256(binary), 'otool': run('otool', '-L', str(binary))}
135
+ modules = {'Qml': 'qtdeclarative', 'Quick': 'qtdeclarative', 'QmlModels': 'qtdeclarative',
136
+ 'QmlWorkerScript': 'qtdeclarative', 'QuickWidgets': 'qtdeclarative',
137
+ 'Svg': 'qtsvg', 'SvgWidgets': 'qtsvg', 'ShaderTools': 'qtshadertools',
138
+ 'Core': 'qtbase', 'Gui': 'qtbase', 'Widgets': 'qtbase', 'Network': 'qtbase',
139
+ 'DBus': 'qtbase', 'OpenGL': 'qtbase', 'OpenGLWidgets': 'qtbase', 'PrintSupport': 'qtbase'}
140
+ for entry in linkage.values():
141
+ for module in re.findall(r'Qt([A-Za-z0-9]+)\.framework/', entry['otool']):
142
+ if module not in modules:
143
+ raise ValueError(f'Unmapped shipped Qt framework: {module}')
144
+ qt_sources.add(modules[module])
145
+ if not linkage or not any('/usr/lib/libsqlite3' in entry['otool'] for entry in linkage.values()):
146
+ raise ValueError('Built macOS bundle does not establish system SQLite linkage')
147
+ result = {}
148
+ for formula in formulae:
149
+ name = formula['name']
150
+ component = {'qtbase': 'qt', 'openssl@3': 'openssl'}.get(name, 'icu' if name.startswith('icu4c') else name)
151
+ # Homebrew's qt umbrella uses a checksums text file, and its CA bundle
152
+ # uses PEM. Only selected runtime notices and Qt module sources are tar.
153
+ if name in {'qt', 'qt@6'} or (component not in required and name not in qt_sources and name != 'capnp'):
154
+ continue
155
+ if component in seen:
156
+ continue
157
+ seen.add(component)
158
+ installed = formula['installed']
159
+ if len(installed) != 1 or installed[0]['version'].split('_')[0] != formula['versions']['stable']:
160
+ raise ValueError(f'Homebrew installed/source version mismatch: {name}')
161
+ prefix = Path(run('brew', '--prefix', name))
162
+ receipt = json.loads((prefix / 'INSTALL_RECEIPT.json').read_text())
163
+ stable = formula['urls']['stable']
164
+ directory = destination / component
165
+ directory.mkdir(parents=True, exist_ok=True)
166
+ source = acquire(stable['url'], stable['checksum'], directory / 'source.tar')
167
+ files = source_notices(source, stable['checksum'], directory / 'licenses')
168
+ record = {'version': installed[0]['version'], 'source': stable['url'],
169
+ 'archive_sha256': stable['checksum'], 'formula': formula, 'installed_receipt': receipt,
170
+ 'bundle_linkage': linkage, 'architecture': run('uname', '-m'),
171
+ 'source_commit': run('git', 'rev-parse', 'HEAD^{commit}')}
172
+ result[component] = write_record(destination, component, record, files, 'pinned-source-archive')
173
+ # Core's macOS build uses the SDK/system SQLite rather than a brewed library.
174
+ record = {'version': 'system library, macOS SDK ' + run('xcrun', '--show-sdk-version'), 'source': 'macOS SDK system SQLite',
175
+ 'sdk_path': run('xcrun', '--show-sdk-path'), 'os': run('sw_vers'), 'bundle_linkage': linkage}
176
+ result['sqlite'] = write_record(destination, 'sqlite', record, [])
177
+ result['sqlite']['role'] = 'system-library'
178
+ return result
179
+
180
+
181
+ def vcpkg_notices(installed, destination):
182
+ status = (installed / 'vcpkg/status').read_text()
183
+ groups = {}
184
+ for paragraph in re.split(r'\n\s*\n', status):
185
+ fields = dict(line.split(': ', 1) for line in paragraph.splitlines() if ': ' in line)
186
+ if fields.get('Status') != 'install ok installed' or 'Version' not in fields:
187
+ continue
188
+ name = fields['Package']
189
+ if fields.get('Architecture') != 'x64-windows-static':
190
+ continue
191
+ component = 'boost' if name.startswith('boost-') else {'qtbase': 'qt', 'sqlite3': 'sqlite', 'libqrencode': 'qrencode'}.get(name, name)
192
+ copyright = installed / fields['Architecture'] / 'share' / name / 'copyright'
193
+ if not copyright.is_file() or not copyright.read_bytes():
194
+ raise ValueError(f'Installed vcpkg copyright missing: {name}')
195
+ ports = groups.setdefault(component, [])
196
+ if any(entry[0]['Package'] == name for entry in ports):
197
+ raise ValueError(f'Duplicate installed vcpkg port: {name}')
198
+ ports.append((fields, copyright))
199
+ result = {}
200
+ baseline = json.loads((notices.ROOT / 'vcpkg.json').read_text())['builtin-baseline']
201
+ for component, ports in groups.items():
202
+ versions = sorted(set(fields['Version'] for fields, _ in ports))
203
+ if len(versions) != 1:
204
+ raise ValueError(f'Mismatched installed component versions: {component}')
205
+ record = {'version': versions[0], 'source': f'vcpkg baseline {baseline}: {component}',
206
+ 'baseline': baseline, 'installed_ports': [fields for fields, _ in ports],
207
+ 'vcpkg_tool_commit': run('git', '-C', os.environ['VCPKG_INSTALLATION_ROOT'], 'rev-parse', 'HEAD') if os.environ.get('VCPKG_INSTALLATION_ROOT') else 'fixture',
208
+ 'copyright_sha256': {fields['Package']: sha256(path) for fields, path in ports},
209
+ 'source_commit': run('git', 'rev-parse', 'HEAD^{commit}')}
210
+ result[component] = write_record(destination, component, record, [path for _, path in ports])
211
+ return result
212
+
213
+
214
+ def main():
215
+ parser = argparse.ArgumentParser(description=__doc__)
216
+ parser.add_argument('platform', choices=sorted(notices.PLATFORMS))
217
+ parser.add_argument('destination', type=Path)
218
+ parser.add_argument('--depends', type=Path)
219
+ parser.add_argument('--host')
220
+ parser.add_argument('--vcpkg-installed', type=Path)
221
+ parser.add_argument('--bundle', type=Path)
222
+ args = parser.parse_args()
223
+ args.destination.mkdir(parents=True, exist_ok=False)
224
+ inputs = args.destination / 'inputs'
225
+ inputs.mkdir()
226
+ if args.platform.startswith('linux'):
227
+ if not args.depends or not args.host:
228
+ parser.error('Linux requires installed depends and host')
229
+ descriptor = depends_notices(args.depends, args.host, inputs)
230
+ elif args.platform.startswith('darwin'):
231
+ if not args.bundle or not args.bundle.is_dir():
232
+ parser.error('macOS requires the actual built application bundle')
233
+ if run('uname', '-m') != args.platform.removeprefix('darwin-'):
234
+ parser.error('macOS runner architecture differs from selected platform')
235
+ descriptor = brew_notices(inputs, args.bundle)
236
+ else:
237
+ if not args.vcpkg_installed:
238
+ parser.error('Windows requires vcpkg installed directory')
239
+ descriptor = vcpkg_notices(args.vcpkg_installed, inputs)
240
+ path = args.destination / 'descriptor.json'
241
+ path.write_text(json.dumps(descriptor, indent=2, sort_keys=True) + '\n')
242
+ notices.collect(args.platform, path, args.destination / 'notices')
243
+ print(args.destination / 'notices/index.json')
244
+
245
+
246
+ if __name__ == '__main__':
247
+ main()