From 9c23fe83dc3b90c1fb588534e513b11bf6f9a34f Mon Sep 17 00:00:00 2001 From: Mingi Cho Date: Fri, 25 Sep 2026 19:48:01 +0900 Subject: [PATCH] ci: publish a validated Central candidate through protected approval --- .github/workflows/publish-central.yml | 149 ++++++++++++++++++++++++++ RELEASING.md | 19 ++++ 2 files changed, 168 insertions(+) create mode 100644 .github/workflows/publish-central.yml diff --git a/.github/workflows/publish-central.yml b/.github/workflows/publish-central.yml new file mode 100644 index 0000000..f1f608a --- /dev/null +++ b/.github/workflows/publish-central.yml @@ -0,0 +1,149 @@ +name: Publish validated Central candidate + +on: + workflow_dispatch: + inputs: + version: + description: Release version, for example 0.2.2 + required: true + type: string + deployment_id: + description: Validated deployment ID from the successful release workflow + required: true + type: string + release_run_id: + description: Successful release workflow run that uploaded this candidate + required: true + type: string + confirm_publish: + description: Publish this exact validated candidate permanently to Maven Central + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: central-publication-${{ inputs.version }} + cancel-in-progress: false + +jobs: + provenance: + name: verify candidate provenance + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.check.outputs.sha }} + steps: + - name: Require signed tag, successful upload and sustained-load validation + id: check + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + DEPLOYMENT_ID: ${{ inputs.deployment_id }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + CONFIRM_PUBLISH: ${{ inputs.confirm_publish }} + shell: bash + run: | + set -euo pipefail + [[ "$CONFIRM_PUBLISH" == true ]] + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$DEPLOYMENT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] + [[ "$RELEASE_RUN_ID" =~ ^[0-9]+$ ]] + + gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/v$VERSION" > tag-ref.json + jq -e '.object.type == "tag"' tag-ref.json >/dev/null + tag_sha="$(jq -r '.object.sha' tag-ref.json)" + gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_sha" > tag.json + jq -e '.verification.verified == true and .object.type == "commit"' tag.json >/dev/null + release_sha="$(jq -r '.object.sha' tag.json)" + gh api "repos/$GITHUB_REPOSITORY/compare/${release_sha}...main" > ancestry.json + jq -e '.status == "ahead" or .status == "identical"' ancestry.json >/dev/null + + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID" > upload-run.json + jq -e --arg sha "$release_sha" ' + .head_sha == $sha and .conclusion == "success" + and .path == ".github/workflows/release.yml" + ' upload-run.json >/dev/null + gh run view "$RELEASE_RUN_ID" --repo "$GITHUB_REPOSITORY" --log > upload-run.log + grep -Fq "$DEPLOYMENT_ID" upload-run.log + + gh run list --repo "$GITHUB_REPOSITORY" --workflow core-operations.yml \ + --commit "$release_sha" --status success --json databaseId,headSha > soak-runs.json + jq -e --arg sha "$release_sha" 'any(.[]; .headSha == $sha)' soak-runs.json >/dev/null + echo "sha=$release_sha" >> "$GITHUB_OUTPUT" + echo "Verified v$VERSION at $release_sha, upload run $RELEASE_RUN_ID, deployment $DEPLOYMENT_ID." >> "$GITHUB_STEP_SUMMARY" + + publish: + name: publish the existing candidate + needs: [provenance] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: central-validation + url: https://central.sonatype.com/publishing/deployments + steps: + - name: Validate coordinates and publish without rebuilding or re-uploading + env: + VERSION: ${{ inputs.version }} + DEPLOYMENT_ID: ${{ inputs.deployment_id }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} + shell: bash + run: | + python3 - <<'PY' + import base64 + import json + import os + import time + import urllib.error + import urllib.request + + version = os.environ['VERSION'] + deployment = os.environ['DEPLOYMENT_ID'] + credentials = os.environ['CENTRAL_USERNAME'] + ':' + os.environ['CENTRAL_TOKEN'] + authorization = 'Bearer ' + base64.b64encode(credentials.encode()).decode() + origin = 'https://central.sonatype.com/api/v1/publisher' + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + client = urllib.request.build_opener(NoRedirect()) + + def post(path): + request = urllib.request.Request(origin + path, data=b'', method='POST', + headers={'Authorization': authorization}) + try: + with client.open(request, timeout=30) as response: + data = response.read() + return json.loads(data) if data else None + except urllib.error.HTTPError as error: + raise SystemExit('Central API returned HTTP ' + str(error.code)) from None + + status = post('/status?id=' + deployment) + assert status['deploymentId'] == deployment, 'Unexpected deployment ID' + assert status['deploymentName'] == 'io.github.chomingi:langfuse-otel-java:' + version, 'Unexpected deployment name' + expected = {'pkg:maven/io.github.chomingi/' + artifact + '@' + version for artifact in + ('langfuse-otel-java', 'langfuse-otel-core', 'langfuse-otel-spring-boot-starter')} + assert expected.issubset(set(status.get('purls', []))), 'Missing expected Maven coordinates' + state = status['deploymentState'] + if state == 'VALIDATED': + post('/deployment/' + deployment) + elif state not in ('PUBLISHING', 'PUBLISHED'): + raise SystemExit('Refusing to publish state ' + state) + + deadline = time.monotonic() + 600 + while state != 'PUBLISHED': + if time.monotonic() >= deadline: + raise SystemExit('Publication is still pending; inspect this deployment before retrying.') + time.sleep(15) + status = post('/status?id=' + deployment) + state = status['deploymentState'] + if state not in ('VALIDATED', 'PUBLISHING', 'PUBLISHED'): + raise SystemExit('Unexpected publication state ' + state) + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write('Maven Central deployment `' + deployment + '` is **PUBLISHED**.\n') + summary.write('Verify public consumer resolution before publishing the GitHub draft.\n') + PY diff --git a/RELEASING.md b/RELEASING.md index 8411e6d..d5e2261 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -226,6 +226,25 @@ The `central-validation` environment is entered only after every gate succeeds. `VALIDATED` is not a public release. Do not publish the GitHub draft while Central still reports `VALIDATED` or `PUBLISHING`. +Alternatively, a maintainer can dispatch **Publish validated Central candidate** from `main` with +`version`, `deployment_id`, `release_run_id` and `confirm_publish=true`. This uses the existing +`central-validation` environment approval and Central secrets; it does not require a browser login, +rebuild artifacts or upload another bundle. Before credentials are exposed it verifies the signed +tag, main ancestry, successful release run containing that deployment ID, and a successful sustained-load +run for the exact release commit. It then checks the Central deployment name and Maven coordinates, +publishes only a validated candidate, and waits for `PUBLISHED`. A retry can resume polling an +already-publishing/published candidate. Public consumer resolution and GitHub draft publication remain +separate final steps. + +```bash +gh workflow run publish-central.yml --ref main \ + -f version=0.2.2 -f deployment_id="$central_deployment_id" \ + -f release_run_id="$release_run_id" -F confirm_publish=true +``` + +The workflow uses the official [Central Publisher API](https://central.sonatype.org/publish/publish-portal-api/#publish-or-drop-the-deployment). + + ## Verify public resolution Avoid a warm local Maven cache. Resolve both public artifacts into a new temporary repository, then