From a7e792569f0df0d5b539673db8333fdbbb89b86d Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 10:30:43 +0800 Subject: [PATCH 1/5] ci: externalize release workflow helper scripts --- .../scripts/release_extract_upload_context.py | 59 +++++ .../release_resolve_repository_context.py | 126 ++++++++++ .github/scripts/release_write_summary.py | 55 +++++ .github/scripts/sonatype_publish.py | 215 ++++++++++++++++++ .github/workflows/release.yml | 91 ++++++-- .github/workflows/sonatype-publish.yml | 72 ++++++ 6 files changed, 600 insertions(+), 18 deletions(-) create mode 100644 .github/scripts/release_extract_upload_context.py create mode 100644 .github/scripts/release_resolve_repository_context.py create mode 100644 .github/scripts/release_write_summary.py create mode 100644 .github/scripts/sonatype_publish.py create mode 100644 .github/workflows/sonatype-publish.yml diff --git a/.github/scripts/release_extract_upload_context.py b/.github/scripts/release_extract_upload_context.py new file mode 100644 index 00000000..ebc16a91 --- /dev/null +++ b/.github/scripts/release_extract_upload_context.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Extract uploaded artifact URLs from Maven deploy logs.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "").strip() + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as out: + out.write(f"{key}={value}\n") + + +def main() -> int: + repository_name = os.environ.get("TARGET_REPOSITORY", "").strip() + log_file = Path(os.environ.get("DEPLOY_LOG", "maven-deploy.log")) + context_file = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) + + log_text = log_file.read_text(encoding="utf-8") + pattern = re.compile(r"Uploaded to (\S+):\s+(\S+)") + + uploaded_urls: list[str] = [] + for target_repo, url in pattern.findall(log_text): + normalized = target_repo.rstrip(":") + if normalized == repository_name: + uploaded_urls.append(url) + + deduped_urls = sorted(set(uploaded_urls)) + jar_urls = [ + url for url in deduped_urls + if url.endswith(".jar") and not url.endswith(".jar.asc") + ] + pom_urls = [ + url for url in deduped_urls + if url.endswith(".pom") and not url.endswith(".pom.asc") + ] + + payload = { + "target_repository": repository_name, + "uploaded_urls": deduped_urls, + "jar_urls": jar_urls, + "pom_urls": pom_urls, + } + context_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + write_output("uploaded_urls_count", str(len(deduped_urls))) + write_output("jar_urls_count", str(len(jar_urls))) + write_output("pom_urls_count", str(len(pom_urls))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py new file mode 100644 index 00000000..5b917c5d --- /dev/null +++ b/.github/scripts/release_resolve_repository_context.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Resolve Sonatype repository context for release deployments.""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" + + +def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[str, Any]]: + request = urllib.request.Request(url=url, method="GET", headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + if not body: + return response.status, {} + return response.status, json.loads(body) + except urllib.error.HTTPError as error: + try: + payload = json.loads(error.read().decode("utf-8")) + except Exception: # noqa: BLE001 + payload = {"error": f"HTTP {error.code}"} + payload.setdefault("error", f"HTTP {error.code}") + return error.code, payload + except Exception as error: # noqa: BLE001 + return None, {"error": str(error)} + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "").strip() + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as stream: + stream.write(f"{key}={value}\n") + + +def main() -> int: + target_repository = os.environ.get("TARGET_REPOSITORY", "").strip() + namespace = os.environ.get("TARGET_NAMESPACE", "").strip() + username = os.environ.get("MAVEN_USERNAME", "") + password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") + context_path = Path( + os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") + ) + + context: dict[str, Any] = { + "target_repository": target_repository, + "namespace": namespace, + "status": "not_applicable", + "reason": "repository input is not releases", + "repository_key": "", + "portal_deployment_id": "", + "search_candidates": [], + } + + if target_repository == "releases": + if not username or not password: + context["status"] = "manual_required" + context["reason"] = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN" + else: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + searches = [ + ("open", "client"), + ("closed", "client"), + ("open", "any"), + ("closed", "any"), + ] + selected: dict[str, Any] | None = None + last_error = "" + + for state, ip in searches: + url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"profile_id={urllib.parse.quote(namespace)}" + f"&state={urllib.parse.quote(state)}" + f"&ip={urllib.parse.quote(ip)}" + ) + status, payload = request_json(url, headers) + if status is None: + last_error = payload.get("error", "unknown error") + continue + + repositories = ( + payload.get("repositories", []) if isinstance(payload, dict) else [] + ) + context["search_candidates"].append( + {"state": state, "ip": ip, "count": len(repositories)} + ) + if repositories: + selected = repositories[0] + break + + if selected: + context["status"] = "resolved" + context["reason"] = "" + context["repository_key"] = selected.get("key", "") or "" + context["portal_deployment_id"] = ( + selected.get("portal_deployment_id", "") or "" + ) + else: + context["status"] = "manual_required" + context["reason"] = last_error or "No staging repository key found" + + context_path.write_text(json.dumps(context, indent=2) + "\n", encoding="utf-8") + write_output("repository_key", context.get("repository_key", "")) + write_output("portal_deployment_id", context.get("portal_deployment_id", "")) + write_output("status", context.get("status", "")) + write_output("reason", context.get("reason", "")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_write_summary.py b/.github/scripts/release_write_summary.py new file mode 100644 index 00000000..4cfafddb --- /dev/null +++ b/.github/scripts/release_write_summary.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Write release publish context summary for GitHub Actions.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +def read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def main() -> int: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "").strip() + if not summary_path: + return 0 + + deploy_path = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) + repository_path = Path( + os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") + ) + + deploy = read_json(deploy_path) + repository = read_json(repository_path) + + lines = [ + "## Publish Context", + "", + f"- target repository: {deploy.get('target_repository', '')}", + f"- uploaded URLs: {len(deploy.get('uploaded_urls', []))}", + f"- jar URLs: {len(deploy.get('jar_urls', []))}", + f"- pom URLs: {len(deploy.get('pom_urls', []))}", + f"- staging key status: {repository.get('status', '')}", + f"- repository_key: {repository.get('repository_key', '')}", + f"- portal_deployment_id: {repository.get('portal_deployment_id', '')}", + ] + reason = repository.get("reason", "") + if reason: + lines.append(f"- reason: {reason}") + + with open(summary_path, "a", encoding="utf-8") as output: + output.write("\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py new file mode 100644 index 00000000..250868d1 --- /dev/null +++ b/.github/scripts/sonatype_publish.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Trigger and monitor Sonatype portal publish flow.""" + +from __future__ import annotations + +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" +PORTAL_BASE = "https://central.sonatype.com" + + +def request_json( + method: str, + url: str, + headers: dict[str, str], +) -> tuple[int | None, dict[str, Any]]: + request = urllib.request.Request(url=url, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + if not body: + return response.status, {} + try: + return response.status, json.loads(body) + except json.JSONDecodeError: + return response.status, {"raw": body} + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8") + try: + payload = json.loads(body) if body else {} + except json.JSONDecodeError: + payload = {"raw": body} + payload.setdefault("error", f"HTTP {error.code}") + return error.code, payload + except Exception as error: # noqa: BLE001 + return None, {"error": str(error)} + + +def extract_deployment_state(payload: dict[str, Any]) -> str: + for key in ("deploymentState", "deployment_state", "state"): + value = payload.get(key) + if isinstance(value, str): + return value + return "unknown" + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "").strip() + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as stream: + stream.write(f"{key}={value}\n") + + +def to_int(value: str, default: int) -> int: + try: + return int(value) + except ValueError: + return default + + +def main() -> int: + namespace = os.environ.get("INPUT_NAMESPACE", "com.ctrip.framework.apollo").strip() + repository_key = os.environ.get("INPUT_REPOSITORY_KEY", "").strip() + timeout_minutes = to_int(os.environ.get("INPUT_TIMEOUT_MINUTES", "60"), 60) + mode = os.environ.get("INPUT_MODE", "portal_api").strip().lower() + + username = os.environ.get("MAVEN_USERNAME", "") + password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") + + result = "manual_required" + final_state = "unknown" + deployment_id = "" + deployment_url = "" + reason = "" + + if not username or not password: + reason = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN secrets" + else: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + if not repository_key: + searches = ("open", "closed") + for state in searches: + search_url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"ip=any&profile_id={urllib.parse.quote(namespace)}" + f"&state={urllib.parse.quote(state)}" + ) + _, payload = request_json("GET", search_url, headers) + repositories = payload.get("repositories", []) if isinstance(payload, dict) else [] + if repositories: + repository_key = repositories[0].get("key", "") or "" + break + + if not repository_key: + reason = "No staging repository key found" + else: + upload_url = ( + f"{OSSRH_BASE}/manual/upload/repository/{urllib.parse.quote(repository_key)}" + f"?publishing_type={urllib.parse.quote(mode)}" + ) + upload_status, upload_payload = request_json("POST", upload_url, headers) + if upload_status is None: + reason = f"Upload API failed: {upload_payload.get('error', 'unknown error')}" + elif upload_status >= 400: + reason = ( + f"Upload API returned HTTP {upload_status}: " + f"{upload_payload.get('error', 'unknown error')}" + ) + + list_url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"ip=any&profile_id={urllib.parse.quote(namespace)}" + ) + _, list_payload = request_json("GET", list_url, headers) + repositories = list_payload.get("repositories", []) if isinstance(list_payload, dict) else [] + for item in repositories: + if item.get("key") == repository_key and item.get("portal_deployment_id"): + deployment_id = item.get("portal_deployment_id") + break + + if deployment_id: + deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" + publish_triggered = False + deadline = time.time() + timeout_minutes * 60 + + while time.time() <= deadline: + status_url = ( + f"{PORTAL_BASE}/api/v1/publisher/status?" + f"id={urllib.parse.quote(deployment_id)}" + ) + _, status_payload = request_json("POST", status_url, headers) + final_state = extract_deployment_state(status_payload) + + if final_state == "PUBLISHED": + result = "published" + reason = "" + break + + if final_state in {"FAILED", "BROKEN", "ERROR"}: + reason = f"Deployment entered terminal state: {final_state}" + break + + if mode == "portal_api" and final_state == "VALIDATED" and not publish_triggered: + publish_url = ( + f"{PORTAL_BASE}/api/v1/publisher/deployment/" + f"{urllib.parse.quote(deployment_id)}" + ) + publish_status, publish_payload = request_json( + "POST", + publish_url, + headers, + ) + if publish_status is None or publish_status >= 400: + reason = ( + "Publish API failed: " + f"{publish_payload.get('error', 'HTTP ' + str(publish_status))}" + ) + break + publish_triggered = True + + if mode == "user_managed" and final_state == "VALIDATED": + reason = "Mode user_managed requires manual publish in portal" + break + + time.sleep(10) + + if result != "published" and not reason and final_state != "unknown": + reason = ( + "Timed out waiting for deployment status. " + f"Latest state={final_state}" + ) + else: + reason = "No portal deployment id found for repository" + + if result != "published" and not reason: + reason = "Automatic publish did not complete" + + write_output("result", result) + write_output("repository_key", repository_key) + write_output("deployment_id", deployment_id) + write_output("deployment_url", deployment_url) + write_output("final_state", final_state) + write_output("reason", reason) + + display_key = repository_key or "-" + display_deployment = deployment_id or "-" + display_url = deployment_url or "-" + print( + "SONATYPE_RESULT " + f"result={result} " + f"repository_key={display_key} " + f"deployment_id={display_deployment} " + f"final_state={final_state} " + f"deployment_url={display_url}" + ) + if reason: + print(f"SONATYPE_REASON {reason}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd02a1ac..a5c72b7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven +# This workflow publishes Apollo Java artifacts. name: publish sdks @@ -25,24 +24,80 @@ on: description: 'Maven Repository(snapshots or releases)' required: true default: 'snapshots' + namespace: + description: 'Sonatype namespace used to search staging repositories' + required: true + default: 'com.ctrip.framework.apollo' jobs: publish: runs-on: ubuntu-latest + outputs: + repository_key: ${{ steps.repository_context.outputs.repository_key }} + portal_deployment_id: ${{ steps.repository_context.outputs.portal_deployment_id }} + uploaded_urls_count: ${{ steps.upload_context.outputs.uploaded_urls_count }} + jar_urls_count: ${{ steps.upload_context.outputs.jar_urls_count }} + pom_urls_count: ${{ steps.upload_context.outputs.pom_urls_count }} steps: - - uses: actions/checkout@v2 - - name: Set up Maven Central Repository - uses: actions/setup-java@v1 - with: - java-version: 8 - server-id: ${{ github.event.inputs.repository }} - server-username: MAVEN_USERNAME - server-password: MAVEN_CENTRAL_TOKEN - gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - - name: Publish to Apache Maven Central - run: mvn clean deploy -DskipTests=true -Prelease "-Dreleases.repo=https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" "-Dsnapshots.repo=https://central.sonatype.com/repository/maven-snapshots/" - env: - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + - uses: actions/checkout@v2 + + - name: Set up Maven Central Repository + uses: actions/setup-java@v1 + with: + java-version: 8 + server-id: ${{ github.event.inputs.repository }} + server-username: MAVEN_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Publish to Apache Maven Central + run: | + set -eo pipefail + mvn clean deploy -DskipTests=true -Prelease \ + "-Dreleases.repo=https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" \ + "-Dsnapshots.repo=https://central.sonatype.com/repository/maven-snapshots/" \ + 2>&1 | tee maven-deploy.log + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Extract uploaded artifact URLs + id: upload_context + env: + TARGET_REPOSITORY: ${{ github.event.inputs.repository }} + DEPLOY_LOG: maven-deploy.log + DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json + run: | + python3 .github/scripts/release_extract_upload_context.py + + - name: Resolve staging repository key + id: repository_context + env: + TARGET_REPOSITORY: ${{ github.event.inputs.repository }} + TARGET_NAMESPACE: ${{ github.event.inputs.namespace }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + REPOSITORY_CONTEXT_FILE: repository-context.json + run: | + python3 .github/scripts/release_resolve_repository_context.py + + - name: Upload release publish context + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-deploy-context + if-no-files-found: warn + path: | + maven-deploy.log + deploy-artifacts.json + repository-context.json + + - name: Publish summary + if: always() + env: + DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json + REPOSITORY_CONTEXT_FILE: repository-context.json + run: | + python3 .github/scripts/release_write_summary.py diff --git a/.github/workflows/sonatype-publish.yml b/.github/workflows/sonatype-publish.yml new file mode 100644 index 00000000..11055447 --- /dev/null +++ b/.github/workflows/sonatype-publish.yml @@ -0,0 +1,72 @@ +# +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: sonatype publish + +on: + workflow_dispatch: + inputs: + namespace: + description: 'Sonatype namespace (e.g. com.ctrip.framework.apollo)' + required: true + default: 'com.ctrip.framework.apollo' + repository_key: + description: 'Optional staging repository key; leave blank to auto-detect' + required: false + default: '' + timeout_minutes: + description: 'Max minutes to wait for deployment status polling' + required: true + default: '60' + mode: + description: 'manual upload mode: portal_api|automatic|user_managed' + required: true + default: 'portal_api' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Publish via Sonatype API + id: publish + env: + INPUT_NAMESPACE: ${{ github.event.inputs.namespace }} + INPUT_REPOSITORY_KEY: ${{ github.event.inputs.repository_key }} + INPUT_TIMEOUT_MINUTES: ${{ github.event.inputs.timeout_minutes }} + INPUT_MODE: ${{ github.event.inputs.mode }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + run: | + python3 .github/scripts/sonatype_publish.py + + - name: Sonatype publish summary + run: | + { + echo "## Sonatype Publish Result" + echo "" + echo "- result: ${{ steps.publish.outputs.result }}" + echo "- repository_key: ${{ steps.publish.outputs.repository_key }}" + echo "- deployment_id: ${{ steps.publish.outputs.deployment_id }}" + echo "- final_state: ${{ steps.publish.outputs.final_state }}" + echo "- deployment_url: ${{ steps.publish.outputs.deployment_url }}" + if [ -n "${{ steps.publish.outputs.reason }}" ]; then + echo "- reason: ${{ steps.publish.outputs.reason }}" + echo "" + echo "Manual fallback: open https://central.sonatype.com/publishing/deployments and complete publish by deployment id." + fi + } >> "$GITHUB_STEP_SUMMARY" From bd0375f00c74674c05d61dd47a91069473194942 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 11:22:47 +0800 Subject: [PATCH 2/5] ci: fix release workflow review findings --- .../scripts/release_extract_upload_context.py | 23 ++- .../release_resolve_repository_context.py | 27 +++- .github/scripts/release_write_summary.py | 13 ++ .github/scripts/sonatype_publish.py | 144 +++++++++++------- .github/workflows/release.yml | 5 +- .github/workflows/sonatype-publish.yml | 26 +++- 6 files changed, 162 insertions(+), 76 deletions(-) diff --git a/.github/scripts/release_extract_upload_context.py b/.github/scripts/release_extract_upload_context.py index ebc16a91..ebada97d 100644 --- a/.github/scripts/release_extract_upload_context.py +++ b/.github/scripts/release_extract_upload_context.py @@ -1,4 +1,17 @@ #!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Extract uploaded artifact URLs from Maven deploy logs.""" from __future__ import annotations @@ -32,14 +45,8 @@ def main() -> int: uploaded_urls.append(url) deduped_urls = sorted(set(uploaded_urls)) - jar_urls = [ - url for url in deduped_urls - if url.endswith(".jar") and not url.endswith(".jar.asc") - ] - pom_urls = [ - url for url in deduped_urls - if url.endswith(".pom") and not url.endswith(".pom.asc") - ] + jar_urls = [url for url in deduped_urls if url.endswith(".jar")] + pom_urls = [url for url in deduped_urls if url.endswith(".pom")] payload = { "target_repository": repository_name, diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py index 5b917c5d..8fb9d1ad 100644 --- a/.github/scripts/release_resolve_repository_context.py +++ b/.github/scripts/release_resolve_repository_context.py @@ -1,4 +1,17 @@ #!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Resolve Sonatype repository context for release deployments.""" from __future__ import annotations @@ -22,7 +35,10 @@ def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[st body = response.read().decode("utf-8") if not body: return response.status, {} - return response.status, json.loads(body) + try: + return response.status, json.loads(body) + except json.JSONDecodeError: + return response.status, {"raw": body} except urllib.error.HTTPError as error: try: payload = json.loads(error.read().decode("utf-8")) @@ -91,6 +107,15 @@ def main() -> int: status, payload = request_json(url, headers) if status is None: last_error = payload.get("error", "unknown error") + context["search_candidates"].append( + { + "state": state, + "ip": ip, + "status": None, + "count": 0, + "error": last_error, + } + ) continue repositories = ( diff --git a/.github/scripts/release_write_summary.py b/.github/scripts/release_write_summary.py index 4cfafddb..4558e5db 100644 --- a/.github/scripts/release_write_summary.py +++ b/.github/scripts/release_write_summary.py @@ -1,4 +1,17 @@ #!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Write release publish context summary for GitHub Actions.""" from __future__ import annotations diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py index 250868d1..b0c9de0d 100644 --- a/.github/scripts/sonatype_publish.py +++ b/.github/scripts/sonatype_publish.py @@ -1,4 +1,17 @@ #!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Trigger and monitor Sonatype portal publish flow.""" from __future__ import annotations @@ -115,75 +128,90 @@ def main() -> int: if upload_status is None: reason = f"Upload API failed: {upload_payload.get('error', 'unknown error')}" elif upload_status >= 400: + upload_error = upload_payload.get("error") + if not upload_error: + upload_error = f"HTTP {upload_status}" reason = ( - f"Upload API returned HTTP {upload_status}: " - f"{upload_payload.get('error', 'unknown error')}" + f"Upload API failed: {upload_error}" ) - list_url = ( - f"{OSSRH_BASE}/manual/search/repositories?" - f"ip=any&profile_id={urllib.parse.quote(namespace)}" - ) - _, list_payload = request_json("GET", list_url, headers) - repositories = list_payload.get("repositories", []) if isinstance(list_payload, dict) else [] - for item in repositories: - if item.get("key") == repository_key and item.get("portal_deployment_id"): - deployment_id = item.get("portal_deployment_id") - break - - if deployment_id: - deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" - publish_triggered = False - deadline = time.time() + timeout_minutes * 60 - - while time.time() <= deadline: - status_url = ( - f"{PORTAL_BASE}/api/v1/publisher/status?" - f"id={urllib.parse.quote(deployment_id)}" - ) - _, status_payload = request_json("POST", status_url, headers) - final_state = extract_deployment_state(status_payload) - - if final_state == "PUBLISHED": - result = "published" - reason = "" + if not reason: + list_url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"ip=any&profile_id={urllib.parse.quote(namespace)}" + ) + _, list_payload = request_json("GET", list_url, headers) + repositories = ( + list_payload.get("repositories", []) + if isinstance(list_payload, dict) + else [] + ) + for item in repositories: + if item.get("key") == repository_key and item.get("portal_deployment_id"): + deployment_id = item.get("portal_deployment_id") break - if final_state in {"FAILED", "BROKEN", "ERROR"}: - reason = f"Deployment entered terminal state: {final_state}" - break + if deployment_id: + deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" + publish_triggered = False + deadline = time.time() + timeout_minutes * 60 - if mode == "portal_api" and final_state == "VALIDATED" and not publish_triggered: - publish_url = ( - f"{PORTAL_BASE}/api/v1/publisher/deployment/" - f"{urllib.parse.quote(deployment_id)}" - ) - publish_status, publish_payload = request_json( - "POST", - publish_url, - headers, + while time.time() <= deadline: + status_url = ( + f"{PORTAL_BASE}/api/v1/publisher/status?" + f"id={urllib.parse.quote(deployment_id)}" ) - if publish_status is None or publish_status >= 400: - reason = ( - "Publish API failed: " - f"{publish_payload.get('error', 'HTTP ' + str(publish_status))}" - ) + _, status_payload = request_json("POST", status_url, headers) + final_state = extract_deployment_state(status_payload) + + if final_state == "PUBLISHED": + result = "published" + reason = "" break - publish_triggered = True - if mode == "user_managed" and final_state == "VALIDATED": - reason = "Mode user_managed requires manual publish in portal" - break + if final_state in {"FAILED", "BROKEN", "ERROR"}: + reason = f"Deployment entered terminal state: {final_state}" + break - time.sleep(10) + if ( + mode == "portal_api" + and final_state == "VALIDATED" + and not publish_triggered + ): + publish_url = ( + f"{PORTAL_BASE}/api/v1/publisher/deployment/" + f"{urllib.parse.quote(deployment_id)}" + ) + publish_status, publish_payload = request_json( + "POST", + publish_url, + headers, + ) + if publish_status is None or publish_status >= 400: + publish_error = publish_payload.get("error") + if not publish_error: + publish_error = ( + f"HTTP {publish_status}" + if publish_status is not None + else "HTTP unknown" + ) + reason = f"Publish API failed: {publish_error}" + break + publish_triggered = True + + if mode == "user_managed" and final_state == "VALIDATED": + reason = "Mode user_managed requires manual publish in portal" + break - if result != "published" and not reason and final_state != "unknown": - reason = ( - "Timed out waiting for deployment status. " - f"Latest state={final_state}" - ) - else: - reason = "No portal deployment id found for repository" + time.sleep(10) + + if result != "published" and not reason and final_state != "unknown": + reason = ( + "Timed out waiting for deployment status. " + f"Latest state={final_state}" + ) + else: + reason = "No portal deployment id found for repository" if result != "published" and not reason: reason = "Automatic publish did not complete" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c72b7b..5e466865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,12 @@ jobs: jar_urls_count: ${{ steps.upload_context.outputs.jar_urls_count }} pom_urls_count: ${{ steps.upload_context.outputs.pom_urls_count }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Maven Central Repository - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: temurin java-version: 8 server-id: ${{ github.event.inputs.repository }} server-username: MAVEN_USERNAME diff --git a/.github/workflows/sonatype-publish.yml b/.github/workflows/sonatype-publish.yml index 11055447..1d551416 100644 --- a/.github/workflows/sonatype-publish.yml +++ b/.github/workflows/sonatype-publish.yml @@ -35,6 +35,11 @@ on: description: 'manual upload mode: portal_api|automatic|user_managed' required: true default: 'portal_api' + type: choice + options: + - portal_api + - automatic + - user_managed jobs: publish: @@ -55,17 +60,24 @@ jobs: python3 .github/scripts/sonatype_publish.py - name: Sonatype publish summary + env: + PUBLISH_RESULT: ${{ steps.publish.outputs.result }} + PUBLISH_REPOSITORY_KEY: ${{ steps.publish.outputs.repository_key }} + PUBLISH_DEPLOYMENT_ID: ${{ steps.publish.outputs.deployment_id }} + PUBLISH_FINAL_STATE: ${{ steps.publish.outputs.final_state }} + PUBLISH_DEPLOYMENT_URL: ${{ steps.publish.outputs.deployment_url }} + PUBLISH_REASON: ${{ steps.publish.outputs.reason }} run: | { echo "## Sonatype Publish Result" echo "" - echo "- result: ${{ steps.publish.outputs.result }}" - echo "- repository_key: ${{ steps.publish.outputs.repository_key }}" - echo "- deployment_id: ${{ steps.publish.outputs.deployment_id }}" - echo "- final_state: ${{ steps.publish.outputs.final_state }}" - echo "- deployment_url: ${{ steps.publish.outputs.deployment_url }}" - if [ -n "${{ steps.publish.outputs.reason }}" ]; then - echo "- reason: ${{ steps.publish.outputs.reason }}" + echo "- result: ${PUBLISH_RESULT}" + echo "- repository_key: ${PUBLISH_REPOSITORY_KEY}" + echo "- deployment_id: ${PUBLISH_DEPLOYMENT_ID}" + echo "- final_state: ${PUBLISH_FINAL_STATE}" + echo "- deployment_url: ${PUBLISH_DEPLOYMENT_URL}" + if [ -n "${PUBLISH_REASON}" ]; then + echo "- reason: ${PUBLISH_REASON}" echo "" echo "Manual fallback: open https://central.sonatype.com/publishing/deployments and complete publish by deployment id." fi From 38a9e1629fb68b69e3ad51eb08d358ace5a8b604 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 12:18:10 +0800 Subject: [PATCH 3/5] ci: improve sonatype workflow error reporting --- .../release_resolve_repository_context.py | 16 ++++++++++++- .github/scripts/sonatype_publish.py | 23 +++++++++++++++++-- .github/workflows/sonatype-publish.yml | 1 + 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py index 8fb9d1ad..802b8da6 100644 --- a/.github/scripts/release_resolve_repository_context.py +++ b/.github/scripts/release_resolve_repository_context.py @@ -118,11 +118,25 @@ def main() -> int: ) continue + if status < 200 or status >= 300: + http_error = payload.get("error", f"HTTP {status}") + last_error = http_error + context["search_candidates"].append( + { + "state": state, + "ip": ip, + "status": status, + "count": 0, + "error": http_error, + } + ) + continue + repositories = ( payload.get("repositories", []) if isinstance(payload, dict) else [] ) context["search_candidates"].append( - {"state": state, "ip": ip, "count": len(repositories)} + {"state": state, "ip": ip, "status": status, "count": len(repositories)} ) if repositories: selected = repositories[0] diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py index b0c9de0d..a09f85ef 100644 --- a/.github/scripts/sonatype_publish.py +++ b/.github/scripts/sonatype_publish.py @@ -105,20 +105,39 @@ def main() -> int: if not repository_key: searches = ("open", "closed") + search_errors: list[str] = [] + successful_search = False for state in searches: search_url = ( f"{OSSRH_BASE}/manual/search/repositories?" f"ip=any&profile_id={urllib.parse.quote(namespace)}" f"&state={urllib.parse.quote(state)}" ) - _, payload = request_json("GET", search_url, headers) + search_status, payload = request_json("GET", search_url, headers) + if search_status is None or search_status < 200 or search_status >= 300: + search_error = payload.get("error") if isinstance(payload, dict) else "" + if not search_error: + search_error = ( + f"HTTP {search_status}" + if search_status is not None + else "HTTP unknown" + ) + search_errors.append( + f"Repository search failed ({state}): {search_error}" + ) + continue + + successful_search = True repositories = payload.get("repositories", []) if isinstance(payload, dict) else [] if repositories: repository_key = repositories[0].get("key", "") or "" break if not repository_key: - reason = "No staging repository key found" + if search_errors and not successful_search: + reason = "; ".join(search_errors) + else: + reason = "No staging repository key found" else: upload_url = ( f"{OSSRH_BASE}/manual/upload/repository/{urllib.parse.quote(repository_key)}" diff --git a/.github/workflows/sonatype-publish.yml b/.github/workflows/sonatype-publish.yml index 1d551416..a96cd600 100644 --- a/.github/workflows/sonatype-publish.yml +++ b/.github/workflows/sonatype-publish.yml @@ -60,6 +60,7 @@ jobs: python3 .github/scripts/sonatype_publish.py - name: Sonatype publish summary + if: always() env: PUBLISH_RESULT: ${{ steps.publish.outputs.result }} PUBLISH_REPOSITORY_KEY: ${{ steps.publish.outputs.repository_key }} From a785a28c3dbe3c731061257b5f386fd6fdbc6222 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 12:30:41 +0800 Subject: [PATCH 4/5] ci: surface repository list api failures --- .github/scripts/sonatype_publish.py | 136 +++++++++++++++------------- 1 file changed, 73 insertions(+), 63 deletions(-) diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py index a09f85ef..9d4a359c 100644 --- a/.github/scripts/sonatype_publish.py +++ b/.github/scripts/sonatype_publish.py @@ -159,78 +159,88 @@ def main() -> int: f"{OSSRH_BASE}/manual/search/repositories?" f"ip=any&profile_id={urllib.parse.quote(namespace)}" ) - _, list_payload = request_json("GET", list_url, headers) - repositories = ( - list_payload.get("repositories", []) - if isinstance(list_payload, dict) - else [] - ) - for item in repositories: - if item.get("key") == repository_key and item.get("portal_deployment_id"): - deployment_id = item.get("portal_deployment_id") - break - - if deployment_id: - deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" - publish_triggered = False - deadline = time.time() + timeout_minutes * 60 - - while time.time() <= deadline: - status_url = ( - f"{PORTAL_BASE}/api/v1/publisher/status?" - f"id={urllib.parse.quote(deployment_id)}" + list_status, list_payload = request_json("GET", list_url, headers) + if list_status is None or list_status < 200 or list_status >= 300: + list_error = list_payload.get("error") if isinstance(list_payload, dict) else "" + if not list_error: + list_error = ( + f"HTTP {list_status}" + if list_status is not None + else "HTTP unknown" ) - _, status_payload = request_json("POST", status_url, headers) - final_state = extract_deployment_state(status_payload) - - if final_state == "PUBLISHED": - result = "published" - reason = "" + reason = f"Repository list API failed after upload: {list_error}" + else: + repositories = ( + list_payload.get("repositories", []) + if isinstance(list_payload, dict) + else [] + ) + for item in repositories: + if item.get("key") == repository_key and item.get("portal_deployment_id"): + deployment_id = item.get("portal_deployment_id") break - if final_state in {"FAILED", "BROKEN", "ERROR"}: - reason = f"Deployment entered terminal state: {final_state}" - break + if deployment_id: + deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" + publish_triggered = False + deadline = time.time() + timeout_minutes * 60 - if ( - mode == "portal_api" - and final_state == "VALIDATED" - and not publish_triggered - ): - publish_url = ( - f"{PORTAL_BASE}/api/v1/publisher/deployment/" - f"{urllib.parse.quote(deployment_id)}" + while time.time() <= deadline: + status_url = ( + f"{PORTAL_BASE}/api/v1/publisher/status?" + f"id={urllib.parse.quote(deployment_id)}" ) - publish_status, publish_payload = request_json( - "POST", - publish_url, - headers, - ) - if publish_status is None or publish_status >= 400: - publish_error = publish_payload.get("error") - if not publish_error: - publish_error = ( - f"HTTP {publish_status}" - if publish_status is not None - else "HTTP unknown" - ) - reason = f"Publish API failed: {publish_error}" + _, status_payload = request_json("POST", status_url, headers) + final_state = extract_deployment_state(status_payload) + + if final_state == "PUBLISHED": + result = "published" + reason = "" break - publish_triggered = True - if mode == "user_managed" and final_state == "VALIDATED": - reason = "Mode user_managed requires manual publish in portal" - break + if final_state in {"FAILED", "BROKEN", "ERROR"}: + reason = f"Deployment entered terminal state: {final_state}" + break - time.sleep(10) + if ( + mode == "portal_api" + and final_state == "VALIDATED" + and not publish_triggered + ): + publish_url = ( + f"{PORTAL_BASE}/api/v1/publisher/deployment/" + f"{urllib.parse.quote(deployment_id)}" + ) + publish_status, publish_payload = request_json( + "POST", + publish_url, + headers, + ) + if publish_status is None or publish_status >= 400: + publish_error = publish_payload.get("error") + if not publish_error: + publish_error = ( + f"HTTP {publish_status}" + if publish_status is not None + else "HTTP unknown" + ) + reason = f"Publish API failed: {publish_error}" + break + publish_triggered = True + + if mode == "user_managed" and final_state == "VALIDATED": + reason = "Mode user_managed requires manual publish in portal" + break - if result != "published" and not reason and final_state != "unknown": - reason = ( - "Timed out waiting for deployment status. " - f"Latest state={final_state}" - ) - else: - reason = "No portal deployment id found for repository" + time.sleep(10) + + if result != "published" and not reason and final_state != "unknown": + reason = ( + "Timed out waiting for deployment status. " + f"Latest state={final_state}" + ) + else: + reason = "No portal deployment id found for repository" if result != "published" and not reason: reason = "Automatic publish did not complete" From efd80873498eb3fa509375e8ac6b9f85733da3be Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 12:40:49 +0800 Subject: [PATCH 5/5] ci: harden sonatype publish status handling --- .github/scripts/github_actions_utils.py | 27 +++++++++++++++ .../scripts/release_extract_upload_context.py | 11 ++----- .../release_resolve_repository_context.py | 17 ++++------ .github/scripts/sonatype_publish.py | 33 +++++++++++++------ 4 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 .github/scripts/github_actions_utils.py diff --git a/.github/scripts/github_actions_utils.py b/.github/scripts/github_actions_utils.py new file mode 100644 index 00000000..8b90c6c4 --- /dev/null +++ b/.github/scripts/github_actions_utils.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Shared helpers for GitHub Actions scripts.""" + +from __future__ import annotations + +import os + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "").strip() + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"{key}={value}\n") diff --git a/.github/scripts/release_extract_upload_context.py b/.github/scripts/release_extract_upload_context.py index ebada97d..52981515 100644 --- a/.github/scripts/release_extract_upload_context.py +++ b/.github/scripts/release_extract_upload_context.py @@ -21,13 +21,7 @@ import re from pathlib import Path - -def write_output(key: str, value: str) -> None: - output_path = os.environ.get("GITHUB_OUTPUT", "").strip() - if not output_path: - return - with open(output_path, "a", encoding="utf-8") as out: - out.write(f"{key}={value}\n") +from github_actions_utils import write_output def main() -> int: @@ -40,8 +34,7 @@ def main() -> int: uploaded_urls: list[str] = [] for target_repo, url in pattern.findall(log_text): - normalized = target_repo.rstrip(":") - if normalized == repository_name: + if target_repo == repository_name: uploaded_urls.append(url) deduped_urls = sorted(set(uploaded_urls)) diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py index 802b8da6..226bb99a 100644 --- a/.github/scripts/release_resolve_repository_context.py +++ b/.github/scripts/release_resolve_repository_context.py @@ -25,6 +25,8 @@ from pathlib import Path from typing import Any +from github_actions_utils import write_output + OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" @@ -40,24 +42,17 @@ def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[st except json.JSONDecodeError: return response.status, {"raw": body} except urllib.error.HTTPError as error: + body = error.read().decode("utf-8") try: - payload = json.loads(error.read().decode("utf-8")) - except Exception: # noqa: BLE001 - payload = {"error": f"HTTP {error.code}"} + payload = json.loads(body) if body else {} + except json.JSONDecodeError: + payload = {"raw": body} payload.setdefault("error", f"HTTP {error.code}") return error.code, payload except Exception as error: # noqa: BLE001 return None, {"error": str(error)} -def write_output(key: str, value: str) -> None: - output_path = os.environ.get("GITHUB_OUTPUT", "").strip() - if not output_path: - return - with open(output_path, "a", encoding="utf-8") as stream: - stream.write(f"{key}={value}\n") - - def main() -> int: target_repository = os.environ.get("TARGET_REPOSITORY", "").strip() namespace = os.environ.get("TARGET_NAMESPACE", "").strip() diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py index 9d4a359c..559c57d5 100644 --- a/.github/scripts/sonatype_publish.py +++ b/.github/scripts/sonatype_publish.py @@ -25,6 +25,8 @@ import urllib.request from typing import Any +from github_actions_utils import write_output + OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" PORTAL_BASE = "https://central.sonatype.com" @@ -64,14 +66,6 @@ def extract_deployment_state(payload: dict[str, Any]) -> str: return "unknown" -def write_output(key: str, value: str) -> None: - output_path = os.environ.get("GITHUB_OUTPUT", "").strip() - if not output_path: - return - with open(output_path, "a", encoding="utf-8") as stream: - stream.write(f"{key}={value}\n") - - def to_int(value: str, default: int) -> int: try: return int(value) @@ -190,7 +184,26 @@ def main() -> int: f"{PORTAL_BASE}/api/v1/publisher/status?" f"id={urllib.parse.quote(deployment_id)}" ) - _, status_payload = request_json("POST", status_url, headers) + poll_status, status_payload = request_json( + "POST", + status_url, + headers, + ) + if poll_status is None or poll_status < 200 or poll_status >= 300: + poll_error = ( + status_payload.get("error") + if isinstance(status_payload, dict) + else "" + ) + if not poll_error: + poll_error = ( + f"HTTP {poll_status}" + if poll_status is not None + else "HTTP unknown" + ) + reason = f"Status polling API failed: {poll_error}" + break + final_state = extract_deployment_state(status_payload) if final_state == "PUBLISHED": @@ -234,7 +247,7 @@ def main() -> int: time.sleep(10) - if result != "published" and not reason and final_state != "unknown": + if result != "published" and not reason: reason = ( "Timed out waiting for deployment status. " f"Latest state={final_state}"