#!/usr/bin/env python3
# File: /home/ywatanabe/proj/scitex-git/src/scitex_git/_gh_secrets.py
"""GitHub Actions secrets / variables — thin wrappers around ``gh`` CLI.
The GitHub Actions REST API (and ``gh secret``) is **write-only** for
secret values: you can list names + ``updated_at``, but never read a
value back. This module bundles the everyday read/write primitives any
"rotate the CI key" workflow needs, plus a SHA256 sidecar pattern to
detect drift between local and remote without exposing the secret:
* :func:`set_secret` / :func:`list_secrets` /
:func:`get_secret_metadata` — direct ``gh secret`` wrappers.
* :func:`set_variable` / :func:`get_variable` — ``gh variable``
wrappers (variables are world-readable; suitable for non-sensitive
fingerprints, never for token material).
* :func:`set_secret_with_sha_sidecar` — pushes the secret AND a
SHA256 fingerprint to a same-name ``…_SHA256`` repo variable so a
later ``get_variable`` call lets the operator tell whether the
GitHub-side secret matches the local one. Hash is irreversible.
* :func:`format_age` — render an ISO-8601 ``updated_at`` as a
human-readable single-unit age (``15min`` / ``2.4d``) for CLI
reports.
* :func:`sha256_hex` — same hashing function the sidecar uses, so
callers can compute a local fingerprint to compare against
:func:`get_variable`.
The ``gh`` CLI must be authenticated. Each helper raises
:class:`GhSecretError` on subprocess failure with the underlying stderr
attached, so callers can surface a clean message rather than a stack
trace.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
from datetime import datetime, timezone
from logging import getLogger
logger = getLogger(__name__)
__all__ = [
"GhSecretError",
"set_secret",
"list_secrets",
"get_secret_metadata",
"set_variable",
"get_variable",
"set_secret_with_sha_sidecar",
"format_age",
"sha256_hex",
]
[docs]
class GhSecretError(RuntimeError):
"""Raised when a ``gh`` subprocess returns non-zero."""
def _run(argv: list[str], *, stdin: str | None = None) -> str:
"""Run ``argv`` and return stdout. Raise :class:`GhSecretError` on failure."""
proc = subprocess.run(
argv,
input=stdin,
text=True,
capture_output=True,
)
if proc.returncode != 0:
msg = proc.stderr.strip() or proc.stdout.strip() or "unknown error"
raise GhSecretError(f"{' '.join(argv[:3])}…: {msg}")
return proc.stdout
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
[docs]
def set_secret(repo: str, name: str, value: str) -> None:
"""Push ``value`` into the Actions secret ``name`` for ``repo``.
``repo`` follows the ``owner/repo`` form. Idempotent — creates if
missing, updates otherwise.
"""
# ``gh secret set`` reads from stdin when ``--body`` is omitted.
# Earlier we passed ``--body -`` thinking ``-`` meant stdin; gh
# actually stored the literal one-byte value ``-``, silently
# truncating the secret. Bug surfaced as in-CI ``$SECRET`` length
# 1 with sha256 mismatching the local file's hash.
_run(["gh", "secret", "set", name, "-R", repo], stdin=value)
[docs]
def list_secrets(repo: str) -> dict[str, str]:
"""Return ``{secret_name: updated_at_iso}`` for the repo.
Pagination is honoured (``--paginate``) so this scales beyond
GitHub's 100-secret default page size.
"""
raw = _run(["gh", "api", f"repos/{repo}/actions/secrets", "--paginate"])
payload = json.loads(raw)
return {s["name"]: s["updated_at"] for s in payload.get("secrets", [])}
# ---------------------------------------------------------------------------
# Variables (world-readable)
# ---------------------------------------------------------------------------
[docs]
def set_variable(repo: str, name: str, value: str) -> None:
"""Create-or-update an Actions variable. Idempotent.
Variables are visible to anyone with repo read access. Use this
for non-sensitive metadata (e.g. SHA256 fingerprints of secrets,
feature flags). NEVER store token material here.
"""
_run(["gh", "variable", "set", name, "-R", repo, "--body", value])
[docs]
def get_variable(repo: str, name: str) -> str | None:
"""Return the variable's value, or None if absent."""
proc = subprocess.run(
["gh", "api", f"repos/{repo}/actions/variables/{name}"],
text=True,
capture_output=True,
)
if proc.returncode != 0:
return None
try:
return json.loads(proc.stdout).get("value")
except json.JSONDecodeError:
return None
# ---------------------------------------------------------------------------
# SHA256 sidecar — secret-drift detection without exposing the value
# ---------------------------------------------------------------------------
[docs]
def sha256_hex(value: str) -> str:
"""Return the SHA256 hex digest of ``value`` (UTF-8)."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
[docs]
def set_secret_with_sha_sidecar(
repo: str, name: str, value: str, *, sidecar_suffix: str = "_SHA256"
) -> str:
"""Push the secret AND its SHA256 fingerprint as a public variable.
Returns the SHA256 hex digest so the caller can show it in CLI
output. The sidecar variable is named ``{name}{sidecar_suffix}`` and
holds the irreversible hash; later
``get_variable(repo, f"{name}{sidecar_suffix}")`` lets you tell
whether the GitHub-side secret matches a local value (compare with
``sha256_hex(local_value)``).
The hash itself is not sensitive — it cannot be reversed to the
original token without a brute-force search of the OAuth/api-key
namespace, which is computationally infeasible. It IS visible to
repo readers, so don't use the sidecar for tokens whose mere
existence is sensitive (the hash leaks "this token was rotated at
{time} to a value with hash {h}" but never the value).
"""
digest = sha256_hex(value)
set_secret(repo, name, value)
set_variable(repo, f"{name}{sidecar_suffix}", digest)
return digest
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
# EOF