CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
1054 lines
38 KiB
Python
1054 lines
38 KiB
Python
# Copyright Advanced Micro Devices, Inc.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""GitHub Actions API: workflow commands and REST API client.
|
|
|
|
This module provides:
|
|
- GitHubAPI class for authenticated GitHub REST API requests
|
|
- Workflow command helpers (gha_set_output, gha_set_env, etc.)
|
|
- Query functions for workflow runs, commits, etc.
|
|
|
|
See also https://pypi.org/project/github-action-utils/.
|
|
"""
|
|
|
|
import base64
|
|
import binascii
|
|
from enum import Enum, auto
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from typing import Any, Mapping
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import quote
|
|
from urllib.request import urlopen, Request
|
|
|
|
|
|
def _log(*args, **kwargs):
|
|
print(*args, **kwargs)
|
|
sys.stdout.flush()
|
|
|
|
|
|
class GitHubAPIError(Exception):
|
|
"""Error from a GitHub API request.
|
|
|
|
Raised when a GitHub API request fails, whether via REST API or gh CLI.
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
class GitHubAPI:
|
|
"""Client for making GitHub API requests.
|
|
|
|
Handles authentication automatically:
|
|
1. If GITHUB_TOKEN env var is set, uses that (CI environment)
|
|
2. If gh CLI is installed and authenticated, uses `gh api` (local dev)
|
|
3. Falls back to unauthenticated requests (rate limited)
|
|
|
|
CI workflows are expected to set the GITHUB_TOKEN environment variable from
|
|
${{ secrets.GITHUB_TOKEN }} or ${{ github.token }}.
|
|
|
|
Developers are encouraged to install the `gh` CLI and authenticate with
|
|
`gh auth login`. They _can_ also use a Personal Access Token in the
|
|
GITHUB_TOKEN env var but this is less secure.
|
|
|
|
References:
|
|
* https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
|
|
* https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#authentication
|
|
* https://cli.github.com/manual/gh_auth_login
|
|
* https://docs.github.com/en/actions/tutorials/authenticate-with-github_token
|
|
|
|
The authentication method is detected once and cached for the lifetime
|
|
of the instance.
|
|
|
|
Usage:
|
|
api = GitHubAPI()
|
|
response = api.send_request("https://api.github.com/repos/owner/repo")
|
|
|
|
For most use cases, use the module-level functions which use a shared
|
|
singleton instance:
|
|
response = gha_send_request("https://api.github.com/repos/owner/repo")
|
|
|
|
When a caller needs to authenticate as something other than the default
|
|
singleton's token (e.g. a GitHub App installation token scoped to a
|
|
different repository), construct a dedicated instance with an explicit
|
|
token instead:
|
|
api = GitHubAPI(github_token=app_installation_token)
|
|
response = api.send_request("https://api.github.com/repos/other/repo")
|
|
This is useful when a single process needs to juggle multiple distinct
|
|
tokens (e.g. two GitHub App tokens minted in the same job), since the
|
|
module-level singleton caches only one token for its lifetime.
|
|
"""
|
|
|
|
class AuthMethod(Enum):
|
|
"""Authentication method for GitHub API requests."""
|
|
|
|
# Use GITHUB_TOKEN env var (CI environment) with the GitHub REST API.
|
|
GITHUB_TOKEN = auto()
|
|
|
|
# Use `gh api` command (local dev with OAuth).
|
|
GH_CLI = auto()
|
|
|
|
# Use the GitHub REST API without authenticating, subject to rate limits.
|
|
UNAUTHENTICATED = auto()
|
|
|
|
def __init__(self, github_token: str | None = None):
|
|
"""Creates a GitHub API client.
|
|
|
|
Args:
|
|
github_token: Optional explicit token to authenticate with. When
|
|
provided, this instance uses that token directly and skips
|
|
the GITHUB_TOKEN env var / gh CLI auto-detection below. Use
|
|
this to bind an instance to a specific token (e.g. a GitHub
|
|
App installation token) without affecting the module-level
|
|
singleton or other instances.
|
|
"""
|
|
self._auth_method: GitHubAPI.AuthMethod | None = (
|
|
GitHubAPI.AuthMethod.GITHUB_TOKEN if github_token else None
|
|
)
|
|
self._github_token: str | None = github_token
|
|
self._gh_cli_path: str | None = None
|
|
|
|
def _detect_auth_method(self) -> AuthMethod:
|
|
"""Detects the best available GitHub API authentication method.
|
|
|
|
Always performs fresh detection. Use get_auth_method() for cached access.
|
|
"""
|
|
# Check for GITHUB_TOKEN (CI environment or PAT)
|
|
token = os.getenv("GITHUB_TOKEN", "")
|
|
if token:
|
|
self._github_token = token
|
|
return GitHubAPI.AuthMethod.GITHUB_TOKEN
|
|
|
|
# Check for gh CLI
|
|
gh_path = shutil.which("gh")
|
|
if gh_path:
|
|
# Verify gh is authenticated by checking auth status
|
|
try:
|
|
result = subprocess.run(
|
|
[gh_path, "auth", "status"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30, # seconds
|
|
)
|
|
if result.returncode == 0:
|
|
self._gh_cli_path = gh_path
|
|
return GitHubAPI.AuthMethod.GH_CLI
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
pass # Fall through to unauthenticated
|
|
|
|
return GitHubAPI.AuthMethod.UNAUTHENTICATED
|
|
|
|
def get_auth_method(self) -> AuthMethod:
|
|
"""Gets the current GitHub API authentication method.
|
|
|
|
Returns the detected auth method (GITHUB_TOKEN, GH_CLI, or UNAUTHENTICATED).
|
|
The result is cached after the first call.
|
|
"""
|
|
if self._auth_method is None:
|
|
self._auth_method = self._detect_auth_method()
|
|
return self._auth_method
|
|
|
|
def is_authenticated(self) -> bool:
|
|
"""Checks if authenticated GitHub API access is available.
|
|
|
|
Returns True if either GITHUB_TOKEN is set or gh CLI is authenticated.
|
|
"""
|
|
return self.get_auth_method() != GitHubAPI.AuthMethod.UNAUTHENTICATED
|
|
|
|
def _get_request_headers(self) -> dict[str, str]:
|
|
"""Gets common request headers for use with the GitHub REST API.
|
|
|
|
Note: This is only used for direct REST API calls (GITHUB_TOKEN or
|
|
unauthenticated). When using gh CLI, headers are handled by gh.
|
|
"""
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
|
|
if self.get_auth_method() == GitHubAPI.AuthMethod.GITHUB_TOKEN:
|
|
headers["Authorization"] = f"Bearer {self._github_token}"
|
|
|
|
return headers
|
|
|
|
def _send_request_via_gh_cli(
|
|
self,
|
|
url: str,
|
|
timeout_seconds: int,
|
|
*,
|
|
method: str = "GET",
|
|
body: object | None = None,
|
|
) -> object:
|
|
"""Sends a GitHub API request using the gh CLI.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the request fails for any reason.
|
|
"""
|
|
assert self._gh_cli_path is not None, (
|
|
"_send_request_via_gh_cli called without gh CLI path set. "
|
|
"Call get_auth_method() first."
|
|
)
|
|
|
|
# Strip the base URL to get the API path
|
|
api_path = url.removeprefix("https://api.github.com")
|
|
|
|
cmd = [self._gh_cli_path, "api", "--method", method, api_path]
|
|
input_data: str | None = None
|
|
if body is not None:
|
|
cmd.extend(["--input", "-"])
|
|
input_data = json.dumps(body)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
input=input_data,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=timeout_seconds,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
raise GitHubAPIError(
|
|
f"gh api request timed out after {timeout_seconds}s for {api_path}"
|
|
) from e
|
|
except OSError as e:
|
|
raise GitHubAPIError(
|
|
f"Failed to execute gh CLI at {self._gh_cli_path}: {e}"
|
|
) from e
|
|
|
|
if result.returncode != 0:
|
|
stderr = result.stderr or "(no error message)"
|
|
raise GitHubAPIError(f"gh api request failed: {stderr}")
|
|
|
|
if not result.stdout or not result.stdout.strip():
|
|
if method in ("POST", "PATCH", "PUT", "DELETE"):
|
|
return {}
|
|
raise GitHubAPIError("gh api returned empty response")
|
|
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except json.JSONDecodeError as e:
|
|
raise GitHubAPIError(
|
|
f"gh api returned invalid JSON: {e.msg} at position {e.pos}"
|
|
) from e
|
|
|
|
def _send_request_via_rest_api(
|
|
self,
|
|
url: str,
|
|
timeout_seconds: int,
|
|
*,
|
|
method: str = "GET",
|
|
body: object | None = None,
|
|
) -> object:
|
|
"""Sends a GitHub API request using the REST API directly.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the request fails for any reason.
|
|
"""
|
|
headers = self._get_request_headers()
|
|
data: bytes | None = None
|
|
if body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
data = json.dumps(body).encode("utf-8")
|
|
request = Request(url, data=data, headers=headers, method=method)
|
|
|
|
try:
|
|
with urlopen(request, timeout=timeout_seconds) as response:
|
|
response_body = response.read().decode("utf-8")
|
|
except HTTPError as e:
|
|
# Try to read the error response body for more context
|
|
error_body = ""
|
|
try:
|
|
error_body = e.read().decode("utf-8")
|
|
except Exception:
|
|
pass # If we can't read it, continue with generic message
|
|
|
|
if e.code == 403:
|
|
# Check if this is a rate limit error
|
|
if "rate limit" in error_body.lower():
|
|
raise GitHubAPIError(
|
|
f"GitHub API rate limit exceeded for {url}. "
|
|
f"Authenticate with `gh auth login` or set GITHUB_TOKEN to increase limits. "
|
|
f"See https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api"
|
|
) from e
|
|
raise GitHubAPIError(
|
|
f"Access denied (403 Forbidden) for {url}. "
|
|
f"Check if your token has the necessary permissions (e.g., `repo`, `workflow`)."
|
|
) from e
|
|
elif e.code == 404:
|
|
raise GitHubAPIError(
|
|
f"Resource not found (404) for {url}. "
|
|
f"Verify the repository, workflow, or run ID exists."
|
|
) from e
|
|
else:
|
|
raise GitHubAPIError(
|
|
f"HTTP {e.code} error for {url}: {e.reason}"
|
|
) from e
|
|
except URLError as e:
|
|
raise GitHubAPIError(f"Network error for {url}: {e.reason}") from e
|
|
except TimeoutError as e:
|
|
raise GitHubAPIError(
|
|
f"Request timed out after {timeout_seconds}s for {url}"
|
|
) from e
|
|
|
|
if not response_body:
|
|
if method in ("POST", "PATCH", "PUT", "DELETE"):
|
|
return {}
|
|
|
|
try:
|
|
return json.loads(response_body)
|
|
except json.JSONDecodeError as e:
|
|
raise GitHubAPIError(
|
|
f"Invalid JSON response from {url}: {e.msg} at position {e.pos}"
|
|
) from e
|
|
|
|
def send_request(
|
|
self,
|
|
url: str,
|
|
timeout_seconds: int = 300,
|
|
*,
|
|
method: str = "GET",
|
|
body: object | None = None,
|
|
) -> object:
|
|
"""Sends a request to the given GitHub REST API URL.
|
|
|
|
Args:
|
|
url: Full GitHub API URL (e.g., https://api.github.com/repos/...)
|
|
timeout_seconds: Request timeout in seconds (default 300).
|
|
method: HTTP method (default GET). POST and PATCH are supported.
|
|
body: Optional JSON-serializable request body for POST/PATCH.
|
|
|
|
Returns:
|
|
Parsed JSON response.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the request fails (network error, HTTP error,
|
|
timeout, invalid JSON, etc.). The original exception is
|
|
available via the __cause__ attribute.
|
|
"""
|
|
auth_method = self.get_auth_method()
|
|
|
|
if auth_method == GitHubAPI.AuthMethod.GH_CLI:
|
|
return self._send_request_via_gh_cli(
|
|
url, timeout_seconds, method=method, body=body
|
|
)
|
|
|
|
if auth_method == GitHubAPI.AuthMethod.UNAUTHENTICATED:
|
|
_log("Warning: No GitHub auth available, requests may be rate limited")
|
|
|
|
return self._send_request_via_rest_api(
|
|
url, timeout_seconds, method=method, body=body
|
|
)
|
|
|
|
|
|
# Module-level singleton with cached state.
|
|
# Tests may opt to use separate instances to exercise the state more precisely.
|
|
_default_github_api = GitHubAPI()
|
|
|
|
|
|
def is_authenticated_github_api_available() -> bool:
|
|
"""Checks if authenticated GitHub API access is available.
|
|
|
|
Returns True if either GITHUB_TOKEN is set or gh CLI is authenticated.
|
|
Useful for tests to decide whether to skip network-dependent tests.
|
|
"""
|
|
return _default_github_api.is_authenticated()
|
|
|
|
|
|
def gha_warn_if_not_running_on_ci():
|
|
# https://docs.github.com/en/actions/reference/variables-reference
|
|
if not os.getenv("CI"):
|
|
_log("Warning: 'CI' env var not set, not running under GitHub Actions?")
|
|
|
|
|
|
def gha_add_to_path(new_path: str | Path):
|
|
"""Adds an entry to the system PATH for future GitHub Actions workflow run steps.
|
|
|
|
This appends to the file located at the $GITHUB_PATH environment variable.
|
|
|
|
See
|
|
* https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#example-of-adding-a-system-path
|
|
"""
|
|
_log(f"Adding to path by appending to $GITHUB_PATH:\n '{new_path}'")
|
|
|
|
path_file = os.getenv("GITHUB_PATH")
|
|
if not path_file:
|
|
_log(" Warning: GITHUB_PATH env var not set, can't add to path")
|
|
return
|
|
|
|
with open(path_file, "a") as f:
|
|
f.write(str(new_path))
|
|
|
|
|
|
def gha_set_env(vars: Mapping[str, str | Path]):
|
|
"""Sets environment variables for future GitHub Actions workflow run steps.
|
|
|
|
This appends to the file located at the $GITHUB_ENV environment variable.
|
|
|
|
See
|
|
* https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#environment-files
|
|
"""
|
|
_log(f"Setting environment variable by appending to $GITHUB_ENV:\n {vars}")
|
|
|
|
env_file = os.getenv("GITHUB_ENV")
|
|
if not env_file:
|
|
_log(" Warning: GITHUB_ENV env var not set, can't set environment variable")
|
|
return
|
|
|
|
with open(env_file, "a") as f:
|
|
f.writelines(f"{k}={str(v)}" + "\n" for k, v in vars.items())
|
|
|
|
|
|
def gha_set_output(vars: Mapping[str, str | Path]):
|
|
"""Sets values in a step's output parameters.
|
|
|
|
This appends to the file located at the $GITHUB_OUTPUT environment variable.
|
|
Multi-line values are written using the heredoc form required by GitHub
|
|
Actions (see "Multiline strings" in the workflow-commands reference).
|
|
|
|
See
|
|
* https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-output-parameter
|
|
* https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#multiline-strings
|
|
* https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs
|
|
"""
|
|
_log(
|
|
f"Setting github output:\n{json.dumps({k: str(v) for k, v in vars.items()}, indent=2)}"
|
|
)
|
|
|
|
step_output_file = os.getenv("GITHUB_OUTPUT")
|
|
if not step_output_file:
|
|
_log(" Warning: GITHUB_OUTPUT env var not set, can't set github outputs")
|
|
return
|
|
|
|
with open(step_output_file, "a", encoding="utf-8") as f:
|
|
for k, v in vars.items():
|
|
value = "" if v is None else str(v)
|
|
if "\n" in value:
|
|
# EOF_mag1c delimiter must NOT occure in value
|
|
f.write(f"{k}<<EOF_mag1c\n{value}\nEOF_mag1c\n")
|
|
else:
|
|
print(f"OUTPUT {k}={value}")
|
|
f.write(f"{k}={value}\n")
|
|
|
|
|
|
def gha_job_summary_mirror_path() -> Path | None:
|
|
"""Return the per-job summary mirror file under $RUNNER_TEMP, or None.
|
|
|
|
GitHub clears $GITHUB_STEP_SUMMARY between steps, so the rendered job
|
|
summary cannot be read back at the end of a job. We mirror every append
|
|
into $RUNNER_TEMP/job_summary.md, which persists for the whole job, so a
|
|
later step can publish it as a job output (see
|
|
gha_set_job_summary_output). $RUNNER_TEMP is emptied at the start and end
|
|
of each job, so the mirror does not leak between jobs.
|
|
|
|
Returns None when $RUNNER_TEMP is not set (the caller should skip
|
|
mirroring rather than fail). Local tests point $RUNNER_TEMP at a temp dir.
|
|
"""
|
|
runner_temp = os.getenv("RUNNER_TEMP")
|
|
if not runner_temp:
|
|
return None
|
|
return Path(runner_temp) / "job_summary.md"
|
|
|
|
|
|
def gha_append_step_summary(summary: str, mirror_to_job_file: bool = True):
|
|
"""Appends a string to the GitHub Actions job summary.
|
|
|
|
This appends to the file located at the $GITHUB_STEP_SUMMARY environment
|
|
variable. When mirror_to_job_file is set (the default), the same text
|
|
is also appended to the persistent mirror at $RUNNER_TEMP/job_summary.md
|
|
(see gha_job_summary_mirror_path) so the accumulated summary can be read
|
|
back later in the job and published as an output.
|
|
|
|
See
|
|
* https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#adding-a-job-summary
|
|
* https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary
|
|
"""
|
|
_log(f"Writing job summary:\n{summary}")
|
|
|
|
step_summary_file = os.getenv("GITHUB_STEP_SUMMARY")
|
|
if not step_summary_file:
|
|
_log(" Warning: GITHUB_STEP_SUMMARY env var not set, can't write job summary")
|
|
return
|
|
|
|
# Use double newlines to split sections in markdown.
|
|
block = summary + "\n\n"
|
|
|
|
with open(step_summary_file, "a") as f:
|
|
# Use double newlines to split sections in markdown.
|
|
f.write(block)
|
|
|
|
if mirror_to_job_file:
|
|
mirror_path = gha_job_summary_mirror_path()
|
|
if mirror_path:
|
|
with open(mirror_path, "a", encoding="utf-8") as f:
|
|
f.write(block)
|
|
else:
|
|
_log(
|
|
" Warning: RUNNER_TEMP env var not set, cannot mirror job summary "
|
|
"for output capture"
|
|
)
|
|
|
|
|
|
def gha_set_job_summary_output(output_name: str = "summary"):
|
|
"""Publishes the accumulated job summary mirror as a step output.
|
|
|
|
Reads $RUNNER_TEMP/job_summary.md (written by gha_append_step_summary) and
|
|
writes it to $GITHUB_OUTPUT under output_name via gha_set_output.
|
|
|
|
Note: A missing or empty mirror file writes no output at all.
|
|
"""
|
|
mirror_path = gha_job_summary_mirror_path()
|
|
summary = ""
|
|
if mirror_path and mirror_path.exists():
|
|
summary = mirror_path.read_text(encoding="utf-8")
|
|
|
|
if not summary:
|
|
_log(
|
|
f" No job summary content to publish; skipping '{output_name}' to add to $GITHUB_OUTPUT"
|
|
)
|
|
return
|
|
|
|
gha_set_output({output_name: summary})
|
|
|
|
|
|
def gha_load_github_event() -> dict[str, Any]:
|
|
"""Loads the JSON event payload pointed to by $GITHUB_EVENT_PATH.
|
|
|
|
Raises:
|
|
KeyError: $GITHUB_EVENT_PATH is not set (not running under GitHub
|
|
Actions, or the step was given no event payload).
|
|
FileNotFoundError: $GITHUB_EVENT_PATH is set but the file
|
|
doesn't exist (CI misconfiguration).
|
|
ValueError: the file contains invalid JSON, or the top-level
|
|
payload is not a JSON object.
|
|
RuntimeError: the file exists but couldn't be read (permissions,
|
|
disk error, etc.).
|
|
|
|
See: https://docs.github.com/en/actions/reference/variables-reference#default-environment-variables
|
|
https://docs.github.com/en/webhooks/webhook-events-and-payloads
|
|
"""
|
|
event_path = Path(os.environ["GITHUB_EVENT_PATH"])
|
|
if not event_path.is_file():
|
|
raise FileNotFoundError(
|
|
f"GITHUB_EVENT_PATH is set to '{event_path}' but no such file exists"
|
|
)
|
|
try:
|
|
with open(event_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
f"GITHUB_EVENT_PATH '{event_path}' contains invalid JSON: {exc}"
|
|
) from exc
|
|
except OSError as exc:
|
|
raise RuntimeError(
|
|
f"Cannot read GITHUB_EVENT_PATH '{event_path}': {exc}"
|
|
) from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError(
|
|
f"GITHUB_EVENT_PATH '{event_path}' must contain a JSON object, "
|
|
f"got {type(data).__name__}"
|
|
)
|
|
return data
|
|
|
|
|
|
def is_current_run_pr_from_fork() -> bool:
|
|
"""Whether the current workflow run is a pull request from a fork.
|
|
|
|
Reads the GitHub event payload to check the ``.fork`` property on the head
|
|
repo, matching the GitHub Actions expression
|
|
``github.event.pull_request.head.repo.fork``.
|
|
|
|
Returns False for non-pull_request events or if the event payload is not
|
|
available (e.g. local development).
|
|
"""
|
|
if os.environ.get("GITHUB_EVENT_NAME", "") != "pull_request":
|
|
return False
|
|
if not os.environ.get("GITHUB_EVENT_PATH"):
|
|
return False
|
|
event = gha_load_github_event()
|
|
return bool(
|
|
event.get("pull_request", {}).get("head", {}).get("repo", {}).get("fork", False)
|
|
)
|
|
|
|
|
|
def gha_send_request(
|
|
url: str,
|
|
timeout_seconds: int = 300,
|
|
*,
|
|
method: str = "GET",
|
|
body: object | None = None,
|
|
) -> object:
|
|
"""Sends a request to the given GitHub REST API URL and returns the response.
|
|
|
|
Authentication is handled automatically:
|
|
1. If GITHUB_TOKEN env var is set, uses that (CI environment)
|
|
2. If gh CLI is installed and authenticated, uses `gh api` (local dev)
|
|
3. Falls back to unauthenticated requests (rate limited)
|
|
|
|
Args:
|
|
url: Full GitHub API URL (e.g., https://api.github.com/repos/...)
|
|
timeout_seconds: Request timeout in seconds (default 300).
|
|
method: HTTP method (default GET). POST and PATCH are supported.
|
|
body: Optional JSON-serializable request body for POST/PATCH.
|
|
|
|
Returns:
|
|
Parsed JSON response.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the request fails (network error, HTTP error,
|
|
timeout, invalid JSON, etc.). The original exception is available
|
|
via the __cause__ attribute.
|
|
"""
|
|
return _default_github_api.send_request(
|
|
url, timeout_seconds=timeout_seconds, method=method, body=body
|
|
)
|
|
|
|
|
|
def gha_update_pr_comment(
|
|
pr_number: int,
|
|
marker: str,
|
|
body: str,
|
|
github_repository: str = "ROCm/TheRock",
|
|
*,
|
|
github_api: "GitHubAPI | None" = None,
|
|
comment_author: str | None = None,
|
|
) -> dict:
|
|
"""Create or update a sticky PR comment identified by an HTML marker.
|
|
|
|
Paginates through existing issue comments on the pull request. If a comment
|
|
whose body contains ``marker`` is found, that comment is updated in place.
|
|
Otherwise a new comment is posted.
|
|
|
|
Args:
|
|
pr_number: Pull request number.
|
|
marker: Unique HTML comment marker embedded in the comment body
|
|
(e.g. ``<!-- example-marker -->``).
|
|
body: Full comment body (must include ``marker``).
|
|
github_repository: Repository in ``owner/repo`` format.
|
|
github_api: Optional ``GitHubAPI`` instance to use instead of the
|
|
module-level singleton. Pass a dedicated ``GitHubAPI(github_token=...)``
|
|
instance for cross-repo comments (e.g. a GitHub App installation
|
|
token scoped to ``github_repository``) so this call doesn't
|
|
disturb the singleton's own token/auth state.
|
|
comment_author: Optional GitHub username to restrict matching to.
|
|
When set, only an existing comment whose author's login equals
|
|
this value is treated as the sticky comment to update; comments
|
|
that merely contain ``marker`` but were authored by someone else
|
|
(e.g. a human who quote-replied the bot's comment) are ignored,
|
|
and a new comment is posted instead of editing theirs.
|
|
|
|
Returns:
|
|
The created or updated comment object from the GitHub API.
|
|
|
|
Authentication defaults to the module-level singleton (see
|
|
:func:`gha_send_request`), which uses :envvar:`GITHUB_TOKEN`. In GitHub
|
|
Actions, set ``GITHUB_TOKEN: ${{ github.token }}`` on the step and grant
|
|
``pull-requests: write`` (or ``issues: write``) in job ``permissions``.
|
|
For cross-repo comments, pass ``github_api=GitHubAPI(github_token=...)``
|
|
with an App installation token that has access to ``github_repository``.
|
|
|
|
See: https://docs.github.com/en/rest/issues/comments
|
|
"""
|
|
if marker not in body:
|
|
_log(
|
|
f"Warning: marker\n {marker}\n"
|
|
f"not found in body for PR comment on "
|
|
f"https://github.com/{github_repository}/pull/{pr_number}\n"
|
|
f"body text:\n {body}"
|
|
)
|
|
|
|
api = github_api or _default_github_api
|
|
|
|
comments_url = (
|
|
f"https://api.github.com/repos/{github_repository}"
|
|
f"/issues/{pr_number}/comments"
|
|
)
|
|
MAX_PAGES = 10
|
|
PER_PAGE = 100
|
|
page = 1
|
|
existing_comment_id = None
|
|
|
|
while page <= MAX_PAGES:
|
|
page_url = f"{comments_url}?per_page={PER_PAGE}&page={page}"
|
|
comments = api.send_request(page_url)
|
|
if not isinstance(comments, list):
|
|
break
|
|
|
|
for comment in comments:
|
|
if marker not in comment.get("body", ""):
|
|
continue
|
|
if (
|
|
comment_author is not None
|
|
and comment.get("user", {}).get("login") != comment_author
|
|
):
|
|
continue
|
|
existing_comment_id = comment["id"]
|
|
break
|
|
|
|
if existing_comment_id is not None:
|
|
break
|
|
|
|
if len(comments) < PER_PAGE:
|
|
break
|
|
page += 1
|
|
|
|
if existing_comment_id is not None:
|
|
patch_url = (
|
|
f"https://api.github.com/repos/{github_repository}"
|
|
f"/issues/comments/{existing_comment_id}"
|
|
)
|
|
response = api.send_request(patch_url, method="PATCH", body={"body": body})
|
|
else:
|
|
response = api.send_request(comments_url, method="POST", body={"body": body})
|
|
|
|
if not isinstance(response, dict):
|
|
raise GitHubAPIError(
|
|
f"Expected comment object from GitHub API for PR #{pr_number}, "
|
|
f"got {type(response).__name__}"
|
|
)
|
|
return response
|
|
|
|
|
|
def gha_query_prs_for_commit(
|
|
github_repository: str,
|
|
sha: str,
|
|
*,
|
|
github_api: "GitHubAPI | None" = None,
|
|
) -> list[dict]:
|
|
"""Gets the pull requests associated with a commit.
|
|
|
|
Uses the GitHub REST API endpoint: /commits/{sha}/pulls
|
|
|
|
A commit can be associated with more than one pull request (e.g. if it
|
|
was cherry-picked, or landed via multiple merge paths).
|
|
|
|
Args:
|
|
github_repository: Repository in "owner/repo" format (e.g. "ROCm/TheRock").
|
|
sha: Full (or unambiguous short) git commit SHA.
|
|
github_api: Optional ``GitHubAPI`` instance to use instead of the
|
|
module-level singleton. Pass a dedicated ``GitHubAPI(github_token=...)``
|
|
instance when querying a repository that requires a different
|
|
token than the singleton's (e.g. a superrepo App installation
|
|
token).
|
|
|
|
Returns:
|
|
List of pull request objects from the GitHub API. Empty list if the
|
|
commit is not associated with any pull request (or hasn't been
|
|
pushed to GitHub, e.g. it only exists locally).
|
|
|
|
See: https://docs.github.com/en/rest/commits/commits#list-pull-requests-associated-with-a-commit
|
|
"""
|
|
api = github_api or _default_github_api
|
|
url = f"https://api.github.com/repos/{github_repository}/commits/{sha}/pulls"
|
|
response = api.send_request(url)
|
|
if not isinstance(response, list):
|
|
raise GitHubAPIError(
|
|
f"Expected a list of pull requests for commit {sha!r} in "
|
|
f"{github_repository!r}, got {type(response).__name__}"
|
|
)
|
|
return response
|
|
|
|
|
|
def gha_query_workflow_run_by_id(github_repository: str, workflow_run_id: str) -> dict:
|
|
"""Gets metadata for a workflow run by its run ID.
|
|
|
|
Uses the GitHub REST API endpoint: /actions/runs/{run_id}
|
|
|
|
Args:
|
|
github_repository: Repository in "owner/repo" format (e.g., "ROCm/TheRock")
|
|
workflow_run_id: The workflow run ID (e.g., "12345678901")
|
|
|
|
Returns:
|
|
Workflow run metadata dict from GitHub API.
|
|
|
|
See: https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run
|
|
"""
|
|
url = f"https://api.github.com/repos/{github_repository}/actions/runs/{workflow_run_id}"
|
|
return gha_send_request(url)
|
|
|
|
|
|
# TODO: Consider accepting a git ref (branch/tag) here and resolving it
|
|
# to a SHA via gha_resolve_git_ref, so callers don't need to do that
|
|
# themselves. Same for other functions that take a commit SHA.
|
|
def gha_query_workflow_runs_for_commit(
|
|
github_repository: str,
|
|
workflow_file_name: str,
|
|
git_commit_sha: str,
|
|
) -> list[dict]:
|
|
"""Gets all workflow runs for a specific commit.
|
|
|
|
Uses the GitHub REST API endpoint: /actions/workflows/{workflow}/runs?head_sha={sha}
|
|
|
|
A commit may have multiple workflow runs if the workflow was retriggered.
|
|
The list is ordered by most recent first.
|
|
|
|
Note: The API returns up to 30 results by default (first page only).
|
|
For a single commit this is typically sufficient.
|
|
|
|
Args:
|
|
github_repository: Repository in "owner/repo" format (e.g., "ROCm/TheRock")
|
|
workflow_file_name: Workflow filename (e.g., "multi_arch_ci.yml")
|
|
git_commit_sha: Full git commit SHA
|
|
|
|
Returns:
|
|
List of workflow run metadata dicts, ordered most recent first.
|
|
Empty list if no runs exist for this commit.
|
|
|
|
See: https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-workflow
|
|
"""
|
|
url = (
|
|
f"https://api.github.com/repos/{github_repository}"
|
|
f"/actions/workflows/{workflow_file_name}/runs"
|
|
f"?head_sha={git_commit_sha}&sort=created&direction=desc"
|
|
)
|
|
response = gha_send_request(url)
|
|
runs = response.get("workflow_runs", [])
|
|
# Sort client-side as defense in depth — the API default order is not
|
|
# documented and community reports suggest it may not be chronological.
|
|
runs.sort(key=lambda r: r["created_at"], reverse=True)
|
|
return runs
|
|
|
|
|
|
def gha_query_last_workflow_run(
|
|
github_repository: str = "ROCm/TheRock",
|
|
workflow_name: str = "multi_arch_ci.yml",
|
|
branch: str = "main",
|
|
accepted_statuses: set[str] | None = None,
|
|
) -> dict | None:
|
|
"""Find the most recent run of a workflow on ``branch`` whose conclusion
|
|
is in ``accepted_statuses`` (default ``{"success"}``).
|
|
|
|
Paginates the most recent runs (newest first), filtering client-side,
|
|
up to ``MAX_PAGES * PER_PAGE`` runs. Returns ``None`` if no matching
|
|
run is found within that window.
|
|
"""
|
|
if accepted_statuses is None:
|
|
accepted_statuses = {"success"}
|
|
MAX_PAGES = 5
|
|
PER_PAGE = 100
|
|
page = 1
|
|
while page <= MAX_PAGES:
|
|
url = (
|
|
f"https://api.github.com/repos/{github_repository}"
|
|
f"/actions/workflows/{workflow_name}/runs"
|
|
f"?branch={branch}&per_page={PER_PAGE}&sort=created&direction=desc"
|
|
f"&page={page}"
|
|
)
|
|
response = gha_send_request(url)
|
|
runs = response.get("workflow_runs", []) if response else []
|
|
for run in runs:
|
|
if run.get("conclusion") in accepted_statuses:
|
|
return run
|
|
if len(runs) < PER_PAGE:
|
|
break # Partial or empty page = no more runs to fetch.
|
|
page += 1
|
|
return None
|
|
|
|
|
|
def gha_query_recent_branch_commits(
|
|
github_repository_name: str = "ROCm/TheRock",
|
|
branch: str = "main",
|
|
max_count: int = 50,
|
|
) -> list[str]:
|
|
"""Gets the list of recent commit SHAs for a branch via the GitHub API.
|
|
|
|
Commits could also be enumerated via local `git log` commands, but using
|
|
the API ensures that we get the latest commits regardless of local
|
|
repository state.
|
|
|
|
Args:
|
|
github_repository_name: Repository in "owner/repo" format
|
|
branch: Branch name (default: "main")
|
|
max_count: Maximum number of commits to retrieve
|
|
(max 100 per API, without pagination)
|
|
|
|
Returns:
|
|
List of commit SHAs, most recent first.
|
|
"""
|
|
if max_count > 100:
|
|
_log(
|
|
f"Warning: max_count of {max_count} commits to query exceeds API per_page limit of 100"
|
|
)
|
|
|
|
url = f"https://api.github.com/repos/{github_repository_name}/commits?sha={branch}&per_page={max_count}"
|
|
response = gha_send_request(url)
|
|
|
|
return [commit["sha"] for commit in response]
|
|
|
|
|
|
def gha_resolve_git_ref(github_repository: str, ref: str) -> str:
|
|
"""Resolve a git ref (branch, tag, or SHA) to a full commit SHA.
|
|
|
|
Args:
|
|
github_repository: Repository in "owner/repo" format (e.g. "ROCm/pytorch").
|
|
ref: Git ref to resolve (branch name, tag, or commit SHA).
|
|
|
|
Returns:
|
|
The full 40-character commit SHA.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the ref cannot be resolved.
|
|
|
|
See: https://docs.github.com/en/rest/commits/commits#get-a-commit
|
|
"""
|
|
encoded_ref = quote(ref, safe="")
|
|
url = f"https://api.github.com/repos/{github_repository}/commits/{encoded_ref}"
|
|
response = gha_send_request(url)
|
|
return response["sha"]
|
|
|
|
|
|
def gha_fetch_file_contents(github_repository: str, path: str, ref: str) -> bytes:
|
|
"""Fetch a file's contents from a GitHub repo at a specific ref.
|
|
|
|
Uses the Contents API to retrieve and decode a single file. The file must
|
|
be small enough for the Contents API to return base64 content; for larger
|
|
files use the Blobs API instead.
|
|
|
|
Args:
|
|
github_repository: Repository in "owner/repo" format (e.g. "ROCm/pytorch").
|
|
path: File path within the repo (e.g. "version.txt").
|
|
ref: Git ref (branch, tag, or commit SHA).
|
|
|
|
Returns:
|
|
The decoded file contents.
|
|
|
|
Raises:
|
|
GitHubAPIError: If the file cannot be fetched (not found, API error, etc.).
|
|
|
|
See: https://docs.github.com/en/rest/repos/contents#get-repository-content
|
|
"""
|
|
encoded_path = quote(path, safe="/")
|
|
encoded_ref = quote(ref, safe="")
|
|
url = (
|
|
f"https://api.github.com/repos/{github_repository}/contents/"
|
|
f"{encoded_path}?ref={encoded_ref}"
|
|
)
|
|
response = gha_send_request(url)
|
|
if not isinstance(response, dict) or response.get("type") != "file":
|
|
response_type = (
|
|
response.get("type") if isinstance(response, dict) else type(response)
|
|
)
|
|
raise GitHubAPIError(
|
|
f"Expected GitHub contents response for a file at {path!r}, "
|
|
f"got {response_type!r}"
|
|
)
|
|
if response.get("encoding") != "base64" or not isinstance(
|
|
response.get("content"), str
|
|
):
|
|
raise GitHubAPIError(
|
|
f"Expected base64 GitHub contents response for {path!r}; "
|
|
"use the Git blobs API for larger files"
|
|
)
|
|
try:
|
|
return base64.b64decode(response["content"])
|
|
except binascii.Error as e:
|
|
raise GitHubAPIError(f"Failed to decode GitHub contents for {path!r}") from e
|
|
|
|
|
|
def gha_fetch_text_file_contents(
|
|
github_repository: str, path: str, ref: str, *, encoding: str = "utf-8"
|
|
) -> str:
|
|
"""Fetch and decode a text file from a GitHub repo at a specific ref."""
|
|
contents = gha_fetch_file_contents(github_repository, path, ref)
|
|
try:
|
|
return contents.decode(encoding)
|
|
except UnicodeDecodeError as e:
|
|
raise GitHubAPIError(
|
|
f"Failed to decode GitHub contents for {path!r} as {encoding}"
|
|
) from e
|
|
|
|
|
|
# TODO: Consider moving str2bool to a general-purpose utils module. It's useful
|
|
# for GitHub Actions (YAML has fuzzy boolean handling) but is also used broadly.
|
|
def str2bool(value: str | None) -> bool:
|
|
"""Convert environment variables to boolean values."""
|
|
if not value:
|
|
return False
|
|
if not isinstance(value, str):
|
|
raise ValueError(
|
|
f"Expected a string value for boolean conversion, got {type(value)}"
|
|
)
|
|
value = value.strip().lower()
|
|
if value in (
|
|
"1",
|
|
"true",
|
|
"t",
|
|
"yes",
|
|
"y",
|
|
"on",
|
|
"enable",
|
|
"enabled",
|
|
"found",
|
|
):
|
|
return True
|
|
if value in (
|
|
"0",
|
|
"false",
|
|
"f",
|
|
"no",
|
|
"n",
|
|
"off",
|
|
"disable",
|
|
"disabled",
|
|
"notfound",
|
|
"none",
|
|
"null",
|
|
"nil",
|
|
"undefined",
|
|
"n/a",
|
|
):
|
|
return False
|
|
raise ValueError(f"Invalid string value for boolean conversion: {value}")
|
|
|
|
|
|
# TODO: Move get_visible_gpu_count and get_first_gpu_architecture to a
|
|
# GPU/rocminfo utils module. They are not specific to GitHub Actions.
|
|
# TODO(#3489): Refactor get_visible_gpu_count and get_first_gpu_architecture to share a
|
|
# common helper that runs rocminfo and returns matching lines; both functions duplicate the first ~12 lines.
|
|
def get_visible_gpu_count(env=None, therock_bin_dir: str | None = None) -> int:
|
|
rocminfo = Path(therock_bin_dir) / "rocminfo"
|
|
rocminfo_cmd = str(rocminfo) if rocminfo.exists() else "rocminfo"
|
|
|
|
result = subprocess.run(
|
|
[rocminfo_cmd],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
check=False,
|
|
)
|
|
|
|
pattern = re.compile(r"^\s*Name:\s+gfx[0-9a-z]+$", re.IGNORECASE)
|
|
|
|
return sum(1 for line in result.stdout.splitlines() if pattern.match(line.strip()))
|
|
|
|
|
|
def get_first_gpu_architecture(env=None, therock_bin_dir: str | None = None) -> str:
|
|
"""Return the first visible GPU architecture (e.g. 'gfx942') from rocminfo."""
|
|
rocminfo = Path(therock_bin_dir) / "rocminfo"
|
|
rocminfo_cmd = str(rocminfo) if rocminfo.exists() else "rocminfo"
|
|
|
|
result = subprocess.run(
|
|
[rocminfo_cmd],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
check=True,
|
|
)
|
|
|
|
pattern = re.compile(r"^\s*Name:\s+(gfx[0-9a-z]+)$", re.IGNORECASE)
|
|
for line in result.stdout.splitlines():
|
|
m = pattern.match(line.strip())
|
|
if m:
|
|
gpu_arch = m.group(1).lower()
|
|
logging.info(f"Detected GPU architecture: {gpu_arch}")
|
|
return gpu_arch
|
|
raise RuntimeError("No GPU architecture found in rocminfo output")
|