From de23600f567c6f90e96927c638b8883422d795f3 Mon Sep 17 00:00:00 2001 From: Miki Palet <64255955+mikipalet@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:22:22 +0000 Subject: [PATCH] fix: resume Central publication after polling timeout --- .github/workflows/publish.yml | 9 +++- scripts/publish_to_central.py | 84 ++++++++++++++++++++++++++++++ scripts/test_publish_to_central.py | 79 ++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 scripts/publish_to_central.py create mode 100644 scripts/test_publish_to_central.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f7a1b5cf2b..16cbe3b9418 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,11 +8,17 @@ on: release: types: [published] workflow_dispatch: + inputs: + deployment_id: + description: Existing Central deployment to resume without uploading again + required: false + type: string jobs: publish: name: Build, sign & deploy runs-on: ubuntu-latest + timeout-minutes: 200 steps: - name: Checkout uses: actions/checkout@v4 @@ -40,8 +46,9 @@ jobs: # maven.test.skip (not skipTests) so the auto-generated, never-built test # stubs are not even compiled — they lag the regenerated API signatures # and generate.yml only ever runs `mvn compile`, so they have never built. - run: mvn --batch-mode --no-transfer-progress -P sign-artifacts -Dmaven.test.skip=true deploy + run: python3 scripts/publish_to_central.py env: + CENTRAL_DEPLOYMENT_ID: ${{ inputs.deployment_id }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/scripts/publish_to_central.py b/scripts/publish_to_central.py new file mode 100644 index 00000000000..3551bf6c3e6 --- /dev/null +++ b/scripts/publish_to_central.py @@ -0,0 +1,84 @@ +import base64 +import json +import os +import re +import subprocess +import time +import urllib.error +import urllib.request +import uuid + + +UUID_PATTERN = r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}' + + +def upload(): + process = subprocess.Popen( + ['mvn', '--batch-mode', '--no-transfer-progress', '-P', 'sign-artifacts', + '-Dmaven.test.skip=true', 'deploy'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + deployment_id = None + timed_out_id = None + for line in process.stdout: + print(line, end='', flush=True) + uploaded = re.search(r'Uploaded bundle successfully,.*deploymentId: (' + UUID_PATTERN + ')', line) + timed_out = re.search(r'Polling for (' + UUID_PATTERN + r') timed out', line) + if uploaded: + deployment_id = uploaded.group(1) + if timed_out: + timed_out_id = timed_out.group(1) + code = process.wait() + if code == 0: + return None + # A polling timeout does not cancel Central's already accepted upload. + if deployment_id and timed_out_id == deployment_id: + print(f'Resuming publication status for existing deployment {deployment_id}', flush=True) + return deployment_id + raise SystemExit(code) + + +def get_status(deployment_id): + credentials = f"{os.environ['MAVEN_CENTRAL_USERNAME']}:{os.environ['MAVEN_CENTRAL_PASSWORD']}" + token = base64.b64encode(credentials.encode()).decode() + request = urllib.request.Request( + f'https://central.sonatype.com/api/v1/publisher/status?id={deployment_id}', + method='POST', headers={'Authorization': f'Bearer {token}'}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def wait_for_publication(deployment_id): + deployment_id = str(uuid.UUID(deployment_id)) + deadline = time.monotonic() + 7200 + while time.monotonic() < deadline: + try: + result = get_status(deployment_id) + except urllib.error.HTTPError as error: + if error.code != 429 and error.code < 500: + raise + print(f'Central status returned HTTP {error.code}; retrying', flush=True) + except (urllib.error.URLError, TimeoutError): + print('Central status request failed; retrying', flush=True) + else: + state = result.get('deploymentState') + print(f'Central deployment {deployment_id}: {state}', flush=True) + if state == 'PUBLISHED': + return + if state == 'FAILED': + raise RuntimeError(f"Central validation/publishing failed: {json.dumps(result.get('errors'))}") + if state not in {'PENDING', 'VALIDATING', 'VALIDATED', 'PUBLISHING'}: + raise RuntimeError(f'Unexpected Central deployment state: {state}') + time.sleep(30) + raise RuntimeError(f'Timed out waiting for {deployment_id}; resume with the deployment_id workflow input') + + +def main(): + deployment_id = os.environ.get('CENTRAL_DEPLOYMENT_ID') or upload() + if deployment_id: + wait_for_publication(deployment_id) + + +if __name__ == '__main__': + main() diff --git a/scripts/test_publish_to_central.py b/scripts/test_publish_to_central.py new file mode 100644 index 00000000000..8cb02906c69 --- /dev/null +++ b/scripts/test_publish_to_central.py @@ -0,0 +1,79 @@ +import io +import os +import unittest +from unittest.mock import Mock, patch +from urllib.error import HTTPError + +import publish_to_central as publish + +DEPLOYMENT = '83840d9c-55bc-4b81-94d4-3feefc561787' + + +class PublishTests(unittest.TestCase): + def run_maven(self, output, code): + process = Mock(stdout=io.StringIO(output)) + process.wait.return_value = code + with patch.object(publish.subprocess, 'Popen', return_value=process): + return publish.upload() + + def test_success_does_not_upload_or_poll_again(self): + self.assertIsNone(self.run_maven('BUILD SUCCESS\n', 0)) + + def test_timeout_resumes_the_uploaded_deployment(self): + output = (f'Uploaded bundle successfully, deploymentId: {DEPLOYMENT}.\n' + f'Polling for {DEPLOYMENT} timed out before the deployment completed.\n') + self.assertEqual(self.run_maven(output, 1), DEPLOYMENT) + + def test_build_failure_is_not_hidden(self): + with self.assertRaises(SystemExit): + self.run_maven('Compilation failure\n', 1) + + def test_timeout_without_matching_upload_is_not_hidden(self): + with self.assertRaises(SystemExit): + self.run_maven(f'Polling for {DEPLOYMENT} timed out\n', 1) + + def test_manual_resume_never_calls_maven(self): + with patch.dict(os.environ, {'CENTRAL_DEPLOYMENT_ID': DEPLOYMENT}), \ + patch.object(publish, 'upload') as upload, \ + patch.object(publish, 'wait_for_publication') as wait: + publish.main() + upload.assert_not_called() + wait.assert_called_once_with(DEPLOYMENT) + + @patch.object(publish.time, 'sleep') + def test_poll_waits_for_published(self, sleep): + with patch.object(publish, 'get_status', side_effect=[ + {'deploymentState': 'PUBLISHING'}, {'deploymentState': 'PUBLISHED'} + ]): + publish.wait_for_publication(DEPLOYMENT) + sleep.assert_called_once() + + def test_validation_failure_is_not_hidden(self): + with patch.object(publish, 'get_status', return_value={ + 'deploymentState': 'FAILED', 'errors': {'artifact': ['invalid signature']} + }), self.assertRaisesRegex(RuntimeError, 'invalid signature'): + publish.wait_for_publication(DEPLOYMENT) + + @patch.object(publish.time, 'sleep') + def test_transient_status_error_is_retried(self, sleep): + with patch.object(publish, 'get_status', side_effect=[ + HTTPError('https://central.sonatype.com', 503, 'unavailable', {}, None), + {'deploymentState': 'PUBLISHED'} + ]): + publish.wait_for_publication(DEPLOYMENT) + sleep.assert_called_once() + + def test_auth_failure_is_not_retried(self): + with patch.object(publish, 'get_status', side_effect=HTTPError( + 'https://central.sonatype.com', 401, 'unauthorized', {}, None + )), self.assertRaises(HTTPError): + publish.wait_for_publication(DEPLOYMENT) + + def test_timeout_does_not_report_success(self): + with patch.object(publish.time, 'monotonic', side_effect=[0, 7201]), \ + self.assertRaisesRegex(RuntimeError, 'Timed out'): + publish.wait_for_publication(DEPLOYMENT) + + +if __name__ == '__main__': + unittest.main()