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