Migrate all repos into monorepo context folders
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.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user