Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 66: [PATCH 66/68] fix(release): validate notices and reuse proven promotion CI

ci/test/test_promotion_ci.py

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

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..344759a5d19a4d19aad579d75d2bc288f281e526

Download original patch · Release notes

Diff rows 1–197 of 197. 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_promotion_ci.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 importlib.util
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import tempfile
11
+ import unittest
12
+ from unittest.mock import patch
13
+
14
+ ROOT = Path(__file__).resolve().parents[2]
15
+
16
+
17
+ def load(name, path):
18
+ spec = importlib.util.spec_from_file_location(name, ROOT / path)
19
+ module = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ promotion = load('promotion', 'ci/verify-promotion-ci.py')
25
+ classifier = load('classifier', 'ci/change-classifier.py')
26
+
27
+
28
+ class PromotionCITest(unittest.TestCase):
29
+ def setUp(self):
30
+ self.repository = 'owner/roots'
31
+ self.head, self.base, self.candidate = 'a' * 40, 'b' * 40, 'c' * 40
32
+ repo = {'full_name': self.repository}
33
+ self.event = {'repository': repo, 'pull_request': {
34
+ 'head': {'ref': 'promote/30.3-fix', 'sha': self.head, 'repo': repo},
35
+ 'base': {'ref': 'main', 'sha': self.base, 'repo': repo}, 'labels': []}}
36
+ self.source_pr = {'head': {'sha': self.candidate, 'repo': repo, 'ref': 'topic/30.3/fix'},
37
+ 'base': {'ref': 'roots/30.3', 'repo': repo}}
38
+ self.run = {'id': 10, 'head_sha': self.candidate, 'event': 'pull_request',
39
+ 'repository': repo, 'head_repository': repo,
40
+ 'workflow_id': 4, 'path': '.github/workflows/ci.yml',
41
+ 'status': 'completed', 'conclusion': 'success',
42
+ 'created_at': '2026-10-08T10:00:00Z', 'head_branch': 'topic/30.3/fix',
43
+ 'pull_requests': []}
44
+ self.runs = [self.run]
45
+ self.jobs = [{'name': name, 'status': 'completed', 'conclusion': 'success'}
46
+ for name in ('classify changes', 'lint', 'required result')]
47
+ self.differences = ['AGENTS.md', 'doc/website/index.md', 'test/lint/lint-files.py']
48
+
49
+ def git(self, *args):
50
+ if args[1] == 'rev-parse':
51
+ return self.head
52
+ if args[1] == 'show':
53
+ return self.base + ' ' + self.candidate
54
+ if args[1] == 'ls-remote':
55
+ return self.candidate + '\trefs/heads/roots/30.3'
56
+ self.assertEqual(args, ('git', 'diff', '--no-renames', '--name-only', self.candidate, self.head))
57
+ return '\n'.join(self.differences)
58
+
59
+ def api(self, path, paginated=False):
60
+ if path.endswith('/actions/workflows/ci.yml'):
61
+ return {'id': 4}
62
+ if '/actions/workflows/ci.yml/runs?' in path:
63
+ self.assertTrue(paginated)
64
+ self.assertIn('head_sha=' + self.candidate, path)
65
+ return [{'total_count': len(self.runs), 'workflow_runs': self.runs}]
66
+ if '/commits/' in path and '/pulls?' in path:
67
+ self.assertTrue(paginated)
68
+ return [[self.source_pr]]
69
+ self.assertTrue(paginated)
70
+ self.assertIn('/actions/runs/10/jobs?', path)
71
+ return [{'total_count': len(self.jobs), 'jobs': self.jobs}]
72
+
73
+ def verify(self):
74
+ return promotion.verify(self.event, self.git, self.api)
75
+
76
+ def test_exact_successful_canonical_ci_and_precise_tree_parity(self):
77
+ self.assertEqual(self.run['pull_requests'], [])
78
+ self.assertEqual(self.verify()['candidate'], self.candidate)
79
+ for path in ('ci/release/runner-notices.py', '.github/workflows/ci.yml',
80
+ 'src/node.cpp', 'test/functional/feature.py'):
81
+ self.differences = [path]
82
+ with self.assertRaisesRegex(ValueError, 'differs from canonical'):
83
+ self.verify()
84
+
85
+ def test_ordinary_pr_never_reuses_and_fork_promotion_is_rejected(self):
86
+ self.event['pull_request']['head']['ref'] = 'topic/30.3/fix'
87
+ self.assertIsNone(self.verify())
88
+ self.event['pull_request']['head']['ref'] = 'promote/30.3-fix'
89
+ self.event['pull_request']['head']['repo'] = {'full_name': 'fork/roots'}
90
+ with self.assertRaisesRegex(ValueError, 'same repository'):
91
+ self.verify()
92
+
93
+ def test_latest_failed_or_running_run_cannot_use_older_success(self):
94
+ newer = {**self.run, 'id': 11, 'created_at': '2026-10-08T11:00:00Z'}
95
+ self.runs.append(newer)
96
+ for status, conclusion in [('completed', 'failure'), ('in_progress', None),
97
+ ('completed', 'cancelled')]:
98
+ newer.update(status=status, conclusion=conclusion)
99
+ with self.assertRaisesRegex(ValueError, 'Latest exact candidate'):
100
+ self.verify()
101
+
102
+ def test_wrong_candidate_target_repo_workflow_and_missing_job_fail(self):
103
+ for field, value in [('head_sha', 'd' * 40), ('workflow_id', 99),
104
+ ('path', '.github/workflows/release.yml'),
105
+ ('head_repository', {'full_name': 'fork/roots'})]:
106
+ old = self.run[field]
107
+ self.run[field] = value
108
+ with self.assertRaisesRegex(ValueError, 'identity mismatch'):
109
+ self.verify()
110
+ self.run[field] = old
111
+ self.source_pr['base']['ref'] = 'main'
112
+ with self.assertRaisesRegex(ValueError, 'canonical-target'):
113
+ self.verify()
114
+ self.source_pr['base']['ref'] = 'roots/30.3'
115
+ self.jobs.pop()
116
+ with self.assertRaisesRegex(ValueError, 'required evidence'):
117
+ self.verify()
118
+
119
+ def test_ambiguous_associated_pr_or_wrong_run_branch_cannot_authorize_reuse(self):
120
+ def ambiguous(path, paginated=False):
121
+ result = self.api(path, paginated)
122
+ if '/commits/' in path:
123
+ result[0].append(dict(self.source_pr))
124
+ return result
125
+ with self.assertRaisesRegex(ValueError, 'unique exact canonical-target'):
126
+ promotion.verify(self.event, self.git, ambiguous)
127
+ self.run['head_branch'] = 'unrelated-branch'
128
+ with self.assertRaisesRegex(ValueError, 'canonical-target'):
129
+ self.verify()
130
+
131
+ def test_incomplete_pagination_fails(self):
132
+ def truncated(path, paginated=False):
133
+ result = self.api(path, paginated)
134
+ if '/runs?' in path:
135
+ result[0]['total_count'] += 1
136
+ return result
137
+ with self.assertRaisesRegex(ValueError, 'inventory is incomplete'):
138
+ promotion.verify(self.event, self.git, truncated)
139
+
140
+ def test_git_parity_detects_mode_symlink_and_move_into_exception(self):
141
+ with tempfile.TemporaryDirectory() as temporary:
142
+ directory = Path(temporary)
143
+ def git(*args):
144
+ return promotion.run('git', '-C', temporary, *args)
145
+ git('init', '--quiet')
146
+ git('config', 'user.name', 'CI parity fixture')
147
+ git('config', 'user.email', 'fixture@invalid')
148
+ (directory / 'src').mkdir()
149
+ script = directory / 'src/tool.py'
150
+ script.write_text('fixture\n')
151
+ (directory / 'src/link').symlink_to('one')
152
+ moved = directory / 'src/moved.txt'
153
+ moved.write_text('file that must remain in product\n')
154
+ git('add', '.')
155
+ git('commit', '--quiet', '-m', 'fixture base')
156
+ base = git('rev-parse', 'HEAD')
157
+ script.chmod(0o755)
158
+ (directory / 'src/link').unlink()
159
+ (directory / 'src/link').symlink_to('two')
160
+ (directory / 'doc/website').mkdir(parents=True)
161
+ moved.rename(directory / 'doc/website/moved.txt')
162
+ git('add', '.')
163
+ git('commit', '--quiet', '-m', 'mode link and move')
164
+ differences = git('diff', '--no-renames', '--name-only', base, 'HEAD').splitlines()
165
+ self.assertTrue({'src/tool.py', 'src/link', 'src/moved.txt'} <= set(differences))
166
+
167
+ def test_reuse_keeps_baseline_and_labels_force_selected_fresh_coverage(self):
168
+ policy = json.loads((ROOT / 'ci/change-classifier-policy.json').read_text())
169
+ normal = classifier.result_for(['ci/release/runner-notices.py'], [], False, False, policy)
170
+ reused = classifier.result_for(['ci/release/runner-notices.py'], [], False, False, policy, True)
171
+ self.assertTrue(normal['selected']['platforms'])
172
+ self.assertTrue(reused['selected']['baseline'])
173
+ self.assertFalse(any(reused['selected'][key] for key in classifier.BROAD_SELECTION))
174
+ for label in classifier.KNOWN_LABELS:
175
+ expected = classifier.result_for(['ci/test/fix.py'], [label], False, False, policy)
176
+ actual = classifier.result_for(['ci/test/fix.py'], [label], False, False, policy, True)
177
+ self.assertEqual(actual, expected)
178
+ incomplete = classifier.result_for(None, [], True, True, policy, True)
179
+ self.assertTrue(incomplete['broad'])
180
+
181
+ def test_explicit_label_bypasses_reuse_and_workflow_keeps_required_baseline(self):
182
+ self.event['pull_request']['labels'] = [{'name': 'ci:full'}]
183
+ with tempfile.TemporaryDirectory() as temporary:
184
+ event_path, output = Path(temporary) / 'event.json', Path(temporary) / 'output'
185
+ event_path.write_text(json.dumps(self.event))
186
+ with patch.dict(os.environ, {'GITHUB_EVENT_PATH': str(event_path), 'GITHUB_OUTPUT': str(output)}), \
187
+ patch.object(promotion, 'verify', side_effect=AssertionError('no reuse')):
188
+ promotion.main()
189
+ self.assertEqual(output.read_text(), 'reuse=false\n')
190
+ workflow = (ROOT / '.github/workflows/ci.yml').read_text()
191
+ self.assertIn('run: python3 ci/verify-promotion-ci.py', workflow)
192
+ self.assertIn('check_selected true "$CLASSIFY" classify', workflow)
193
+ self.assertIn('check_selected true "$LINT" lint', workflow)
194
+
195
+
196
+ if __name__ == '__main__':
197
+ unittest.main()