143 lines
4.9 KiBLFS
Python
143 lines
4.9 KiBLFS
Python
"""GHCR publication preflight checks for AgentBeats public image evidence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
REQUIRED_GHCR_SCOPES = frozenset({"read:packages", "write:packages"})
|
|
SCOPE_RE = re.compile(r"'([^']+)'")
|
|
HEADER_SCOPE_RE = re.compile(r"^x-oauth-scopes:\s*(.*)$", re.IGNORECASE | re.MULTILINE)
|
|
|
|
|
|
def active_account_scopes(gh_auth_status_output: str) -> set[str]:
|
|
"""Parse `gh auth status` output and return scopes for the active account."""
|
|
|
|
for block in _account_blocks(gh_auth_status_output):
|
|
if "Active account: true" not in block:
|
|
continue
|
|
scope_line = next((line for line in block.splitlines() if "Token scopes:" in line), "")
|
|
return set(SCOPE_RE.findall(scope_line))
|
|
raise ValueError("could not find an active GitHub account in `gh auth status` output")
|
|
|
|
|
|
def missing_ghcr_package_scopes(scopes: set[str]) -> set[str]:
|
|
"""Return GHCR package scopes that are missing from the active token."""
|
|
|
|
return set(REQUIRED_GHCR_SCOPES - scopes)
|
|
|
|
|
|
def ensure_ghcr_package_scopes(gh_auth_status_output: str) -> set[str]:
|
|
"""Validate active GitHub token package scopes and return the parsed scopes."""
|
|
|
|
scopes = active_account_scopes(gh_auth_status_output)
|
|
missing = missing_ghcr_package_scopes(scopes)
|
|
if missing:
|
|
missing_list = ", ".join(sorted(missing))
|
|
raise ValueError(
|
|
f"active GitHub token is missing GHCR package scopes: {missing_list}. "
|
|
"Run `gh auth refresh -h github.com --scopes write:packages,read:packages` "
|
|
"with an operator-controlled browser/device-code flow before pushing images."
|
|
)
|
|
return scopes
|
|
|
|
|
|
def run_gh_auth_status() -> str:
|
|
"""Run `gh auth status` and return its combined output."""
|
|
|
|
completed = subprocess.run(
|
|
["gh", "auth", "status"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
output = completed.stdout + completed.stderr
|
|
if completed.returncode != 0:
|
|
raise RuntimeError(output.strip() or "`gh auth status` failed")
|
|
return output
|
|
|
|
|
|
def run_gh_token_scope_status() -> set[str]:
|
|
"""Inspect scopes for a PAT supplied through GH_TOKEN or GITHUB_TOKEN."""
|
|
|
|
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
if not token:
|
|
raise ValueError("set GH_TOKEN or GITHUB_TOKEN to validate a browser-created GitHub token")
|
|
|
|
env = os.environ.copy()
|
|
env.pop("GH_TOKEN", None)
|
|
env.pop("GITHUB_TOKEN", None)
|
|
env["GH_TOKEN"] = token
|
|
completed = subprocess.run(
|
|
["gh", "api", "-i", "user"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
output = completed.stdout + completed.stderr
|
|
if completed.returncode != 0:
|
|
raise RuntimeError(_redact_token(output.strip() or "`gh api -i user` failed", token))
|
|
return _scopes_from_api_header(output)
|
|
|
|
|
|
def _scopes_from_api_header(output: str) -> set[str]:
|
|
match = HEADER_SCOPE_RE.search(output)
|
|
if match is None:
|
|
raise ValueError("could not find X-OAuth-Scopes header in `gh api -i user` output")
|
|
scopes = {scope.strip() for scope in match.group(1).split(",") if scope.strip()}
|
|
return scopes
|
|
|
|
|
|
def _redact_token(message: str, token: str) -> str:
|
|
return message.replace(token, "[REDACTED_TOKEN]")
|
|
|
|
|
|
def _account_blocks(output: str) -> list[str]:
|
|
blocks: list[list[str]] = []
|
|
current: list[str] = []
|
|
for line in output.splitlines():
|
|
if "Logged in to github.com account" in line:
|
|
if current:
|
|
blocks.append(current)
|
|
current = [line]
|
|
elif current:
|
|
current.append(line)
|
|
if current:
|
|
blocks.append(current)
|
|
return ["\n".join(block) for block in blocks]
|
|
|
|
|
|
def _main() -> None:
|
|
parser = argparse.ArgumentParser(description="Check GitHub CLI scopes before pushing SkillsBench AgentBeats images to GHCR.")
|
|
parser.add_argument("--print-scopes", action="store_true", help="Print the active account scopes after validation.")
|
|
parser.add_argument(
|
|
"--token-from-env",
|
|
action="store_true",
|
|
help="Validate GH_TOKEN or GITHUB_TOKEN scopes instead of the stored active gh account.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
scopes = run_gh_token_scope_status() if args.token_from_env else ensure_ghcr_package_scopes(run_gh_auth_status())
|
|
missing = missing_ghcr_package_scopes(scopes)
|
|
if missing:
|
|
missing_list = ", ".join(sorted(missing))
|
|
source = "environment GitHub token" if args.token_from_env else "active GitHub token"
|
|
raise ValueError(f"{source} is missing GHCR package scopes: {missing_list}")
|
|
except (RuntimeError, ValueError) as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
raise SystemExit(1) from exc
|
|
|
|
if args.print_scopes:
|
|
print(",".join(sorted(scopes)))
|
|
else:
|
|
print("GHCR package scopes available")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|