Skip to content

Diagnostic GitHub API

The signature audit reads verified and never reads reason

The compliance answer says every commit on the release branch is signed. Somebody wrote a script to prove it, the script has been green for eighteen months, and nobody has looked at it since. Then an auditor asks how the check works and the answer is that it walks the commit list and confirms every author is on the approved list of engineers. That is not a signature check. The commit author is a string the committing client sets, it is never authenticated, and a script that reads it is checking that the person who pushed knew how to type a colleague's name. The signature result is right there in the same response, on a field the script never opens.

Read-only token Python and Node.js Tests included
Server nameplates
Photo by Marc PEZIN on Unsplash
The short answer

GET /repos/{owner}/{repo}/commits returns a verification object on every commit with five fields: verified, reason, signature, payload and verified_at. Only verified is a boolean, and it is the least informative of the five on its own.

verified: true means GitHub cryptographically checked a signature against a key registered to a GitHub account and matched the committer email to a verified email on that account. It says nothing about commit.author, which is unauthenticated free text. verified: false is not one finding but about a dozen, and reason is where they live: unsigned means nobody signed, invalid means somebody signed and the signature does not check out, unknown_key means the signature is fine and the key was never registered, and gpgverify_unavailable means GitHub could not check at all. Those need four different responses.

And a verification object that is absent from the payload is not a false one. Treat missing as unknown, report it as unknown, and never let it fall through a truthiness test into either column.

The problem in plain words

The reason this survives so long is that the check is green, and it is green for a reason that has nothing to do with signatures. Every commit does have an author, the authors are all real colleagues, so the assertion passes on every run and keeps passing. The one input that would make it fail is a commit whose author string is somebody not on the list, which is also the one thing an attacker would never do. The test is passing on the majority case and would pass on the attack too.

The second version of this is subtler and more common: the script does read verification, and reads it wrongly. if commit["commit"]["verification"]["verified"]: raises a KeyError the first time an endpoint omits the object, so somebody wraps it in a .get() chain with a default, and the default they pick decides the whole policy. Default to True and every commit GitHub could not check is recorded as signed. Default to False and the first time GitHub's verification service has a bad ten minutes the pipeline blocks a release that is perfectly fine. Neither is correct, because the honest answer to "was this signed" in that moment is "I do not know yet".

The third version passes the audit and guarantees nothing at all. A script that reports the history is not a rule that constrains the future. If no ruleset on that branch requires signatures, a hundred per cent verified history is a description of what people happened to do, and the next push is free to be unsigned. The report and the rule are different objects and only one of them stops anything.

Policy says allsignedwritten downScript checksauthora client setstringGreen for 18monthsno commit everfailsVerificationnever readsame responseAudit asks howand there is noanswerand the check passes again
Every reading here is true and none of them is a signature. The field being asserted on is not the field that was verified.

Why it happens

Author and committer are different people and only one of them is checked. A git commit carries an author (who wrote the change) and a committer (who created this commit object), and both are plain strings written by the client. Signature verification binds the committer identity: GitHub matches the committer email against verified emails on the account whose key made the signature. So a verified commit does not assert that commit.author.email belongs to anybody in particular, and a script comparing author names to a roster is reading the one field a signature was never about.

The linked account is a separate field again. Alongside commit.author and commit.committer — the raw git strings — the response carries top-level author and committer objects, which are the GitHub accounts those emails resolve to, or null when they resolve to nothing. A commit whose top-level author is null was written by an email GitHub does not recognise. That is worth reporting and it is still not the signature.

reason sorts into groups with different repairs. valid is the only value that accompanies verified: true. The rest fall into four families and conflating them produces bad alerts: nobody signed (unsigned); a signature exists and fails cryptographically (invalid, malformed_signature, expired_key, not_signing_key, unknown_signature_type); a signature is fine but the identity is not linked to an account (unknown_key, no_user, unverified_email, bad_email); and GitHub could not perform the check (gpgverify_error, gpgverify_unavailable). The third family is usually a person who never uploaded their public key, which is a five-minute fix. The fourth is not about your repository at all.

Verification is recorded, not recomputed on read. GitHub stores the verification result, so a commit signed with a key that has since been rotated or revoked stays verified, and a commit whose author has left the organization stays verified. That is the intended behaviour and it means "verified" is a statement about the moment of signing, which verified_at dates. It is not a claim that the key is still trusted today.

A report is not an enforcement. GET /repos/{owner}/{repo}/rules/branches/{branch} returns the rules actually in force on a branch, and required_signatures is the one that makes unsigned pushes bounce. Reading it turns "our history happens to be signed" into "unsigned pushes are rejected", and the gap between those two sentences is the entire value of this note. The script reads the rule; it does not create one.

The fix, as a flow

The whole diagnosis is one list read twice: once by the check that was written and once by the check that was meant. The first reads a string the committing client chose and authenticates nothing; the second reads the verification object GitHub produced. Running both over the same commits turns an argument about policy into a count of commits where they disagree, in both directions. Then the reason field is sorted into families, because a good signature from an unregistered key and a signature that failed to verify are not the same finding, and GitHub being unable to check at all is not a finding about your repository. Last comes the rule, since a report describes commits that exist and a rule is what stops the next one.

One verification object, fivefieldsreason before verifiedUnsigned or rejectedthe finding the policy exists forGood key, no accountupload a public key, nothing elseGitHub could not checkan outage, not a violationObject absent entirelyunknown, and never counted either wayVerified and a rule requires itthe only combination that binds
Rows are values of one field. Two of them mean unknown, which is the answer a boolean was never able to carry.

How to fix it

Say out loud which field your current check reads

Before running anything: open the existing compliance script and find the expression it asserts on. If the words author or committer appear and verification does not, you do not have a signature check, and no amount of tuning will turn it into one. The script below takes an --author-allowlist precisely so it can run both checks side by side and count the commits on which they disagree.

Pull the commits and normalise the verification object

One page of GET /repos/{owner}/{repo}/commits?per_page=100 per --pages, against --ref if you want a specific branch. Each commit is normalised into present/absent, verified, reason and whether a signature blob is there at all, so that a missing object becomes an explicit verification-absent rather than a falsy value flowing into a boolean.

Sort by reason, not by the boolean

The tally is by family: verified, unsigned, signature rejected, identity not linked, and GitHub could not check. The last one is deliberately kept out of the violation count. An outage in the verification service is not a compliance failure in your repository, and a check that treats it as one will page somebody at three in the morning about somebody else's incident.

Count the disagreements between the two checks

With --author-allowlist alice@example.com,bob@example.com the script runs the naive author test and the signature test over the same commits and prints every commit where they differ. Two columns matter: commits the author test passes and the signature test does not, which is what your policy has been missing, and commits the signature test passes and the author test does not, which is what it has been falsely flagging.

Read the rule that would have enforced it

--branch main reads GET /repos/{owner}/{repo}/rules/branches/main and reports whether a required_signatures rule is active. No rule means the history is a coincidence. The script prints the repair as a sentence for somebody with admin to action; it does not create rulesets, and a diagnostic that could add a branch rule would be a worse thing to hold a token for than the problem it solves.

How to check it worked

After the key is registered and the rule is switched on, the same run reports no unlinked identities and an active rule.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_commit_signatures.py acme/payments \
    --pages 2 --branch main --author-allowlist alice@example.com,bob@example.com
# read cost: 3 REST request(s) against the core hourly quota
# 143 commit(s) read from acme/payments
# verified: 138  unsigned: 0  signature-rejected: 0  identity-not-linked: 5
#   github-could-not-check: 0  verification-absent: 0
# identity-not-linked is not a bad signature: 5 commit(s) carry a good
#   signature from a key that is not registered to any GitHub account.
# author-check-disagreement: 5 commit(s) the author allowlist passed and the
#   signature check did not. That gap is what the policy has been missing.
# enforcement: no-rule — no required_signatures rule is active on main, so the
#   verified history is a description of past behaviour and not a constraint.
# repair: ask the key owners to add their public keys to their GitHub accounts,
#   and ask an admin of acme/payments to add a ruleset requiring signed commits
#   on main. Nothing here writes: this script only reads.

The full code

One live call per page of commits, plus one for the branch rules, and everything that decides anything is pure. That split matters more here than usual because the interesting commits are ones you cannot easily manufacture: a commit whose key was never registered, one GitHub failed to check, one from an endpoint that omitted the object entirely. Those are fixtures, and the classifier they feed is a table lookup with an explicit unknown branch rather than a chain of truthiness tests.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_commit_signatures.py
"""Report what a repository's commit signatures actually say.

Read only. One GET per page of commits and one for the branch rules. Nothing
is signed, no ruleset is created, and no commit is touched: where a rule is
missing the script prints the request for an admin to action.

The point of the note: verification.verified is one field of five, and reading
it alone throws away the difference between "nobody signed this", "somebody
signed it badly", "the signature is good and the key is not registered" and
"GitHub could not check". Those have four different repairs. A verification
object that is absent is a fifth state and it is not a false one.

What this can and cannot see: GitHub records the verification result, so this
reports what GitHub concluded at signing time, dated by verified_at. It does
not re-verify anything locally and it cannot tell you whether the signing key
is still trusted today.

Environment:

    GITHUB_TOKEN    a read-only token that can see the repository
"""
import argparse
import json
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_commit_signatures")

API = "https://api.github.com"
UA = "github-commit-signatures/1.0"

# Every documented value of verification.reason, mapped to the family whose
# repair it shares. Written as a table rather than a chain of conditionals so
# that a reason GitHub adds later lands in "unknown-reason" and is reported,
# instead of silently taking the else branch of somebody's if.
REASONS = {
    "valid": ("verified",
              "the signature was checked and the committer identity resolved."),
    "unsigned": ("unsigned",
                 "the commit object carries no signature at all."),
    "invalid": ("signature-rejected",
                "a signature is present and did not verify against the key."),
    "malformed_signature": ("signature-rejected",
                            "the signature could not be parsed."),
    "expired_key": ("signature-rejected",
                    "the key that made the signature has expired."),
    "not_signing_key": ("signature-rejected",
                        "the key is not flagged for signing."),
    "unknown_signature_type": ("signature-rejected",
                               "the signature is not a type GitHub verifies."),
    "unknown_key": ("identity-not-linked",
                    "the key that made the signature is not registered to any "
                    "GitHub account. The cryptography is fine; the account "
                    "link is missing."),
    "no_user": ("identity-not-linked",
                "no GitHub account owns the committer email address."),
    "unverified_email": ("identity-not-linked",
                         "the committer email belongs to an account and has "
                         "not been verified on it."),
    "bad_email": ("identity-not-linked",
                  "the committer email is not among the identities on the key."),
    "gpgverify_error": ("github-could-not-check",
                        "GitHub's verification service errored. This is not a "
                        "statement about the commit."),
    "gpgverify_unavailable": ("github-could-not-check",
                              "GitHub's verification service was unavailable. "
                              "This is not a statement about the commit."),
}

# Order the tally is printed in, and the order the grade considers them.
FAMILIES = ("verified", "unsigned", "signature-rejected", "identity-not-linked",
            "github-could-not-check", "verification-absent", "unknown-reason")

# Families that are a real finding about this repository. Deliberately excludes
# github-could-not-check: an outage in GitHub's checker is somebody else's
# incident and paging on it teaches people to ignore the alert.
VIOLATIONS = ("unsigned", "signature-rejected")


def read_cost(pages, with_rules):
    """REST requests this run will spend. Pure. Printed before any are spent."""
    return max(1, int(pages)) + (1 if with_rules else 0)


def verification_of(commit):
    """Normalise one commit's verification object. Pure.

    Returns a dict with an explicit `present` flag. Every caller downstream
    branches on that flag first, so an endpoint that omits the object can never
    be read as a commit that failed verification, which is the mistake the
    whole note is about.
    """
    inner = (commit or {}).get("commit") or {}
    raw = inner.get("verification")
    if not isinstance(raw, dict):
        return {"present": False, "verified": None, "reason": None,
                "has_signature": False, "verified_at": None}
    signature = raw.get("signature")
    return {
        "present": True,
        "verified": raw.get("verified"),
        "reason": raw.get("reason"),
        "has_signature": bool(signature),
        "verified_at": raw.get("verified_at"),
    }


def family_of(verification):
    """Sort one normalised verification into its family. Pure. (family, detail).

    The boolean is checked against the reason rather than trusted on its own:
    the only reason that accompanies a true is `valid`, so a true beside any
    other reason is a shape this script does not recognise and says so.
    """
    if not verification.get("present"):
        return ("verification-absent",
                "this payload carried no verification object. That is unknown, "
                "not unsigned, and it must not be counted as either.")
    reason = verification.get("reason")
    verified = verification.get("verified")
    if reason is None:
        return ("unknown-reason",
                "the verification object has no reason field, so the boolean "
                "is the only evidence and it is not enough to act on.")
    known = REASONS.get(str(reason))
    if known is None:
        return ("unknown-reason",
                "reason %r is not one this script knows. Report it rather than "
                "letting it fall into a default." % reason)
    family, detail = known
    if family == "verified" and verified is not True:
        return ("unknown-reason",
                "reason is valid and verified is not true, which is a shape "
                "GitHub does not normally produce. Treat it as unknown.")
    if family != "verified" and verified is True:
        return ("unknown-reason",
                "verified is true beside reason %r. Only valid accompanies a "
                "true, so this pair is not readable." % reason)
    return (family, detail)


def identity_split(commit):
    """What the commit says about who wrote it. Pure. (state, detail).

    Reported alongside the signature and never instead of it. The author and
    committer strings are set by the client and are not authenticated by
    anything; the top-level author and committer objects are the GitHub
    accounts those emails resolve to, or null.
    """
    inner = (commit or {}).get("commit") or {}
    author_email = ((inner.get("author") or {}).get("email")) or ""
    committer_email = ((inner.get("committer") or {}).get("email")) or ""
    linked_author = (commit or {}).get("author")
    linked_committer = (commit or {}).get("committer")
    if not author_email and not committer_email:
        return ("no-emails",
                "the commit carries no author or committer email to compare.")
    if author_email.lower() != committer_email.lower():
        return ("author-differs-from-committer",
                "the author and the committer are different identities, and a "
                "signature speaks for the committer. A verified commit here "
                "does not assert the author consented to it.")
    if linked_author is None or linked_committer is None:
        return ("email-resolves-to-no-account",
                "an email on this commit resolves to no GitHub account, so "
                "there is no account for a signature to be matched against.")
    return ("author-is-committer",
            "author and committer are the same identity and both resolve to "
            "GitHub accounts.")


def author_allowlist_pass(commit, allowed):
    """The check people actually wrote. Pure. True, False or None.

    Kept in the script on purpose so the two policies can be run over the same
    commits and the disagreement counted. It reads commit.author.email, which
    is a string the committing client chose, and it authenticates nothing.
    """
    if not allowed:
        return None
    inner = (commit or {}).get("commit") or {}
    email = (((inner.get("author") or {}).get("email")) or "").lower()
    return email in {str(a).strip().lower() for a in allowed if str(a).strip()}


def signature_pass(commit):
    """The check the policy meant. Pure. True, False or None for unknown.

    None is a first-class answer here. A commit GitHub could not check, and a
    commit whose verification object never arrived, are both unknown, and
    collapsing either into a boolean is how a policy ends up fail-open or
    fail-noisy depending on which default somebody picked.
    """
    family, _ = family_of(verification_of(commit))
    if family == "verified":
        return True
    if family in VIOLATIONS or family == "identity-not-linked":
        return False
    return None


def disagreements(commits, allowed):
    """Where the two checks differ, commit by commit. Pure. List of dicts."""
    out = []
    for commit in commits or []:
        naive = author_allowlist_pass(commit, allowed)
        careful = signature_pass(commit)
        if naive is None or naive == careful:
            continue
        out.append({
            "sha": (commit or {}).get("sha"),
            "author_check": naive,
            "signature_check": careful,
            "gap": "author-passed-signature-did-not" if naive
                   else "signature-passed-author-did-not",
        })
    return out


def tally(commits):
    """Count the families across a list of commits. Pure. dict."""
    counts = {name: 0 for name in FAMILIES}
    for commit in commits or []:
        family, _ = family_of(verification_of(commit))
        counts[family] = counts.get(family, 0) + 1
    return counts


def enforcement_from_rules(rules, readable=True):
    """Is a signature rule actually in force on the branch. Pure. (state, detail).

    An unreadable answer is its own state. Saying "no rule" when the rules
    could not be read would tell somebody their branch is unprotected on the
    strength of a permission problem.
    """
    if not readable:
        return ("rule-unreadable",
                "the branch rules could not be read with this token, so "
                "whether signatures are enforced is unknown. That is not the "
                "same as unenforced.")
    if not isinstance(rules, list):
        return ("rule-unreadable",
                "the rules endpoint did not return a list, so nothing can be "
                "concluded about enforcement.")
    for rule in rules:
        if isinstance(rule, dict) and rule.get("type") == "required_signatures":
            return ("enforced",
                    "a required_signatures rule is active on this branch, so "
                    "an unsigned push is rejected rather than reported.")
    return ("no-rule",
            "no required_signatures rule is active on this branch. Whatever "
            "the history shows, the next push is free to be unsigned.")


def grade(counts, enforcement_state):
    """The finding, in one word. Pure. (state, detail)."""
    counts = counts or {}
    if counts.get("verification-absent"):
        return ("verification-unknown",
                "%d commit(s) arrived with no verification object. Until that "
                "is understood, no percentage from this run is trustworthy."
                % counts["verification-absent"])
    violations = sum(counts.get(name, 0) for name in VIOLATIONS)
    if violations:
        return ("unsigned-or-rejected-present",
                "%d commit(s) are unsigned or carry a signature that did not "
                "verify. This is the finding a signed-commit policy exists to "
                "produce." % violations)
    if counts.get("identity-not-linked"):
        return ("identity-not-linked-present",
                "%d commit(s) carry a good signature from a key no GitHub "
                "account claims. Nothing is cryptographically wrong; a public "
                "key needs uploading." % counts["identity-not-linked"])
    if counts.get("unknown-reason"):
        return ("unreadable-verification",
                "%d commit(s) have a verification shape this script does not "
                "recognise. Report them rather than grading them."
                % counts["unknown-reason"])
    if counts.get("github-could-not-check"):
        return ("checker-unavailable",
                "%d commit(s) could not be checked by GitHub. That is an "
                "outage, not a violation, and re-reading later is the whole "
                "response." % counts["github-could-not-check"])
    if enforcement_state == "enforced":
        return ("verified-and-enforced",
                "every commit read is verified and a rule requires it, which "
                "is the only combination that is a guarantee.")
    return ("verified-but-not-enforced",
            "every commit read is verified and nothing requires it. That is a "
            "description of past behaviour, not a constraint on the next push.")


def repair(state, enforcement_state, repo, branch):
    """The sentence a reader has to act on. Pure. Nothing here is executed."""
    lines = []
    if state == "unsigned-or-rejected-present":
        lines.append("find the commits listed as unsigned or signature-rejected "
                     "and get them re-signed or reverted")
    if state == "identity-not-linked-present":
        lines.append("ask the key owners to add their public keys to their "
                     "GitHub accounts; the signatures are already good")
    if state == "verification-unknown":
        lines.append("find out why a verification object was missing before "
                     "reporting any signing percentage from this repository")
    if state == "checker-unavailable":
        lines.append("re-read later: GitHub could not check these commits and "
                     "that is not a fact about your repository")
    if enforcement_state == "no-rule":
        lines.append("ask an admin of %s to add a ruleset requiring signed "
                     "commits on %s, so unsigned pushes are rejected rather "
                     "than reported" % (repo, branch or "the default branch"))
    if enforcement_state == "rule-unreadable":
        lines.append("re-run with a token that can read branch rules on %s, "
                     "because unreadable is not unenforced" % repo)
    if not lines:
        lines.append("nothing to repair from this reading")
    return ". ".join(lines) + ". Nothing here writes."


def get(session, path):
    """One GET. Returns the response object."""
    response = session.get(API + path, timeout=30)
    if response.status_code == 401:
        log.warning("401 on %s: the credential was not accepted, which is a "
                    "different note", path)
    return response


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("repo", help="owner/name")
    parser.add_argument("--ref", help="branch, tag or sha to walk from")
    parser.add_argument("--pages", type=int, default=1,
                        help="pages of 100 commits to read, default 1")
    parser.add_argument("--branch",
                        help="read the rules in force on this branch")
    parser.add_argument("--author-allowlist", default="",
                        help="comma-separated author emails, to run the naive "
                             "check beside the real one")
    args = parser.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    log.info("read cost: %d REST request(s) against the core hourly quota",
             read_cost(args.pages, bool(args.branch)))

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        # GitHub refuses requests with no User-Agent before it looks at auth.
        "User-Agent": UA,
    })

    commits = []
    for page in range(1, max(1, args.pages) + 1):
        path = "/repos/%s/commits?per_page=100&page=%d" % (args.repo, page)
        if args.ref:
            path += "&sha=" + args.ref
        response = get(session, path)
        if response.status_code != 200:
            log.error("GET %s -> HTTP %s; stopping", path, response.status_code)
            break
        batch = response.json()
        if not isinstance(batch, list) or not batch:
            break
        commits.extend(batch)
    log.info("%d commit(s) read from %s", len(commits), args.repo)

    counts = tally(commits)
    log.info("verified: %d  unsigned: %d  signature-rejected: %d  "
             "identity-not-linked: %d  github-could-not-check: %d  "
             "verification-absent: %d",
             counts["verified"], counts["unsigned"], counts["signature-rejected"],
             counts["identity-not-linked"], counts["github-could-not-check"],
             counts["verification-absent"])

    allowed = [part for part in args.author_allowlist.split(",") if part.strip()]
    gaps = disagreements(commits, allowed)
    if allowed:
        missed = [g for g in gaps if g["gap"] == "author-passed-signature-did-not"]
        log.info("author-check-disagreement: %d commit(s) the author allowlist "
                 "passed and the signature check did not", len(missed))

    splits = {}
    for commit in commits:
        state, _ = identity_split(commit)
        splits[state] = splits.get(state, 0) + 1
    log.info("identity: %s", splits)

    rules, readable = None, False
    if args.branch:
        path = "/repos/%s/rules/branches/%s" % (args.repo, args.branch)
        response = get(session, path)
        readable = response.status_code == 200
        rules = response.json() if readable else None
    enforcement_state, enforcement_detail = enforcement_from_rules(
        rules, readable if args.branch else False)
    if args.branch:
        log.info("enforcement: %s. %s", enforcement_state, enforcement_detail)

    state, detail = grade(counts, enforcement_state)
    log.info("%s: %s", state, detail)
    fix = repair(state, enforcement_state if args.branch else "not-read",
                 args.repo, args.branch)
    log.info("repair: %s", fix)

    print(json.dumps({
        "repository": args.repo,
        "commits_read": len(commits),
        "counts": counts,
        "identity_split": splits,
        "disagreements": gaps[:20],
        "disagreement_count": len(gaps),
        "enforcement_state": enforcement_state if args.branch else "not-read",
        "state": state,
        "detail": detail,
        "repair": fix,
    }, indent=2, default=str))
    return 1 if state in ("unsigned-or-rejected-present", "verification-unknown",
                          "identity-not-linked-present",
                          "verified-but-not-enforced") else 0


if __name__ == "__main__":
    sys.exit(main())
github-commit-signatures.mjs
/**
 * Report what a repository's commit signatures actually say.
 *
 * Read only. One GET per page of commits and one for the branch rules.
 * Nothing is signed and no ruleset is created: where a rule is missing the
 * script prints the request for an admin to action.
 *
 * verification.verified is one field of five. Reading it alone throws away the
 * difference between unsigned, badly signed, well signed by an unregistered
 * key, and not checked at all. A missing verification object is a fifth state
 * and it is not a false one.
 *
 * Environment:
 *   GITHUB_TOKEN      a read-only token that can see the repository
 *   GITHUB_REPO       owner/name
 *   GITHUB_REF        optional branch, tag or sha to walk from
 *   GITHUB_PAGES      optional pages of 100 commits, default 1
 *   GITHUB_BRANCH     optional branch whose rules to read
 *   GITHUB_AUTHORS    optional comma-separated author allowlist
 */
const API = 'https://api.github.com';
const UA = 'github-commit-signatures/1.0';

/** Every documented reason, mapped to the family whose repair it shares. */
export const REASONS = {
  valid: ['verified', 'the signature was checked and the committer identity resolved.'],
  unsigned: ['unsigned', 'the commit object carries no signature at all.'],
  invalid: ['signature-rejected', 'a signature is present and did not verify against the key.'],
  malformed_signature: ['signature-rejected', 'the signature could not be parsed.'],
  expired_key: ['signature-rejected', 'the key that made the signature has expired.'],
  not_signing_key: ['signature-rejected', 'the key is not flagged for signing.'],
  unknown_signature_type: ['signature-rejected', 'the signature is not a type GitHub verifies.'],
  unknown_key: ['identity-not-linked', 'the key that made the signature is not '
    + 'registered to any GitHub account. The cryptography is fine; the account '
    + 'link is missing.'],
  no_user: ['identity-not-linked', 'no GitHub account owns the committer email address.'],
  unverified_email: ['identity-not-linked', 'the committer email belongs to an '
    + 'account and has not been verified on it.'],
  bad_email: ['identity-not-linked', 'the committer email is not among the '
    + 'identities on the key.'],
  gpgverify_error: ['github-could-not-check', "GitHub's verification service "
    + 'errored. This is not a statement about the commit.'],
  gpgverify_unavailable: ['github-could-not-check', "GitHub's verification "
    + 'service was unavailable. This is not a statement about the commit.'],
};

export const FAMILIES = ['verified', 'unsigned', 'signature-rejected',
  'identity-not-linked', 'github-could-not-check', 'verification-absent',
  'unknown-reason'];

/** Families that are a finding about this repository. Excludes the outage one. */
export const VIOLATIONS = ['unsigned', 'signature-rejected'];

/** REST requests this run will spend. Pure. */
export function readCost(pages, withRules) {
  return Math.max(1, Number(pages) || 1) + (withRules ? 1 : 0);
}

/** Normalise one commit's verification object. Pure. */
export function verificationOf(commit) {
  const inner = (commit && commit.commit) || {};
  const raw = inner.verification;
  if (!raw || typeof raw !== 'object') {
    return { present: false, verified: null, reason: null, hasSignature: false, verifiedAt: null };
  }
  return {
    present: true,
    verified: raw.verified,
    reason: raw.reason,
    hasSignature: Boolean(raw.signature),
    verifiedAt: raw.verified_at ?? null,
  };
}

/** Sort one normalised verification into its family. Pure. [family, detail]. */
export function familyOf(verification) {
  if (!verification || !verification.present) {
    return ['verification-absent', 'this payload carried no verification '
      + 'object. That is unknown, not unsigned, and it must not be counted as '
      + 'either.'];
  }
  const { reason, verified } = verification;
  if (reason === null || reason === undefined) {
    return ['unknown-reason', 'the verification object has no reason field, so '
      + 'the boolean is the only evidence and it is not enough to act on.'];
  }
  const known = REASONS[String(reason)];
  if (!known) {
    return ['unknown-reason', `reason ${JSON.stringify(reason)} is not one this `
      + 'script knows. Report it rather than letting it fall into a default.'];
  }
  const [family, detail] = known;
  if (family === 'verified' && verified !== true) {
    return ['unknown-reason', 'reason is valid and verified is not true, which '
      + 'is a shape GitHub does not normally produce. Treat it as unknown.'];
  }
  if (family !== 'verified' && verified === true) {
    return ['unknown-reason', `verified is true beside reason `
      + `${JSON.stringify(reason)}. Only valid accompanies a true, so this pair `
      + 'is not readable.'];
  }
  return [family, detail];
}

/** What the commit says about who wrote it. Pure. [state, detail]. */
export function identitySplit(commit) {
  const inner = (commit && commit.commit) || {};
  const authorEmail = ((inner.author && inner.author.email) || '');
  const committerEmail = ((inner.committer && inner.committer.email) || '');
  const linkedAuthor = commit ? commit.author : null;
  const linkedCommitter = commit ? commit.committer : null;
  if (!authorEmail && !committerEmail) {
    return ['no-emails', 'the commit carries no author or committer email to compare.'];
  }
  if (authorEmail.toLowerCase() !== committerEmail.toLowerCase()) {
    return ['author-differs-from-committer', 'the author and the committer are '
      + 'different identities, and a signature speaks for the committer. A '
      + 'verified commit here does not assert the author consented to it.'];
  }
  if (!linkedAuthor || !linkedCommitter) {
    return ['email-resolves-to-no-account', 'an email on this commit resolves '
      + 'to no GitHub account, so there is no account for a signature to be '
      + 'matched against.'];
  }
  return ['author-is-committer', 'author and committer are the same identity '
    + 'and both resolve to GitHub accounts.'];
}

/** The check people actually wrote. Pure. true, false or null. */
export function authorAllowlistPass(commit, allowed) {
  if (!allowed || allowed.length === 0) return null;
  const inner = (commit && commit.commit) || {};
  const email = (((inner.author && inner.author.email) || '')).toLowerCase();
  const set = new Set(allowed.map((a) => String(a).trim().toLowerCase()).filter(Boolean));
  return set.has(email);
}

/** The check the policy meant. Pure. true, false or null for unknown. */
export function signaturePass(commit) {
  const [family] = familyOf(verificationOf(commit));
  if (family === 'verified') return true;
  if (VIOLATIONS.includes(family) || family === 'identity-not-linked') return false;
  return null;
}

/** Where the two checks differ, commit by commit. Pure. */
export function disagreements(commits, allowed) {
  const out = [];
  for (const commit of commits || []) {
    const naive = authorAllowlistPass(commit, allowed);
    const careful = signaturePass(commit);
    if (naive === null || naive === careful) continue;
    out.push({
      sha: commit ? commit.sha : null,
      author_check: naive,
      signature_check: careful,
      gap: naive ? 'author-passed-signature-did-not' : 'signature-passed-author-did-not',
    });
  }
  return out;
}

/** Count the families across a list of commits. Pure. */
export function tally(commits) {
  const counts = {};
  for (const name of FAMILIES) counts[name] = 0;
  for (const commit of commits || []) {
    const [family] = familyOf(verificationOf(commit));
    counts[family] = (counts[family] || 0) + 1;
  }
  return counts;
}

/** Is a signature rule actually in force on the branch. Pure. [state, detail]. */
export function enforcementFromRules(rules, readable = true) {
  if (!readable) {
    return ['rule-unreadable', 'the branch rules could not be read with this '
      + 'token, so whether signatures are enforced is unknown. That is not the '
      + 'same as unenforced.'];
  }
  if (!Array.isArray(rules)) {
    return ['rule-unreadable', 'the rules endpoint did not return a list, so '
      + 'nothing can be concluded about enforcement.'];
  }
  for (const rule of rules) {
    if (rule && rule.type === 'required_signatures') {
      return ['enforced', 'a required_signatures rule is active on this branch, '
        + 'so an unsigned push is rejected rather than reported.'];
    }
  }
  return ['no-rule', 'no required_signatures rule is active on this branch. '
    + 'Whatever the history shows, the next push is free to be unsigned.'];
}

/** The finding, in one word. Pure. [state, detail]. */
export function grade(counts, enforcementState) {
  const c = counts || {};
  if (c['verification-absent']) {
    return ['verification-unknown', `${c['verification-absent']} commit(s) `
      + 'arrived with no verification object. Until that is understood, no '
      + 'percentage from this run is trustworthy.'];
  }
  const violations = VIOLATIONS.reduce((n, k) => n + (c[k] || 0), 0);
  if (violations) {
    return ['unsigned-or-rejected-present', `${violations} commit(s) are `
      + 'unsigned or carry a signature that did not verify. This is the finding '
      + 'a signed-commit policy exists to produce.'];
  }
  if (c['identity-not-linked']) {
    return ['identity-not-linked-present', `${c['identity-not-linked']} commit(s) `
      + 'carry a good signature from a key no GitHub account claims. Nothing is '
      + 'cryptographically wrong; a public key needs uploading.'];
  }
  if (c['unknown-reason']) {
    return ['unreadable-verification', `${c['unknown-reason']} commit(s) have a `
      + 'verification shape this script does not recognise. Report them rather '
      + 'than grading them.'];
  }
  if (c['github-could-not-check']) {
    return ['checker-unavailable', `${c['github-could-not-check']} commit(s) `
      + 'could not be checked by GitHub. That is an outage, not a violation, '
      + 'and re-reading later is the whole response.'];
  }
  if (enforcementState === 'enforced') {
    return ['verified-and-enforced', 'every commit read is verified and a rule '
      + 'requires it, which is the only combination that is a guarantee.'];
  }
  return ['verified-but-not-enforced', 'every commit read is verified and '
    + 'nothing requires it. That is a description of past behaviour, not a '
    + 'constraint on the next push.'];
}

/** The sentence a reader has to act on. Pure. Nothing here is executed. */
export function repair(state, enforcementState, repo, branch) {
  const lines = [];
  if (state === 'unsigned-or-rejected-present') {
    lines.push('find the commits listed as unsigned or signature-rejected and '
      + 'get them re-signed or reverted');
  }
  if (state === 'identity-not-linked-present') {
    lines.push('ask the key owners to add their public keys to their GitHub '
      + 'accounts; the signatures are already good');
  }
  if (state === 'verification-unknown') {
    lines.push('find out why a verification object was missing before reporting '
      + 'any signing percentage from this repository');
  }
  if (state === 'checker-unavailable') {
    lines.push('re-read later: GitHub could not check these commits and that is '
      + 'not a fact about your repository');
  }
  if (enforcementState === 'no-rule') {
    lines.push(`ask an admin of ${repo} to add a ruleset requiring signed `
      + `commits on ${branch || 'the default branch'}, so unsigned pushes are `
      + 'rejected rather than reported');
  }
  if (enforcementState === 'rule-unreadable') {
    lines.push(`re-run with a token that can read branch rules on ${repo}, `
      + 'because unreadable is not unenforced');
  }
  if (lines.length === 0) lines.push('nothing to repair from this reading');
  return `${lines.join('. ')}. Nothing here writes.`;
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const repo = process.env.GITHUB_REPO;
  if (!token || !repo) {
    console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPO');
    process.exitCode = 2;
    return;
  }
  const pages = Number(process.env.GITHUB_PAGES || '1') || 1;
  const branch = process.env.GITHUB_BRANCH || '';
  const ref = process.env.GITHUB_REF || '';
  const allowed = (process.env.GITHUB_AUTHORS || '').split(',')
    .map((s) => s.trim()).filter(Boolean);
  console.log(`read cost: ${readCost(pages, Boolean(branch))} REST request(s) `
    + 'against the core hourly quota');

  const commits = [];
  for (let page = 1; page <= Math.max(1, pages); page += 1) {
    let path = `/repos/${repo}/commits?per_page=100&page=${page}`;
    if (ref) path += `&sha=${ref}`;
    const response = await fetch(`${API}${path}`, { headers: headers(token) });
    if (response.status !== 200) {
      console.error(`GET ${path} -> HTTP ${response.status}; stopping`);
      break;
    }
    const batch = await response.json();
    if (!Array.isArray(batch) || batch.length === 0) break;
    commits.push(...batch);
  }
  console.log(`${commits.length} commit(s) read from ${repo}`);

  const counts = tally(commits);
  console.log(`verified: ${counts.verified}  unsigned: ${counts.unsigned}  `
    + `signature-rejected: ${counts['signature-rejected']}  `
    + `identity-not-linked: ${counts['identity-not-linked']}  `
    + `github-could-not-check: ${counts['github-could-not-check']}  `
    + `verification-absent: ${counts['verification-absent']}`);

  const gaps = disagreements(commits, allowed);
  if (allowed.length) {
    const missed = gaps.filter((g) => g.gap === 'author-passed-signature-did-not');
    console.log(`author-check-disagreement: ${missed.length} commit(s) the `
      + 'author allowlist passed and the signature check did not');
  }

  const splits = {};
  for (const commit of commits) {
    const [state] = identitySplit(commit);
    splits[state] = (splits[state] || 0) + 1;
  }
  console.log(`identity: ${JSON.stringify(splits)}`);

  let rules = null;
  let readable = false;
  if (branch) {
    const response = await fetch(`${API}/repos/${repo}/rules/branches/${branch}`,
      { headers: headers(token) });
    readable = response.status === 200;
    rules = readable ? await response.json() : null;
  }
  const [enforcementState, enforcementDetail] = enforcementFromRules(
    rules, branch ? readable : false,
  );
  if (branch) console.log(`enforcement: ${enforcementState}. ${enforcementDetail}`);

  const [state, detail] = grade(counts, enforcementState);
  console.log(`${state}: ${detail}`);
  const fix = repair(state, branch ? enforcementState : 'not-read', repo, branch);
  console.log(`repair: ${fix}`);

  console.log(JSON.stringify({
    repository: repo,
    commits_read: commits.length,
    counts,
    identity_split: splits,
    disagreements: gaps.slice(0, 20),
    disagreement_count: gaps.length,
    enforcement_state: branch ? enforcementState : 'not-read',
    state,
    detail,
    repair: fix,
  }, null, 2));
  process.exitCode = ['unsigned-or-rejected-present', 'verification-unknown',
    'identity-not-linked-present', 'verified-but-not-enforced'].includes(state) ? 1 : 0;
}

// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The fixtures are commits, because the commits worth handling are the ones you cannot easily make: one signed with a key nobody registered, one GitHub failed to check, one from an endpoint that omitted the verification object. The first group asserts that every documented reason lands in a family and that an undocumented one is reported rather than defaulted. The second is the note's headline: the naive author check and the signature check are run over the same list and the disagreement is counted in both directions. The last group protects the two states that must never collapse into a boolean, absent and could-not-check.

test_github_commit_signatures.py
from github_commit_signatures import (
    FAMILIES, REASONS, VIOLATIONS, author_allowlist_pass, disagreements,
    enforcement_from_rules, family_of, grade, identity_split, read_cost,
    repair, signature_pass, tally, verification_of,
)


def commit(sha, reason=None, verified=None, present=True, signature="-----BEGIN-----",
           author="alice@example.com", committer=None, linked=True):
    inner = {
        "author": {"email": author, "name": "Alice"},
        "committer": {"email": committer or author, "name": "Alice"},
    }
    if present:
        inner["verification"] = {"verified": verified, "reason": reason,
                                 "signature": signature, "payload": "tree 1",
                                 "verified_at": "2026-01-01T00:00:00Z"}
    return {"sha": sha, "commit": inner,
            "author": {"login": "alice"} if linked else None,
            "committer": {"login": "alice"} if linked else None}


SIGNED = commit("aaa", "valid", True)
UNSIGNED = commit("bbb", "unsigned", False, signature=None)
BAD = commit("ccc", "invalid", False)
UNREGISTERED = commit("ddd", "unknown_key", False)
OUTAGE = commit("eee", "gpgverify_unavailable", False)
ABSENT = commit("fff", present=False)


def test_every_documented_reason_has_a_family_and_a_sentence():
    for reason, (family, detail) in REASONS.items():
        assert family in FAMILIES, reason
        assert detail.endswith("."), reason
    assert REASONS["valid"][0] == "verified"


def test_the_four_kinds_of_false_are_four_different_findings():
    assert family_of(verification_of(UNSIGNED))[0] == "unsigned"
    assert family_of(verification_of(BAD))[0] == "signature-rejected"
    assert family_of(verification_of(UNREGISTERED))[0] == "identity-not-linked"
    assert family_of(verification_of(OUTAGE))[0] == "github-could-not-check"
    # And the one that matters most: a good signature nobody registered is not
    # a bad signature, and the sentence says so.
    assert "cryptography is fine" in family_of(verification_of(UNREGISTERED))[1]


def test_a_missing_verification_object_is_unknown_and_not_false():
    normalised = verification_of(ABSENT)
    assert normalised["present"] is False
    assert normalised["verified"] is None
    family, detail = family_of(normalised)
    assert family == "verification-absent"
    assert "not unsigned" in detail
    assert signature_pass(ABSENT) is None


def test_an_outage_is_unknown_rather_than_a_violation():
    assert signature_pass(OUTAGE) is None
    assert "github-could-not-check" not in VIOLATIONS
    state, detail = grade(tally([SIGNED, OUTAGE]), "no-rule")
    assert state == "checker-unavailable"
    assert "not a violation" in detail


def test_a_reason_github_adds_later_is_reported_not_defaulted():
    future = commit("ggg", "quantum_key_rotated", False)
    family, detail = family_of(verification_of(future))
    assert family == "unknown-reason"
    assert "rather than letting it fall into a default" in detail


def test_verified_true_beside_the_wrong_reason_is_not_believed():
    weird = commit("hhh", "unsigned", True)
    assert family_of(verification_of(weird))[0] == "unknown-reason"
    inverted = commit("iii", "valid", False)
    assert family_of(verification_of(inverted))[0] == "unknown-reason"


def test_the_author_check_and_the_signature_check_disagree_in_both_directions():
    allowed = ["alice@example.com"]
    # Author on the list, unsigned: the gap the policy has been missing.
    missed = disagreements([UNSIGNED], allowed)
    assert missed[0]["gap"] == "author-passed-signature-did-not"
    # Signed by somebody not on the roster: falsely flagged by the old check.
    outsider = commit("jjj", "valid", True, author="carol@example.com")
    flagged = disagreements([outsider], allowed)
    assert flagged[0]["gap"] == "signature-passed-author-did-not"
    # And where the two agree there is nothing to report.
    assert disagreements([SIGNED], allowed) == []


def test_the_author_check_authenticates_nothing():
    # The whole point: an unsigned commit claiming an approved author sails
    # through the check most people wrote.
    forged = commit("kkk", "unsigned", False, signature=None,
                    author="alice@example.com")
    assert author_allowlist_pass(forged, ["alice@example.com"]) is True
    assert signature_pass(forged) is False
    assert author_allowlist_pass(forged, []) is None


def test_the_signature_speaks_for_the_committer_not_the_author():
    split = commit("lll", "valid", True, author="alice@example.com",
                   committer="bob@example.com")
    state, detail = identity_split(split)
    assert state == "author-differs-from-committer"
    assert "speaks for the committer" in detail
    assert identity_split(SIGNED)[0] == "author-is-committer"
    unlinked = commit("mmm", "valid", True, linked=False)
    assert identity_split(unlinked)[0] == "email-resolves-to-no-account"


def test_an_unreadable_rule_is_not_an_absent_rule():
    state, detail = enforcement_from_rules(None, readable=False)
    assert state == "rule-unreadable"
    assert "not the same as unenforced" in detail
    assert enforcement_from_rules([], readable=True)[0] == "no-rule"
    rules = [{"type": "deletion"}, {"type": "required_signatures"}]
    assert enforcement_from_rules(rules, readable=True)[0] == "enforced"


def test_a_verified_history_with_no_rule_is_not_a_guarantee():
    counts = tally([SIGNED, SIGNED])
    assert grade(counts, "no-rule")[0] == "verified-but-not-enforced"
    assert grade(counts, "enforced")[0] == "verified-and-enforced"
    assert "not a constraint" in grade(counts, "no-rule")[1]


def test_absent_verification_outranks_every_other_grade():
    # A run that could not see the field is not a run that found violations.
    counts = tally([UNSIGNED, ABSENT])
    assert grade(counts, "enforced")[0] == "verification-unknown"


def test_the_tally_covers_every_family():
    counts = tally([SIGNED, UNSIGNED, BAD, UNREGISTERED, OUTAGE, ABSENT])
    assert set(counts) >= set(FAMILIES)
    assert counts["verified"] == 1 and counts["unsigned"] == 1
    assert counts["verification-absent"] == 1
    assert tally([]) == {name: 0 for name in FAMILIES}


def test_the_repair_asks_a_human_and_writes_nothing():
    fix = repair("identity-not-linked-present", "no-rule", "acme/payments", "main")
    assert "add their public keys" in fix
    assert "ask an admin of acme/payments" in fix
    assert fix.endswith("Nothing here writes.")
    assert "unreadable is not unenforced" in repair(
        "verified-and-enforced", "rule-unreadable", "acme/payments", "main")


def test_the_read_cost_is_known_before_anything_is_spent():
    assert read_cost(1, False) == 1
    assert read_cost(3, True) == 4
    assert read_cost(0, False) == 1
github-commit-signatures.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  FAMILIES, REASONS, VIOLATIONS, authorAllowlistPass, disagreements,
  enforcementFromRules, familyOf, grade, identitySplit, readCost, repair,
  signaturePass, tally, verificationOf,
} from './github-commit-signatures.mjs';

function commit(sha, reason = null, verified = null, opts = {}) {
  const author = opts.author || 'alice@example.com';
  const inner = {
    author: { email: author, name: 'Alice' },
    committer: { email: opts.committer || author, name: 'Alice' },
  };
  if (opts.present !== false) {
    inner.verification = {
      verified, reason, signature: opts.signature ?? '-----BEGIN-----',
      payload: 'tree 1', verified_at: '2026-01-01T00:00:00Z',
    };
  }
  const linked = opts.linked === false ? null : { login: 'alice' };
  return { sha, commit: inner, author: linked, committer: linked };
}

const SIGNED = commit('aaa', 'valid', true);
const UNSIGNED = commit('bbb', 'unsigned', false, { signature: null });
const BAD = commit('ccc', 'invalid', false);
const UNREGISTERED = commit('ddd', 'unknown_key', false);
const OUTAGE = commit('eee', 'gpgverify_unavailable', false);
const ABSENT = commit('fff', null, null, { present: false });

test('every documented reason has a family and a sentence', () => {
  for (const [reason, [family, detail]] of Object.entries(REASONS)) {
    assert.ok(FAMILIES.includes(family), reason);
    assert.ok(detail.endsWith('.'), reason);
  }
  assert.equal(REASONS.valid[0], 'verified');
});

test('the four kinds of false are four different findings', () => {
  assert.equal(familyOf(verificationOf(UNSIGNED))[0], 'unsigned');
  assert.equal(familyOf(verificationOf(BAD))[0], 'signature-rejected');
  assert.equal(familyOf(verificationOf(UNREGISTERED))[0], 'identity-not-linked');
  assert.equal(familyOf(verificationOf(OUTAGE))[0], 'github-could-not-check');
  assert.match(familyOf(verificationOf(UNREGISTERED))[1], /cryptography is fine/);
});

test('a missing verification object is unknown and not false', () => {
  const normalised = verificationOf(ABSENT);
  assert.equal(normalised.present, false);
  assert.equal(normalised.verified, null);
  const [family, detail] = familyOf(normalised);
  assert.equal(family, 'verification-absent');
  assert.match(detail, /not unsigned/);
  assert.equal(signaturePass(ABSENT), null);
});

test('an outage is unknown rather than a violation', () => {
  assert.equal(signaturePass(OUTAGE), null);
  assert.ok(!VIOLATIONS.includes('github-could-not-check'));
  const [state, detail] = grade(tally([SIGNED, OUTAGE]), 'no-rule');
  assert.equal(state, 'checker-unavailable');
  assert.match(detail, /not a violation/);
});

test('a reason GitHub adds later is reported not defaulted', () => {
  const future = commit('ggg', 'quantum_key_rotated', false);
  const [family, detail] = familyOf(verificationOf(future));
  assert.equal(family, 'unknown-reason');
  assert.match(detail, /rather than letting it fall into a default/);
});

test('verified true beside the wrong reason is not believed', () => {
  assert.equal(familyOf(verificationOf(commit('hhh', 'unsigned', true)))[0], 'unknown-reason');
  assert.equal(familyOf(verificationOf(commit('iii', 'valid', false)))[0], 'unknown-reason');
});

test('the author check and the signature check disagree in both directions', () => {
  const allowed = ['alice@example.com'];
  assert.equal(disagreements([UNSIGNED], allowed)[0].gap, 'author-passed-signature-did-not');
  const outsider = commit('jjj', 'valid', true, { author: 'carol@example.com' });
  assert.equal(disagreements([outsider], allowed)[0].gap, 'signature-passed-author-did-not');
  assert.deepEqual(disagreements([SIGNED], allowed), []);
});

test('the author check authenticates nothing', () => {
  const forged = commit('kkk', 'unsigned', false, { signature: null });
  assert.equal(authorAllowlistPass(forged, ['alice@example.com']), true);
  assert.equal(signaturePass(forged), false);
  assert.equal(authorAllowlistPass(forged, []), null);
});

test('the signature speaks for the committer not the author', () => {
  const split = commit('lll', 'valid', true, { committer: 'bob@example.com' });
  const [state, detail] = identitySplit(split);
  assert.equal(state, 'author-differs-from-committer');
  assert.match(detail, /speaks for the committer/);
  assert.equal(identitySplit(SIGNED)[0], 'author-is-committer');
  const unlinked = commit('mmm', 'valid', true, { linked: false });
  assert.equal(identitySplit(unlinked)[0], 'email-resolves-to-no-account');
});

test('an unreadable rule is not an absent rule', () => {
  const [state, detail] = enforcementFromRules(null, false);
  assert.equal(state, 'rule-unreadable');
  assert.match(detail, /not the same as unenforced/);
  assert.equal(enforcementFromRules([], true)[0], 'no-rule');
  assert.equal(
    enforcementFromRules([{ type: 'deletion' }, { type: 'required_signatures' }], true)[0],
    'enforced',
  );
});

test('a verified history with no rule is not a guarantee', () => {
  const counts = tally([SIGNED, SIGNED]);
  assert.equal(grade(counts, 'no-rule')[0], 'verified-but-not-enforced');
  assert.equal(grade(counts, 'enforced')[0], 'verified-and-enforced');
  assert.match(grade(counts, 'no-rule')[1], /not a constraint/);
});

test('absent verification outranks every other grade', () => {
  assert.equal(grade(tally([UNSIGNED, ABSENT]), 'enforced')[0], 'verification-unknown');
});

test('the tally covers every family', () => {
  const counts = tally([SIGNED, UNSIGNED, BAD, UNREGISTERED, OUTAGE, ABSENT]);
  for (const name of FAMILIES) assert.ok(name in counts);
  assert.equal(counts.verified, 1);
  assert.equal(counts['verification-absent'], 1);
});

test('the repair asks a human and writes nothing', () => {
  const fix = repair('identity-not-linked-present', 'no-rule', 'acme/payments', 'main');
  assert.match(fix, /add their public keys/);
  assert.match(fix, /ask an admin of acme\/payments/);
  assert.ok(fix.endsWith('Nothing here writes.'));
  assert.match(
    repair('verified-and-enforced', 'rule-unreadable', 'acme/payments', 'main'),
    /unreadable is not unenforced/,
  );
});

test('the read cost is known before anything is spent', () => {
  assert.equal(readCost(1, false), 1);
  assert.equal(readCost(3, true), 4);
  assert.equal(readCost(0, false), 1);
});

FAQ

Does <code>verified: true</code> mean the person named on the commit wrote it?

No. It means a signature on the commit object was cryptographically checked against a key registered to a GitHub account, and that the <em>committer</em> email matched a verified email on that account. The <code>author</code> fields are separate strings the committing client set, and nothing authenticates them. A commit can be genuinely verified and still carry any author name at all, which is exactly why an audit that reads the author is checking something else.

Our script defaults <code>verified</code> to <code>False</code> when the key is missing. Is that safe?

It is safe against fail-open and it produces a different bug. A commit GitHub could not check &mdash; <code>gpgverify_error</code> or <code>gpgverify_unavailable</code> &mdash; is not an unsigned commit, and grading it as one turns an incident on GitHub's side into a blocked release on yours. The honest third value is unknown, which is why the script returns <code>None</code> rather than a boolean and why the tally keeps that family out of the violation count.

What is the difference between <code>unknown_key</code> and <code>invalid</code>?

Everything, in terms of what to do next. <code>invalid</code> means a signature exists and does not check out against the key, which is a real problem. <code>unknown_key</code> means the signature is perfectly good and the key that made it has never been uploaded to a GitHub account, so GitHub has nothing to check it against. The second is nearly always somebody who set up signing locally and skipped the last step, and the repair is that person adding a public key rather than anything touching the repository.

Why does a commit stay verified after the signing key is revoked?

Because GitHub records the verification result rather than recomputing it on every read, and documents that signatures stay verified over time even when keys are rotated or revoked and when contributors leave. That is what you want for history &mdash; the alternative is a repository whose past silently changes status &mdash; but it means <code>verified</code> is a statement about the moment of signing, dated by <code>verified_at</code>, and not a claim about today's trust in that key.

The report says every commit is signed. Is the policy satisfied?

Only if something is enforcing it. A report describes commits that already exist; a <code>required_signatures</code> rule on the branch is what causes an unsigned push to be rejected. Read <code>GET /repos/{owner}/{repo}/rules/branches/{branch}</code> and look for that rule type. If it is not there, your history is signed because everybody happened to have signing configured, and the guarantee lasts exactly until somebody clones on a new laptop.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.