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())
|