Skip to content
Bitcoin Roots

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