Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
"""GitHub Actions secret updater using the GitHub API.
|
|
|
|
Updates repository secrets by encrypting values with the repository's
|
|
public key using libsodium sealed box encryption (NaCl).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
|
|
import httpx
|
|
from nacl.public import PublicKey, SealedBox
|
|
|
|
from .errors import SecretUpdateError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GITHUB_API_BASE = "https://api.github.com"
|
|
|
|
|
|
class SecretUpdater:
|
|
"""Updates GitHub Actions repository secrets via the GitHub API.
|
|
|
|
Uses libsodium sealed box encryption to encrypt secret values before
|
|
sending them to the GitHub API.
|
|
|
|
Attributes:
|
|
github_token: GitHub PAT with repo scope for secret management.
|
|
repo_owner: Repository owner (user or organization).
|
|
repo_name: Repository name.
|
|
"""
|
|
|
|
def __init__(self, github_token: str, repo_owner: str, repo_name: str) -> None:
|
|
self._github_token = github_token
|
|
self._repo_owner = repo_owner
|
|
self._repo_name = repo_name
|
|
|
|
def _headers(self) -> dict[str, str]:
|
|
"""Build authorization headers for GitHub API requests."""
|
|
return {
|
|
"Authorization": f"Bearer {self._github_token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
|
|
def _encrypt_secret(self, public_key_b64: str, secret_value: str) -> str:
|
|
"""Encrypt a secret value using the repository's public key.
|
|
|
|
Args:
|
|
public_key_b64: Base64-encoded repository public key from GitHub API.
|
|
secret_value: The plaintext secret value to encrypt.
|
|
|
|
Returns:
|
|
Base64-encoded encrypted value ready for the GitHub API.
|
|
"""
|
|
public_key_bytes = base64.b64decode(public_key_b64)
|
|
public_key = PublicKey(public_key_bytes)
|
|
sealed_box = SealedBox(public_key)
|
|
encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
|
|
return base64.b64encode(encrypted).decode("utf-8")
|
|
|
|
async def update_secret(self, secret_name: str, secret_value: str) -> bool:
|
|
"""Update a GitHub Actions repository secret.
|
|
|
|
Fetches the repository's public key, encrypts the secret value using
|
|
libsodium sealed box encryption, and updates the secret via the GitHub API.
|
|
|
|
On failure, the previous secret value remains unchanged (GitHub API is atomic).
|
|
|
|
Args:
|
|
secret_name: Name of the secret to update.
|
|
secret_value: New plaintext value for the secret.
|
|
|
|
Returns:
|
|
True if the secret was updated successfully.
|
|
|
|
Raises:
|
|
SecretUpdateError: If the update fails for any reason.
|
|
The error message never contains the secret value.
|
|
"""
|
|
base_url = (
|
|
f"{GITHUB_API_BASE}/repos/{self._repo_owner}/{self._repo_name}"
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
# Step 1: Get the repository's public key for secret encryption
|
|
try:
|
|
key_response = await client.get(
|
|
f"{base_url}/actions/secrets/public-key",
|
|
headers=self._headers(),
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise SecretUpdateError(
|
|
secret_name,
|
|
f"Failed to fetch repository public key: {type(exc).__name__}",
|
|
) from exc
|
|
|
|
if key_response.status_code != 200:
|
|
raise SecretUpdateError(
|
|
secret_name,
|
|
f"Failed to fetch repository public key "
|
|
f"(HTTP {key_response.status_code})",
|
|
)
|
|
|
|
key_data = key_response.json()
|
|
public_key_b64 = key_data["key"]
|
|
key_id = key_data["key_id"]
|
|
|
|
# Step 2: Encrypt the secret value using the public key
|
|
try:
|
|
encrypted_value = self._encrypt_secret(public_key_b64, secret_value)
|
|
except Exception as exc:
|
|
raise SecretUpdateError(
|
|
secret_name,
|
|
f"Failed to encrypt secret value: {type(exc).__name__}",
|
|
) from exc
|
|
|
|
# Step 3: Update the secret via PUT request
|
|
try:
|
|
put_response = await client.put(
|
|
f"{base_url}/actions/secrets/{secret_name}",
|
|
headers=self._headers(),
|
|
json={
|
|
"encrypted_value": encrypted_value,
|
|
"key_id": key_id,
|
|
},
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise SecretUpdateError(
|
|
secret_name,
|
|
f"Failed to update secret: {type(exc).__name__}",
|
|
) from exc
|
|
|
|
# GitHub returns 201 (created) or 204 (updated) on success
|
|
if put_response.status_code not in (201, 204):
|
|
raise SecretUpdateError(
|
|
secret_name,
|
|
f"Failed to update secret (HTTP {put_response.status_code})",
|
|
)
|
|
|
|
logger.info("Successfully updated GitHub secret '%s'", secret_name)
|
|
return True
|