Skip to content
Bitcoin Roots

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

ci/release/runner-notices.py

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

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..c4d73139b6d2d8a2bcd8eabf7df70e5a6bf02c78

Download original patch · Release notes

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