Skip to content

Commit c414655

Browse files
authored
ci: externalize release workflow helper scripts (#128)
* ci: externalize release workflow helper scripts * ci: fix release workflow review findings * ci: improve sonatype workflow error reporting * ci: surface repository list api failures * ci: harden sonatype publish status handling
1 parent e087759 commit c414655

File tree

7 files changed

+758
-18
lines changed

7 files changed

+758
-18
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Apollo Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Shared helpers for GitHub Actions scripts."""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
21+
22+
def write_output(key: str, value: str) -> None:
23+
output_path = os.environ.get("GITHUB_OUTPUT", "").strip()
24+
if not output_path:
25+
return
26+
with open(output_path, "a", encoding="utf-8") as output:
27+
output.write(f"{key}={value}\n")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Apollo Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Extract uploaded artifact URLs from Maven deploy logs."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import os
21+
import re
22+
from pathlib import Path
23+
24+
from github_actions_utils import write_output
25+
26+
27+
def main() -> int:
28+
repository_name = os.environ.get("TARGET_REPOSITORY", "").strip()
29+
log_file = Path(os.environ.get("DEPLOY_LOG", "maven-deploy.log"))
30+
context_file = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json"))
31+
32+
log_text = log_file.read_text(encoding="utf-8")
33+
pattern = re.compile(r"Uploaded to (\S+):\s+(\S+)")
34+
35+
uploaded_urls: list[str] = []
36+
for target_repo, url in pattern.findall(log_text):
37+
if target_repo == repository_name:
38+
uploaded_urls.append(url)
39+
40+
deduped_urls = sorted(set(uploaded_urls))
41+
jar_urls = [url for url in deduped_urls if url.endswith(".jar")]
42+
pom_urls = [url for url in deduped_urls if url.endswith(".pom")]
43+
44+
payload = {
45+
"target_repository": repository_name,
46+
"uploaded_urls": deduped_urls,
47+
"jar_urls": jar_urls,
48+
"pom_urls": pom_urls,
49+
}
50+
context_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
51+
52+
write_output("uploaded_urls_count", str(len(deduped_urls)))
53+
write_output("jar_urls_count", str(len(jar_urls)))
54+
write_output("pom_urls_count", str(len(pom_urls)))
55+
return 0
56+
57+
58+
if __name__ == "__main__":
59+
raise SystemExit(main())
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Apollo Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Resolve Sonatype repository context for release deployments."""
16+
17+
from __future__ import annotations
18+
19+
import base64
20+
import json
21+
import os
22+
import urllib.error
23+
import urllib.parse
24+
import urllib.request
25+
from pathlib import Path
26+
from typing import Any
27+
28+
from github_actions_utils import write_output
29+
30+
OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com"
31+
32+
33+
def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[str, Any]]:
34+
request = urllib.request.Request(url=url, method="GET", headers=headers)
35+
try:
36+
with urllib.request.urlopen(request, timeout=30) as response:
37+
body = response.read().decode("utf-8")
38+
if not body:
39+
return response.status, {}
40+
try:
41+
return response.status, json.loads(body)
42+
except json.JSONDecodeError:
43+
return response.status, {"raw": body}
44+
except urllib.error.HTTPError as error:
45+
body = error.read().decode("utf-8")
46+
try:
47+
payload = json.loads(body) if body else {}
48+
except json.JSONDecodeError:
49+
payload = {"raw": body}
50+
payload.setdefault("error", f"HTTP {error.code}")
51+
return error.code, payload
52+
except Exception as error: # noqa: BLE001
53+
return None, {"error": str(error)}
54+
55+
56+
def main() -> int:
57+
target_repository = os.environ.get("TARGET_REPOSITORY", "").strip()
58+
namespace = os.environ.get("TARGET_NAMESPACE", "").strip()
59+
username = os.environ.get("MAVEN_USERNAME", "")
60+
password = os.environ.get("MAVEN_CENTRAL_TOKEN", "")
61+
context_path = Path(
62+
os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json")
63+
)
64+
65+
context: dict[str, Any] = {
66+
"target_repository": target_repository,
67+
"namespace": namespace,
68+
"status": "not_applicable",
69+
"reason": "repository input is not releases",
70+
"repository_key": "",
71+
"portal_deployment_id": "",
72+
"search_candidates": [],
73+
}
74+
75+
if target_repository == "releases":
76+
if not username or not password:
77+
context["status"] = "manual_required"
78+
context["reason"] = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN"
79+
else:
80+
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8")
81+
headers = {
82+
"Authorization": f"Bearer {token}",
83+
"Accept": "application/json",
84+
}
85+
86+
searches = [
87+
("open", "client"),
88+
("closed", "client"),
89+
("open", "any"),
90+
("closed", "any"),
91+
]
92+
selected: dict[str, Any] | None = None
93+
last_error = ""
94+
95+
for state, ip in searches:
96+
url = (
97+
f"{OSSRH_BASE}/manual/search/repositories?"
98+
f"profile_id={urllib.parse.quote(namespace)}"
99+
f"&state={urllib.parse.quote(state)}"
100+
f"&ip={urllib.parse.quote(ip)}"
101+
)
102+
status, payload = request_json(url, headers)
103+
if status is None:
104+
last_error = payload.get("error", "unknown error")
105+
context["search_candidates"].append(
106+
{
107+
"state": state,
108+
"ip": ip,
109+
"status": None,
110+
"count": 0,
111+
"error": last_error,
112+
}
113+
)
114+
continue
115+
116+
if status < 200 or status >= 300:
117+
http_error = payload.get("error", f"HTTP {status}")
118+
last_error = http_error
119+
context["search_candidates"].append(
120+
{
121+
"state": state,
122+
"ip": ip,
123+
"status": status,
124+
"count": 0,
125+
"error": http_error,
126+
}
127+
)
128+
continue
129+
130+
repositories = (
131+
payload.get("repositories", []) if isinstance(payload, dict) else []
132+
)
133+
context["search_candidates"].append(
134+
{"state": state, "ip": ip, "status": status, "count": len(repositories)}
135+
)
136+
if repositories:
137+
selected = repositories[0]
138+
break
139+
140+
if selected:
141+
context["status"] = "resolved"
142+
context["reason"] = ""
143+
context["repository_key"] = selected.get("key", "") or ""
144+
context["portal_deployment_id"] = (
145+
selected.get("portal_deployment_id", "") or ""
146+
)
147+
else:
148+
context["status"] = "manual_required"
149+
context["reason"] = last_error or "No staging repository key found"
150+
151+
context_path.write_text(json.dumps(context, indent=2) + "\n", encoding="utf-8")
152+
write_output("repository_key", context.get("repository_key", ""))
153+
write_output("portal_deployment_id", context.get("portal_deployment_id", ""))
154+
write_output("status", context.get("status", ""))
155+
write_output("reason", context.get("reason", ""))
156+
return 0
157+
158+
159+
if __name__ == "__main__":
160+
raise SystemExit(main())
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Apollo Authors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Write release publish context summary for GitHub Actions."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import os
21+
from pathlib import Path
22+
from typing import Any
23+
24+
25+
def read_json(path: Path) -> dict[str, Any]:
26+
if not path.exists():
27+
return {}
28+
try:
29+
return json.loads(path.read_text(encoding="utf-8"))
30+
except json.JSONDecodeError:
31+
return {}
32+
33+
34+
def main() -> int:
35+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "").strip()
36+
if not summary_path:
37+
return 0
38+
39+
deploy_path = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json"))
40+
repository_path = Path(
41+
os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json")
42+
)
43+
44+
deploy = read_json(deploy_path)
45+
repository = read_json(repository_path)
46+
47+
lines = [
48+
"## Publish Context",
49+
"",
50+
f"- target repository: {deploy.get('target_repository', '')}",
51+
f"- uploaded URLs: {len(deploy.get('uploaded_urls', []))}",
52+
f"- jar URLs: {len(deploy.get('jar_urls', []))}",
53+
f"- pom URLs: {len(deploy.get('pom_urls', []))}",
54+
f"- staging key status: {repository.get('status', '')}",
55+
f"- repository_key: {repository.get('repository_key', '')}",
56+
f"- portal_deployment_id: {repository.get('portal_deployment_id', '')}",
57+
]
58+
reason = repository.get("reason", "")
59+
if reason:
60+
lines.append(f"- reason: {reason}")
61+
62+
with open(summary_path, "a", encoding="utf-8") as output:
63+
output.write("\n".join(lines) + "\n")
64+
return 0
65+
66+
67+
if __name__ == "__main__":
68+
raise SystemExit(main())

0 commit comments

Comments
 (0)