Ctrl K
ComunidadGuías…

scripts/check-cla-gpg-binding.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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())