Skip to content
Bitcoin Roots

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