Skip to content

Commit 3331a59

Browse files
authored
Merge pull request #1 from QoderAI/codex/java-central-release
ci: add verified Maven Central publication and deployment recovery
2 parents 05ccf63 + 98ed5f6 commit 3331a59

8 files changed

Lines changed: 1001 additions & 9 deletions

File tree

‎.github/scripts/central_release.py‎

Lines changed: 361 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: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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_preflight_without_environment_still_requires_current_main_and_ci(self):
129+
source, candidate = self.output / 'source', self.output / 'candidate'
130+
coordinates = '<groupId>com.qoder</groupId><artifactId>qca-sdk</artifactId><version>1.2.3</version>'
131+
project = '<project xmlns="http://maven.apache.org/POM/4.0.0">{}</project>'
132+
files = {
133+
'pom.xml': project.format(coordinates),
134+
'examples/pom.xml': project.format(
135+
'<version>1.2.3</version><dependencies><dependency>' + coordinates + '</dependency></dependencies>'),
136+
'src/main/resources/com/qoder/cloudagents/version.properties': 'version=${project.version}\n',
137+
'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n\n## [1.2.3]\n\n### Added\n\n- Release example.\n',
138+
}
139+
for name, contents in files.items():
140+
path = source / name
141+
path.parent.mkdir(parents=True, exist_ok=True)
142+
path.write_text(contents)
143+
responses = {
144+
'commits/main': dict(sha='a' * 40),
145+
'git/ref/tags/v1.2.3': None,
146+
'actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=' + 'a' * 40:
147+
dict(workflow_runs=[dict(id=10, head_sha='a' * 40, conclusion='success')]),
148+
'actions/runs/10/jobs?per_page=100':
149+
dict(jobs=[dict(name=name, conclusion='success') for name in release.CHECKS]),
150+
}
151+
# Any request for repository protections or an environment fails this test.
152+
with patch.object(release, 'git', return_value='a' * 40), \
153+
patch.object(release, 'gh', side_effect=responses.__getitem__):
154+
release.preflight(source, candidate)
155+
self.assertEqual(release.identity(), json.loads((candidate / 'intent.json').read_text()))
156+
self.assertIn('Release example.', (candidate / 'release-notes.md').read_text())
157+
responses['commits/main']['sha'] = 'c' * 40
158+
with self.assertRaisesRegex(ValueError, 'main changed'):
159+
release.preflight(source, candidate)
160+
responses['commits/main']['sha'] = 'a' * 40
161+
responses['actions/runs/10/jobs?per_page=100']['jobs'][0]['conclusion'] = 'failure'
162+
with self.assertRaisesRegex(ValueError, 'all five'):
163+
release.preflight(source, candidate)
164+
165+
def test_recovery_cannot_upload(self):
166+
with patch.dict(os.environ, RESUME_RUN_ID='123'), patch.object(release, 'check_tag'), \
167+
patch.object(release, 'portal_request') as request, self.assertRaisesRegex(ValueError, 'cannot upload'):
168+
release.upload(self.output)
169+
request.assert_not_called()
170+
171+
def test_upload_records_receipt_and_never_retries_uncertain_response(self):
172+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=UUID.encode()) as request:
173+
release.upload(self.output)
174+
self.assertEqual(1, request.call_count)
175+
self.assertIn('publishingType=USER_MANAGED', request.call_args.args[0])
176+
self.assertIn(b'name="bundle"', request.call_args.args[1])
177+
self.assertEqual(UUID, json.loads((self.output / 'receipt.json').read_text())['deployment_id'])
178+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=TimeoutError) as request:
179+
with self.assertRaises(TimeoutError):
180+
release.upload(self.output)
181+
self.assertEqual(1, request.call_count)
182+
183+
def test_publication_resumes_validation_and_publishes_once(self):
184+
responses = [self.status('VALIDATING'), self.status('VALIDATED'), b'',
185+
self.status('VALIDATED'), self.status('PUBLISHING'), self.status('PUBLISHED')]
186+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=responses) as request, \
187+
patch.object(release.time, 'sleep'):
188+
release.publish(self.output)
189+
self.assertEqual(1, sum(c.args[0] == '/deployment/' + UUID for c in request.call_args_list))
190+
self.assertTrue((self.output / 'published.json').exists())
191+
192+
def test_already_published_deployment_is_not_republished(self):
193+
with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=self.status('PUBLISHED')) as request:
194+
release.publish(self.output)
195+
self.assertEqual(1, request.call_count)
196+
self.assertTrue(request.call_args.args[0].startswith('/status?'))
197+
198+
def test_publication_rejects_wrong_deployment_and_failed_validation(self):
199+
statuses = [dict(deploymentId=UUID, deploymentName='another upload', deploymentState='VALIDATED'),
200+
json.loads(self.status('FAILED')),
201+
dict(deploymentId=UUID, deploymentName=release.deployment_name(self.info),
202+
deploymentState='VALIDATED', purls=['pkg:maven/other/package@1.2.3'])]
203+
for status in statuses:
204+
with self.subTest(status=status), patch.object(release, 'check_tag'), \
205+
patch.object(release, 'portal_request', return_value=json.dumps(status).encode()) as request:
206+
with self.assertRaises(ValueError):
207+
release.publish(self.output)
208+
self.assertEqual(1, request.call_count)
209+
210+
def test_verification_compares_public_bytes_and_does_not_republish(self):
211+
def open_file(url, timeout):
212+
self.assertTrue(url.startswith(release.CENTRAL))
213+
return io.BytesIO(self.contents[url.removeprefix(release.CENTRAL)])
214+
with patch.object(release, 'check_tag'), patch.object(release.urllib.request, 'build_opener') as opener:
215+
opener.return_value.open.side_effect = open_file
216+
release.verify(self.output)
217+
opener.return_value.open.side_effect = lambda *a, **kw: io.BytesIO(b'wrong public bytes')
218+
with self.assertRaisesRegex(ValueError, 'differs from approved bundle'):
219+
release.verify(self.output)
220+
221+
def test_existing_release_is_reused_only_if_notes_match(self):
222+
existing = dict(tag_name='v1.2.3', name='v1.2.3', body='Reviewed notes\n', draft=False, prerelease=False)
223+
with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing) as gh:
224+
release.release(self.output)
225+
self.assertEqual(1, gh.call_count)
226+
existing['body'] = 'different notes'
227+
with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing), \
228+
self.assertRaisesRegex(ValueError, 'do not overwrite'):
229+
release.release(self.output)
230+
231+
def test_portal_does_not_follow_redirects_or_print_response_secrets(self):
232+
with self.assertRaisesRegex(ValueError, 'credentials were not forwarded'):
233+
release.NoRedirect().redirect_request(None, None, 307, '', {}, 'https://other.invalid')
234+
error = urllib.error.HTTPError(release.PORTAL, 401, 'test', {}, io.BytesIO(b'private token'))
235+
with patch.dict(os.environ, CENTRAL_USERNAME='synthetic', CENTRAL_PASSWORD='synthetic'), \
236+
patch.object(release.urllib.request, 'build_opener') as opener:
237+
opener.return_value.open.side_effect = error
238+
with self.assertRaisesRegex(ValueError, '^Central returned HTTP 401;'):
239+
release.portal_request('/status?id=' + UUID)
240+
241+
242+
if __name__ == '__main__':
243+
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)