Skip to content
Bitcoin Roots

v29.4-roots.3 · Commit 7: [PATCH 07/30] release: establish Roots maintenance and assurance

ci/test/test_validate_release_source.py

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

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..5b12192d6e5fc28dd58562314aa64fd1da609351

Download original patch · Release notes

Diff rows 1–127 of 127. 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_validate_release_source.py ADDED
@@ -0,0 +1,127 @@
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 os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import tempfile
10
+ import unittest
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ SCRIPT = ROOT / "ci/release/validate-release-source.sh"
15
+ RELEASE_TAG = "v29.4-roots.1"
16
+ CANONICAL_REF = "refs/heads/roots/29.4"
17
+
18
+
19
+ class ValidateReleaseSourceTest(unittest.TestCase):
20
+ def git(self, repository, *args, **kwargs):
21
+ return subprocess.run(["git", "-C", repository, *args], check=True, **kwargs)
22
+
23
+ def make_repository(self, *, with_core_tag=True):
24
+ temporary_dir = tempfile.TemporaryDirectory()
25
+ repository = Path(temporary_dir.name)
26
+ self.addCleanup(temporary_dir.cleanup)
27
+ subprocess.run(["git", "init", "--initial-branch=main", repository], check=True, capture_output=True)
28
+ self.git(repository, "config", "user.name", "Release test")
29
+ self.git(repository, "config", "user.email", "release@example.invalid")
30
+ (repository / "source").write_text("core\n", encoding="utf-8")
31
+ self.git(repository, "add", "source")
32
+ self.git(repository, "commit", "-m", "core", capture_output=True)
33
+ if with_core_tag:
34
+ self.git(repository, "tag", "-a", "v29.4", "-m", "Core v29.4")
35
+ self.git(repository, "switch", "-c", "roots/29.4", capture_output=True)
36
+ (repository / "source").write_text("roots\n", encoding="utf-8")
37
+ self.git(repository, "commit", "-am", "roots", capture_output=True)
38
+ return repository
39
+
40
+ def tag_and_checkout(self, repository, tag=RELEASE_TAG, *, annotated=True):
41
+ args = ["tag"]
42
+ if annotated:
43
+ args.extend(["-a", tag, "-m", tag])
44
+ else:
45
+ args.append(tag)
46
+ self.git(repository, *args)
47
+ self.git(repository, "checkout", "--detach", tag, capture_output=True)
48
+
49
+ def validate(self, repository, tag=RELEASE_TAG, canonical_ref=CANONICAL_REF):
50
+ return subprocess.run(
51
+ [SCRIPT, tag], cwd=repository,
52
+ env={**os.environ, "RELEASE_CANONICAL_REF": canonical_ref},
53
+ capture_output=True, text=True,
54
+ )
55
+
56
+ def test_accepts_annotated_tag_at_exact_canonical_tip(self):
57
+ repository = self.make_repository()
58
+ self.tag_and_checkout(repository)
59
+ self.assertEqual(self.validate(repository).returncode, 0)
60
+
61
+ def test_rejects_lightweight_tag(self):
62
+ repository = self.make_repository()
63
+ self.tag_and_checkout(repository, annotated=False)
64
+ self.assertNotEqual(self.validate(repository).returncode, 0)
65
+
66
+ def test_rejects_head_mismatch(self):
67
+ repository = self.make_repository()
68
+ self.git(repository, "tag", "-a", RELEASE_TAG, "-m", "release")
69
+ self.git(repository, "switch", "--detach", "v29.4", capture_output=True)
70
+ self.assertNotEqual(self.validate(repository).returncode, 0)
71
+
72
+ def test_rejects_missing_canonical_reference(self):
73
+ repository = self.make_repository()
74
+ self.tag_and_checkout(repository)
75
+ self.assertNotEqual(self.validate(repository, canonical_ref="missing").returncode, 0)
76
+
77
+ def test_rejects_tag_behind_canonical_tip(self):
78
+ repository = self.make_repository()
79
+ self.git(repository, "tag", "-a", RELEASE_TAG, "-m", "release")
80
+ (repository / "later").write_text("later\n", encoding="utf-8")
81
+ self.git(repository, "add", "later")
82
+ self.git(repository, "commit", "-m", "later", capture_output=True)
83
+ self.git(repository, "checkout", "--detach", RELEASE_TAG, capture_output=True)
84
+ result = self.validate(repository)
85
+ self.assertNotEqual(result.returncode, 0)
86
+ self.assertIn("does not match canonical branch tip", result.stderr)
87
+
88
+ def test_rejects_missing_core_base(self):
89
+ repository = self.make_repository(with_core_tag=False)
90
+ self.tag_and_checkout(repository)
91
+ result = self.validate(repository)
92
+ self.assertNotEqual(result.returncode, 0)
93
+ self.assertIn("Bitcoin Core base reference is unavailable", result.stderr)
94
+
95
+ def test_rejects_core_base_that_is_not_an_ancestor(self):
96
+ repository = self.make_repository(with_core_tag=False)
97
+ self.git(repository, "tag", "-a", RELEASE_TAG, "-m", "release")
98
+ self.git(repository, "switch", "--orphan", "unrelated-core", capture_output=True)
99
+ self.git(repository, "rm", "-rf", "--ignore-unmatch", "source", capture_output=True)
100
+ (repository / "source").write_text("unrelated\n", encoding="utf-8")
101
+ self.git(repository, "add", "source")
102
+ self.git(repository, "commit", "-m", "unrelated core", capture_output=True)
103
+ self.git(repository, "tag", "-a", "v29.4", "-m", "Core v29.4")
104
+ self.git(repository, "checkout", "--detach", RELEASE_TAG, capture_output=True)
105
+ result = self.validate(repository)
106
+ self.assertNotEqual(result.returncode, 0)
107
+ self.assertIn("is not based on", result.stderr)
108
+
109
+ def test_rejects_merge_in_patch_stack(self):
110
+ repository = self.make_repository()
111
+ self.git(repository, "switch", "-c", "topic", capture_output=True)
112
+ (repository / "topic").write_text("topic\n", encoding="utf-8")
113
+ self.git(repository, "add", "topic")
114
+ self.git(repository, "commit", "-m", "topic", capture_output=True)
115
+ self.git(repository, "switch", "roots/29.4", capture_output=True)
116
+ (repository / "mainline").write_text("mainline\n", encoding="utf-8")
117
+ self.git(repository, "add", "mainline")
118
+ self.git(repository, "commit", "-m", "mainline", capture_output=True)
119
+ self.git(repository, "merge", "--no-ff", "topic", "-m", "merge topic", capture_output=True)
120
+ self.tag_and_checkout(repository)
121
+ result = self.validate(repository)
122
+ self.assertNotEqual(result.returncode, 0)
123
+ self.assertIn("must be linear", result.stderr)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()