Files
ljwx-deploy/scripts/knowledge/github_artifacts.py
T

160 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""GitHub Actions artifact helpers for the knowledge supply chain."""
from __future__ import annotations
import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
from .common import JSONMapping, ensure_parent, require_mapping, require_non_empty_str
REQUEST_TIMEOUT_SECONDS = 20
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
class GitHubArtifactError(Exception):
"""Raised when a GitHub artifact request fails."""
class _NoRedirectHandler(HTTPRedirectHandler):
"""Prevent urllib from forwarding GitHub auth headers to signed blob URLs."""
def redirect_request(
self,
req: Request,
fp: object,
code: int,
msg: str,
headers: object,
newurl: str,
) -> None:
return None
def _request_json(url: str, token: str) -> JSONMapping:
request = Request(
url=url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
method="GET",
)
try:
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
body = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise GitHubArtifactError(
f"GitHub API 请求失败: HTTP {exc.code}: {detail}"
) from exc
except URLError as exc:
raise GitHubArtifactError(f"GitHub API 请求失败: {exc.reason}") from exc
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise GitHubArtifactError(f"GitHub API 返回无效 JSON: {body}") from exc
if not isinstance(payload, dict):
raise GitHubArtifactError("GitHub API 返回根节点必须是 object")
return {str(key): value for key, value in payload.items()}
def _download_signed_artifact(url: str) -> bytes:
request = Request(
url=url,
headers={
"Accept": "application/zip",
},
method="GET",
)
try:
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
return response.read()
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise GitHubArtifactError(
f"下载 artifact 失败: HTTP {exc.code}: {detail}"
) from exc
except URLError as exc:
raise GitHubArtifactError(f"下载 artifact 失败: {exc.reason}") from exc
def download_artifact_zip(
*,
repository: str,
run_id: int,
artifact_name: str,
token: str,
output_path: Path,
) -> Path:
"""Download one named artifact from a workflow run."""
artifacts_url = (
f"https://api.github.com/repos/{repository}/actions/runs/{run_id}/artifacts"
)
listing = _request_json(artifacts_url, token)
raw_items = listing.get("artifacts", [])
if not isinstance(raw_items, list):
raise GitHubArtifactError("/artifacts 必须是列表")
artifact_id: int | None = None
for index, raw_item in enumerate(raw_items):
item = require_mapping(raw_item, f"artifacts[{index}]")
candidate_name = require_non_empty_str(
item.get("name"), f"artifacts[{index}].name"
)
if candidate_name != artifact_name:
continue
raw_id = item.get("id")
if not isinstance(raw_id, int) or raw_id <= 0:
raise GitHubArtifactError(f"artifacts[{index}].id 必须是正整数")
artifact_id = raw_id
break
if artifact_id is None:
raise GitHubArtifactError(
f"未找到 artifact: repo={repository} run_id={run_id} name={artifact_name}"
)
download_url = (
f"https://api.github.com/repos/{repository}/actions/artifacts/{artifact_id}/zip"
)
request = Request(
url=download_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
method="GET",
)
opener = build_opener(_NoRedirectHandler())
try:
with opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
payload = response.read()
except HTTPError as exc:
if exc.code in REDIRECT_STATUS_CODES:
redirect_url = exc.headers.get("Location")
if not redirect_url:
raise GitHubArtifactError("下载 artifact 失败: 缺少重定向地址") from exc
payload = _download_signed_artifact(redirect_url)
else:
detail = exc.read().decode("utf-8", errors="replace")
raise GitHubArtifactError(
f"下载 artifact 失败: HTTP {exc.code}: {detail}"
) from exc
except URLError as exc:
raise GitHubArtifactError(f"下载 artifact 失败: {exc.reason}") from exc
if not payload:
raise GitHubArtifactError("下载 artifact 失败: 返回内容为空")
ensure_parent(output_path)
output_path.write_bytes(payload)
return output_path