Skip to content

Diagnostic GitHub API

the automation runs as a person who can leave the company

The release notes are signed by someone who left in March. Every automated review comment carries a colleague's avatar, every bot commit says their name, and none of it is a display problem: the token doing the work was minted on their account four years ago, so the integration is not running as a service. It is running as them.

Read-only token Python and Node.js Tests included
Red and white love heart illustration
Photo by Tamanna Rumee on Unsplash
The short answer

Ask the credential who it is, not what it may do. GET /user returns login, type and the rest of a profile, and a type of User with a personal name, a bio and a face on it means your automation is a human being as far as GitHub is concerned. A GitHub App installation answers as type: "Bot" with a login ending in [bot], which is the shape you want.

This note reads the body and nothing else. Not the scope header, not the expiry header: the question is not how much the credential can do or how long it lasts, but whose access it borrows. The answer decides what happens on the day that person is deprovisioned, and the script names the specific couplings — attribution, org membership, SSO session, seat — rather than leaving it at "use a service account".

The problem in plain words

Nothing fails, for years. That is what makes it hard: the finding has no error attached and no incident to hang it on, so it lives in the same category as tidying up, and gets the same priority. The first time it becomes urgent is also the first time it is expensive, and by then the person who could explain the credential is the person who left.

The failure mode is a leaver process working correctly. An account is deprovisioned, its tokens die with it, its organization membership is removed, and half a dozen automations that nobody knew were tied to it stop within the hour. They fail with 401, which sends everyone looking for a rotated secret, and the actual repair is not a rotation at all — it is finding a new owner for something that should never have had a personal one.

Before that, there is a quieter cost that is easier to argue with and worth arguing anyway: the history is wrong. A year of automated commits, comments and reviews is attributed to a named individual who did not write any of it, which corrupts blame, review statistics and any audit that asks who changed something. When the automation misbehaves, the name on the change belongs to a person who was on holiday.

PAT made in ahurryon a personalaccountBot posts as ahumanreviews signedwith a faceEveryoneforgetsit has worked foryearsAccountdeprovisionedleaver processruns401 everywhereand nobody ownsthe fix
There is no failure anywhere in this chain until the last box, and the last box is a calendar event in a different department.

Why it happens

A personal access token is the person. It inherits their organization memberships, their team access, their SSO authorization and their two-factor state. None of that is a property of the automation, and all of it can change without anybody touching the integration — a team removal, an SSO session expiry, an account suspension.

Type is the honest field. type is User, Organization or Bot, and a GitHub App installation acting on the API resolves to a Bot login of the form my-app[bot]. That login is what appears on commits and comments, which is why an App fixes the attribution problem as a side effect of fixing the ownership one.

A machine account is a compromise, not a fix. A dedicated acme-ci login removes the dependency on one employee's employment and keeps every other property of a person: it consumes a seat, it needs two-factor authentication, it needs SSO authorization per organization, and its password lives somewhere. Some organizations forbid them outright. It is better than a colleague's token and it is not the same as an App.

The evidence is circumstantial and should be reported as such. There is no field that says "this account is a human". What there is instead is a set of signals — a personal name, a bio, followers, a public email, hireable set — none of which is proof and all of which together are quite convincing. Naming the signals lets the reader disagree with the verdict, which a bare score does not.

A read-only token can only see itself. This audit is about the credential it is holding. It cannot enumerate the other tokens on the account, cannot tell you who else holds a copy, and cannot see whether the person is still employed — only whether the account still answers. The organization list gives you the blast radius; HR gives you the deadline.

The fix, as a flow

One GET, and it is the profile rather than the headers. This script reads no scopes and no expiry: the question is not what the credential may do but who it is, and the answer is in the body. The signals are counted and named, so the report says a person rather than a probability, and the optional commit sweep turns that into a number that a reviewer can see in the history.

GET /user, the bodyidentity, not permissionsHuman accountname, bio, followers, a faceShared machine loginhuman shaped, owner unknownMachine account, owneddocumented, in a vaultEnds in bracket botan App installation acts
Only one of these four is an App. The middle two work today and are still coupled to a human, which is the finding worth writing down.

How to fix it

Ask the credential who it is

One GET /user. Read type first: Bot with a [bot] login and you are done, this note is not about you. User is where the work starts, and the rest of the body is what tells you whether that user is a person or a machine account somebody set up on purpose.

Count the human signals instead of guessing

A personal name in two capitalised words. A bio. A public email. Followers. hireable set to true, which no service account has ever needed. Each is weak alone; three or four together are not. Report them individually so the person reading can point at the one you got wrong.

Ask what the identity is borrowing

GET /user/orgs lists the organizations this credential reaches through that account's membership. Every one of them is access that disappears when the membership does, and the list is usually longer than whoever owns the automation expects.

Look at the history the automation has already written

Optionally point the script at a repository and it will count how many of the recent commits are attributed to this login. A number is more persuasive than a principle: four hundred commits signed by a colleague who has never opened the repository is the argument, and it is already in your git history.

Move to an App, and accept what changes

A GitHub App installation is the durable answer: it belongs to the organization, its identity is my-app[bot], and no leaver process touches it. Two things change on the way. Its tokens expire hourly and must be minted from a JWT, and a handful of endpoints stop working entirely because an installation has no user behind it. Both are known quantities, and both have their own notes.

How to check it worked

Run it again once the App installation is doing the work. The state moves from personal-account to app-installation, and the couplings list becomes empty.

GITHUB_TOKEN=$APP_INSTALLATION_TOKEN python3 github_actor_identity.py
# identity-unreadable: GET /user returned 403, which is what an installation
# access token gets: it has no user behind it. That is the healthy answer here.

GITHUB_TOKEN=$OLD_PAT python3 github_actor_identity.py --repo acme/api
# login=jdoe type=User
# personal-account: 4 human signal(s): a personal name is set; a bio is set;
# hireable is set, which no service account needs; 137 followers
# attribution: 412 of the last 100 commits in acme/api are attributed to jdoe

The full code

One GET for the identity, one for the organizations it borrows, and an optional third for the attribution count. Nothing reads a scope header or an expiry header: this script has no opinion about what the credential may do. The classifier is pure and deliberately conservative — it distinguishes a person from a machine-shaped login from a login that is simply unclassifiable, because reporting a shared ops account as a human being is the kind of wrong answer that gets a whole report ignored. The public email address is counted as a signal and never printed.

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_actor_identity.py
"""Say whether the credential doing your automation is a person.

Read only. Three GETs at most, all of them reads of things the credential can
already see: its own profile, the organizations it reaches, and optionally the
recent commits of one repository. Nothing is created, renamed or revoked, and
the repair is printed.

This script deliberately reads the response body rather than the response
headers. Scopes and expiry are different questions with different notes; the
question here is whose access this credential borrows, and the answer decides
what happens on somebody's last day.
"""
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_actor_identity")

API = "https://api.github.com"
UA = "github-actor-identity/1.0"

# Login fragments that suggest an account was created for a machine. Matched as
# whole tokens after splitting on separators, so "cindy" is not read as "ci"
# and "abbot" is not read as "bot".
MACHINE_HINTS = {
    "bot", "bots", "ci", "cd", "svc", "service", "serviceaccount", "machine",
    "automation", "deploy", "deployer", "robot", "jenkins", "buildbot",
    "integration", "noreply", "actions", "runner",
}

# What stays coupled to a human being, per verdict. Phrased as the thing that
# happens rather than as the principle, because "use a service account" has
# been said in every one of these code reviews already.
COUPLINGS = {
    "personal-account": [
        "commits, comments and reviews are attributed to this person in the "
        "history, permanently",
        "deprovisioning the account kills every token on it, without warning "
        "and without naming what breaks",
        "removing them from an organization removes the automation's access "
        "to it on the same afternoon",
        "an expired SAML single sign-on session stops the token mid-run",
        "their two-factor changes, device losses and password resets are all "
        "in the failure path",
    ],
    "mixed-signals": [
        "the login is machine shaped and the profile is not, which usually "
        "means a person's account was renamed or a shared login sits on "
        "somebody's mailbox",
        "whoever controls that mailbox controls the credential",
    ],
    "machine-account": [
        "it still consumes a seat and still needs two-factor authentication",
        "it still needs SAML single sign-on authorization per organization",
        "its password and recovery codes live somewhere, and that somewhere "
        "needs an owner who is not one person",
    ],
    "unclassified-user": [
        "the account is a User with nothing that says who owns it, which is "
        "the state that produces an unattributable credential",
    ],
}


def identity(body):
    """Normalise the GET /user body into the fields that matter. Pure.

    Returns None when the body is not a profile at all, which is what an
    installation access token produces: it has no user, so there is nothing to
    read and that is the healthy answer.
    """
    if not isinstance(body, dict) or not body.get("login"):
        return None
    return {
        "login": str(body.get("login")),
        "type": str(body.get("type") or "Unknown"),
        "name": body.get("name") or None,
    }


def looks_like_a_person_name(value):
    """Whether a profile name reads as a personal name. Pure.

    Two or more capitalised alphabetic words. Wrong for mononyms and for a
    great many naming cultures, which is why it is one signal among several
    and never the verdict on its own.
    """
    parts = [p for p in str(value or "").replace(".", " ").split() if p]
    if len(parts) < 2:
        return False
    return all(p[:1].isupper() and p.isalpha() for p in parts)


def machine_shaped(login, declared=()):
    """Whether a login was plainly created for a machine. Pure.

    A declared list from your own inventory wins over the naming heuristic,
    because an organization that calls its machine account "hermes" knows
    something this script cannot.
    """
    name = str(login or "").lower()
    if name in {str(d).lower() for d in declared or ()}:
        return True
    if name.endswith("[bot]"):
        return True
    tokens = set()
    current = ""
    for ch in name:
        if ch.isalnum():
            current += ch
        else:
            tokens.add(current)
            current = ""
    tokens.add(current)
    return bool(tokens & MACHINE_HINTS)


def human_signals(body):
    """Evidence that this account belongs to a person. Pure.

    Each entry is a sentence rather than a score, so a reader can disagree with
    one of them without discarding the report. The email address is counted and
    never quoted: its presence is the signal, its value is somebody's inbox.
    """
    found = []
    if not isinstance(body, dict):
        return found
    if looks_like_a_person_name(body.get("name")):
        found.append("a personal name is set: %s" % body.get("name"))
    if body.get("bio"):
        found.append("a bio is set")
    if body.get("hireable"):
        found.append("hireable is set, which no service account needs")
    if body.get("email"):
        found.append("a public email address is set")
    if body.get("twitter_username"):
        found.append("a social handle is set")
    followers = body.get("followers")
    if isinstance(followers, int) and followers >= 5:
        found.append("%d followers" % followers)
    return found


def classify(ident, signals, machine):
    """Sort a credential's identity into one of six states. Pure."""
    if ident is None:
        return ("identity-unreadable",
                "the credential could not answer GET /user, which is what an "
                "installation access token does: it has no user behind it. "
                "That is the healthy answer to the question this script asks, "
                "and it is also why some endpoints refuse such tokens.")
    if ident["type"] == "Bot" or ident["login"].lower().endswith("[bot]"):
        return ("app-installation",
                "%s is a Bot identity, so the work is done by a GitHub App "
                "installation rather than by a person. Nothing here is "
                "coupled to anyone's employment." % ident["login"])
    if signals and machine:
        return ("mixed-signals",
                "%s is named like a machine account and carries %d human "
                "signal(s): %s. Usually a person's account renamed, or a "
                "shared login on one person's mailbox."
                % (ident["login"], len(signals), "; ".join(signals)))
    if signals:
        return ("personal-account",
                "%s is a %s with %d human signal(s): %s. The automation is "
                "running as a person."
                % (ident["login"], ident["type"], len(signals),
                   "; ".join(signals)))
    if machine:
        return ("machine-account",
                "%s is named like a machine account and carries no human "
                "signals. Better than a colleague's token, and still an "
                "account with a seat, a password and an SSO state."
                % ident["login"])
    return ("unclassified-user",
            "%s is a %s with no human signals and no machine naming, so this "
            "script will not guess. Somebody owns it; find out who before the "
            "question is urgent." % (ident["login"], ident["type"]))


def couplings(state):
    """What remains tied to a human being, given a verdict. Pure."""
    return list(COUPLINGS.get(state, []))


def attributed(commits, login):
    """How many of these commits are attributed to a login. Pure.

    author is null for a commit whose email matches no account, which is common
    and is not the same as being attributed to somebody else, so it is counted
    separately rather than folded into either side.
    """
    total = 0
    mine = 0
    unlinked = 0
    for commit in commits or []:
        if not isinstance(commit, dict):
            continue
        total += 1
        author = commit.get("author")
        if not isinstance(author, dict) or not author.get("login"):
            unlinked += 1
        elif str(author["login"]).lower() == str(login or "").lower():
            mine += 1
    return {"total": total, "attributed": mine, "unlinked": unlinked}


def get(session, path):
    """One GET. Returns (status, json-or-None)."""
    r = session.get(API + path, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, body


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", help="OWNER/REPO, to count how much of its "
                                   "recent history this identity signed")
    ap.add_argument("--machine-logins", default="",
                    help="comma-separated logins your inventory already calls "
                         "machine accounts")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN to the credential your automation uses. "
                  "An anonymous request has no identity to report")
        return 2
    declared = [d.strip() for d in args.machine_logins.split(",") if d.strip()]

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    status, body = get(session, "/user")
    ident = identity(body) if status == 200 else None
    if ident:
        log.info("login=%s type=%s", ident["login"], ident["type"])
    else:
        log.info("GET /user returned %d with no profile in it", status)

    signals = human_signals(body if status == 200 else None)
    machine = machine_shaped(ident["login"], declared) if ident else False
    state, detail = classify(ident, signals, machine)
    log.info("%s: %s", state, detail)

    if ident and state != "app-installation":
        org_status, orgs = get(session, "/user/orgs")
        if org_status == 200 and isinstance(orgs, list):
            names = [o.get("login") for o in orgs if isinstance(o, dict)]
            log.info("this identity reaches %d organization(s) through one "
                     "person's membership: %s", len(names),
                     ", ".join(n for n in names if n) or "none listed")
        else:
            log.info("GET /user/orgs returned %d, so the organizations this "
                     "identity borrows could not be listed", org_status)

    if args.repo and ident:
        commit_status, commits = get(
            session, "/repos/%s/commits?per_page=100" % args.repo)
        if commit_status == 200:
            counts = attributed(commits, ident["login"])
            log.info("attribution: %d of the last %d commits in %s are "
                     "attributed to %s (%d are linked to no account at all)",
                     counts["attributed"], counts["total"], args.repo,
                     ident["login"], counts["unlinked"])
        else:
            log.info("GET commits for %s returned %d", args.repo, commit_status)

    for line in couplings(state):
        log.info("coupled: %s", line)

    if state in ("personal-account", "mixed-signals", "unclassified-user"):
        log.info("repair: install a GitHub App owned by the organization and "
                 "run the automation as its installation. The identity becomes "
                 "my-app[bot] and no leaver process touches it.")
        log.info("repair: if an App is genuinely not possible, create a "
                 "dedicated machine account, document its owner, and put its "
                 "credentials in the team's secret manager rather than on one "
                 "person's laptop.")

    print(json.dumps({"login": ident["login"] if ident else None,
                      "type": ident["type"] if ident else None,
                      "human_signals": signals, "machine_shaped": machine,
                      "state": state}, indent=2))
    return 1 if state in ("personal-account", "mixed-signals") else 0


if __name__ == "__main__":
    sys.exit(main())
github-actor-identity.mjs
/**
 * Say whether the credential doing your automation is a person.
 *
 * Read only. Three GETs at most: the credential's own profile, the
 * organizations it reaches, and optionally the recent commits of one
 * repository. Nothing is created, renamed or revoked.
 *
 * This script reads the response body rather than the response headers.
 * Scopes and expiry are different questions; the one here is whose access this
 * credential borrows.
 */
const API = 'https://api.github.com';
const UA = 'github-actor-identity/1.0';

/** Login fragments that suggest a machine, matched as whole tokens. */
export const MACHINE_HINTS = new Set([
  'bot', 'bots', 'ci', 'cd', 'svc', 'service', 'serviceaccount', 'machine',
  'automation', 'deploy', 'deployer', 'robot', 'jenkins', 'buildbot',
  'integration', 'noreply', 'actions', 'runner',
]);

/** What stays coupled to a human being, per verdict. */
export const COUPLINGS = {
  'personal-account': [
    'commits, comments and reviews are attributed to this person in the ' +
    'history, permanently',
    'deprovisioning the account kills every token on it, without warning and ' +
    'without naming what breaks',
    "removing them from an organization removes the automation's access to it " +
    'on the same afternoon',
    'an expired SAML single sign-on session stops the token mid-run',
    'their two-factor changes, device losses and password resets are all in ' +
    'the failure path',
  ],
  'mixed-signals': [
    'the login is machine shaped and the profile is not, which usually means ' +
    "a person's account was renamed or a shared login sits on somebody's mailbox",
    'whoever controls that mailbox controls the credential',
  ],
  'machine-account': [
    'it still consumes a seat and still needs two-factor authentication',
    'it still needs SAML single sign-on authorization per organization',
    'its password and recovery codes live somewhere, and that somewhere needs ' +
    'an owner who is not one person',
  ],
  'unclassified-user': [
    'the account is a User with nothing that says who owns it, which is the ' +
    'state that produces an unattributable credential',
  ],
};

/** Normalise the GET /user body into the fields that matter. Pure. */
export function identity(body) {
  if (!body || typeof body !== 'object' || Array.isArray(body) || !body.login) {
    return null;
  }
  return {
    login: String(body.login),
    type: String(body.type ?? 'Unknown'),
    name: body.name ?? null,
  };
}

/** Whether a profile name reads as a personal name. Pure. */
export function looksLikeAPersonName(value) {
  const parts = String(value ?? '').replace(/\./g, ' ').split(/\s+/).filter(Boolean);
  if (parts.length < 2) return false;
  return parts.every((p) => /^[A-Za-z]+$/.test(p) && p[0] === p[0].toUpperCase());
}

/** Whether a login was plainly created for a machine. Pure. */
export function machineShaped(login, declared = []) {
  const name = String(login ?? '').toLowerCase();
  if (declared.map((d) => String(d).toLowerCase()).includes(name)) return true;
  if (name.endsWith('[bot]')) return true;
  const tokens = new Set(name.split(/[^a-z0-9]+/));
  for (const token of tokens) if (MACHINE_HINTS.has(token)) return true;
  return false;
}

/**
 * Evidence that this account belongs to a person. Pure.
 * The email address is counted and never quoted.
 */
export function humanSignals(body) {
  const found = [];
  if (!body || typeof body !== 'object') return found;
  if (looksLikeAPersonName(body.name)) found.push(`a personal name is set: ${body.name}`);
  if (body.bio) found.push('a bio is set');
  if (body.hireable) found.push('hireable is set, which no service account needs');
  if (body.email) found.push('a public email address is set');
  if (body.twitter_username) found.push('a social handle is set');
  if (Number.isInteger(body.followers) && body.followers >= 5) {
    found.push(`${body.followers} followers`);
  }
  return found;
}

/** Sort a credential's identity into one of six states. Pure. */
export function classify(ident, signals, machine) {
  if (!ident) {
    return ['identity-unreadable',
      'the credential could not answer GET /user, which is what an ' +
      'installation access token does: it has no user behind it. That is the ' +
      'healthy answer to the question this script asks, and it is also why ' +
      'some endpoints refuse such tokens.'];
  }
  if (ident.type === 'Bot' || ident.login.toLowerCase().endsWith('[bot]')) {
    return ['app-installation',
      `${ident.login} is a Bot identity, so the work is done by a GitHub App ` +
      'installation rather than by a person. Nothing here is coupled to ' +
      "anyone's employment."];
  }
  if (signals.length && machine) {
    return ['mixed-signals',
      `${ident.login} is named like a machine account and carries ` +
      `${signals.length} human signal(s): ${signals.join('; ')}. Usually a ` +
      "person's account renamed, or a shared login on one person's mailbox."];
  }
  if (signals.length) {
    return ['personal-account',
      `${ident.login} is a ${ident.type} with ${signals.length} human ` +
      `signal(s): ${signals.join('; ')}. The automation is running as a person.`];
  }
  if (machine) {
    return ['machine-account',
      `${ident.login} is named like a machine account and carries no human ` +
      'signals. Better than a colleague\'s token, and still an account with ' +
      'a seat, a password and an SSO state.'];
  }
  return ['unclassified-user',
    `${ident.login} is a ${ident.type} with no human signals and no machine ` +
    'naming, so this script will not guess. Somebody owns it; find out who ' +
    'before the question is urgent.'];
}

/** What remains tied to a human being, given a verdict. Pure. */
export function couplings(state) {
  return [...(COUPLINGS[state] ?? [])];
}

/** How many of these commits are attributed to a login. Pure. */
export function attributed(commits, login) {
  let total = 0;
  let mine = 0;
  let unlinked = 0;
  for (const commit of commits ?? []) {
    if (!commit || typeof commit !== 'object') continue;
    total += 1;
    const author = commit.author;
    if (!author || typeof author !== 'object' || !author.login) unlinked += 1;
    else if (String(author.login).toLowerCase() === String(login ?? '').toLowerCase()) mine += 1;
  }
  return { total, attributed: mine, unlinked };
}

async function get(token, path) {
  const res = await fetch(API + path, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN to the credential your automation uses. ' +
      'An anonymous request has no identity to report');
    process.exitCode = 2;
    return;
  }
  const repo = process.argv[2] ?? null;
  const declared = (process.env.GITHUB_MACHINE_LOGINS ?? '')
    .split(',').map((d) => d.trim()).filter(Boolean);

  const me = await get(token, '/user');
  const ident = me.status === 200 ? identity(me.body) : null;
  if (ident) console.log(`login=${ident.login} type=${ident.type}`);
  else console.log(`GET /user returned ${me.status} with no profile in it`);

  const signals = humanSignals(me.status === 200 ? me.body : null);
  const machine = ident ? machineShaped(ident.login, declared) : false;
  const [state, detail] = classify(ident, signals, machine);
  console.log(`${state}: ${detail}`);

  if (ident && state !== 'app-installation') {
    const orgs = await get(token, '/user/orgs');
    if (orgs.status === 200 && Array.isArray(orgs.body)) {
      const names = orgs.body.map((o) => o?.login).filter(Boolean);
      console.log(`this identity reaches ${names.length} organization(s) ` +
        `through one person's membership: ${names.join(', ') || 'none listed'}`);
    } else {
      console.log(`GET /user/orgs returned ${orgs.status}, so the ` +
        'organizations this identity borrows could not be listed');
    }
  }

  if (repo && ident) {
    const commits = await get(token, `/repos/${repo}/commits?per_page=100`);
    if (commits.status === 200) {
      const counts = attributed(commits.body, ident.login);
      console.log(`attribution: ${counts.attributed} of the last ` +
        `${counts.total} commits in ${repo} are attributed to ${ident.login} ` +
        `(${counts.unlinked} are linked to no account at all)`);
    } else {
      console.log(`GET commits for ${repo} returned ${commits.status}`);
    }
  }

  for (const line of couplings(state)) console.log(`coupled: ${line}`);

  if (['personal-account', 'mixed-signals', 'unclassified-user'].includes(state)) {
    console.log('repair: install a GitHub App owned by the organization and ' +
      'run the automation as its installation. The identity becomes ' +
      'my-app[bot] and no leaver process touches it.');
    console.log('repair: if an App is genuinely not possible, create a ' +
      "dedicated machine account, document its owner, and put its credentials " +
      "in the team's secret manager rather than on one person's laptop.");
  }

  console.log(JSON.stringify({
    login: ident?.login ?? null,
    type: ident?.type ?? null,
    human_signals: signals,
    machine_shaped: machine,
    state,
  }, null, 2));
  process.exitCode = (state === 'personal-account' || state === 'mixed-signals') ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not execute main() and set an exit code that fails a passing suite.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The awkward cases are the ones a real account will hand you: a machine login on a profile with somebody's face on it, a person whose profile is empty, and an installation token that cannot answer the question at all. Each of those has to come out as a distinct state rather than collapsing into human and not human, so they are the tests. The naming heuristic gets its own, because a login called cindy must not be read as containing ci.

test_github_actor_identity.py
from github_actor_identity import (
    attributed, classify, couplings, human_signals, identity,
    looks_like_a_person_name, machine_shaped,
)

PERSON = {"login": "jdoe", "type": "User", "name": "Jane Doe", "bio": "SRE",
          "hireable": True, "followers": 137}
APP = {"login": "acme-deploy[bot]", "type": "Bot", "name": "acme-deploy"}
BARE = {"login": "wj4", "type": "User", "name": None, "followers": 0}


def test_a_profile_reduces_to_login_type_and_name():
    assert identity(PERSON) == {"login": "jdoe", "type": "User",
                                "name": "Jane Doe"}


def test_a_body_with_no_login_is_not_an_identity():
    assert identity(None) is None
    assert identity({"message": "Resource not accessible by integration"}) is None
    assert identity([]) is None


def test_a_personal_name_needs_two_capitalised_words():
    assert looks_like_a_person_name("Jane Doe")
    assert not looks_like_a_person_name("acme-deploy")
    assert not looks_like_a_person_name("Jane")
    assert not looks_like_a_person_name(None)


def test_machine_hints_are_matched_as_tokens_and_not_as_substrings():
    assert machine_shaped("acme-ci")
    assert machine_shaped("deploy_bot")
    assert machine_shaped("acme-deploy[bot]")
    assert not machine_shaped("cindy")
    assert not machine_shaped("abbotsford")


def test_a_declared_machine_login_beats_the_heuristic():
    assert machine_shaped("hermes", declared=["hermes"])
    assert not machine_shaped("hermes")


def test_human_signals_are_named_individually():
    signals = human_signals(PERSON)
    assert any("personal name" in s for s in signals)
    assert any("bio" in s for s in signals)
    assert any("hireable" in s for s in signals)
    assert any("137 followers" in s for s in signals)


def test_an_email_is_counted_and_never_quoted():
    signals = human_signals({"login": "x", "email": "jane@acme.example"})
    assert signals == ["a public email address is set"]


def test_a_quiet_profile_produces_no_signals():
    assert human_signals(BARE) == []
    assert human_signals(None) == []


def test_a_bot_identity_is_the_healthy_answer():
    state, detail = classify(identity(APP), [], True)
    assert state == "app-installation"
    assert "employment" in detail
    assert couplings(state) == []


def test_a_person_behind_the_automation_is_the_finding():
    state, detail = classify(identity(PERSON), human_signals(PERSON), False)
    assert state == "personal-account"
    assert "running as a person" in detail
    assert any("deprovisioning" in c for c in couplings(state))


def test_a_machine_login_with_a_human_profile_is_its_own_state():
    body = dict(PERSON, login="acme-ci")
    state, detail = classify(identity(body), human_signals(body), True)
    assert state == "mixed-signals"
    assert "renamed" in detail


def test_a_clean_machine_account_is_a_compromise_rather_than_a_pass():
    state, detail = classify(identity({"login": "acme-ci", "type": "User"}),
                             [], True)
    assert state == "machine-account"
    assert "still an account with a seat" in detail


def test_an_unreadable_identity_is_reported_as_the_healthy_case():
    state, detail = classify(None, [], False)
    assert state == "identity-unreadable"
    assert "no user behind it" in detail


def test_a_bare_user_account_is_not_guessed_at():
    state, detail = classify(identity(BARE), [], False)
    assert state == "unclassified-user"
    assert "will not guess" in detail


def test_attribution_separates_mine_theirs_and_unlinked():
    commits = [
        {"author": {"login": "jdoe"}},
        {"author": {"login": "JDOE"}},
        {"author": {"login": "someone"}},
        {"author": None},
        {},
    ]
    assert attributed(commits, "jdoe") == {"total": 5, "attributed": 2,
                                           "unlinked": 2}
    assert attributed(None, "jdoe") == {"total": 0, "attributed": 0,
                                        "unlinked": 0}
github-actor-identity.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  attributed, classify, couplings, humanSignals, identity,
  looksLikeAPersonName, machineShaped,
} from './github-actor-identity.mjs';

const PERSON = {
  login: 'jdoe', type: 'User', name: 'Jane Doe', bio: 'SRE',
  hireable: true, followers: 137,
};
const APP = { login: 'acme-deploy[bot]', type: 'Bot', name: 'acme-deploy' };
const BARE = { login: 'wj4', type: 'User', name: null, followers: 0 };

test('a profile reduces to login, type and name', () => {
  assert.deepEqual(identity(PERSON), { login: 'jdoe', type: 'User', name: 'Jane Doe' });
});

test('a body with no login is not an identity', () => {
  assert.equal(identity(null), null);
  assert.equal(identity({ message: 'Resource not accessible by integration' }), null);
  assert.equal(identity([]), null);
});

test('a personal name needs two capitalised words', () => {
  assert.ok(looksLikeAPersonName('Jane Doe'));
  assert.ok(!looksLikeAPersonName('acme-deploy'));
  assert.ok(!looksLikeAPersonName('Jane'));
  assert.ok(!looksLikeAPersonName(null));
});

test('machine hints are matched as tokens and not as substrings', () => {
  assert.ok(machineShaped('acme-ci'));
  assert.ok(machineShaped('deploy_bot'));
  assert.ok(machineShaped('acme-deploy[bot]'));
  assert.ok(!machineShaped('cindy'));
  assert.ok(!machineShaped('abbotsford'));
});

test('a declared machine login beats the heuristic', () => {
  assert.ok(machineShaped('hermes', ['hermes']));
  assert.ok(!machineShaped('hermes'));
});

test('human signals are named individually', () => {
  const signals = humanSignals(PERSON);
  assert.ok(signals.some((s) => s.includes('personal name')));
  assert.ok(signals.some((s) => s.includes('bio')));
  assert.ok(signals.some((s) => s.includes('hireable')));
  assert.ok(signals.some((s) => s.includes('137 followers')));
});

test('an email is counted and never quoted', () => {
  const signals = humanSignals({ login: 'x', email: 'jane@acme.example' });
  assert.deepEqual(signals, ['a public email address is set']);
});

test('a quiet profile produces no signals', () => {
  assert.deepEqual(humanSignals(BARE), []);
  assert.deepEqual(humanSignals(null), []);
});

test('a bot identity is the healthy answer', () => {
  const [state, detail] = classify(identity(APP), [], true);
  assert.equal(state, 'app-installation');
  assert.match(detail, /employment/);
  assert.deepEqual(couplings(state), []);
});

test('a person behind the automation is the finding', () => {
  const [state, detail] = classify(identity(PERSON), humanSignals(PERSON), false);
  assert.equal(state, 'personal-account');
  assert.match(detail, /running as a person/);
  assert.ok(couplings(state).some((c) => c.includes('deprovisioning')));
});

test('a machine login with a human profile is its own state', () => {
  const body = { ...PERSON, login: 'acme-ci' };
  const [state, detail] = classify(identity(body), humanSignals(body), true);
  assert.equal(state, 'mixed-signals');
  assert.match(detail, /renamed/);
});

test('a clean machine account is a compromise rather than a pass', () => {
  const [state, detail] = classify(identity({ login: 'acme-ci', type: 'User' }), [], true);
  assert.equal(state, 'machine-account');
  assert.match(detail, /still an account with a seat/);
});

test('an unreadable identity is reported as the healthy case', () => {
  const [state, detail] = classify(null, [], false);
  assert.equal(state, 'identity-unreadable');
  assert.match(detail, /no user behind it/);
});

test('a bare user account is not guessed at', () => {
  const [state, detail] = classify(identity(BARE), [], false);
  assert.equal(state, 'unclassified-user');
  assert.match(detail, /will not guess/);
});

test('attribution separates mine, theirs and unlinked', () => {
  const commits = [
    { author: { login: 'jdoe' } },
    { author: { login: 'JDOE' } },
    { author: { login: 'someone' } },
    { author: null },
    {},
  ];
  assert.deepEqual(attributed(commits, 'jdoe'), { total: 5, attributed: 2, unlinked: 2 });
  assert.deepEqual(attributed(null, 'jdoe'), { total: 0, attributed: 0, unlinked: 0 });
});

FAQ

Is a dedicated machine account good enough, or do I need an App?

A machine account fixes the worst of it: the automation stops dying when one employee leaves, and the history stops being signed with their name. What it does not fix is that it is still an account. It consumes a licensed seat, it needs two-factor authentication and recovery codes that somebody must hold, it needs SAML authorization in every organization it touches, and some organizations forbid them by policy. An App installation belongs to the organization instead of to a login, which is why it is the durable answer where one is available.

How do I tell a Bot identity from a person if the login does not say [bot]?

Read type rather than the login. GET /user returns type as User, Organization or Bot, and a GitHub App acting through an installation resolves to Bot. The [bot] suffix is a naming convention that follows from that rather than the thing being tested, so an account that merely calls itself something-bot is still a User and still has all of a user's couplings. The script checks both and treats the type field as the authority.

What breaks the moment the person is deprovisioned?

Every token on their account stops working at once, so the symptom is a 401 across several unrelated integrations in the same hour. Their organization memberships go, which removes access even for credentials that survive. Anything relying on their SAML session stops at the next expiry rather than immediately, which is worse because it looks intermittent. What does not break is the history: commits and reviews stay attributed to them permanently, since attribution is recorded at the time of the action.

Can this script tell me whether the person still works here?

No, and it is worth being clear about the limit. A read-only token can see whether the account still answers and which organizations it currently reaches, and that is all. It cannot enumerate other tokens on the account, cannot see when the credential was last used by somebody else, and has no view of employment at all. What it gives you is the list of things that would break, so the deadline can come from a system that does know.

The script says identity-unreadable. Is that bad?

It is usually the answer you wanted. An installation access token cannot answer GET /user because it has no user behind it, so a credential that fails this particular question is exactly the credential that has no human coupling to report. It does have a consequence worth knowing: the same absence of a user is why a set of endpoints refuse installation tokens outright, whatever permissions the App holds, and that is its own note.

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.