Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 62: [PATCH 62/68] fix(release): create drafts from reviewed immutable assets

ci/release/create-draft.py

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

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..c17568cdd8b71e91f6b8cfa0fc7729dab05965da

Download original patch · Release notes

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