Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
84 changes: 84 additions & 0 deletions scripts/publish_to_central.py
Original file line number Diff line number Diff line change
@@ -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()
79 changes: 79 additions & 0 deletions scripts/test_publish_to_central.py
Original file line number Diff line number Diff line change
@@ -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()
Loading