Skip to content

Commit 393e457

Browse files
committed
ci(release): publish reviewed Maven bundles with recoverable deployments
1 parent 05ccf63 commit 393e457

8 files changed

Lines changed: 985 additions & 4 deletions

File tree

‎.github/scripts/central_release.py‎

Lines changed: 376 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env python3
2+
"""Exercise release signing with a disposable key and no Central upload."""
3+
import os
4+
from pathlib import Path
5+
import subprocess
6+
import tarfile
7+
import tempfile
8+
9+
import central_release
10+
import check_package
11+
import check_repository
12+
13+
14+
def main():
15+
root = check_repository.ROOT
16+
with tempfile.TemporaryDirectory(prefix='qca-central-dry-run-') as temporary:
17+
work = Path(temporary)
18+
source, keys, candidate, bundle = (work / name for name in ('source', 'keys', 'candidate', 'bundle'))
19+
source.mkdir()
20+
keys.mkdir(mode=0o700)
21+
archive = work / 'source.tar'
22+
subprocess.run(['git', 'archive', '--output', str(archive), 'HEAD'], cwd=root, check=True)
23+
with tarfile.open(archive) as tar:
24+
tar.extractall(source, filter='data')
25+
version = check_repository.check_versions(root)
26+
for pom in (source / 'pom.xml', source / 'examples/pom.xml'):
27+
pom.write_text(pom.read_text().replace(version, '0.0.0'))
28+
env = dict(os.environ, GNUPGHOME=str(keys), MAVEN_GPG_PASSPHRASE='disposable-test-key')
29+
for name in ('CENTRAL_USERNAME', 'CENTRAL_PASSWORD'):
30+
env.pop(name, None)
31+
gpg = ['gpg', '--batch', '--pinentry-mode', 'loopback', '--passphrase', env['MAVEN_GPG_PASSPHRASE']]
32+
try:
33+
subprocess.run(gpg + ['--quick-generate-key', 'SDK Release Test <test@example.invalid>',
34+
'rsa2048', 'sign', '1d'], env=env, check=True)
35+
env['MAVEN_GPG_KEY'] = subprocess.check_output(
36+
gpg + ['--armor', '--export-secret-keys'], env=env, text=True)
37+
subprocess.run(['./mvnw', '-B', '-ntp', 'clean', 'package'], cwd=source, env=env, check=True)
38+
check_package.check(source)
39+
candidate.mkdir()
40+
(candidate / 'package-check.json').write_bytes((source / 'target/package-check.json').read_bytes())
41+
(candidate / 'release-notes.md').write_text('Disposable signing check; never published.\n')
42+
subprocess.run(['./mvnw', '-B', '-ntp', '-Pcentral', '-Dgpg.signer=bc',
43+
'-Dcentral.skipUpload=true', '-DskipTests', 'clean', 'deploy'],
44+
cwd=source, env=env, check=True)
45+
os.environ.update(RELEASE_VERSION='0.0.0', RELEASE_SHA='a' * 40, RELEASE_BATCH='offline-signing-check',
46+
GITHUB_REPOSITORY=central_release.REPOSITORY, GITHUB_REF='refs/heads/main',
47+
GITHUB_EVENT_NAME='workflow_dispatch', GITHUB_RUN_ID='1', GITHUB_SHA='a' * 40)
48+
central_release.save(candidate / 'intent.json', central_release.identity())
49+
central_release.seal(source, candidate, bundle)
50+
contents = central_release.read_bundle(bundle / 'central-bundle.zip', '0.0.0')
51+
for name, data in contents.items():
52+
if not name.endswith('.asc'):
53+
continue
54+
signature, payload = work / 'signature.asc', work / 'payload'
55+
signature.write_bytes(data)
56+
payload.write_bytes(contents[name[:-4]])
57+
subprocess.run(['gpg', '--batch', '--verify', str(signature), str(payload)], env=env, check=True)
58+
print('Central dry run passed: reproducible SDK/POM/sources/Javadoc, four valid signatures, no upload')
59+
finally:
60+
subprocess.run(['gpgconf', '--kill', 'gpg-agent'], env=env, check=False)
61+
62+
63+
if __name__ == '__main__':
64+
main()
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import hashlib
2+
import io
3+
import json
4+
import os
5+
from pathlib import Path
6+
import tempfile
7+
import unittest
8+
from unittest.mock import patch
9+
import urllib.error
10+
import zipfile
11+
12+
import central_release as release
13+
14+
ENV = dict(RELEASE_VERSION='1.2.3', RELEASE_SHA='a' * 40, RELEASE_BATCH='test-batch',
15+
GITHUB_REPOSITORY=release.REPOSITORY, GITHUB_REF='refs/heads/main',
16+
GITHUB_EVENT_NAME='workflow_dispatch', GITHUB_SHA='b' * 40, GITHUB_RUN_ID='123',
17+
RESUME_RUN_ID='', CENTRAL_DEPLOYMENT_ID='')
18+
UUID = '12345678-1234-1234-1234-123456789abc'
19+
20+
21+
def write_bundle(path, transform=lambda values: values):
22+
prefix = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3'
23+
values = {}
24+
for suffix in ('.pom', '.jar', '-sources.jar', '-javadoc.jar'):
25+
name = prefix + suffix
26+
values[name] = ('payload' + suffix).encode()
27+
values[name + '.asc'] = b'synthetic signature'
28+
for algo in ('md5', 'sha1', 'sha256', 'sha512'):
29+
values[name + '.' + algo] = hashlib.new(algo, values[name]).hexdigest().encode()
30+
with zipfile.ZipFile(path, 'w') as archive:
31+
for name, data in transform(values).items():
32+
archive.writestr(name, data)
33+
return values
34+
35+
36+
class CentralReleaseTests(unittest.TestCase):
37+
def setUp(self):
38+
self.env = patch.dict(os.environ, ENV)
39+
self.env.start()
40+
self.addCleanup(self.env.stop)
41+
self.temp = tempfile.TemporaryDirectory()
42+
self.addCleanup(self.temp.cleanup)
43+
self.output = Path(self.temp.name)
44+
self.contents = write_bundle(self.output / 'central-bundle.zip')
45+
self.info = dict(release.identity(), run_id='123', workflow_sha='b' * 40,
46+
bundle_sha256=hashlib.sha256((self.output / 'central-bundle.zip').read_bytes()).hexdigest(),
47+
files={n: hashlib.sha256(v).hexdigest() for n, v in self.contents.items()})
48+
release.save(self.output / 'manifest.json', self.info)
49+
release.save(self.output / 'receipt.json', dict(deployment_id=UUID, bundle_sha256=self.info['bundle_sha256']))
50+
(self.output / 'release-notes.md').write_text('Reviewed notes\n')
51+
52+
def status(self, state, **changes):
53+
return json.dumps(dict(deploymentId=UUID, deploymentName=release.deployment_name(self.info),
54+
deploymentState=state, purls=['pkg:maven/com.qoder/qca-sdk@1.2.3'],
55+
**changes)).encode()
56+
57+
def test_identity_rejects_forks_events_and_snapshots(self):
58+
for key, value in [('GITHUB_REPOSITORY', 'someone/fork'), ('GITHUB_REF', 'refs/heads/feature'),
59+
('GITHUB_EVENT_NAME', 'push'), ('RELEASE_VERSION', '1.2.3-SNAPSHOT')]:
60+
with self.subTest(key=key), patch.dict(os.environ, {key: value}), self.assertRaises(ValueError):
61+
release.identity()
62+
63+
def test_bundle_requires_signatures_checksums_and_exact_coordinates(self):
64+
self.assertEqual(self.info, release.load(self.output))
65+
prefix = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3'
66+
for name in (prefix + '.pom.asc', prefix + '.jar.md5'):
67+
with self.subTest(name=name):
68+
write_bundle(self.output / 'central-bundle.zip', lambda values: {k: v for k, v in values.items() if k != name})
69+
with self.assertRaises(ValueError):
70+
release.read_bundle(self.output / 'central-bundle.zip', '1.2.3')
71+
write_bundle(self.output / 'central-bundle.zip', lambda values: dict(values, **{'../credential.txt': b'private'}))
72+
with self.assertRaisesRegex(ValueError, 'only the signed SDK'):
73+
release.read_bundle(self.output / 'central-bundle.zip', '1.2.3')
74+
75+
def test_bundle_rejects_wrong_checksums_and_manifest_tampering(self):
76+
key = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3.jar.sha1'
77+
write_bundle(self.output / 'central-bundle.zip', lambda values: dict(values, **{key: b'bad'}))
78+
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
79+
release.read_bundle(self.output / 'central-bundle.zip', '1.2.3')
80+
with self.assertRaisesRegex(ValueError, 'Bundle digest differs'):
81+
release.load(self.output)
82+
83+
def test_seal_detects_rebuild_differences(self):
84+
source, candidate = self.output / 'source', self.output / 'candidate'
85+
bundle_dir = source / 'target/central-publishing'
86+
bundle_dir.mkdir(parents=True)
87+
(bundle_dir / 'central-bundle.zip').write_bytes((self.output / 'central-bundle.zip').read_bytes())
88+
release.save(candidate / 'intent.json', release.identity())
89+
checks = {}
90+
for suffix in ('.pom', '.jar', '-sources.jar', '-javadoc.jar'):
91+
filename = 'qca-sdk-1.2.3' + suffix
92+
key = 'pom.xml' if suffix == '.pom' else 'target/' + filename
93+
checks[key] = self.info['files']['com/qoder/qca-sdk/1.2.3/' + filename]
94+
release.save(candidate / 'package-check.json', dict(sha256=checks))
95+
(candidate / 'release-notes.md').write_text('Reviewed notes\n')
96+
release.seal(source, candidate, self.output / 'sealed')
97+
checks['target/qca-sdk-1.2.3.jar'] = '0' * 64
98+
release.save(candidate / 'package-check.json', dict(sha256=checks))
99+
with self.assertRaisesRegex(ValueError, 'differs from reviewed build'):
100+
release.seal(source, candidate, self.output / 'sealed')
101+
102+
def test_ci_requires_latest_success_and_all_five_jdks(self):
103+
runs = dict(workflow_runs=[dict(id=10, head_sha='a' * 40, conclusion='success')])
104+
jobs = dict(jobs=[dict(name=name, conclusion='success') for name in release.CHECKS])
105+
with patch.object(release, 'gh', side_effect=[runs, jobs]):
106+
release.check_ci(self.info)
107+
jobs['jobs'][0]['conclusion'] = 'skipped'
108+
with patch.object(release, 'gh', side_effect=[runs, jobs]), self.assertRaisesRegex(ValueError, 'all five'):
109+
release.check_ci(self.info)
110+
runs['workflow_runs'].append(dict(id=11, head_sha='a' * 40, conclusion='failure'))
111+
with patch.object(release, 'gh', return_value=runs), self.assertRaisesRegex(ValueError, 'Latest main CI'):
112+
release.check_ci(self.info)
113+
114+
def test_tag_rejects_moved_sha_and_changed_bundle(self):
115+
ref = dict(object=dict(type='tag', sha='c' * 40))
116+
tag = dict(object=dict(type='commit', sha='a' * 40,
117+
url=f'https://api.github.com/repos/{release.REPOSITORY}/git/commits/' + 'a' * 40),
118+
message=release.tag_message(self.info) + '\n')
119+
with patch.object(release, 'gh', side_effect=[ref, tag]):
120+
release.check_tag(self.info)
121+
tag['message'] = 'different batch or digest'
122+
with patch.object(release, 'gh', side_effect=[ref, tag]), self.assertRaisesRegex(ValueError, 'batch or bundle'):
123+
release.check_tag(self.info)
124+
tag['object']['sha'] = 'e' * 40
125+
with patch.object(release, 'gh', side_effect=[ref, tag]), self.assertRaisesRegex(ValueError, 'source differs'):
126+
release.check_tag(self.info)
127+
128+
def test_release_requires_reviewers_and_main_only_environment(self):
129+
environment = dict(protection_rules=[dict(type='required_reviewers', reviewers=[dict(id=1)])],
130+
deployment_branch_policy=dict(custom_branch_policies=True, protected_branches=False))
131+
branches = dict(branch_policies=[dict(name='main', type='branch')])
132+
with patch.object(release, 'gh', side_effect=[environment, branches]):
133+
release.check_environment()
134+
with patch.object(release, 'gh', return_value=None), self.assertRaisesRegex(ValueError, 'required reviewers'):
135+
release.check_environment()
136+
branches['branch_policies'].append(dict(name='*', type='branch'))
137+
with patch.object(release, 'gh', side_effect=[environment, branches]), self.assertRaisesRegex(ValueError, 'only the main'):
138+
release.check_environment()
139+
140+
def test_recovery_cannot_upload(self):
141+
with patch.dict(os.environ, RESUME_RUN_ID='123'), patch.object(release, 'check_tag'), \
142+
patch.object(release, 'portal_request') as request, self.assertRaisesRegex(ValueError, 'cannot upload'):
143+
release.upload(self.output)
144+
request.assert_not_called()
145+
146+
def test_upload_records_receipt_and_never_retries_uncertain_response(self):
147+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=UUID.encode()) as request:
148+
release.upload(self.output)
149+
self.assertEqual(1, request.call_count)
150+
self.assertIn('publishingType=USER_MANAGED', request.call_args.args[0])
151+
self.assertIn(b'name="bundle"', request.call_args.args[1])
152+
self.assertEqual(UUID, json.loads((self.output / 'receipt.json').read_text())['deployment_id'])
153+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=TimeoutError) as request:
154+
with self.assertRaises(TimeoutError):
155+
release.upload(self.output)
156+
self.assertEqual(1, request.call_count)
157+
158+
def test_publication_resumes_validation_and_publishes_once(self):
159+
responses = [self.status('VALIDATING'), self.status('VALIDATED'), b'',
160+
self.status('VALIDATED'), self.status('PUBLISHING'), self.status('PUBLISHED')]
161+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=responses) as request, \
162+
patch.object(release.time, 'sleep'):
163+
release.publish(self.output)
164+
self.assertEqual(1, sum(c.args[0] == '/deployment/' + UUID for c in request.call_args_list))
165+
self.assertTrue((self.output / 'published.json').exists())
166+
167+
def test_already_published_deployment_is_not_republished(self):
168+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=self.status('PUBLISHED')) as request:
169+
release.publish(self.output)
170+
self.assertEqual(1, request.call_count)
171+
self.assertTrue(request.call_args.args[0].startswith('/status?'))
172+
173+
def test_publication_rejects_wrong_deployment_and_failed_validation(self):
174+
statuses = [dict(deploymentId=UUID, deploymentName='another upload', deploymentState='VALIDATED'),
175+
json.loads(self.status('FAILED')),
176+
dict(deploymentId=UUID, deploymentName=release.deployment_name(self.info),
177+
deploymentState='VALIDATED', purls=['pkg:maven/other/package@1.2.3'])]
178+
for status in statuses:
179+
with self.subTest(status=status), patch.object(release, 'check_tag'), \
180+
patch.object(release, 'portal_request', return_value=json.dumps(status).encode()) as request:
181+
with self.assertRaises(ValueError):
182+
release.publish(self.output)
183+
self.assertEqual(1, request.call_count)
184+
185+
def test_verification_compares_public_bytes_and_does_not_republish(self):
186+
def open_file(url, timeout):
187+
self.assertTrue(url.startswith(release.CENTRAL))
188+
return io.BytesIO(self.contents[url.removeprefix(release.CENTRAL)])
189+
with patch.object(release, 'check_tag'), patch.object(release.urllib.request, 'build_opener') as opener:
190+
opener.return_value.open.side_effect = open_file
191+
release.verify(self.output)
192+
opener.return_value.open.side_effect = lambda *a, **kw: io.BytesIO(b'wrong public bytes')
193+
with self.assertRaisesRegex(ValueError, 'differs from approved bundle'):
194+
release.verify(self.output)
195+
196+
def test_existing_release_is_reused_only_if_notes_match(self):
197+
existing = dict(tag_name='v1.2.3', name='v1.2.3', body='Reviewed notes\n', draft=False, prerelease=False)
198+
with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing) as gh:
199+
release.release(self.output)
200+
self.assertEqual(1, gh.call_count)
201+
existing['body'] = 'different notes'
202+
with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing), \
203+
self.assertRaisesRegex(ValueError, 'do not overwrite'):
204+
release.release(self.output)
205+
206+
def test_portal_does_not_follow_redirects_or_print_response_secrets(self):
207+
with self.assertRaisesRegex(ValueError, 'credentials were not forwarded'):
208+
release.NoRedirect().redirect_request(None, None, 307, '', {}, 'https://other.invalid')
209+
error = urllib.error.HTTPError(release.PORTAL, 401, 'test', {}, io.BytesIO(b'private token'))
210+
with patch.dict(os.environ, CENTRAL_USERNAME='synthetic', CENTRAL_PASSWORD='synthetic'), \
211+
patch.object(release.urllib.request, 'build_opener') as opener:
212+
opener.return_value.open.side_effect = error
213+
with self.assertRaisesRegex(ValueError, '^Central returned HTTP 401;'):
214+
release.portal_request('/status?id=' + UUID)
215+
216+
217+
if __name__ == '__main__':
218+
unittest.main()

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ jobs:
2424
with:
2525
python-version: '3.12'
2626
- run: make quality
27+
- name: Exercise Central bundle signing without publishing
28+
run: python3 .github/scripts/check_central_bundle.py
2729
java:
2830
name: check (Java ${{ matrix.java }})
2931
runs-on: ubuntu-latest

0 commit comments

Comments
 (0)