Skip to content
Bitcoin Roots

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

ci/release/create-draft.py

Before: /dev/null · After: ci/release/create-draft.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..6591133387d3176c6316658718d437578838205d

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. The two line-number columns refer to before and after.

ci/release/create-draft.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
+ """Validate a pinned, reviewed unsigned asset packet and optionally create its draft."""
7
+
8
+ import argparse
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import tarfile
19
+ import zipfile
20
+
21
+ import archive
22
+
23
+ ROOT = Path(__file__).resolve().parents[2]
24
+ SCRIPTS = ROOT / "ci/release"
25
+ PACKAGE_NAMES = {
26
+ f"bitcoin-roots-{platform}.{'tar.gz' if platform.startswith('linux') else 'zip'}": platform
27
+ for platform in ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64", "windows-x86_64")
28
+ }
29
+
30
+
31
+ def run(command, **kwargs):
32
+ return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs).stdout.strip()
33
+
34
+
35
+ def digest(path, algorithm):
36
+ result = hashlib.new(algorithm)
37
+ with path.open("rb") as stream:
38
+ while chunk := stream.read(1024 * 1024):
39
+ result.update(chunk)
40
+ return result.hexdigest()
41
+
42
+
43
+ def snapshot_packet(args, destination):
44
+ patch_name = f"bitcoin-roots-{args.tag[1:]}.patch"
45
+ expected = {*PACKAGE_NAMES, patch_name, "SHA512SUMS"}
46
+ if not args.assets.is_dir() or args.assets.is_symlink():
47
+ raise ValueError("Assets must be a regular directory")
48
+ if {path.name for path in args.assets.iterdir()} != expected:
49
+ raise ValueError("Expected exactly five platform packages, the release patch and SHA512SUMS")
50
+ for name in sorted(expected):
51
+ source = args.assets / name
52
+ if source.is_symlink() or not source.is_file() or source.stat().st_size == 0:
53
+ raise ValueError(f"Asset must be a nonempty regular file: {name}")
54
+ shutil.copyfile(source, destination / name)
55
+ if digest(destination / "SHA512SUMS", "sha512") != args.manifest_sha512:
56
+ raise ValueError("SHA512SUMS differs from the reviewed digest")
57
+ entries = {}
58
+ for line in (destination / "SHA512SUMS").read_text(encoding="utf-8").splitlines():
59
+ if not line or line.startswith("#"):
60
+ continue
61
+ match = re.fullmatch(r"([0-9a-f]{128}) (.+)", line)
62
+ if not match or match[2] not in expected - {"SHA512SUMS"} or match[2] in entries:
63
+ raise ValueError("Invalid, duplicate or unexpected manifest entry")
64
+ entries[match[2]] = match[1]
65
+ if set(entries) != expected - {"SHA512SUMS"}:
66
+ raise ValueError("Manifest must contain exactly the six release assets")
67
+ for name, checksum in entries.items():
68
+ if digest(destination / name, "sha512") != checksum:
69
+ raise ValueError(f"Release checksum mismatch: {name}")
70
+ expected_root = archive.archive_root_name(args.tag, None)
71
+ for name in PACKAGE_NAMES:
72
+ archive.validate_archive_root(destination / name, expected_root, ROOT / "COPYING")
73
+ if args.notes.is_symlink() or not args.notes.is_file() or not args.notes.stat().st_size:
74
+ raise ValueError("Reviewed release notes must be a nonempty regular file")
75
+ # Use a private snapshot for upload so changes to the input packet cannot
76
+ # substitute different bytes between validation and gh release create.
77
+ notes_path = destination / "reviewed-notes.md"
78
+ shutil.copyfile(args.notes, notes_path)
79
+ if digest(notes_path, "sha256") != args.notes_sha256:
80
+ raise ValueError("Release notes differ from the reviewed digest")
81
+ return sorted(expected), patch_name, notes_path
82
+
83
+
84
+ def validate_source(args, patch, temporary):
85
+ if Path(run(["git", "rev-parse", "--show-toplevel"])).resolve() != ROOT:
86
+ raise ValueError("Run the draft helper from its own tagged source checkout")
87
+ run(["git", "ls-files", "--error-unmatch", "ci/release/create-draft.py"])
88
+ if run(["git", "rev-parse", "HEAD^{commit}"]) != args.commit:
89
+ raise ValueError("HEAD does not match the reviewed release commit")
90
+ if run(["git", "status", "--porcelain"]):
91
+ raise ValueError("Release source has staged, unstaged or untracked changes")
92
+ tag_object = run(["git", "rev-parse", f"refs/tags/{args.tag}^{{tag}}"])
93
+ if run(["git", "rev-parse", f"refs/tags/{args.tag}^{{commit}}"]) != args.commit:
94
+ raise ValueError("Annotated release tag does not match the reviewed commit")
95
+ core_version = args.tag[1:].split("-roots.", 1)[0]
96
+ environment = {**os.environ, "RELEASE_CANONICAL_REF": f"refs/tags/{args.tag}",
97
+ "RELEASE_CORE_REF": f"refs/tags/v{core_version}"}
98
+ # The actual remote canonical tip is independently checked below; this
99
+ # local override avoids treating a stale remote-tracking ref as evidence.
100
+ run([str(SCRIPTS / "validate-release-source.sh"), args.tag], env=environment)
101
+ regenerated = temporary / "regenerated.patch"
102
+ run([str(SCRIPTS / "create-patch-series.sh"), args.tag, str(regenerated)], env=environment)
103
+ if digest(regenerated, "sha512") != digest(patch, "sha512"):
104
+ raise ValueError("Delivered patch differs from the replay-verified tagged series")
105
+ return tag_object, core_version
106
+
107
+
108
+ def validate_remote(args, tag_object, core_version):
109
+ canonical = f"refs/heads/roots/{core_version}"
110
+ tag = f"refs/tags/{args.tag}"
111
+ output = run(["git", "ls-remote", f"https://github.com/{args.repository}.git",
112
+ canonical, tag, f"{tag}^{{}}"])
113
+ refs = {}
114
+ for line in output.splitlines():
115
+ oid, ref = line.split("\t")
116
+ if ref in refs or not re.fullmatch(r"[0-9a-f]{40}", oid):
117
+ raise ValueError("Invalid remote ref response")
118
+ refs[ref] = oid
119
+ if refs != {canonical: args.commit, tag: tag_object, f"{tag}^{{}}": args.commit}:
120
+ raise ValueError("Remote canonical tip or annotated tag identity differs from the reviewed source")
121
+ pages = json.loads(run(["gh", "api", "--paginate", "--slurp",
122
+ f"repos/{args.repository}/releases?per_page=100"]))
123
+ if not isinstance(pages, list) or any(not isinstance(page, list) for page in pages):
124
+ raise ValueError("Invalid release inventory response")
125
+ for page in pages:
126
+ for release in page:
127
+ if not isinstance(release, dict) or "tag_name" not in release:
128
+ raise ValueError("Invalid release inventory entry")
129
+ if release["tag_name"] == args.tag:
130
+ raise ValueError("Release already exists; refusing to replace any draft or public assets")
131
+
132
+
133
+ def prepare_draft(args):
134
+ if not archive.VERSION_TAG_RE.fullmatch(args.tag):
135
+ raise ValueError("Invalid Roots release tag")
136
+ for value, pattern, name in (
137
+ (args.commit, r"[0-9a-f]{40}", "release commit"),
138
+ (args.manifest_sha512, r"[0-9a-f]{128}", "reviewed manifest SHA512"),
139
+ (args.notes_sha256, r"[0-9a-f]{64}", "reviewed notes SHA256"),
140
+ (args.repository, r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", "GitHub repository"),
141
+ ):
142
+ if not re.fullmatch(pattern, value):
143
+ raise ValueError(f"Invalid {name}")
144
+ with tempfile.TemporaryDirectory(prefix="roots-reviewed-draft-") as directory:
145
+ temporary = Path(directory)
146
+ assets = temporary / "assets"
147
+ assets.mkdir()
148
+ names, patch_name, notes_path = snapshot_packet(args, assets)
149
+ tag_object, core_version = validate_source(args, assets / patch_name, temporary)
150
+ # Refresh irreversible-action identities after the potentially slow
151
+ # package inspection and patch replay, immediately before draft creation.
152
+ validate_remote(args, tag_object, core_version)
153
+ if args.create_draft:
154
+ command = ["gh", "release", "create", args.tag, "--repo", args.repository,
155
+ "--draft", "--verify-tag", "--title", f"Bitcoin Roots {args.tag}",
156
+ "--notes-file", str(notes_path)]
157
+ if re.search(r"rc[0-9]+-roots\.", args.tag):
158
+ command.append("--prerelease")
159
+ command.extend(str(assets / name) for name in names)
160
+ print(run(command))
161
+ else:
162
+ print("Validated immutable source, six assets, canonical COPYING, patch replay and reviewed notes; no draft created")
163
+
164
+
165
+ def main():
166
+ parser = argparse.ArgumentParser(description=__doc__)
167
+ parser.add_argument("--tag", required=True)
168
+ parser.add_argument("--commit", required=True)
169
+ parser.add_argument("--repository", required=True)
170
+ parser.add_argument("--assets", required=True, type=Path)
171
+ parser.add_argument("--notes", required=True, type=Path)
172
+ parser.add_argument("--manifest-sha512", required=True)
173
+ parser.add_argument("--notes-sha256", required=True)
174
+ parser.add_argument("--create-draft", action="store_true",
175
+ help="Create a new draft after verification; requires separate release authority")
176
+ args = parser.parse_args()
177
+ try:
178
+ prepare_draft(args)
179
+ except (OSError, ValueError, KeyError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
180
+ print(error, file=sys.stderr)
181
+ return 1
182
+ return 0
183
+
184
+
185
+ if __name__ == "__main__":
186
+ sys.exit(main())