Skip to content
Bitcoin Roots

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

ci/verify-promotion-ci.py

Before: /dev/null · After: ci/verify-promotion-ci.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..5134ea64a8917f6d486b19caa7da0e4b4a92e242

Download original patch · Release notes

Diff rows 1–113 of 113. 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/verify-promotion-ci.py ADDED
@@ -0,0 +1,113 @@
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
+ """Reuse successful canonical CI only for a verified pure main promotion."""
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import subprocess
13
+
14
+
15
+ # These are the reviewed main-only exceptions. CI and release workflows,
16
+ # build inputs and feature tests are deliberately not allowed exceptions.
17
+ EXCEPTIONS = {'AGENTS.md', 'CONTRIBUTING.md', 'contrib/roots/README.md',
18
+ '.github/workflows/deploy-docs.yml', 'test/lint/lint-files.py',
19
+ 'test/lint/lint_ignore_dirs.py', 'test/lint/test_runner/src/main.rs'}
20
+
21
+
22
+ def run(*command):
23
+ return subprocess.check_output(command, text=True, encoding='utf8').strip()
24
+
25
+
26
+ def api(path, paginated=False):
27
+ arguments = ['gh', 'api']
28
+ if paginated:
29
+ arguments += ['--paginate', '--slurp']
30
+ return json.loads(run(*arguments, path))
31
+
32
+
33
+ def verify(event, git=run, request=api):
34
+ pr = event['pull_request']
35
+ if not pr['head']['ref'].startswith('promote/'):
36
+ return None
37
+ repository = event['repository']['full_name']
38
+ if pr['base']['ref'] != 'main' or pr['head']['repo']['full_name'] != repository:
39
+ raise ValueError('Promotion must target main from the same repository')
40
+ match = re.fullmatch(r'promote/([0-9]+\.[0-9]+)(?:[/\-].+)?', pr['head']['ref'])
41
+ if not match:
42
+ raise ValueError('Promotion branch must identify its canonical Core line')
43
+ canonical = 'roots/' + match[1]
44
+ head = pr['head']['sha']
45
+ base = pr['base']['sha']
46
+ if git('git', 'rev-parse', 'HEAD^{commit}') != head:
47
+ raise ValueError('Promotion checkout differs from PR head')
48
+ parents = git('git', 'show', '-s', '--format=%P', head).split()
49
+ if len(parents) != 2 or parents[0] != base:
50
+ raise ValueError('Promotion must have exact current main and canonical parents')
51
+ candidate = parents[1]
52
+ remote = git('git', 'ls-remote', f'https://github.com/{repository}.git', f'refs/heads/{canonical}')
53
+ if remote.split() != [candidate, f'refs/heads/{canonical}']:
54
+ raise ValueError('Promotion candidate differs from current canonical tip')
55
+ # Git diff includes content, mode and symlink-target changes together.
56
+ differences = git('git', 'diff', '--no-renames', '--name-only', candidate, head).splitlines()
57
+ if any(path not in EXCEPTIONS and not path.startswith('doc/website/') for path in differences):
58
+ raise ValueError('Promotion product/build/workflow differs from canonical')
59
+ workflow = request(f'repos/{repository}/actions/workflows/ci.yml')
60
+ # GitHub can clear run.pull_requests after merge. Associated PRs retain
61
+ # the exact source head/ref and canonical target needed for provenance.
62
+ pr_pages = request(f'repos/{repository}/commits/{candidate}/pulls?per_page=100', True)
63
+ source_prs = [source for page in pr_pages for source in page
64
+ if source['base']['ref'] == canonical and source['head']['sha'] == candidate
65
+ and source['head']['repo']['full_name'] == repository
66
+ and source['base']['repo']['full_name'] == repository]
67
+ if len(source_prs) != 1:
68
+ raise ValueError('No unique exact canonical-target candidate PR')
69
+ source_branch = source_prs[0]['head']['ref']
70
+ pages = request(f'repos/{repository}/actions/workflows/ci.yml/runs?head_sha={candidate}&event=pull_request&per_page=100', True)
71
+ if not pages or sum(len(page['workflow_runs']) for page in pages) != pages[0]['total_count']:
72
+ raise ValueError('Candidate CI run inventory is incomplete')
73
+ runs = [item for page in pages for item in page['workflow_runs']]
74
+ matching = []
75
+ for item in runs:
76
+ if (item['head_sha'] != candidate or item['event'] != 'pull_request' or
77
+ item['workflow_id'] != workflow['id'] or item['path'] != '.github/workflows/ci.yml' or
78
+ item['repository']['full_name'] != repository or
79
+ item['head_repository']['full_name'] != repository):
80
+ raise ValueError('Candidate CI run identity mismatch')
81
+ if item['head_branch'] == source_branch:
82
+ matching.append(item)
83
+ if not matching:
84
+ raise ValueError('No exact canonical-target candidate CI evidence')
85
+ latest = max(matching, key=lambda item: (item['created_at'], item['id']))
86
+ if latest['status'] != 'completed' or latest['conclusion'] != 'success':
87
+ raise ValueError('Latest exact candidate CI is not successful')
88
+ job_pages = request(f'repos/{repository}/actions/runs/{latest["id"]}/jobs?per_page=100', True)
89
+ jobs = [job for page in job_pages for job in page['jobs']]
90
+ if not job_pages or len(jobs) != job_pages[0]['total_count']:
91
+ raise ValueError('Candidate job inventory is incomplete')
92
+ for name in ('classify changes', 'lint', 'required result'):
93
+ matches = [job for job in jobs if job['name'] == name]
94
+ if len(matches) != 1 or matches[0]['status'] != 'completed' or matches[0]['conclusion'] != 'success':
95
+ raise ValueError(f'Candidate required evidence failed: {name}')
96
+ return {'candidate': candidate, 'run_id': latest['id'], 'canonical': canonical,
97
+ 'exceptions': differences}
98
+
99
+
100
+ def main():
101
+ event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())
102
+ # Explicit CI labels request fresh coverage, including reproducibility.
103
+ labels = [label['name'] for label in event['pull_request']['labels']]
104
+ requested = any(label.startswith('ci:') for label in labels)
105
+ proof = None if requested else verify(event)
106
+ reuse = proof is not None
107
+ with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf8') as output:
108
+ output.write(f"reuse={'true' if reuse else 'false'}\n")
109
+ print(json.dumps({'reuse': reuse, 'proof': proof}, sort_keys=True))
110
+
111
+
112
+ if __name__ == '__main__':
113
+ main()