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. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
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()
|