Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 63: [PATCH 63/68] fix(release): restore signed five-platform CI releases

ci/test/test_create_ci_release_draft.py

Before: /dev/null · After: ci/test/test_create_ci_release_draft.py

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..53199a4d30a87d4a671a350fa9d0567c64a1e5b0

Download original patch · Release notes

Diff rows 1–105 of 105. 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/test/test_create_ci_release_draft.py ADDED
@@ -0,0 +1,105 @@
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
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import unittest
13
+
14
+ import test_create_release_draft as fixtures
15
+
16
+ ROOT = fixtures.ROOT
17
+ RELEASE_TAG = fixtures.RELEASE_TAG
18
+
19
+
20
+ @unittest.skipUnless(shutil.which('gpg'), 'gpg is required')
21
+ class CreateCIDraftTest(unittest.TestCase):
22
+ @classmethod
23
+ def setUpClass(cls):
24
+ cls.temporary = tempfile.TemporaryDirectory()
25
+ cls.key_home = Path(cls.temporary.name)
26
+ cls.key_home.chmod(0o700)
27
+ cls.gpg('--passphrase', '', '--quick-generate-key', 'CI Fixture <fixture@example.invalid>', 'ed25519', 'sign', '0')
28
+ cls.fingerprint = cls.gpg('--with-colons', '--list-keys').stdout.decode().split('fpr:::::::::', 1)[1].split(':', 1)[0]
29
+ cls.public = cls.gpg('--armor', '--export', cls.fingerprint).stdout
30
+ cls.secret = cls.gpg('--armor', '--export-secret-keys', cls.fingerprint).stdout.decode()
31
+
32
+ @classmethod
33
+ def tearDownClass(cls):
34
+ cls.temporary.cleanup()
35
+
36
+ @classmethod
37
+ def gpg(cls, *args):
38
+ return subprocess.run(['gpg', '--batch', '--homedir', cls.key_home, *args], check=True, capture_output=True)
39
+
40
+ def setUp(self):
41
+ self.fixture = fixtures.CreateReleaseDraftTest()
42
+ self.fixture.setUp()
43
+ self.addCleanup(self.fixture.doCleanups)
44
+ f = self.fixture
45
+ self.public_key = f.work / 'public.asc'
46
+ self.public_key.write_bytes(self.public)
47
+ subprocess.run([ROOT / 'ci/release/sign-manifest.sh', f.manifest, self.public_key, self.fingerprint],
48
+ env={**os.environ, 'BITCOIN_ROOTS_GPG_SK': self.secret, 'REQUIRE_RELEASE_SIGNATURE': '1'},
49
+ check=True, capture_output=True)
50
+
51
+ def invoke(self, **environment):
52
+ f = self.fixture
53
+ return subprocess.run(['python3', f.repository / 'ci/release/create-ci-draft.py', '--tag', RELEASE_TAG,
54
+ '--repository', 'owner/repository', '--assets', f.assets, '--notes', f.notes,
55
+ '--public-key', self.public_key, '--fingerprint', self.fingerprint, '--run-id', '123'],
56
+ cwd=f.repository, capture_output=True, text=True,
57
+ env={**os.environ, 'PATH': f'{f.tools}:{os.environ["PATH"]}',
58
+ 'DRAFT_TEST_REMOTE': str(f.repository), 'DRAFT_TEST_RECORD': str(f.record), **environment})
59
+
60
+ def test_actual_signed_ci_draft_creates_exact_eight_assets_with_provenance(self):
61
+ result = self.invoke()
62
+ self.assertEqual(result.returncode, 0, result.stderr)
63
+ record = json.loads(self.fixture.record.read_text())
64
+ self.assertEqual(len(record['assets']), 8)
65
+ self.assertIn('SHA512SUMS.asc', record['assets'])
66
+ self.assertIn(self.fingerprint, record['notes'])
67
+ self.assertIn(self.fixture.commit, record['notes'])
68
+ self.assertIn('/actions/runs/123', record['notes'])
69
+ self.assertIn('--draft', record['args'])
70
+ self.assertNotIn('--clobber', record['args'])
71
+
72
+ def test_rejects_existing_draft_or_public_release_without_write(self):
73
+ for is_draft in (True, False):
74
+ with self.subTest(draft=is_draft):
75
+ result = self.invoke(DRAFT_TEST_RELEASES=json.dumps([[{'tag_name': RELEASE_TAG, 'draft': is_draft}]]))
76
+ self.assertNotEqual(result.returncode, 0)
77
+ self.assertIn('already exists', result.stderr)
78
+ self.assertFalse(self.fixture.record.exists())
79
+
80
+ def test_rejects_missing_or_invalid_signature_and_extra_assets(self):
81
+ signature = self.fixture.assets / 'SHA512SUMS.asc'
82
+ original = signature.read_bytes()
83
+ signature.unlink()
84
+ self.assertNotEqual(self.invoke().returncode, 0)
85
+ signature.write_bytes(b'Not a signature\n')
86
+ self.assertNotEqual(self.invoke().returncode, 0)
87
+ signature.write_bytes(original)
88
+ (self.fixture.assets / 'unexpected').write_text('extra')
89
+ self.assertNotEqual(self.invoke().returncode, 0)
90
+ self.assertFalse(self.fixture.record.exists())
91
+
92
+ def test_required_signing_rejects_missing_secret_and_wrong_public_fingerprint(self):
93
+ environment = {k: v for k, v in os.environ.items() if k != 'BITCOIN_ROOTS_GPG_SK'}
94
+ environment['REQUIRE_RELEASE_SIGNATURE'] = '1'
95
+ command = [ROOT / 'ci/release/sign-manifest.sh', self.fixture.manifest, self.public_key, self.fingerprint]
96
+ result = subprocess.run(command, env=environment, capture_output=True, text=True)
97
+ self.assertNotEqual(result.returncode, 0)
98
+ self.assertIn('Required release signing key', result.stderr)
99
+ command[-1] = '0' * 40
100
+ result = subprocess.run(command, env={**environment, 'BITCOIN_ROOTS_GPG_SK': self.secret}, capture_output=True)
101
+ self.assertNotEqual(result.returncode, 0)
102
+
103
+
104
+ if __name__ == '__main__':
105
+ unittest.main()