Class to manage GitHub repository actions through the GitHub API.
| Parameters: |
-
token
(str)
–
GitHub API token for authentication.
-
repo
(str)
–
Full name of the GitHub repository in the format "owner/repo".
|
| Attributes: |
-
headers
(dict)
–
Headers for HTTP requests to GitHub API.
-
github
(Github)
–
Instance of Github object for interacting with the GitHub API.
|
Source code in gha_runner/gh.py
class GitHubInstance:
"""Class to manage GitHub repository actions through the GitHub API.
Parameters
----------
token : str
GitHub API token for authentication.
repo : str
Full name of the GitHub repository in the format "owner/repo".
Attributes
----------
headers : dict
Headers for HTTP requests to GitHub API.
github : Github
Instance of Github object for interacting with the GitHub API.
"""
BASE_URL = "https://api.github.com"
def __init__(self, token: str, repo: str):
self.token = token
self.headers = self._headers({})
self.repo = repo
def _headers(self, header_kwargs):
"""Generate headers for API requests, adding authorization and specific API version.
Can be removed if this is added into PyGitHub.
Parameters
----------
header_kwargs : dict
Additional headers to include in the request.
Returns
-------
dict
Headers including authorization, API version, and any additional headers.
"""
headers = {
"Authorization": f"Bearer {self.token}",
"X-Github-Api-Version": "2022-11-28",
"Accept": "application/vnd.github+json",
}
headers.update(header_kwargs)
return headers
def _do_request(self, func, endpoint, **kwargs):
"""Make a request to the GitHub API.
This can be removed if this is added into PyGitHub.
"""
endpoint_url = urllib.parse.urljoin(self.BASE_URL, endpoint)
headers = self.headers
resp: requests.Response = func(endpoint_url, headers=headers, **kwargs)
if not resp.ok:
raise RuntimeError(
f"Error in API call for {endpoint_url}: "
f"{resp.status_code} {resp.reason}: {resp.content}"
)
else:
try:
return resp.json()
except JSONDecodeError:
return resp.content
def create_runner_tokens(self, count: int) -> list[str]:
"""Generate registration tokens for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
Parameters
----------
count : int
The number of runner tokens to generate.
Returns
-------
list[str]
A list of runner registration tokens.
Raises
------
TokenCreationError
If there is an error generating the tokens.
"""
tokens = []
for _ in range(count):
token = self.create_runner_token()
tokens.append(token)
return tokens
def create_runner_token(self) -> str:
"""Generate a registration token for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
Returns
-------
str
A runner registration token.
Raises
------
TokenRetrievalError
If there is an error generating the token.
"""
try:
res = self.post(
f"repos/{self.repo}/actions/runners/registration-token"
)
return res["token"]
except Exception as e:
raise TokenRetrievalError(f"Error creating runner token: {e}")
def post(self, endpoint, **kwargs):
"""Make a POST request to the GitHub API.
This can be removed if this is added into PyGitHub.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.post documentation for more information.
"""
return self._do_request(requests.post, endpoint, **kwargs)
def get(self, endpoint, **kwargs):
"""Make a GET request to the GitHub API.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.get documentation for more information.
"""
return self._do_request(requests.get, endpoint, **kwargs)
def delete(self, endpoint, **kwargs):
"""Make a DELETE request to the GitHub API.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.delete documentation for more information.
"""
return self._do_request(requests.delete, endpoint, **kwargs)
def get_runners(self) -> list[SelfHostedRunner] | None:
"""Get a list of self-hosted runners in the repository.
Returns
-------
list[SelfHostedRunner] | None
A list of self-hosted runners in the repository if they exist,
otherwise None.
Raises
------
RunnerListError
If there is an error getting the list of runners. Either because of
an error in the request or the response is not a mapping object.
"""
runners = []
per_page = 30 # GH API default
page = 1
total_runners = float("inf")
# paginate through the pages until we have all the runners
while (len(runners) < total_runners):
try:
res = self.get(f"repos/{self.repo}/actions/runners?per_page={per_page}&page={page}")
# This allows for arbitrary mappable objects to be used
if not isinstance(res, collections.abc.Mapping):
# This could be related to the API or the request itself.
# ie the response is not a JSON object
raise RunnerListError(f"Did not receive mapping object: {res}")
total_runners = res["total_count"]
page += 1
# protect from bug/issue where total_count is higher than actual # of runners
if len(res["runners"]) < 1:
break
for runner in res["runners"]:
id = runner["id"]
name = runner["name"]
os = runner["os"]
labels = [label["name"] for label in runner["labels"]]
runners.append(SelfHostedRunner(id, name, os, labels))
except RuntimeError as e:
# This occurs when we receive a status code is > 400
raise RunnerListError(f"Error getting runners: {e}")
# Other exceptions are bubbled up to the caller
return runners if len(runners) > 0 else None
def get_runner(self, label: str) -> SelfHostedRunner:
"""Get a runner by a given label for a repository.
Returns
-------
SelfHostedRunner
The runner with the given label.
Raises
------
MissingRunnerLabel
If the runner with the given label is not found.
"""
runners = self.get_runners()
if runners is not None:
for runner in runners:
if label in runner.labels:
return runner
raise MissingRunnerLabel(f"Runner {label} not found")
def wait_for_runner(
self, label: str, timeout: int, wait: int = 15
) -> SelfHostedRunner:
"""Wait for the runner with the given label to be online.
Parameters
----------
label : str
The label of the runner to wait for.
wait : int
The time in seconds to wait between checks. Defaults to 15 seconds.
timeout : int
The maximum time in seconds to wait for the runner to be online.
Returns
-------
SelfHostedRunner
The runner with the given label.
"""
max = time.time() + timeout
try:
runner = self.get_runner(label)
return runner
except MissingRunnerLabel:
print(f"Waiting for runner {label}...")
while True:
if time.time() > max:
raise RuntimeError(
f"Timeout reached: Runner {label} not found"
)
try:
runner = self.get_runner(label)
return runner
except MissingRunnerLabel:
print(f"Runner {label} not found. Waiting...")
time.sleep(wait)
def remove_runner(self, label: str):
"""Remove a runner by a given label.
Parameters
----------
label : str
The label of the runner to remove.
Raises
------
RuntimeError
If there is an error removing the runner or the runner is not found.
"""
runner = self.get_runner(label)
try:
self.delete(f"repos/{self.repo}/actions/runners/{runner.id}")
except Exception as e:
raise RuntimeError(f"Error removing runner {label}. Error: {e}")
@staticmethod
def generate_random_label() -> str:
"""Generate a random label for a runner.
Returns
-------
str
A random label for a runner. The label is in the format
"runner-<random_string>". The random string is 8 characters long
and consists of lowercase letters and digits.
"""
letters = string.ascii_lowercase + string.digits
result_str = "".join(random.choice(letters) for i in range(8))
return f"runner-{result_str}"
def _get_latest_release(self, repo: str) -> dict:
"""Get the latest release for a repository.
Parameters
----------
repo : str
The repository to get the latest release for.
Returns
-------
str
The tag name of the latest release.
"""
try:
release = self.get(f"repos/{repo}/releases/latest")
return release
except Exception as e:
raise RuntimeError(f"Error getting latest release: {e}")
def get_latest_runner_release(
self, platform: str, architecture: str
) -> str:
"""Return the latest runner for the given platform and architecture.
Parameters
----------
platform : str
The platform of the runner to download.
architecture : str
The architecture of the runner to download.
Returns
-------
str
The download URL of the runner.
Raises
------
RuntimeError
If the runner is not found for the given platform and architecture.
ValueError
If the platform or architecture is not supported.
"""
repo = "actions/runner"
supported_platforms = {"linux": ["x64", "arm", "arm64"]}
if platform not in supported_platforms:
raise ValueError(
f"Platform '{platform}' not supported. "
f"Supported platforms are {list(supported_platforms)}"
)
if architecture not in supported_platforms[platform]:
raise ValueError(
f"Architecture '{architecture}' not supported for platform '{platform}'. "
f"Supported architectures are {supported_platforms[platform]}"
)
release = self._get_latest_release(repo)
assets = release["assets"]
for asset in assets:
if platform in asset["name"] and architecture in asset["name"]:
return asset["browser_download_url"]
raise RuntimeError(
f"Runner release not found for platform {platform} and architecture {architecture}"
)
create_runner_token
create_runner_token() -> str
Generate a registration token for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
| Returns: |
-
str
–
A runner registration token.
|
Source code in gha_runner/gh.py
def create_runner_token(self) -> str:
"""Generate a registration token for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
Returns
-------
str
A runner registration token.
Raises
------
TokenRetrievalError
If there is an error generating the token.
"""
try:
res = self.post(
f"repos/{self.repo}/actions/runners/registration-token"
)
return res["token"]
except Exception as e:
raise TokenRetrievalError(f"Error creating runner token: {e}")
create_runner_tokens
create_runner_tokens(count: int) -> list[str]
Generate registration tokens for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
| Parameters: |
-
count
(int)
–
The number of runner tokens to generate.
|
| Returns: |
-
list[str]
–
A list of runner registration tokens.
|
| Raises: |
-
TokenCreationError
–
If there is an error generating the tokens.
|
Source code in gha_runner/gh.py
def create_runner_tokens(self, count: int) -> list[str]:
"""Generate registration tokens for GitHub Actions runners.
This can be removed if this is added into PyGitHub.
Parameters
----------
count : int
The number of runner tokens to generate.
Returns
-------
list[str]
A list of runner registration tokens.
Raises
------
TokenCreationError
If there is an error generating the tokens.
"""
tokens = []
for _ in range(count):
token = self.create_runner_token()
tokens.append(token)
return tokens
delete
delete(endpoint, **kwargs)
Make a DELETE request to the GitHub API.
| Parameters: |
-
endpoint
(str)
–
The endpoint to make the request to.
-
**kwargs
(dict, default:
{}
)
–
Additional keyword arguments to pass to the request.
See the requests.delete documentation for more information.
|
Source code in gha_runner/gh.py
def delete(self, endpoint, **kwargs):
"""Make a DELETE request to the GitHub API.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.delete documentation for more information.
"""
return self._do_request(requests.delete, endpoint, **kwargs)
generate_random_label
staticmethod
generate_random_label() -> str
Generate a random label for a runner.
| Returns: |
-
str
–
A random label for a runner. The label is in the format
"runner-". The random string is 8 characters long
and consists of lowercase letters and digits.
|
Source code in gha_runner/gh.py
@staticmethod
def generate_random_label() -> str:
"""Generate a random label for a runner.
Returns
-------
str
A random label for a runner. The label is in the format
"runner-<random_string>". The random string is 8 characters long
and consists of lowercase letters and digits.
"""
letters = string.ascii_lowercase + string.digits
result_str = "".join(random.choice(letters) for i in range(8))
return f"runner-{result_str}"
get
get(endpoint, **kwargs)
Make a GET request to the GitHub API.
| Parameters: |
-
endpoint
(str)
–
The endpoint to make the request to.
-
**kwargs
(dict, default:
{}
)
–
Additional keyword arguments to pass to the request.
See the requests.get documentation for more information.
|
Source code in gha_runner/gh.py
def get(self, endpoint, **kwargs):
"""Make a GET request to the GitHub API.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.get documentation for more information.
"""
return self._do_request(requests.get, endpoint, **kwargs)
get_latest_runner_release
get_latest_runner_release(platform: str, architecture: str) -> str
Return the latest runner for the given platform and architecture.
| Parameters: |
-
platform
(str)
–
The platform of the runner to download.
-
architecture
(str)
–
The architecture of the runner to download.
|
| Returns: |
-
str
–
The download URL of the runner.
|
| Raises: |
-
RuntimeError
–
If the runner is not found for the given platform and architecture.
-
ValueError
–
If the platform or architecture is not supported.
|
Source code in gha_runner/gh.py
def get_latest_runner_release(
self, platform: str, architecture: str
) -> str:
"""Return the latest runner for the given platform and architecture.
Parameters
----------
platform : str
The platform of the runner to download.
architecture : str
The architecture of the runner to download.
Returns
-------
str
The download URL of the runner.
Raises
------
RuntimeError
If the runner is not found for the given platform and architecture.
ValueError
If the platform or architecture is not supported.
"""
repo = "actions/runner"
supported_platforms = {"linux": ["x64", "arm", "arm64"]}
if platform not in supported_platforms:
raise ValueError(
f"Platform '{platform}' not supported. "
f"Supported platforms are {list(supported_platforms)}"
)
if architecture not in supported_platforms[platform]:
raise ValueError(
f"Architecture '{architecture}' not supported for platform '{platform}'. "
f"Supported architectures are {supported_platforms[platform]}"
)
release = self._get_latest_release(repo)
assets = release["assets"]
for asset in assets:
if platform in asset["name"] and architecture in asset["name"]:
return asset["browser_download_url"]
raise RuntimeError(
f"Runner release not found for platform {platform} and architecture {architecture}"
)
get_runner
get_runner(label: str) -> SelfHostedRunner
Get a runner by a given label for a repository.
| Returns: |
-
SelfHostedRunner
–
The runner with the given label.
|
Source code in gha_runner/gh.py
def get_runner(self, label: str) -> SelfHostedRunner:
"""Get a runner by a given label for a repository.
Returns
-------
SelfHostedRunner
The runner with the given label.
Raises
------
MissingRunnerLabel
If the runner with the given label is not found.
"""
runners = self.get_runners()
if runners is not None:
for runner in runners:
if label in runner.labels:
return runner
raise MissingRunnerLabel(f"Runner {label} not found")
get_runners
get_runners() -> list[SelfHostedRunner] | None
Get a list of self-hosted runners in the repository.
| Returns: |
-
list[SelfHostedRunner] | None
–
A list of self-hosted runners in the repository if they exist,
otherwise None.
|
| Raises: |
-
RunnerListError
–
If there is an error getting the list of runners. Either because of
an error in the request or the response is not a mapping object.
|
Source code in gha_runner/gh.py
def get_runners(self) -> list[SelfHostedRunner] | None:
"""Get a list of self-hosted runners in the repository.
Returns
-------
list[SelfHostedRunner] | None
A list of self-hosted runners in the repository if they exist,
otherwise None.
Raises
------
RunnerListError
If there is an error getting the list of runners. Either because of
an error in the request or the response is not a mapping object.
"""
runners = []
per_page = 30 # GH API default
page = 1
total_runners = float("inf")
# paginate through the pages until we have all the runners
while (len(runners) < total_runners):
try:
res = self.get(f"repos/{self.repo}/actions/runners?per_page={per_page}&page={page}")
# This allows for arbitrary mappable objects to be used
if not isinstance(res, collections.abc.Mapping):
# This could be related to the API or the request itself.
# ie the response is not a JSON object
raise RunnerListError(f"Did not receive mapping object: {res}")
total_runners = res["total_count"]
page += 1
# protect from bug/issue where total_count is higher than actual # of runners
if len(res["runners"]) < 1:
break
for runner in res["runners"]:
id = runner["id"]
name = runner["name"]
os = runner["os"]
labels = [label["name"] for label in runner["labels"]]
runners.append(SelfHostedRunner(id, name, os, labels))
except RuntimeError as e:
# This occurs when we receive a status code is > 400
raise RunnerListError(f"Error getting runners: {e}")
# Other exceptions are bubbled up to the caller
return runners if len(runners) > 0 else None
post
post(endpoint, **kwargs)
Make a POST request to the GitHub API.
This can be removed if this is added into PyGitHub.
| Parameters: |
-
endpoint
(str)
–
The endpoint to make the request to.
-
**kwargs
(dict, default:
{}
)
–
Additional keyword arguments to pass to the request.
See the requests.post documentation for more information.
|
Source code in gha_runner/gh.py
def post(self, endpoint, **kwargs):
"""Make a POST request to the GitHub API.
This can be removed if this is added into PyGitHub.
Parameters
----------
endpoint : str
The endpoint to make the request to.
**kwargs : dict, optional
Additional keyword arguments to pass to the request.
See the requests.post documentation for more information.
"""
return self._do_request(requests.post, endpoint, **kwargs)
remove_runner
remove_runner(label: str)
Remove a runner by a given label.
| Parameters: |
-
label
(str)
–
The label of the runner to remove.
|
| Raises: |
-
RuntimeError
–
If there is an error removing the runner or the runner is not found.
|
Source code in gha_runner/gh.py
def remove_runner(self, label: str):
"""Remove a runner by a given label.
Parameters
----------
label : str
The label of the runner to remove.
Raises
------
RuntimeError
If there is an error removing the runner or the runner is not found.
"""
runner = self.get_runner(label)
try:
self.delete(f"repos/{self.repo}/actions/runners/{runner.id}")
except Exception as e:
raise RuntimeError(f"Error removing runner {label}. Error: {e}")
wait_for_runner
wait_for_runner(label: str, timeout: int, wait: int = 15) -> SelfHostedRunner
Wait for the runner with the given label to be online.
| Parameters: |
-
label
(str)
–
The label of the runner to wait for.
-
wait
(int, default:
15
)
–
The time in seconds to wait between checks. Defaults to 15 seconds.
-
timeout
(int)
–
The maximum time in seconds to wait for the runner to be online.
|
| Returns: |
-
SelfHostedRunner
–
The runner with the given label.
|
Source code in gha_runner/gh.py
def wait_for_runner(
self, label: str, timeout: int, wait: int = 15
) -> SelfHostedRunner:
"""Wait for the runner with the given label to be online.
Parameters
----------
label : str
The label of the runner to wait for.
wait : int
The time in seconds to wait between checks. Defaults to 15 seconds.
timeout : int
The maximum time in seconds to wait for the runner to be online.
Returns
-------
SelfHostedRunner
The runner with the given label.
"""
max = time.time() + timeout
try:
runner = self.get_runner(label)
return runner
except MissingRunnerLabel:
print(f"Waiting for runner {label}...")
while True:
if time.time() > max:
raise RuntimeError(
f"Timeout reached: Runner {label} not found"
)
try:
runner = self.get_runner(label)
return runner
except MissingRunnerLabel:
print(f"Runner {label} not found. Waiting...")
time.sleep(wait)