v30.3-roots.1 · Commit 63: [PATCH 63/68] fix(release): restore signed five-platform CI releases
ci/release/create-ci-draft.py
Before: /dev/null · After: ci/release/create-ci-draft.py
File metadata
new file mode 100644 index 0000000000000000000000000000000000000000..90d123f1a5b5422bd3af80a1fd24c4cfe38085f6
Download original patch · Release notes
Diff rows 1–103 of 103. 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/release/create-ci-draft.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
"""Create one signed CI draft from this run's validated five-platform outputs."""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import importlib.util
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
spec = importlib.util.spec_from_file_location('draft', Path(__file__).with_name('create-draft.py'))
|
|
18
|
+
draft = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(draft)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_signature(manifest, signature, public_key, fingerprint, home):
|
|
23
|
+
if not re.fullmatch(r'[0-9A-F]{40}', fingerprint):
|
|
24
|
+
raise ValueError('Invalid expected signing fingerprint')
|
|
25
|
+
environment = {**os.environ, 'GNUPGHOME': str(home)}
|
|
26
|
+
home.mkdir(mode=0o700)
|
|
27
|
+
imported = draft.run(['gpg', '--batch', '--with-colons', '--import-options', 'show-only',
|
|
28
|
+
'--import', str(public_key)], env=environment)
|
|
29
|
+
fingerprints = [line.split(':')[9] for line in imported.splitlines() if line.startswith('fpr:')]
|
|
30
|
+
if not fingerprints or fingerprints[0] != fingerprint:
|
|
31
|
+
raise ValueError('Release public key differs from expected fingerprint')
|
|
32
|
+
draft.run(['gpg', '--batch', '--import', str(public_key)], env=environment)
|
|
33
|
+
status = draft.run(['gpg', '--batch', '--status-fd=1', '--verify', str(signature), str(manifest)], env=environment)
|
|
34
|
+
valid = [line.split() for line in status.splitlines() if line.startswith('[GNUPG:] VALIDSIG ')]
|
|
35
|
+
if len(valid) != 1 or fingerprint not in (valid[0][2], valid[0][-1]):
|
|
36
|
+
raise ValueError('Manifest signature does not match expected release key')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create(args):
|
|
40
|
+
expected = {*draft.PACKAGE_NAMES, f'bitcoin-roots-{args.tag[1:]}.patch', 'SHA512SUMS', 'SHA512SUMS.asc'}
|
|
41
|
+
if not args.assets.is_dir() or args.assets.is_symlink() or set(p.name for p in args.assets.iterdir()) != expected:
|
|
42
|
+
raise ValueError('Signed CI draft requires exactly eight release assets')
|
|
43
|
+
for path in args.assets.iterdir():
|
|
44
|
+
if path.is_symlink() or not path.is_file() or not path.stat().st_size:
|
|
45
|
+
raise ValueError('Release asset must be a nonempty regular file')
|
|
46
|
+
if not re.fullmatch(r'[1-9][0-9]*', args.run_id):
|
|
47
|
+
raise ValueError('CI run identity must be a positive integer')
|
|
48
|
+
args.commit = draft.run(['git', 'rev-parse', 'HEAD^{commit}'])
|
|
49
|
+
with tempfile.TemporaryDirectory(prefix='roots-signed-ci-draft-') as temporary_dir:
|
|
50
|
+
temporary = Path(temporary_dir)
|
|
51
|
+
unsigned = temporary / 'unsigned'
|
|
52
|
+
unsigned.mkdir()
|
|
53
|
+
for name in expected - {'SHA512SUMS.asc'}:
|
|
54
|
+
shutil.copyfile(args.assets / name, unsigned / name)
|
|
55
|
+
template = args.notes.read_text()
|
|
56
|
+
notes = temporary / 'notes.md'
|
|
57
|
+
notes.write_text(template + '\n## CI build and signing provenance\n\n'
|
|
58
|
+
f'All five platform packages were built from `{args.commit}` in '
|
|
59
|
+
f'[GitHub Actions run {args.run_id}](https://github.com/{args.repository}/actions/runs/{args.run_id}). '
|
|
60
|
+
'Dependency notices were collected from each build runner.\n\n'
|
|
61
|
+
f'`SHA512SUMS.asc` signs the SHA512 manifest with release key `{args.fingerprint}`. '
|
|
62
|
+
'This checksum signature is separate from Git-tag and platform-code signing. '
|
|
63
|
+
'The annotated Git tag is unsigned. macOS applications retain ad-hoc signatures, '
|
|
64
|
+
'without Developer ID signing or notarization; Windows executables have no Authenticode signature.\n')
|
|
65
|
+
args.assets = unsigned
|
|
66
|
+
args.notes = notes
|
|
67
|
+
args.manifest_sha512 = draft.digest(unsigned / 'SHA512SUMS', 'sha512')
|
|
68
|
+
args.notes_sha256 = draft.digest(notes, 'sha256')
|
|
69
|
+
snapshot = temporary / 'snapshot'
|
|
70
|
+
snapshot.mkdir()
|
|
71
|
+
names, patch, snapshot_notes = draft.snapshot_packet(args, snapshot)
|
|
72
|
+
# snapshot_packet validates every checksum, COPYING and platform notice index.
|
|
73
|
+
signature = snapshot / 'SHA512SUMS.asc'
|
|
74
|
+
shutil.copyfile(Path(args.original_assets) / signature.name, signature)
|
|
75
|
+
verify_signature(snapshot / 'SHA512SUMS', signature, args.public_key, args.fingerprint, temporary / 'verify-home')
|
|
76
|
+
tag_object, core_version = draft.validate_source(args, snapshot / patch, temporary)
|
|
77
|
+
# Refresh identities/inventory only after all slow local checks. Existing
|
|
78
|
+
# drafts/public releases fail; an interrupted create is inspected by an operator.
|
|
79
|
+
draft.validate_remote(args, tag_object, core_version)
|
|
80
|
+
command = ['gh', 'release', 'create', args.tag, '--repo', args.repository, '--draft', '--verify-tag',
|
|
81
|
+
'--title', f'Bitcoin Roots {args.tag}', '--notes-file', str(snapshot_notes)]
|
|
82
|
+
if re.search(r'rc[0-9]+-roots\.', args.tag):
|
|
83
|
+
command.append('--prerelease')
|
|
84
|
+
command.extend(str(snapshot / name) for name in sorted([*names, signature.name]))
|
|
85
|
+
print(draft.run(command))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
90
|
+
for name in ('tag', 'repository', 'fingerprint', 'run-id'):
|
|
91
|
+
parser.add_argument('--' + name, required=True)
|
|
92
|
+
for name in ('assets', 'notes', 'public-key'):
|
|
93
|
+
parser.add_argument('--' + name, required=True, type=Path)
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
args.original_assets = args.assets
|
|
96
|
+
try:
|
|
97
|
+
create(args)
|
|
98
|
+
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
|
|
99
|
+
parser.exit(1, f'{error}\n')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == '__main__':
|
|
103
|
+
main()
|