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