Skip to content
Bitcoin Roots

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

ci/release/repack.py

Before: /dev/null · After: ci/release/repack.py

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..22ac25c74b5b5233293bfd1d9dfea8ab6da86066

Download original patch · Release notes

Diff rows 1–142 of 142. 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/release/repack.py ADDED
@@ -0,0 +1,142 @@
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
+ """Add reviewed notices to an existing package without changing its payload."""
7
+
8
+ import argparse
9
+ import copy
10
+ import hashlib
11
+ import io
12
+ import json
13
+ from pathlib import Path
14
+ import shutil
15
+ import sys
16
+ import tarfile
17
+ import tempfile
18
+ import zipfile
19
+
20
+ from archive import validate_archive_root
21
+ from notices import ROOT, stage, validate_archive
22
+
23
+
24
+ def digest_stream(stream):
25
+ digest = hashlib.sha256()
26
+ while data := stream.read(1024 * 1024):
27
+ digest.update(data)
28
+ return digest.hexdigest()
29
+
30
+
31
+ def inventory(path):
32
+ entries = {}
33
+ if path.suffix == ".zip":
34
+ with zipfile.ZipFile(path) as archive:
35
+ for member in archive.infolist():
36
+ with archive.open(member) as stream:
37
+ sha256 = digest_stream(stream)
38
+ entries[member.filename] = (member.external_attr, member.create_system,
39
+ member.date_time, sha256)
40
+ else:
41
+ with tarfile.open(path, "r:gz") as archive:
42
+ for member in archive:
43
+ stream = archive.extractfile(member) if member.isfile() else None
44
+ sha256 = digest_stream(stream) if stream is not None else None
45
+ if stream is not None:
46
+ stream.close()
47
+ entries[member.name] = (member.type, member.mode, member.linkname,
48
+ member.uid, member.gid, member.uname,
49
+ member.gname, member.mtime, member.pax_headers, sha256)
50
+ return entries
51
+
52
+
53
+ def repack(source, destination, expected_root, inputs, platform):
54
+ if source.resolve() == destination.resolve() or destination.exists() or destination.is_symlink():
55
+ raise ValueError("Repacking requires a new destination; existing assets are immutable")
56
+ if source.suffix != destination.suffix:
57
+ raise ValueError("Repacking must preserve archive format")
58
+ validate_archive_root(source, expected_root)
59
+ before = inventory(source)
60
+ has_copying = any(name.rstrip("/") == f"{expected_root}/COPYING" for name in before)
61
+ if has_copying:
62
+ validate_archive_root(source, expected_root, ROOT / "COPYING")
63
+ if any(name.rstrip("/") == f"{expected_root}/notices" or
64
+ name.startswith(f"{expected_root}/notices/") for name in before):
65
+ raise ValueError("Source already contains notices; refuse to replace them")
66
+ with tempfile.TemporaryDirectory() as temporary_dir:
67
+ root = Path(temporary_dir) / expected_root
68
+ root.mkdir()
69
+ (root / "COPYING").write_bytes((ROOT / "COPYING").read_bytes())
70
+ stage(root, inputs, platform)
71
+ additions = sorted(path for path in root.rglob("*") if path.is_file() and
72
+ f"{expected_root}/{path.relative_to(root).as_posix()}" not in before)
73
+ # Exclusive creation also rejects a symlink or another asset created
74
+ # between the initial check and opening the destination.
75
+ destination_file = destination.open("xb")
76
+ try:
77
+ if source.suffix == ".zip":
78
+ with zipfile.ZipFile(source) as original, zipfile.ZipFile(destination_file, "w") as output:
79
+ output.comment = original.comment
80
+ for member in original.infolist():
81
+ # Preserve Unix mode/link data and all bytes inside signed apps.
82
+ copied_member = copy.copy(member)
83
+ with original.open(member) as stream, output.open(copied_member, "w") as target:
84
+ shutil.copyfileobj(stream, target, length=1024 * 1024)
85
+ # zipfile supplies Unix 0600 when this field is zero.
86
+ # Windows archives can deliberately carry no Unix mode.
87
+ copied_member.external_attr = member.external_attr
88
+ for path in additions:
89
+ member = zipfile.ZipInfo(f"{expected_root}/{path.relative_to(root).as_posix()}")
90
+ member.create_system = 3
91
+ member.external_attr = 0o100644 << 16
92
+ member.compress_type = zipfile.ZIP_DEFLATED
93
+ output.writestr(member, path.read_bytes())
94
+ else:
95
+ with tarfile.open(source, "r:gz") as original, tarfile.open(fileobj=destination_file, mode="w:gz") as output:
96
+ for member in original:
97
+ stream = original.extractfile(member) if member.isfile() else None
98
+ output.addfile(copy.copy(member), stream)
99
+ if stream is not None:
100
+ stream.close()
101
+ for path in additions:
102
+ data = path.read_bytes()
103
+ member = tarfile.TarInfo(f"{expected_root}/{path.relative_to(root).as_posix()}")
104
+ member.mode = 0o644
105
+ member.size = len(data)
106
+ output.addfile(member, io.BytesIO(data))
107
+ destination_file.close()
108
+ validate_archive_root(destination, expected_root, ROOT / "COPYING")
109
+ validate_archive(destination, expected_root, platform)
110
+ after = inventory(destination)
111
+ if any(after.get(name) != metadata for name, metadata in before.items()):
112
+ raise ValueError("Repacking changed existing archive content or metadata")
113
+ allowed = {f"{expected_root}/{path.relative_to(root).as_posix()}" for path in additions}
114
+ if set(after) - set(before) != allowed:
115
+ raise ValueError("Repacking introduced unexpected members")
116
+ except Exception:
117
+ destination_file.close()
118
+ destination.unlink(missing_ok=True)
119
+ raise
120
+ return {"source": str(source), "destination": str(destination),
121
+ "unchanged_members": len(before), "added_notice_members": len(allowed),
122
+ "payload_bytes_modes_links_unchanged": True}
123
+
124
+
125
+ def main():
126
+ parser = argparse.ArgumentParser(description=__doc__)
127
+ parser.add_argument("source", type=Path)
128
+ parser.add_argument("destination", type=Path)
129
+ parser.add_argument("expected_root")
130
+ parser.add_argument("notice_inputs", type=Path)
131
+ parser.add_argument("platform")
132
+ args = parser.parse_args()
133
+ try:
134
+ print(json.dumps(repack(args.source, args.destination, args.expected_root, args.notice_inputs, args.platform), sort_keys=True))
135
+ except (OSError, ValueError, KeyError, tarfile.TarError, zipfile.BadZipFile) as error:
136
+ print(error, file=sys.stderr)
137
+ return 1
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())