scripts/check-cla-gpg-binding.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Enforce Identity H8: every commit on a contributor PR is GPG-signed with the
fingerprint declared in their CLA record (.github/cla-config.yml).
Failure modes:
- PR author has no entry in cla-config.yml -> unsigned_cla
- a PR commit has no/invalid GPG signature -> unsigned_commit
- a PR commit's GPG fingerprint != CLA fingerprint -> fingerprint_drift
- declared cla_text_sha256 != current CLA.md hash -> stale_cla_artefact
Bootstrap / founder handling:
- The CI job runs this ONLY on external-fork pull_request events (see
supply-chain.yml `if:`). Push, schedule, dispatch, owner branches, and
same-repo bot merges never reach the job.
- If the repo owner's record does not yet carry a fingerprint, the script treats
it as owner auto-attestation and exits 0. External contributors must provide a
fingerprinted record before merge.
- When run with no PR context (local invocation, or CI push/schedule where the
workflow `if:` was bypassed) it verifies what it can and exits 0. GITHUB_ACTOR
is the pusher/merger in those events — not a CLA subject.
Sub-spec §9.3-§9.4; acceptance gate #13.
"""
from __future__ import annotations
import hashlib
import os
import pathlib
import subprocess
import sys
import yaml
REPO = pathlib.Path(__file__).resolve().parents[1]
CLA_CONFIG = REPO / ".github" / "cla-config.yml"
CLA_TEXT = REPO / "CLA.md"
def _git(*args: str) -> str:
return subprocess.run(
["git", *args], cwd=REPO, capture_output=True, text=True, check=False
).stdout.strip()
def _commit_range() -> list[str]:
"""Return the SHAs introduced by the PR, newest first.
Uses GITHUB_BASE_REF when present (PR context); otherwise inspects only HEAD.
"""
base = os.environ.get("GITHUB_BASE_REF")
if base:
# ensure the base ref is fetched; CI checks out with fetch-depth: 0
merge_base = _git("merge-base", f"origin/{base}", "HEAD") or _git("rev-parse", f"origin/{base}")
if merge_base:
out = _git("rev-list", f"{merge_base}..HEAD")
return [s for s in out.splitlines() if s]
head = _git("rev-parse", "HEAD")
return [head] if head else []
def _signature(sha: str) -> tuple[str, str]:
"""Return (status, fingerprint) for a commit. status is git's %G? code."""
out = _git("log", "-1", "--format=%G?%x1f%GF", sha)
if "\x1f" in out:
status, fp = out.split("\x1f", 1)
return status.strip(), fp.strip().upper()
return (out.strip(), "")
def _norm_fp(fp: str) -> str:
return fp.replace(" ", "").strip().upper()
def _resolve_pr_author() -> str:
"""Return the CLA subject, or empty when Identity H8 does not apply.
``PR_AUTHOR`` is the only authoritative subject. ``GITHUB_ACTOR`` on
push/schedule/dispatch is the merger, scheduler, or bot (for example
``cursor[bot]`` after a squash-merge) and must not be treated as a
contributor PR author. Local dry-runs without a GitHub event name may
still use ``GITHUB_ACTOR``.
"""
author = (os.environ.get("PR_AUTHOR") or "").strip()
if author:
return author.lower()
event = (os.environ.get("GITHUB_EVENT_NAME") or "").strip().lower()
if event and event != "pull_request":
return ""
return (os.environ.get("GITHUB_ACTOR") or "").strip().lower()
def main() -> int:
if not CLA_CONFIG.exists():
print(f"FAIL: {CLA_CONFIG} missing", file=sys.stderr)
return 1
cfg = yaml.safe_load(CLA_CONFIG.read_text(encoding="utf-8")) or {}
contributors = {
str(c.get("github_username", "")).lower(): c
for c in cfg.get("contributors", [])
}
actor = _resolve_pr_author()
if not actor:
print("NOTE: no PR author in environment (local run or non-PR CI event); CLA binding not enforced")
return 0
record = contributors.get(actor)
if record is None:
print(
f"FAIL (unsigned_cla): PR author {actor!r} has no entry in cla-config.yml. "
f"They must sign CLA.md (GPG) and be ratified before merge.",
file=sys.stderr,
)
return 1
# cla_text_sha256 drift check (skipped when no signed hash is recorded)
artefact = record.get("signed_artefact") or {}
declared_hash = str(artefact.get("cla_text_sha256", ""))
if declared_hash:
current = hashlib.sha256(CLA_TEXT.read_bytes()).hexdigest()
if current != declared_hash:
print(
f"FAIL (stale_cla_artefact): CLA.md sha256 {current} does not match the "
f"signed value {declared_hash} for {actor}; re-sign the current CLA text.",
file=sys.stderr,
)
return 1
fp = _norm_fp(str(record.get("gpg_fingerprint", "")))
repo_owner = str(cfg.get("repo_owner", "navaneeshnagarajan")).lower()
if not fp and actor == repo_owner:
print(
f"NOTE: {actor} is repo owner with auto-attested CLA record; "
"commit-signature binding not enforced."
)
return 0
if not fp:
print(
f"FAIL (missing_gpg_fingerprint): PR author {actor!r} has a CLA "
"record but no GPG fingerprint. External contributors must provide "
"a fingerprinted CLA record before merge.",
file=sys.stderr,
)
return 1
if not cfg.get("require_gpg_signed_commits", True):
print(f"NOTE: require_gpg_signed_commits disabled; skipping commit check for {actor}")
return 0
failures: list[str] = []
commits = _commit_range()
if not commits:
print("NOTE: no commits resolved for verification")
return 0
for sha in commits:
status, commit_fp = _signature(sha)
# G = good signature, U = good with unknown validity (acceptable: key not in CI keyring)
if status not in ("G", "U"):
failures.append(f"unsigned_commit: {sha[:12]} signature status {status!r}")
continue
if commit_fp and not commit_fp.endswith(fp[-16:]):
failures.append(
f"fingerprint_drift: {sha[:12]} signed by {commit_fp} != CLA fingerprint {fp}"
)
if failures:
for f in failures:
print(f"FAIL: {f}", file=sys.stderr)
return 1
print(f"CLA GPG binding OK for {actor} ({len(commits)} commits verified)")
return 0
if __name__ == "__main__":
raise SystemExit(main())