diff --git a/.github/scripts/central_release.py b/.github/scripts/central_release.py new file mode 100644 index 0000000..dd2acf0 --- /dev/null +++ b/.github/scripts/central_release.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Release one reviewed Maven bundle; recovery never uploads a replacement.""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +import zipfile + +import check_repository +import release_notes +import release_preflight + +REPOSITORY = 'QoderAI/qoder-cloud-agents-sdk-java' +PORTAL = 'https://central.sonatype.com/api/v1/publisher' +CENTRAL = 'https://repo.maven.apache.org/maven2/' +CHECKS = {'quality'} | {f'check (Java {v})' for v in (8, 11, 17, 21, 25)} + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def save(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + '\n') + + +def gh(endpoint, body=None, method=None): + command = ['gh', 'api', f'repos/{REPOSITORY}/{endpoint}'] + if method: + command += ['--method', method] + if body is not None: + command += ['--input', '-'] + result = subprocess.run(command, input=json.dumps(body) if body is not None else None, + text=True, capture_output=True) + if result.returncode: + # Only a verified 404 means absence; auth/network failures must not create duplicates. + if '(HTTP 404)' in result.stderr: + return None + raise ValueError(f'GitHub API failed for {endpoint}; check access and connectivity') + return json.loads(result.stdout) if result.stdout.strip() else {} + + +def git(source, *args): + return subprocess.check_output(['git', *args], cwd=source, text=True).strip() + + +def identity(): + version, sha, batch = (os.environ[n] for n in ('RELEASE_VERSION', 'RELEASE_SHA', 'RELEASE_BATCH')) + release_preflight.validate_inputs(version, sha, batch) + require(os.environ.get('GITHUB_REPOSITORY') == REPOSITORY, 'Unexpected release repository') + require(os.environ.get('GITHUB_REF') == 'refs/heads/main', 'Dispatch release from main') + require(os.environ.get('GITHUB_EVENT_NAME') == 'workflow_dispatch', 'Manual release dispatch required') + return dict(repository=REPOSITORY, version=version, source_sha=sha, batch_id=batch) + + +def run_id(value): + require(re.fullmatch(r'[1-9][0-9]*', value or ''), 'Use a numeric GitHub Actions run ID') + return value + + +def deployment_id(value): + require(str(uuid.UUID(value)) == value, 'Use a canonical Central deployment UUID') + return value + + +def tag_name(info): + return 'v' + info['version'] + + +def tag_message(info): + return (f"Release {tag_name(info)} (batch_id: {info['batch_id']})\n\n" + f"Source: {info['source_sha']}\nBundle-SHA256: {info['bundle_sha256']}") + + +def check_tag(info): + ref = gh(f"git/ref/tags/{tag_name(info)}") + require(ref is not None and ref['object']['type'] == 'tag', 'Matching annotated release tag required') + tag = gh('git/tags/' + ref['object']['sha']) + require(tag and tag['object']['type'] == 'commit' and tag['object']['sha'] == info['source_sha'], + 'Release tag source differs') + require(tag['message'].rstrip('\n') == tag_message(info), 'Release tag batch or bundle digest differs') + + +def check_main(info): + main = gh('commits/main') + require(main and main['sha'] == info['source_sha'], 'main changed; prepare a new release dispatch') + + +def check_ci(info): + runs = gh('actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=' + info['source_sha']) + require(runs and runs['workflow_runs'], 'No main CI run for the release SHA') + current = max(runs['workflow_runs'], key=lambda r: r['id']) + require(current['head_sha'] == info['source_sha'] and current['conclusion'] == 'success', + 'Latest main CI for this source must succeed') + jobs = gh(f"actions/runs/{current['id']}/jobs?per_page=100") + require(jobs and {j['name'] for j in jobs['jobs'] if j['conclusion'] == 'success'} >= CHECKS, + 'Quality and all five Java CI jobs must succeed') + + +def read_bundle(path, version): + prefix = f'com/qoder/qca-sdk/{version}/qca-sdk-{version}' + primary = {prefix + suffix for suffix in ('.pom', '.jar', '-sources.jar', '-javadoc.jar')} + required = primary | {name + '.asc' for name in primary} + allowed = required | {name + '.' + algo for name in required for algo in ('md5', 'sha1', 'sha256', 'sha512')} + with zipfile.ZipFile(path) as archive: + members = [entry for entry in archive.infolist() if not entry.is_dir()] + names = {entry.filename for entry in members} + require(len(names) == len(members), 'Duplicate bundle entry') + require(required <= names <= allowed, 'Bundle must contain only the signed SDK coordinates') + contents = {entry.filename: archive.read(entry) for entry in members} + for name in required: + require(contents[name], 'Empty bundle payload: ' + name) + for name in primary: + require(name + '.md5' in names and name + '.sha1' in names, 'Required Maven checksums missing') + for name, data in contents.items(): + base, _, algo = name.rpartition('.') + if algo in ('md5', 'sha1', 'sha256', 'sha512'): + require(data.decode('ascii').strip() == hashlib.new(algo, contents[base]).hexdigest(), + 'Bundle checksum mismatch: ' + name) + return contents + + +def preflight(source, output): + info = identity() + require(git(source, 'rev-parse', 'HEAD') == info['source_sha'], 'Wrong source checkout') + require(check_repository.check_versions(source) == info['version'], 'POM version differs from input') + notes = release_notes.extract_notes((source / 'CHANGELOG.md').read_text(), info['version']) + resume = os.environ.get('RESUME_RUN_ID', '') + if resume: + original = gh(f'actions/runs/{run_id(resume)}') + require(original and original['event'] == 'workflow_dispatch' and original['head_branch'] == 'main' + and original['path'] == '.github/workflows/release.yml', 'Not a main release workflow run') + else: + require(not os.environ.get('CENTRAL_DEPLOYMENT_ID'), 'deployment_id requires resume_run_id') + check_main(info) + require(gh(f'git/ref/tags/{tag_name(info)}') is None, 'Tag already exists; use resume_run_id') + check_ci(info) + output.mkdir(parents=True, exist_ok=True) + save(output / 'intent.json', info) + (output / 'release-notes.md').write_text(notes) + print('Source, release notes and five-JDK CI verified') + + +def seal(source, candidate, output): + info = identity() + require(json.loads((candidate / 'intent.json').read_text()) == info, 'Candidate intent differs') + bundle = source / 'target/central-publishing/central-bundle.zip' + contents = read_bundle(bundle, info['version']) + checks = json.loads((candidate / 'package-check.json').read_text())['sha256'] + for local, expected in checks.items(): + filename = f"qca-sdk-{info['version']}.pom" if local == 'pom.xml' else Path(local).name + path = f"com/qoder/qca-sdk/{info['version']}/{filename}" + require(hashlib.sha256(contents[path]).hexdigest() == expected, + 'Signed bundle differs from reviewed build: ' + filename) + expected_files = {'pom.xml'} | {f"target/qca-sdk-{info['version']}{suffix}.jar" + for suffix in ('', '-sources', '-javadoc')} + require(set(checks) == expected_files, 'Incomplete candidate digests') + info.update(run_id=run_id(os.environ['GITHUB_RUN_ID']), workflow_sha=os.environ['GITHUB_SHA'], + bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), + files={name: hashlib.sha256(data).hexdigest() for name, data in contents.items()}) + output.mkdir(parents=True, exist_ok=True) + shutil.copyfile(bundle, output / 'central-bundle.zip') + shutil.copyfile(candidate / 'release-notes.md', output / 'release-notes.md') + save(output / 'manifest.json', info) + print('Signed bundle matches all reviewed candidate digests') + + +def load(output): + info = json.loads((output / 'manifest.json').read_text()) + require(all(info.get(k) == v for k, v in identity().items()), 'Bundle release identity differs') + path = output / 'central-bundle.zip' + require(hashlib.sha256(path.read_bytes()).hexdigest() == info['bundle_sha256'], 'Bundle digest differs') + contents = read_bundle(path, info['version']) + require({n: hashlib.sha256(v).hexdigest() for n, v in contents.items()} == info['files'], + 'Bundle file manifest differs') + return info + + +def recover(source, output): + resume = run_id(os.environ.get('RESUME_RUN_ID')) + original = gh(f'actions/runs/{resume}') + require(original and original['event'] == 'workflow_dispatch' and original['head_branch'] == 'main' + and original['path'] == '.github/workflows/release.yml', 'Not a main release workflow run') + subprocess.run(['gh', 'run', 'download', resume, '--repo', REPOSITORY, + '--name', 'release-bundle', '--dir', str(output)], check=True) + info = load(output) + require(info['run_id'] == resume and info['workflow_sha'] == original['head_sha'], + 'Bundle provenance differs from the original workflow') + notes = release_notes.extract_notes((source / 'CHANGELOG.md').read_text(), info['version']) + require((output / 'release-notes.md').read_text() == notes, 'Saved release notes differ') + check_tag(info) + explicit_id = os.environ.get('CENTRAL_DEPLOYMENT_ID', '') + if explicit_id: + save(output / 'receipt.json', dict(deployment_id=deployment_id(explicit_id), + bundle_sha256=info['bundle_sha256'])) + else: + subprocess.run(['gh', 'run', 'download', resume, '--repo', REPOSITORY, + '--name', 'central-receipt', '--dir', str(output)], check=True) + print('Recovered original bundle; no rebuild or upload will occur') + + +def create_tag(output): + info = load(output) + check_main(info) + require(gh(f'git/ref/tags/{tag_name(info)}') is None, 'Tag already exists; recover the original run') + tag = gh('git/tags', dict(tag=tag_name(info), message=tag_message(info), + object=info['source_sha'], type='commit')) + require(tag is not None, 'Cannot create annotated tag') + ref = gh('git/refs', dict(ref='refs/tags/' + tag_name(info), sha=tag['sha'])) + require(ref is not None, 'Cannot create tag reference') + check_tag(info) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError('Unexpected HTTP redirect; credentials were not forwarded') + + +def portal_request(path, data=b'', content_type=None): + username, password = os.environ.get('CENTRAL_USERNAME'), os.environ.get('CENTRAL_PASSWORD') + require(username and password, 'Central Portal token secrets are missing') + token = base64.b64encode(f'{username}:{password}'.encode()).decode() + headers = {'Authorization': 'Bearer ' + token} + if content_type: + headers['Content-Type'] = content_type + request = urllib.request.Request(PORTAL + path, data=data, headers=headers, method='POST') + try: + with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response: + return response.read() + except urllib.error.HTTPError as error: + raise ValueError(f'Central returned HTTP {error.code}; inspect Portal and retained deployment ID') from None + except (urllib.error.URLError, TimeoutError, OSError): + raise ValueError('Central connection failed. Do not repeat an uncertain upload; inspect Portal first') from None + + +def deployment_name(info): + return f"qca-sdk-{info['version']}-{info['source_sha']}-{info['batch_id']}-{info['bundle_sha256']}" + + +def upload(output): + info = load(output) + check_tag(info) + require(not os.environ.get('RESUME_RUN_ID'), 'Recovery cannot upload') + boundary = 'qca-' + uuid.uuid4().hex + body = (f'--{boundary}\r\nContent-Disposition: form-data; name="bundle"; ' + 'filename="central-bundle.zip"\r\nContent-Type: application/octet-stream\r\n\r\n').encode() + body += (output / 'central-bundle.zip').read_bytes() + f'\r\n--{boundary}--\r\n'.encode() + query = urllib.parse.urlencode(dict(name=deployment_name(info), publishingType='USER_MANAGED')) + result = portal_request('/upload?' + query, body, 'multipart/form-data; boundary=' + boundary) + identifier = deployment_id(result.decode().strip()) + save(output / 'receipt.json', dict(deployment_id=identifier, bundle_sha256=info['bundle_sha256'])) + print('Central upload accepted; deployment ID:', identifier) + + +def publish(output): + info = load(output) + check_tag(info) + receipt = json.loads((output / 'receipt.json').read_text()) + require(receipt['bundle_sha256'] == info['bundle_sha256'], 'Receipt bundle digest differs') + identifier = deployment_id(receipt['deployment_id']) + deadline = time.monotonic() + 1800 + requested = False + while time.monotonic() < deadline: + status = json.loads(portal_request('/status?' + urllib.parse.urlencode(dict(id=identifier)))) + require(status.get('deploymentId') == identifier + and status.get('deploymentName') == deployment_name(info), 'Central deployment identity differs') + expected = f"pkg:maven/com.qoder/qca-sdk@{info['version']}" + require(not status.get('purls') or status['purls'] == [expected], 'Unexpected Central coordinates') + state = status.get('deploymentState') + require(state in ('PENDING', 'VALIDATING', 'VALIDATED', 'PUBLISHING', 'PUBLISHED', 'FAILED'), + 'Unrecognized Central deployment state') + print('Central deployment state:', state, flush=True) + if state == 'FAILED': + raise ValueError('Central validation failed; inspect this deployment in Portal') + if state == 'PUBLISHED': + save(output / 'published.json', dict(deployment_id=identifier, state=state)) + return + if state == 'VALIDATED' and not requested: + portal_request('/deployment/' + identifier) + requested = True + time.sleep(15) + raise ValueError('Central publication timed out; resume with the original run, never reupload') + + +def verify(output): + info = load(output) + check_tag(info) + pending = dict(info['files']) + deadline = time.monotonic() + 1200 + opener = urllib.request.build_opener(NoRedirect) + while pending and time.monotonic() < deadline: + for name, expected in list(pending.items()): + try: + with opener.open(CENTRAL + name, timeout=30) as response: + actual = hashlib.sha256(response.read()).hexdigest() + except urllib.error.HTTPError as error: + if error.code in (404, 429, 500, 502, 503, 504): + continue + raise ValueError(f'Central download returned HTTP {error.code}') from None + require(actual == expected, 'Published file differs from approved bundle: ' + name) + del pending[name] + if pending: + print(f'Waiting for {len(pending)} public Maven files', flush=True) + time.sleep(15) + require(not pending, 'Maven Central files are not yet all visible; resume without reuploading') + save(output / 'verification.json', dict(version=info['version'], source_sha=info['source_sha'], + files_verified=len(info['files']))) + print('All Maven Central payloads, signatures and checksums match the approved bundle') + + +def release(output): + info = load(output) + check_tag(info) + body = (output / 'release-notes.md').read_text() + payload = dict(tag_name=tag_name(info), name=tag_name(info), body=body, + draft=False, prerelease='-' in info['version']) + existing = gh('releases/tags/' + tag_name(info)) + if existing: + require(all(existing.get(key) == value for key, value in payload.items()), + 'Existing GitHub Release differs; do not overwrite it') + else: + require(gh('releases', payload) is not None, 'Cannot create GitHub Release') + print('GitHub Release notes published') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=('preflight', 'seal', 'recover', 'tag', 'upload', 'publish', 'verify', 'release')) + parser.add_argument('--source', type=Path, default=Path('source')) + parser.add_argument('--candidate', type=Path, default=Path('candidate')) + parser.add_argument('--output', type=Path, default=Path('bundle')) + args = parser.parse_args() + if args.command == 'preflight': + preflight(args.source, args.output) + elif args.command == 'seal': + seal(args.source, args.candidate, args.output) + elif args.command == 'recover': + recover(args.source, args.output) + else: + {'tag': create_tag, 'upload': upload, 'publish': publish, + 'verify': verify, 'release': release}[args.command](args.output) + + +if __name__ == '__main__': + try: + main() + except (ValueError, KeyError, OSError, subprocess.CalledProcessError, zipfile.BadZipFile) as error: + sys.exit(str(error)) diff --git a/.github/scripts/check_central_bundle.py b/.github/scripts/check_central_bundle.py new file mode 100644 index 0000000..02eb7a2 --- /dev/null +++ b/.github/scripts/check_central_bundle.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Exercise release signing with a disposable key and no Central upload.""" +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile + +import central_release +import check_package +import check_repository + + +def main(): + root = check_repository.ROOT + with tempfile.TemporaryDirectory(prefix='qca-central-dry-run-') as temporary: + work = Path(temporary) + source, keys, candidate, bundle = (work / name for name in ('source', 'keys', 'candidate', 'bundle')) + source.mkdir() + keys.mkdir(mode=0o700) + archive = work / 'source.tar' + subprocess.run(['git', 'archive', '--output', str(archive), 'HEAD'], cwd=root, check=True) + with tarfile.open(archive) as tar: + tar.extractall(source, filter='data') + version = check_repository.check_versions(root) + for pom in (source / 'pom.xml', source / 'examples/pom.xml'): + pom.write_text(pom.read_text().replace(version, '0.0.0')) + env = dict(os.environ, GNUPGHOME=str(keys), MAVEN_GPG_PASSPHRASE='disposable-test-key') + for name in ('CENTRAL_USERNAME', 'CENTRAL_PASSWORD'): + env.pop(name, None) + gpg = ['gpg', '--batch', '--pinentry-mode', 'loopback', '--passphrase', env['MAVEN_GPG_PASSPHRASE']] + try: + subprocess.run(gpg + ['--quick-generate-key', 'SDK Release Test ', + 'rsa2048', 'sign', '1d'], env=env, check=True) + env['MAVEN_GPG_KEY'] = subprocess.check_output( + gpg + ['--armor', '--export-secret-keys'], env=env, text=True) + subprocess.run(['./mvnw', '-B', '-ntp', 'clean', 'package'], cwd=source, env=env, check=True) + check_package.check(source) + candidate.mkdir() + (candidate / 'package-check.json').write_bytes((source / 'target/package-check.json').read_bytes()) + (candidate / 'release-notes.md').write_text('Disposable signing check; never published.\n') + subprocess.run(['./mvnw', '-B', '-ntp', '-Pcentral', '-Dgpg.signer=bc', + '-Dcentral.skipUpload=true', '-DskipTests', 'clean', 'deploy'], + cwd=source, env=env, check=True) + os.environ.update(RELEASE_VERSION='0.0.0', RELEASE_SHA='a' * 40, RELEASE_BATCH='offline-signing-check', + GITHUB_REPOSITORY=central_release.REPOSITORY, GITHUB_REF='refs/heads/main', + GITHUB_EVENT_NAME='workflow_dispatch', GITHUB_RUN_ID='1', GITHUB_SHA='a' * 40) + central_release.save(candidate / 'intent.json', central_release.identity()) + central_release.seal(source, candidate, bundle) + contents = central_release.read_bundle(bundle / 'central-bundle.zip', '0.0.0') + for name, data in contents.items(): + if not name.endswith('.asc'): + continue + signature, payload = work / 'signature.asc', work / 'payload' + signature.write_bytes(data) + payload.write_bytes(contents[name[:-4]]) + subprocess.run(['gpg', '--batch', '--verify', str(signature), str(payload)], env=env, check=True) + print('Central dry run passed: reproducible SDK/POM/sources/Javadoc, four valid signatures, no upload') + finally: + subprocess.run(['gpgconf', '--kill', 'gpg-agent'], env=env, check=False) + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/test_central_release.py b/.github/scripts/test_central_release.py new file mode 100644 index 0000000..8b2d5f5 --- /dev/null +++ b/.github/scripts/test_central_release.py @@ -0,0 +1,243 @@ +import hashlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import urllib.error +import zipfile + +import central_release as release + +ENV = dict(RELEASE_VERSION='1.2.3', RELEASE_SHA='a' * 40, RELEASE_BATCH='test-batch', + GITHUB_REPOSITORY=release.REPOSITORY, GITHUB_REF='refs/heads/main', + GITHUB_EVENT_NAME='workflow_dispatch', GITHUB_SHA='b' * 40, GITHUB_RUN_ID='123', + RESUME_RUN_ID='', CENTRAL_DEPLOYMENT_ID='') +UUID = '12345678-1234-1234-1234-123456789abc' + + +def write_bundle(path, transform=lambda values: values): + prefix = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3' + values = {} + for suffix in ('.pom', '.jar', '-sources.jar', '-javadoc.jar'): + name = prefix + suffix + values[name] = ('payload' + suffix).encode() + values[name + '.asc'] = b'synthetic signature' + for algo in ('md5', 'sha1', 'sha256', 'sha512'): + values[name + '.' + algo] = hashlib.new(algo, values[name]).hexdigest().encode() + with zipfile.ZipFile(path, 'w') as archive: + for name, data in transform(values).items(): + archive.writestr(name, data) + return values + + +class CentralReleaseTests(unittest.TestCase): + def setUp(self): + self.env = patch.dict(os.environ, ENV) + self.env.start() + self.addCleanup(self.env.stop) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) + self.contents = write_bundle(self.output / 'central-bundle.zip') + self.info = dict(release.identity(), run_id='123', workflow_sha='b' * 40, + bundle_sha256=hashlib.sha256((self.output / 'central-bundle.zip').read_bytes()).hexdigest(), + files={n: hashlib.sha256(v).hexdigest() for n, v in self.contents.items()}) + release.save(self.output / 'manifest.json', self.info) + release.save(self.output / 'receipt.json', dict(deployment_id=UUID, bundle_sha256=self.info['bundle_sha256'])) + (self.output / 'release-notes.md').write_text('Reviewed notes\n') + + def status(self, state, **changes): + return json.dumps(dict(deploymentId=UUID, deploymentName=release.deployment_name(self.info), + deploymentState=state, purls=['pkg:maven/com.qoder/qca-sdk@1.2.3'], + **changes)).encode() + + def test_identity_rejects_forks_events_and_snapshots(self): + for key, value in [('GITHUB_REPOSITORY', 'someone/fork'), ('GITHUB_REF', 'refs/heads/feature'), + ('GITHUB_EVENT_NAME', 'push'), ('RELEASE_VERSION', '1.2.3-SNAPSHOT')]: + with self.subTest(key=key), patch.dict(os.environ, {key: value}), self.assertRaises(ValueError): + release.identity() + + def test_bundle_requires_signatures_checksums_and_exact_coordinates(self): + self.assertEqual(self.info, release.load(self.output)) + prefix = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3' + for name in (prefix + '.pom.asc', prefix + '.jar.md5'): + with self.subTest(name=name): + write_bundle(self.output / 'central-bundle.zip', lambda values: {k: v for k, v in values.items() if k != name}) + with self.assertRaises(ValueError): + release.read_bundle(self.output / 'central-bundle.zip', '1.2.3') + write_bundle(self.output / 'central-bundle.zip', lambda values: dict(values, **{'../credential.txt': b'private'})) + with self.assertRaisesRegex(ValueError, 'only the signed SDK'): + release.read_bundle(self.output / 'central-bundle.zip', '1.2.3') + + def test_bundle_rejects_wrong_checksums_and_manifest_tampering(self): + key = 'com/qoder/qca-sdk/1.2.3/qca-sdk-1.2.3.jar.sha1' + write_bundle(self.output / 'central-bundle.zip', lambda values: dict(values, **{key: b'bad'})) + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): + release.read_bundle(self.output / 'central-bundle.zip', '1.2.3') + with self.assertRaisesRegex(ValueError, 'Bundle digest differs'): + release.load(self.output) + + def test_seal_detects_rebuild_differences(self): + source, candidate = self.output / 'source', self.output / 'candidate' + bundle_dir = source / 'target/central-publishing' + bundle_dir.mkdir(parents=True) + (bundle_dir / 'central-bundle.zip').write_bytes((self.output / 'central-bundle.zip').read_bytes()) + release.save(candidate / 'intent.json', release.identity()) + checks = {} + for suffix in ('.pom', '.jar', '-sources.jar', '-javadoc.jar'): + filename = 'qca-sdk-1.2.3' + suffix + key = 'pom.xml' if suffix == '.pom' else 'target/' + filename + checks[key] = self.info['files']['com/qoder/qca-sdk/1.2.3/' + filename] + release.save(candidate / 'package-check.json', dict(sha256=checks)) + (candidate / 'release-notes.md').write_text('Reviewed notes\n') + release.seal(source, candidate, self.output / 'sealed') + checks['target/qca-sdk-1.2.3.jar'] = '0' * 64 + release.save(candidate / 'package-check.json', dict(sha256=checks)) + with self.assertRaisesRegex(ValueError, 'differs from reviewed build'): + release.seal(source, candidate, self.output / 'sealed') + + def test_ci_requires_latest_success_and_all_five_jdks(self): + runs = dict(workflow_runs=[dict(id=10, head_sha='a' * 40, conclusion='success')]) + jobs = dict(jobs=[dict(name=name, conclusion='success') for name in release.CHECKS]) + with patch.object(release, 'gh', side_effect=[runs, jobs]): + release.check_ci(self.info) + jobs['jobs'][0]['conclusion'] = 'skipped' + with patch.object(release, 'gh', side_effect=[runs, jobs]), self.assertRaisesRegex(ValueError, 'all five'): + release.check_ci(self.info) + runs['workflow_runs'].append(dict(id=11, head_sha='a' * 40, conclusion='failure')) + with patch.object(release, 'gh', return_value=runs), self.assertRaisesRegex(ValueError, 'Latest main CI'): + release.check_ci(self.info) + + def test_tag_rejects_moved_sha_and_changed_bundle(self): + ref = dict(object=dict(type='tag', sha='c' * 40)) + tag = dict(object=dict(type='commit', sha='a' * 40, + url=f'https://api.github.com/repos/{release.REPOSITORY}/git/commits/' + 'a' * 40), + message=release.tag_message(self.info) + '\n') + with patch.object(release, 'gh', side_effect=[ref, tag]): + release.check_tag(self.info) + tag['message'] = 'different batch or digest' + with patch.object(release, 'gh', side_effect=[ref, tag]), self.assertRaisesRegex(ValueError, 'batch or bundle'): + release.check_tag(self.info) + tag['object']['sha'] = 'e' * 40 + with patch.object(release, 'gh', side_effect=[ref, tag]), self.assertRaisesRegex(ValueError, 'source differs'): + release.check_tag(self.info) + + def test_preflight_without_environment_still_requires_current_main_and_ci(self): + source, candidate = self.output / 'source', self.output / 'candidate' + coordinates = 'com.qoderqca-sdk1.2.3' + project = '{}' + files = { + 'pom.xml': project.format(coordinates), + 'examples/pom.xml': project.format( + '1.2.3' + coordinates + ''), + 'src/main/resources/com/qoder/cloudagents/version.properties': 'version=${project.version}\n', + 'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n\n## [1.2.3]\n\n### Added\n\n- Release example.\n', + } + for name, contents in files.items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + responses = { + 'commits/main': dict(sha='a' * 40), + 'git/ref/tags/v1.2.3': None, + 'actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=' + 'a' * 40: + dict(workflow_runs=[dict(id=10, head_sha='a' * 40, conclusion='success')]), + 'actions/runs/10/jobs?per_page=100': + dict(jobs=[dict(name=name, conclusion='success') for name in release.CHECKS]), + } + # Any request for repository protections or an environment fails this test. + with patch.object(release, 'git', return_value='a' * 40), \ + patch.object(release, 'gh', side_effect=responses.__getitem__): + release.preflight(source, candidate) + self.assertEqual(release.identity(), json.loads((candidate / 'intent.json').read_text())) + self.assertIn('Release example.', (candidate / 'release-notes.md').read_text()) + responses['commits/main']['sha'] = 'c' * 40 + with self.assertRaisesRegex(ValueError, 'main changed'): + release.preflight(source, candidate) + responses['commits/main']['sha'] = 'a' * 40 + responses['actions/runs/10/jobs?per_page=100']['jobs'][0]['conclusion'] = 'failure' + with self.assertRaisesRegex(ValueError, 'all five'): + release.preflight(source, candidate) + + def test_recovery_cannot_upload(self): + with patch.dict(os.environ, RESUME_RUN_ID='123'), patch.object(release, 'check_tag'), \ + patch.object(release, 'portal_request') as request, self.assertRaisesRegex(ValueError, 'cannot upload'): + release.upload(self.output) + request.assert_not_called() + + def test_upload_records_receipt_and_never_retries_uncertain_response(self): + with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=UUID.encode()) as request: + release.upload(self.output) + self.assertEqual(1, request.call_count) + self.assertIn('publishingType=USER_MANAGED', request.call_args.args[0]) + self.assertIn(b'name="bundle"', request.call_args.args[1]) + self.assertEqual(UUID, json.loads((self.output / 'receipt.json').read_text())['deployment_id']) + with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=TimeoutError) as request: + with self.assertRaises(TimeoutError): + release.upload(self.output) + self.assertEqual(1, request.call_count) + + def test_publication_resumes_validation_and_publishes_once(self): + responses = [self.status('VALIDATING'), self.status('VALIDATED'), b'', + self.status('VALIDATED'), self.status('PUBLISHING'), self.status('PUBLISHED')] + with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', side_effect=responses) as request, \ + patch.object(release.time, 'sleep'): + release.publish(self.output) + self.assertEqual(1, sum(c.args[0] == '/deployment/' + UUID for c in request.call_args_list)) + self.assertTrue((self.output / 'published.json').exists()) + + def test_already_published_deployment_is_not_republished(self): + with patch.object(release, 'check_tag'), patch.object(release, 'portal_request', return_value=self.status('PUBLISHED')) as request: + release.publish(self.output) + self.assertEqual(1, request.call_count) + self.assertTrue(request.call_args.args[0].startswith('/status?')) + + def test_publication_rejects_wrong_deployment_and_failed_validation(self): + statuses = [dict(deploymentId=UUID, deploymentName='another upload', deploymentState='VALIDATED'), + json.loads(self.status('FAILED')), + dict(deploymentId=UUID, deploymentName=release.deployment_name(self.info), + deploymentState='VALIDATED', purls=['pkg:maven/other/package@1.2.3'])] + for status in statuses: + with self.subTest(status=status), patch.object(release, 'check_tag'), \ + patch.object(release, 'portal_request', return_value=json.dumps(status).encode()) as request: + with self.assertRaises(ValueError): + release.publish(self.output) + self.assertEqual(1, request.call_count) + + def test_verification_compares_public_bytes_and_does_not_republish(self): + def open_file(url, timeout): + self.assertTrue(url.startswith(release.CENTRAL)) + return io.BytesIO(self.contents[url.removeprefix(release.CENTRAL)]) + with patch.object(release, 'check_tag'), patch.object(release.urllib.request, 'build_opener') as opener: + opener.return_value.open.side_effect = open_file + release.verify(self.output) + opener.return_value.open.side_effect = lambda *a, **kw: io.BytesIO(b'wrong public bytes') + with self.assertRaisesRegex(ValueError, 'differs from approved bundle'): + release.verify(self.output) + + def test_existing_release_is_reused_only_if_notes_match(self): + existing = dict(tag_name='v1.2.3', name='v1.2.3', body='Reviewed notes\n', draft=False, prerelease=False) + with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing) as gh: + release.release(self.output) + self.assertEqual(1, gh.call_count) + existing['body'] = 'different notes' + with patch.object(release, 'check_tag'), patch.object(release, 'gh', return_value=existing), \ + self.assertRaisesRegex(ValueError, 'do not overwrite'): + release.release(self.output) + + def test_portal_does_not_follow_redirects_or_print_response_secrets(self): + with self.assertRaisesRegex(ValueError, 'credentials were not forwarded'): + release.NoRedirect().redirect_request(None, None, 307, '', {}, 'https://other.invalid') + error = urllib.error.HTTPError(release.PORTAL, 401, 'test', {}, io.BytesIO(b'private token')) + with patch.dict(os.environ, CENTRAL_USERNAME='synthetic', CENTRAL_PASSWORD='synthetic'), \ + patch.object(release.urllib.request, 'build_opener') as opener: + opener.return_value.open.side_effect = error + with self.assertRaisesRegex(ValueError, '^Central returned HTTP 401;'): + release.portal_request('/status?id=' + UUID) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3938d79..5403829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: with: python-version: '3.12' - run: make quality + - name: Exercise Central bundle signing without publishing + run: python3 .github/scripts/check_central_bundle.py java: name: check (Java ${{ matrix.java }}) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b1175f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,250 @@ +name: Release +on: + workflow_dispatch: + inputs: + version: + description: Version already committed in both POMs and changelog; no v or SNAPSHOT + required: true + type: string + commit_sha: + description: Full lowercase source SHA; current main for a first publication + required: true + type: string + batch_id: + description: 1-64 letters, digits, dots, underscores or hyphens; start with a letter or digit + required: true + type: string + resume_run_id: + description: Recovery only; original Release run containing the signed release-bundle artifact + default: '' + type: string + deployment_id: + description: Recovery only; Central UUID if the original upload receipt was not retained + default: '' + type: string +permissions: + contents: read + actions: read +concurrency: + group: java-central-release + cancel-in-progress: false +env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SHA: ${{ inputs.commit_sha }} + RELEASE_BATCH: ${{ inputs.batch_id }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + CENTRAL_DEPLOYMENT_ID: ${{ inputs.deployment_id }} +defaults: + run: + shell: bash +jobs: + preflight: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + path: workflow + persist-credentials: false + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit_sha }} + path: source + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate source, five-JDK CI and release intent + env: + GH_TOKEN: ${{ github.token }} + run: python3 workflow/.github/scripts/central_release.py preflight --output candidate + - name: Recheck source quality and unsigned candidate + if: inputs.resume_run_id == '' + working-directory: source + run: | + make quality + make test + cp target/package-check.json ../candidate/ + - name: Record source, release notes and candidate digests + run: | + printf '### Release source\n\nVersion: `%s`\n\nSHA: `%s`\n\nBatch: `%s`\n\n' "$RELEASE_VERSION" "$RELEASE_SHA" "$RELEASE_BATCH" >> "$GITHUB_STEP_SUMMARY" + cat candidate/release-notes.md >> "$GITHUB_STEP_SUMMARY" + if [[ -n "$RESUME_RUN_ID" ]]; then + printf '\nRecovery uses the original signed bundle: https://github.com/QoderAI/qoder-cloud-agents-sdk-java/actions/runs/%s\n' "$RESUME_RUN_ID" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -f candidate/package-check.json ]]; then + printf '\n```json\n' >> "$GITHUB_STEP_SUMMARY" + cat candidate/package-check.json >> "$GITHUB_STEP_SUMMARY" + printf '\n```\n' >> "$GITHUB_STEP_SUMMARY" + fi + - uses: actions/upload-artifact@v4 + with: + name: release-candidate + path: candidate/ + retention-days: 90 + if-no-files-found: error + publish: + needs: preflight + runs-on: ubuntu-latest + timeout-minutes: 50 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@v4 + with: + path: workflow + persist-credentials: false + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit_sha }} + path: source + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + name: release-candidate + path: candidate + - name: Require publishing setup + env: + CENTRAL_NAMESPACE: ${{ vars.CENTRAL_NAMESPACE }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_GPG_KEY: ${{ secrets.MAVEN_GPG_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + test "$CENTRAL_NAMESPACE" = com.qoder || { echo 'Verify com.qoder in Central Portal and set the CENTRAL_NAMESPACE repository Actions variable'; exit 1; } + test -n "$CENTRAL_USERNAME" && test -n "$CENTRAL_PASSWORD" || { echo 'Central Portal token secrets are missing'; exit 1; } + if [[ -z "$RESUME_RUN_ID" ]]; then + test -n "$MAVEN_GPG_KEY" && test -n "$MAVEN_GPG_PASSPHRASE" || { echo 'GPG signing secrets are missing'; exit 1; } + fi + - name: Build signed bundle without uploading + if: inputs.resume_run_id == '' + working-directory: source + env: + MAVEN_GPG_KEY: ${{ secrets.MAVEN_GPG_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: ./mvnw -B -ntp -Pcentral -Dgpg.signer=bc -Dcentral.skipUpload=true clean deploy + - name: Bind signed bundle to reviewed candidate + if: inputs.resume_run_id == '' + run: python3 workflow/.github/scripts/central_release.py seal + - name: Preserve signed bundle before creating the tag or uploading + if: inputs.resume_run_id == '' + uses: actions/upload-artifact@v4 + with: + name: release-bundle + path: bundle/ + retention-days: 90 + if-no-files-found: error + - name: Recover original bundle and deployment + if: inputs.resume_run_id != '' + env: + GH_TOKEN: ${{ github.token }} + run: python3 workflow/.github/scripts/central_release.py recover + - name: Recheck main and create immutable source tag + if: inputs.resume_run_id == '' + env: + GH_TOKEN: ${{ github.token }} + run: python3 workflow/.github/scripts/central_release.py tag + - name: Upload once for Central validation + if: inputs.resume_run_id == '' + env: + GH_TOKEN: ${{ github.token }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + run: python3 workflow/.github/scripts/central_release.py upload + - name: Preserve deployment receipt before publishing + if: ${{ !cancelled() && hashFiles('bundle/receipt.json') != '' }} + uses: actions/upload-artifact@v4 + with: + name: central-receipt + path: bundle/receipt.json + retention-days: 90 + if-no-files-found: error + - name: Publish validated deployment and wait for completion + env: + GH_TOKEN: ${{ github.token }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + run: python3 workflow/.github/scripts/central_release.py publish + verify: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + with: + path: workflow + persist-credentials: false + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit_sha }} + path: source + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + name: release-bundle + path: bundle + run-id: ${{ inputs.resume_run_id || github.run_id }} + github-token: ${{ github.token }} + - name: Compare public Maven files to approved signed bundle + env: + GH_TOKEN: ${{ github.token }} + run: python3 workflow/.github/scripts/central_release.py verify + - name: Test public package from a clean Maven cache + run: | + printf '\n' > "$RUNNER_TEMP/central-consumer-settings.xml" + source/mvnw -B -ntp -s "$RUNNER_TEMP/central-consumer-settings.xml" -Dmaven.repo.local="$RUNNER_TEMP/central-consumer-cache" -f source/examples/pom.xml clean verify + - uses: actions/upload-artifact@v4 + if: always() + with: + name: public-consumer-verification + path: | + bundle/verification.json + source/examples/target/surefire-reports/ + retention-days: 90 + release-notes: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@v4 + with: + path: workflow + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + name: release-bundle + path: bundle + run-id: ${{ inputs.resume_run_id || github.run_id }} + github-token: ${{ github.token }} + - name: Create matching GitHub Release notes after public verification + env: + GH_TOKEN: ${{ github.token }} + run: python3 workflow/.github/scripts/central_release.py release diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e2718..0beacbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Validation +- Manual Maven Central release workflow with source/CI checks, verified artifact digests, signing, retained deployment recovery, public package verification and matching GitHub Release notes. Publishing uses repository Actions secrets and proceeds after preflight without a separate environment approval. +- Disposable-key signing checks verify the release bundle without uploading it to Maven Central. - Accept documented assistant-message projections in Realtime Work History while still checking Work IDs, terminal events and pagination. - Complete Schedule/Batch execution, Usage pagination/filtering, Identity SAT permissions/revocation and Realtime Work/History acceptance in CN and Global. - JDK 8/11/17/21/25 compatibility CI, package-content and Java 8 bytecode checks, and retained test reports and candidate checksums. diff --git a/docs/maintaining.md b/docs/maintaining.md index c8d2d60..7c665e6 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -15,6 +15,8 @@ make test GitHub CI has six required-check names: `quality`, `check (Java 8)`, `check (Java 11)`, `check (Java 17)`, `check (Java 21)` and `check (Java 25)`. The [main ruleset payload](../.github/config/main-ruleset.json) must keep those names aligned with the workflow. Adding the JSON file does not activate GitHub protection. +The GitHub `quality` job also exercises the Central bundle profile with a temporary source copy and disposable signing key. It verifies reproducible artifact bytes and signatures without uploading a package or requiring publishing credentials. + CODEOWNERS assigns the same maintainer as the other SDK repositories. The prepared protection policy follows the Go baseline: a PR, resolved discussions, required checks, linear history, and no force pushes or branch deletion. It does not add a mandatory review count or mandatory CODEOWNER approval. Use the PR template to record cross-SDK impact, validation, skipped checks and candidate source evidence. ## Changelog and versions @@ -41,4 +43,4 @@ The current version remains `0.1.0-SNAPSHOT`. A SNAPSHOT can resolve to differen GitHub CI produces candidate JARs, sources, Javadoc, checksums and test reports. A successful CI run is not a Maven Central publication or a live-environment acceptance result. The default workflow uses mock services; run the opt-in checks in [live testing](live-testing.md) with separately supplied credentials and isolated resources when needed. -See [release setup](releasing.md) for repository protection payloads, read-only release preflight and the opt-in Maven Central profile. Public package publishing is not yet enabled. +See [release setup](releasing.md) for optional repository protection, Central registration and repository Actions secrets, the manual publication workflow and deployment recovery. A manual dispatch from `main` proceeds after preflight without a separate environment approval. Central credentials and signing setup are still required; adding workflow files alone does not enable public publication. diff --git a/docs/releasing.md b/docs/releasing.md index 44705b8..dd9f318 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,20 +1,22 @@ # Java SDK release setup -The development version is `com.qoder:qca-sdk:0.1.0-SNAPSHOT`. Maven Central publication is not enabled. The GitHub repository, Central namespace and publishing credentials are separate resources. +The development version is `com.qoder:qca-sdk:0.1.0-SNAPSHOT`. The manual [Release workflow](../.github/workflows/release.yml) implements publication using repository Actions secrets. Dispatching it from `main` authorizes publication after all automated checks pass; there is no separate environment approval. The Central account, namespace, signing key and repository settings below must be configured first. The GitHub repository and internal Maven access do not grant Maven Central publishing access. No public release has been made by adding this workflow. -## Activate GitHub protection +## Optional GitHub protection -After the first reviewed public source push, run CI and confirm all six checks described in [maintenance](maintaining.md) appear. A repository administrator can then apply the prepared payloads. Inspect existing rules first and update matching rules rather than creating duplicates: +Branch/tag rules and a `release` environment are not prerequisites for this workflow. Its own checks require a manual dispatch from `main`, the intended source SHA and all six successful CI jobs described in [maintenance](maintaining.md). + +A repository administrator can optionally apply the prepared main and tag protection payloads later. Inspect existing rules first and update matching rules rather than creating duplicates: ```sh gh api repos/QoderAI/qoder-cloud-agents-sdk-java/rulesets gh api --method POST repos/QoderAI/qoder-cloud-agents-sdk-java/rulesets --input .github/config/main-ruleset.json gh api --method POST repos/QoderAI/qoder-cloud-agents-sdk-java/rulesets --input .github/config/tag-ruleset.json -gh api --method PUT repos/QoderAI/qoder-cloud-agents-sdk-java/environments/release --input .github/config/release-environment.json -gh api --method POST repos/QoderAI/qoder-cloud-agents-sdk-java/environments/release/deployment-branch-policies --input .github/config/release-branch.json ``` -The main and `v*` tag protection follows the Go repository baseline. The prepared `release` environment uses maintainer `moonyue-w` (GitHub user ID 300878504), permits `main`, and does not prevent self-review, matching the existing SDK setup. Check that reviewer membership and repository policy are still correct before activation. Applying these settings requires repository administrator access. Configuration files do not activate remote settings by themselves. +The main and `v*` tag protection follows the Go repository baseline. Configuration files do not activate remote settings by themselves. Without tag protection, the workflow still rejects conflicting tags and never overwrites them, but GitHub does not prevent another authorized actor from changing them. + +The unused `release-environment.json` and `release-branch.json` files remain optional templates for adopting environment approval later. To use them, first review the named reviewer and policy, explicitly add `environment: release` to the publishing job, and configure its credentials in that environment. Creating an environment alone does not attach it to this workflow. ## Read-only release preflight @@ -38,8 +40,74 @@ After preparing the release version and configuring a signing key locally, build The profile sets `central.skipUpload=true` and `autoPublish=false`, so this command prepares the signed bundle without uploading it. Do not override `central.skipUpload` until the publication action is authorized. Ordinary builds do not activate the profile, sign artifacts or upload anything. -Before enabling an actual publish workflow, confirm ownership of the `com.qoder` Central namespace, configure the Central Portal token under server ID `central` in external Maven settings, and configure the signing key and passphrase using CI secrets. Keep those credentials out of Git. The public repository existing does not establish namespace ownership. +The release workflow uses this bundle-only mode, then uploads the retained bundle through the Central Publisher API. It does not pass the publishing token to Maven or re-run Maven to recover an uploaded deployment. + +## One-time Central registration + +1. Register or sign in to [Central Portal](https://central.sonatype.com/). Use the team's intended publisher account, not an unrelated personal namespace. +2. Open the account menu, **View Namespaces**, and add `com.qoder`. If the namespace is already owned by the team, obtain publisher access from its owner rather than creating another namespace or silently changing the SDK coordinates. +3. For a new `com.qoder` namespace, ask the `qoder.com` DNS administrator to add the exact verification TXT value shown by Portal at `qoder.com`. Check DNS visibility before clicking **Verify Namespace**; wait for **Verified**. A GitHub repository under QoderAI does not verify this domain-based namespace. +4. In the publisher's [account page](https://central.sonatype.com/account), generate a **User Token**. The generated username/password pair is used for publication; it is not the normal account login password. +5. Prepare a team-owned, passphrase-protected GPG signing key. Retain its backup securely and make its public key discoverable as required by [Central's GPG guide](https://central.sonatype.org/publish/requirements/gpg/). The workflow expects an ASCII-armored exported OpenPGP secret key, compatible with Maven GPG Plugin's BC signer. Export only the intended signing key. + +In repository **Settings → Secrets and variables → Actions**, configure repository-level values: + +| Kind | Name | Value | +| --- | --- | --- | +| Variable | `CENTRAL_NAMESPACE` | `com.qoder`, after the namespace is verified and the publisher has access | +| Secret | `CENTRAL_USERNAME` | Central Portal User Token username | +| Secret | `CENTRAL_PASSWORD` | Central Portal User Token password | +| Secret | `MAVEN_GPG_KEY` | ASCII-armored exported private signing key | +| Secret | `MAVEN_GPG_PASSPHRASE` | Passphrase for that signing key | + +Do not put credentials in POMs, repository files, command-line arguments or chat. For example, `gh secret set CENTRAL_USERNAME --repo QoderAI/qoder-cloud-agents-sdk-java` reads the value interactively; repeat for the password and passphrase. An exported key file can be redirected to `gh secret set MAVEN_GPG_KEY ...` without printing it. Do not use `--env release`: environment-only secrets are unavailable to this workflow. The public signing key must be registered separately; configuring a private-key secret does not publish its public key. + +The namespace variable is a maintainer's setup confirmation, not an automatic ownership check. Portal enforces actual publishing permissions during upload. The workflow stops before signing/uploading when its required settings are absent. Configuring repository secrets is separate from setting branch/tag protections; a repository administrator may need to enter the credentials if the current maintainer lacks that permission. + +## Prepare and publish a release + +Prepare a PR that updates the root POM, the example project's version and SDK dependency, plus the changelog version section. Keep an empty `Unreleased` section. Review any version-specific install examples. Merge only after `quality` and all five Java checks succeed. The workflow does not bump versions or merge the PR. + +Open **Actions → Release → Run workflow**, select **main**, and verify the following inputs before dispatch. Successful preflight automatically proceeds to signing and publication; it does not pause for an additional approval. + +| Input | First publication | +| --- | --- | +| `version` | The reviewed version without `v` or `SNAPSHOT`, for example `0.1.0` | +| `commit_sha` | Full 40-character lowercase SHA of the current GitHub main | +| `batch_id` | 1–64 letters/digits/dots/underscores/hyphens, starting with a letter or digit | +| `resume_run_id` | Empty | +| `deployment_id` | Empty | + +The stages are: + +1. **preflight** checks input, POM/changelog, current main and the latest successful main CI run with all six required jobs. It re-runs quality, SDK tests and packaged consumer tests, then records the notes and unsigned package hashes in the run summary. +2. **publish** starts automatically after preflight succeeds. It rebuilds and signs with JDK 17, compares the JAR/POM/sources/Javadoc bytes to the verified candidate, and retains the exact signed bundle before any upload. It checks main again and creates an annotated `v` tag binding the SHA, batch and bundle digest. It uploads once with `USER_MANAGED` publishing, immediately retains the deployment ID, waits for Central validation, requests publication, and waits for `PUBLISHED`. `USER_MANAGED` separates Central validation from the publish API call; the workflow makes that call automatically. +3. **verify** waits for the public Maven files and compares every payload, signature and checksum with the retained bundle. It then runs the independent example/consumer tests against Maven Central using empty Maven settings and a fresh local dependency cache; it never installs the SDK locally first. +4. **release-notes** creates the matching GitHub Release only after verification succeeds. Existing notes must match exactly; the workflow does not overwrite conflicting releases. + +The `release-bundle` artifact contains the signed archive, file digests, source/batch/workflow identity and release notes. `central-receipt` records the deployment ID. Retention is 90 days; keep a durable copy for longer recovery needs. The workflow does not include signing secrets in artifacts. Public download verification or consumer failures do not undo a Central publication. + +## Recover without replacing a published version + +After a source tag or upload exists, start a new **Release** dispatch from main using the **same version, source SHA and batch**, and set `resume_run_id` to the **original** release run containing `release-bundle`. A newer workflow can execute recovery for the original source. It verifies the originating workflow, immutable annotated tag, bundle hashes and Central deployment identity. It skips rebuilding, signing and uploading. + +Normally leave `deployment_id` empty: recovery downloads the original `central-receipt`. If the upload response or receipt was lost, first inspect the original run and Central Portal. Supply the exact Portal UUID only after locating the deployment whose name identifies the same source, batch and bundle. A mismatched deployment is rejected. Do not blindly retry an upload that timed out: the server may already have accepted it. + +| Failure | Recovery | +| --- | --- | +| Before source tag creation, no upload | Fix setup/source and start a normal dispatch; source changes require the new SHA and successful CI | +| Bundle/tag retained but no receipt | Inspect Portal. If no upload was accepted, an authorized publisher can manually upload the retained `central-bundle.zip` with the exact deployment name described below, then provide its deployment UUID. Do not rebuild the archive | +| Central `FAILED` | Inspect validation errors in Portal. Fixing artifact contents requires a new version; do not replace the tagged bundle | +| Central still validating/publishing or wait timed out | Resume the original run and deployment; no second upload | +| Central already `PUBLISHED`, public propagation/consumer verification failed | Resume; compare the same public files and re-run the clean consumer | +| GitHub Release creation failed | Re-run only the failed release-notes job when its workflow is unchanged; otherwise resume the original publication | +| Tag, notes, source, batch, archive or deployment identity conflict | Stop and investigate the conflict; never move/delete the release tag or overwrite the version | +| Original artifacts expired or missing | Recover the preserved original archive/manifest with maintainer review; this workflow fails rather than rebuilding a replacement | + +Central deployment names use `qca-sdk----` from `manifest.json`. This name binds a manually recovered UUID to the retained bundle. Recovery always points to the original bundle run, not to a later recovery run. If a later recovery supplies the UUID manually, keep supplying it when the original run has no receipt. + +## Offline release validation -The eventual publish workflow must bind approval to the reviewed SHA/version, upload the approved bundle, retain the Central deployment ID, verify the published coordinates from a clean consumer cache, and publish matching GitHub tag/release notes. Recovery must reuse the recorded deployment and immutable released version. That upload and recovery workflow is a later step after publisher identity is confirmed; this preflight must not be mistaken for it. +`make quality` tests input validation, CI gates, bundle integrity, source/tag identity, one-time upload behavior, deployment recovery, public-file comparison and immutable release notes. GitHub's `quality` job also runs `check_central_bundle.py`: it uses a disposable GPG key and a temporary `0.0.0` source copy, runs real Maven packaging/signing with `skipUpload=true`, compares both builds and verifies all four signatures. It has no Central token and makes no publisher API calls. It does not prove actual namespace access, signing-key registration or production publication; the first authorized release must verify those conditions. Reference: [Central publication requirements](https://central.sonatype.org/publish/requirements/) and [Central Publishing Maven Plugin](https://central.sonatype.org/publish/publish-portal-maven/).