v30.3-roots.1 · Commit 62: [PATCH 62/68] fix(release): create drafts from reviewed immutable assets
ci/test/test_create_release_draft.py
Before: /dev/null · After: ci/test/test_create_release_draft.py
File metadata
new file mode 100644 index 0000000000000000000000000000000000000000..d874f08e8a2371b2f39ccb11b6e6608cadfe48d1
Download original patch · Release notes
Diff rows 1–220 of 220. 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/test/test_create_release_draft.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import shutil
|
| 11 |
+
import subprocess
|
| 12 |
+
import tempfile
|
| 13 |
+
import unittest
|
| 14 |
+
|
| 15 |
+
import test_create_patch_series as patch_fixtures
|
| 16 |
+
import test_prepare_release as package_fixtures
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 20 |
+
SCRIPT = ROOT / "ci/release/create-draft.py"
|
| 21 |
+
RELEASE_TAG = patch_fixtures.RELEASE_TAG
|
| 22 |
+
PLATFORMS = ("linux-x86_64", "linux-aarch64", "darwin-x86_64", "darwin-arm64", "windows-x86_64")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CreateReleaseDraftTest(unittest.TestCase):
|
| 26 |
+
def setUp(self):
|
| 27 |
+
self.fixture = patch_fixtures.CreatePatchSeriesTest()
|
| 28 |
+
self.repository, _ = self.fixture.make_release_repository()
|
| 29 |
+
self.addCleanup(self.fixture.doCleanups)
|
| 30 |
+
# Run the real helper from the isolated tagged source tree rather than
|
| 31 |
+
# allowing a different checkout to supply its validators or licenses.
|
| 32 |
+
shutil.copytree(ROOT / "ci/release", self.repository / "ci/release", ignore=shutil.ignore_patterns("__pycache__"))
|
| 33 |
+
for source in ("COPYING", *package_fixtures.notice_fixtures.NOTICES.TRACKED_NOTICES.values()):
|
| 34 |
+
destination = self.repository / source
|
| 35 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
shutil.copyfile(ROOT / source, destination)
|
| 37 |
+
(self.repository / ".gitignore").write_text("__pycache__/\n")
|
| 38 |
+
self.git("add", ".")
|
| 39 |
+
self.git("commit", "-m", "release tools and notices")
|
| 40 |
+
self.git("branch", "--force", "roots/29.4", "HEAD")
|
| 41 |
+
self.git("tag", "--delete", RELEASE_TAG)
|
| 42 |
+
self.git("tag", "-a", RELEASE_TAG, "-m", RELEASE_TAG)
|
| 43 |
+
self.helper = self.repository / "ci/release/create-draft.py"
|
| 44 |
+
self.temporary = tempfile.TemporaryDirectory()
|
| 45 |
+
self.addCleanup(self.temporary.cleanup)
|
| 46 |
+
self.work = Path(self.temporary.name)
|
| 47 |
+
self.assets = self.work / "assets"
|
| 48 |
+
self.assets.mkdir()
|
| 49 |
+
self.commit = self.git("rev-parse", "HEAD").stdout.strip()
|
| 50 |
+
self.package_fixture = package_fixtures.PrepareReleaseTest()
|
| 51 |
+
for platform in PLATFORMS:
|
| 52 |
+
name = f"bitcoin-roots-{platform}.{'tar.gz' if platform.startswith('linux') else 'zip'}"
|
| 53 |
+
self.package_fixture.write_package(self.assets / name, "bitcoin-roots-29.4-roots.1", b"binary")
|
| 54 |
+
self.patch = self.assets / "bitcoin-roots-29.4-roots.1.patch"
|
| 55 |
+
subprocess.run([self.repository / "ci/release/create-patch-series.sh", RELEASE_TAG, self.patch],
|
| 56 |
+
cwd=self.repository, env={**os.environ, "RELEASE_CANONICAL_REF": "roots/29.4"},
|
| 57 |
+
check=True, capture_output=True)
|
| 58 |
+
self.manifest = self.assets / "SHA512SUMS"
|
| 59 |
+
self.refresh_manifest()
|
| 60 |
+
self.notes = self.work / "notes.md"
|
| 61 |
+
self.notes.write_text("Reviewed release notes and contributor credit\n")
|
| 62 |
+
self.tools = self.work / "tools"
|
| 63 |
+
self.tools.mkdir()
|
| 64 |
+
self.record = self.work / "created.json"
|
| 65 |
+
# Only network boundaries are replaced. The real git source validators,
|
| 66 |
+
# archive/notice validators, patch exporter and replay execute offline.
|
| 67 |
+
real_git = shutil.which("git")
|
| 68 |
+
(self.tools / "git").write_text(
|
| 69 |
+
"#!/usr/bin/env python3\nimport os,sys\n"
|
| 70 |
+
"args=sys.argv[1:]\n"
|
| 71 |
+
"if args[0]=='ls-remote': args[1]=os.environ['DRAFT_TEST_REMOTE']\n"
|
| 72 |
+
f"os.execv({real_git!r}, ['git', *args])\n"
|
| 73 |
+
)
|
| 74 |
+
(self.tools / "gh").write_text(
|
| 75 |
+
"#!/usr/bin/env python3\nimport hashlib,json,os,pathlib,sys\n"
|
| 76 |
+
"args=sys.argv[1:]\n"
|
| 77 |
+
"if args[:1]==['api']:\n"
|
| 78 |
+
" if os.environ.get('DRAFT_TEST_API_FAILURE'): sys.exit(1)\n"
|
| 79 |
+
" print(os.environ.get('DRAFT_TEST_RELEASES', '[[]]'))\n"
|
| 80 |
+
"elif args[:2]==['release','create']:\n"
|
| 81 |
+
" notes=pathlib.Path(args[args.index('--notes-file')+1]).read_text()\n"
|
| 82 |
+
" assets={pathlib.Path(x).name:hashlib.sha512(pathlib.Path(x).read_bytes()).hexdigest() "
|
| 83 |
+
"for x in args if pathlib.Path(x).is_file() and x!=args[args.index('--notes-file')+1]}\n"
|
| 84 |
+
" pathlib.Path(os.environ['DRAFT_TEST_RECORD']).write_text(json.dumps({'args':args,'notes':notes,'assets':assets}))\n"
|
| 85 |
+
" print('https://example.invalid/release/draft')\n"
|
| 86 |
+
"else: sys.exit(2)\n"
|
| 87 |
+
)
|
| 88 |
+
for path in self.tools.iterdir():
|
| 89 |
+
path.chmod(0o755)
|
| 90 |
+
|
| 91 |
+
def git(self, *args):
|
| 92 |
+
return subprocess.run(["git", "-C", self.repository, *args], check=True, capture_output=True, text=True)
|
| 93 |
+
|
| 94 |
+
def refresh_manifest(self):
|
| 95 |
+
names = sorted(path for path in self.assets.iterdir() if path.name != "SHA512SUMS")
|
| 96 |
+
self.manifest.write_text("# Bitcoin Roots release: " + RELEASE_TAG + "\n" + "".join(
|
| 97 |
+
f"{hashlib.sha512(path.read_bytes()).hexdigest()} {path.name}\n" for path in names))
|
| 98 |
+
|
| 99 |
+
def invoke(self, create=True, **environment):
|
| 100 |
+
command = ["python3", self.helper, "--tag", RELEASE_TAG, "--commit", self.commit,
|
| 101 |
+
"--repository", "owner/repository", "--assets", self.assets, "--notes", self.notes,
|
| 102 |
+
"--manifest-sha512", hashlib.sha512(self.manifest.read_bytes()).hexdigest(),
|
| 103 |
+
"--notes-sha256", hashlib.sha256(self.notes.read_bytes()).hexdigest()]
|
| 104 |
+
if create:
|
| 105 |
+
command.append("--create-draft")
|
| 106 |
+
return subprocess.run(command, cwd=self.repository, capture_output=True, text=True,
|
| 107 |
+
env={**os.environ, "PATH": f"{self.tools}:{os.environ['PATH']}",
|
| 108 |
+
"DRAFT_TEST_REMOTE": str(self.repository),
|
| 109 |
+
"DRAFT_TEST_RECORD": str(self.record), **environment})
|
| 110 |
+
|
| 111 |
+
def assert_rejected(self, **environment):
|
| 112 |
+
result = self.invoke(**environment)
|
| 113 |
+
self.assertNotEqual(result.returncode, 0, result.stdout)
|
| 114 |
+
self.assertFalse(self.record.exists(), result.stderr)
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
def test_creates_only_verified_draft_with_exact_snapshot_and_reviewed_notes(self):
|
| 118 |
+
result = self.invoke()
|
| 119 |
+
self.assertEqual(result.returncode, 0, result.stderr)
|
| 120 |
+
record = json.loads(self.record.read_text())
|
| 121 |
+
self.assertIn("--draft", record["args"])
|
| 122 |
+
self.assertIn("--verify-tag", record["args"])
|
| 123 |
+
self.assertNotIn("--clobber", record["args"])
|
| 124 |
+
self.assertEqual(record["notes"], self.notes.read_text())
|
| 125 |
+
self.assertEqual(record["assets"], {path.name: hashlib.sha512(path.read_bytes()).hexdigest()
|
| 126 |
+
for path in self.assets.iterdir()})
|
| 127 |
+
self.assertTrue(all(str(self.assets) not in arg for arg in record["args"]))
|
| 128 |
+
|
| 129 |
+
def test_verification_without_create_draft_has_no_write(self):
|
| 130 |
+
result = self.invoke(create=False)
|
| 131 |
+
self.assertEqual(result.returncode, 0, result.stderr)
|
| 132 |
+
self.assertFalse(self.record.exists())
|
| 133 |
+
|
| 134 |
+
def test_refuses_existing_draft_or_public_release_on_any_page(self):
|
| 135 |
+
for draft in (True, False):
|
| 136 |
+
with self.subTest(draft=draft):
|
| 137 |
+
result = self.assert_rejected(DRAFT_TEST_RELEASES=json.dumps(
|
| 138 |
+
[[{"tag_name": "v29.4-roots.9"}], [{"tag_name": RELEASE_TAG, "draft": draft}]]))
|
| 139 |
+
self.assertIn("Release already exists", result.stderr)
|
| 140 |
+
|
| 141 |
+
def test_release_inventory_network_failure_cannot_authorize_creation(self):
|
| 142 |
+
self.assert_rejected(DRAFT_TEST_API_FAILURE="1")
|
| 143 |
+
|
| 144 |
+
def test_rejects_missing_extra_symlink_and_modified_asset(self):
|
| 145 |
+
path = self.assets / "bitcoin-roots-windows-x86_64.zip"
|
| 146 |
+
original = path.read_bytes()
|
| 147 |
+
path.unlink()
|
| 148 |
+
self.assert_rejected()
|
| 149 |
+
path.symlink_to(self.work / "missing")
|
| 150 |
+
self.assert_rejected()
|
| 151 |
+
path.unlink()
|
| 152 |
+
path.write_bytes(original + b"alteration")
|
| 153 |
+
self.assert_rejected()
|
| 154 |
+
path.write_bytes(original)
|
| 155 |
+
(self.assets / "unexpected").write_text("extra")
|
| 156 |
+
self.assert_rejected()
|
| 157 |
+
|
| 158 |
+
def test_rejects_duplicate_and_incomplete_manifest(self):
|
| 159 |
+
self.manifest.write_text(self.manifest.read_text() + self.manifest.read_text().splitlines()[-1] + "\n")
|
| 160 |
+
self.assert_rejected()
|
| 161 |
+
self.manifest.write_text("\n".join(self.manifest.read_text().splitlines()[:2]) + "\n")
|
| 162 |
+
self.assert_rejected()
|
| 163 |
+
|
| 164 |
+
def test_rejects_patch_from_different_source_even_with_matching_checksum(self):
|
| 165 |
+
self.patch.write_text("different patch\n")
|
| 166 |
+
self.refresh_manifest()
|
| 167 |
+
result = self.assert_rejected()
|
| 168 |
+
self.assertIn("replay-verified tagged series", result.stderr)
|
| 169 |
+
|
| 170 |
+
def test_rejects_missing_notices_even_with_matching_checksum(self):
|
| 171 |
+
path = self.assets / "bitcoin-roots-linux-x86_64.tar.gz"
|
| 172 |
+
self.package_fixture.write_package(path, "bitcoin-roots-29.4-roots.1", b"binary", notices=False)
|
| 173 |
+
self.refresh_manifest()
|
| 174 |
+
self.assert_rejected()
|
| 175 |
+
|
| 176 |
+
def test_rejects_changed_remote_canonical_or_annotated_tag(self):
|
| 177 |
+
self.git("branch", "--force", "roots/29.4", "v29.4")
|
| 178 |
+
result = self.assert_rejected()
|
| 179 |
+
self.assertIn("Remote canonical tip or annotated tag", result.stderr)
|
| 180 |
+
self.git("branch", "--force", "roots/29.4", self.commit)
|
| 181 |
+
self.git("tag", "--delete", RELEASE_TAG)
|
| 182 |
+
self.git("tag", RELEASE_TAG, self.commit)
|
| 183 |
+
self.assert_rejected()
|
| 184 |
+
|
| 185 |
+
def test_rejects_wrong_head_and_dirty_source(self):
|
| 186 |
+
self.git("checkout", "--detach", "v29.4")
|
| 187 |
+
self.assert_rejected()
|
| 188 |
+
self.git("checkout", "--detach", self.commit)
|
| 189 |
+
(self.repository / "source").write_text("dirty\n")
|
| 190 |
+
self.assert_rejected()
|
| 191 |
+
|
| 192 |
+
def test_rejects_untracked_source_file(self):
|
| 193 |
+
(self.repository / "untracked-release-input").write_text("unreviewed\n")
|
| 194 |
+
result = self.assert_rejected()
|
| 195 |
+
self.assertIn("untracked changes", result.stderr)
|
| 196 |
+
|
| 197 |
+
def test_reviewed_digest_anchors_reject_replaced_manifest_or_notes(self):
|
| 198 |
+
# Snapshot failures are checked directly to preserve the pre-change
|
| 199 |
+
# reviewed digests, unlike invoke() which deliberately repins fixtures.
|
| 200 |
+
import importlib.util
|
| 201 |
+
import sys
|
| 202 |
+
from types import SimpleNamespace
|
| 203 |
+
sys.path.insert(0, str(ROOT / "ci/release"))
|
| 204 |
+
self.addCleanup(sys.path.remove, str(ROOT / "ci/release"))
|
| 205 |
+
spec = importlib.util.spec_from_file_location("draft_helper", SCRIPT)
|
| 206 |
+
helper = importlib.util.module_from_spec(spec)
|
| 207 |
+
spec.loader.exec_module(helper)
|
| 208 |
+
args = SimpleNamespace(tag=RELEASE_TAG, assets=self.assets, notes=self.notes,
|
| 209 |
+
manifest_sha512=hashlib.sha512(self.manifest.read_bytes()).hexdigest(),
|
| 210 |
+
notes_sha256=hashlib.sha256(self.notes.read_bytes()).hexdigest())
|
| 211 |
+
for target in (self.notes, self.manifest):
|
| 212 |
+
original = target.read_bytes()
|
| 213 |
+
target.write_bytes(original + b"replacement")
|
| 214 |
+
with tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError):
|
| 215 |
+
helper.snapshot_packet(args, Path(directory))
|
| 216 |
+
target.write_bytes(original)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
unittest.main()
|