Skip to content
Bitcoin Roots

v29.4-roots.3 · Commit 12: [PATCH 12/30] release: add pre-tag artifact rehearsal

ci/test/test_prepare_release_source.py

Before: /dev/null · After: ci/test/test_prepare_release_source.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..baab2cad2d390d1272669e19c89fbc3bb0e6115f

Download original patch · Release notes

Diff rows 1–166 of 166. 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/test/test_prepare_release_source.py ADDED
@@ -0,0 +1,166 @@
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
+ from pathlib import Path
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ import unittest
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ SCRIPT = ROOT / "ci/release/prepare-release-source.sh"
15
+ RELEASE_TAG = "v29.4-roots.1"
16
+
17
+
18
+ class PrepareReleaseSourceTest(unittest.TestCase):
19
+ def git(self, repository, *args, check=True, **kwargs):
20
+ return subprocess.run(
21
+ ["git", "-C", repository, *args], check=check, **kwargs
22
+ )
23
+
24
+ def make_checkout(self, *, publish_release_tag=False, checkout_tag=False):
25
+ temporary_dir = tempfile.TemporaryDirectory()
26
+ self.addCleanup(temporary_dir.cleanup)
27
+ work = Path(temporary_dir.name)
28
+ remote = work / "origin.git"
29
+ source = work / "source"
30
+ checkout = work / "checkout"
31
+
32
+ subprocess.run(
33
+ ["git", "init", "--bare", "--initial-branch=main", remote],
34
+ check=True,
35
+ capture_output=True,
36
+ )
37
+ subprocess.run(
38
+ ["git", "init", "--initial-branch=main", source],
39
+ check=True,
40
+ capture_output=True,
41
+ )
42
+ self.git(source, "config", "user.name", "Release source test")
43
+ self.git(source, "config", "user.email", "release-source@example.invalid")
44
+ self.git(source, "remote", "add", "origin", str(remote))
45
+ (source / "source").write_text("core\n", encoding="utf-8")
46
+ self.git(source, "add", "source")
47
+ self.git(source, "commit", "-m", "core", capture_output=True)
48
+ self.git(source, "tag", "-a", "v29.4", "-m", "Core v29.4")
49
+ self.git(source, "switch", "-c", "roots/29.4", capture_output=True)
50
+ (source / "source").write_text("roots\n", encoding="utf-8")
51
+ self.git(source, "commit", "-am", "roots", capture_output=True)
52
+ release_commit = self.git(
53
+ source, "rev-parse", "HEAD", capture_output=True, text=True
54
+ ).stdout.strip()
55
+ if publish_release_tag:
56
+ self.git(source, "tag", "-a", RELEASE_TAG, "-m", RELEASE_TAG)
57
+ self.git(source, "push", "origin", "roots/29.4", "v29.4", capture_output=True)
58
+ if publish_release_tag:
59
+ self.git(source, "push", "origin", RELEASE_TAG, capture_output=True)
60
+
61
+ subprocess.run(
62
+ ["git", "clone", "--no-tags", str(remote), checkout],
63
+ check=True,
64
+ capture_output=True,
65
+ )
66
+ if checkout_tag:
67
+ self.git(
68
+ checkout,
69
+ "fetch",
70
+ "--no-tags",
71
+ "origin",
72
+ f"refs/tags/{RELEASE_TAG}:refs/tags/{RELEASE_TAG}",
73
+ capture_output=True,
74
+ )
75
+ self.git(
76
+ checkout,
77
+ "checkout",
78
+ "--detach",
79
+ RELEASE_TAG if checkout_tag else release_commit,
80
+ capture_output=True,
81
+ )
82
+ return checkout, source, release_commit
83
+
84
+ def prepare(self, checkout, release_commit, event_name):
85
+ return subprocess.run(
86
+ [SCRIPT, RELEASE_TAG, release_commit, event_name],
87
+ cwd=checkout,
88
+ env={**os.environ, "RELEASE_CORE_REMOTE": "origin"},
89
+ capture_output=True,
90
+ text=True,
91
+ )
92
+
93
+ def test_manual_rehearsal_creates_only_an_ephemeral_annotated_tag(self):
94
+ checkout, source, release_commit = self.make_checkout()
95
+ result = self.prepare(checkout, release_commit, "workflow_dispatch")
96
+ self.assertEqual(result.returncode, 0, result.stderr)
97
+ local_type = self.git(
98
+ checkout,
99
+ "cat-file",
100
+ "-t",
101
+ f"refs/tags/{RELEASE_TAG}",
102
+ capture_output=True,
103
+ text=True,
104
+ ).stdout.strip()
105
+ self.assertEqual(local_type, "tag")
106
+ remote_tag = self.git(
107
+ source,
108
+ "ls-remote",
109
+ "--tags",
110
+ "origin",
111
+ f"refs/tags/{RELEASE_TAG}",
112
+ capture_output=True,
113
+ text=True,
114
+ ).stdout
115
+ self.assertEqual(remote_tag, "")
116
+
117
+ def test_manual_rehearsal_rejects_noncanonical_and_abbreviated_commits(self):
118
+ checkout, _, release_commit = self.make_checkout()
119
+ abbreviated = self.prepare(
120
+ checkout, release_commit[:12], "workflow_dispatch"
121
+ )
122
+ self.assertNotEqual(abbreviated.returncode, 0)
123
+ self.assertIn("full lowercase 40-hex commit", abbreviated.stderr)
124
+
125
+ core_commit = self.git(
126
+ checkout, "rev-parse", "v29.4^{commit}", capture_output=True, text=True
127
+ ).stdout.strip()
128
+ self.git(checkout, "checkout", "--detach", core_commit, capture_output=True)
129
+ noncanonical = self.prepare(checkout, core_commit, "workflow_dispatch")
130
+ self.assertNotEqual(noncanonical.returncode, 0)
131
+ self.assertIn("does not match canonical branch tip", noncanonical.stderr)
132
+
133
+ def test_manual_rehearsal_rejects_an_existing_remote_tag(self):
134
+ checkout, _, release_commit = self.make_checkout(publish_release_tag=True)
135
+ result = self.prepare(checkout, release_commit, "workflow_dispatch")
136
+ self.assertNotEqual(result.returncode, 0)
137
+ self.assertIn("requires the remote tag to be absent", result.stderr)
138
+
139
+ def test_tag_push_validates_the_remote_annotated_tag(self):
140
+ checkout, _, release_commit = self.make_checkout(
141
+ publish_release_tag=True, checkout_tag=True
142
+ )
143
+ result = self.prepare(checkout, "", "push")
144
+ self.assertEqual(result.returncode, 0, result.stderr)
145
+ peeled = self.git(
146
+ checkout,
147
+ "rev-parse",
148
+ f"{RELEASE_TAG}^{{commit}}",
149
+ capture_output=True,
150
+ text=True,
151
+ ).stdout.strip()
152
+ self.assertEqual(peeled, release_commit)
153
+
154
+ def test_rejects_unknown_events_and_manual_input_on_tag_push(self):
155
+ checkout, _, release_commit = self.make_checkout()
156
+ unknown = self.prepare(checkout, release_commit, "pull_request")
157
+ self.assertNotEqual(unknown.returncode, 0)
158
+ self.assertIn("Unsupported release event", unknown.stderr)
159
+
160
+ manual_input = self.prepare(checkout, release_commit, "push")
161
+ self.assertNotEqual(manual_input.returncode, 0)
162
+ self.assertIn("do not accept a manual release commit", manual_input.stderr)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()