Skip to content

Diagnostic GitHub API

the deploy key is read-only and the push needs write

The pipeline has cloned this repository twice a day for eighteen months. Somebody adds a step that pushes a version bump back, and it fails with ERROR: The key you are authenticating with has been marked as read only. — from Git, over SSH, with no HTTP status and nothing in the API logs. So the investigation starts in the wrong tool, goes through the known-hosts file and the agent and the private key in the secret store, and none of that is where the answer is. The answer is a boolean on an object the API will hand you in one request.

Read-only token Python and Node.js Tests included
A headset near a laptop
Photo by Petr Machacek on Unsplash
The short answer

A deploy key carries a read_only flag chosen when the key was created, and read-only is both the default and the right choice for almost every use. A read-only key clones and fetches perfectly and refuses to push, and the refusal comes from Git rather than from the API, which is why the diagnosis usually starts somewhere unhelpful.

GET /repos/{owner}/{repo}/keys lists every deploy key on the repository with its id, title, read_only, created_at, verified and added_by. A repository whose automation pushes and whose every key is read_only: true is the finding. No scope, App permission or token change alters it — the capability is declared on the key object itself. Replace the key with one created with write access, or better, move that job to a GitHub App installation token with contents: write, which is scoped, expiring and auditable.

The problem in plain words

The refusal arrives outside the API, and that is most of the difficulty. There is no status code, no x-accepted header, no JSON body and no entry in any HTTP log; there is a line on stderr from a Git subprocess, usually buried inside a build log, often truncated. Everything this section normally works with is absent, so the usual triage does not start.

What starts instead is an SSH investigation, because that is what the error looks like. Someone checks whether the key is loaded in the agent, whether the private key in the secret store matches the public key on GitHub, whether the host key changed, whether the runner has an IPv6 problem. All of that is careful work on a connection that is working perfectly: the key authenticated, GitHub identified it, and then declined the operation. Authentication succeeded and authorisation did not, which is exactly the distinction the error line is making and exactly the one that gets missed at four in the afternoon.

The second wrong turn is the token. On a repository where a personal access token or an App installation is also in play, the natural next thought is scopes, and there is always something to adjust there. Widening the token changes nothing at all, because the push is not authenticating with the token; SSH and HTTPS are different credentials to the same repository and the deploy key is the one being used. Time spent on the token is time spent on a credential that is not involved.

Underneath all of it is a perfectly reasonable history. The key was added years ago for a CI job that only ever read, and read-only was the correct and careful choice at the time. Nothing was misconfigured. The requirement changed and the credential did not, and nothing in the repository records that the assumption was ever made.

Clone works formonthsfetch twice a dayPush step addeda version bumpbackMarked as readonlyfrom git, with nostatusAgent and hostkey readboth entirely fineToken widenedanywaynot the credentialin useand the wrong credential again
The key was accepted and then the operation was declined. Authentication worked; authorisation is what did not.

Why it happens

The capability is a field on the key, not a property of your account. This is what keeps it out of the rest of the section's permission notes. A missing OAuth scope, an App permission the endpoint wanted and a narrowed installation token are all statements about a credential's grants and are answered by re-issuing the credential with more. A deploy key's read_only is a fixed attribute of that specific key object, set when it was created and not editable afterwards. There is no widening; there is only replacement.

Read-only is the default, and it should be. GitHub's own guidance is to prefer read-only deploy keys, and most uses of one are a build fetching source. That means the shape of this failure is not a mistake anybody made but a requirement that changed underneath a correct decision. It also means a repository full of read-only keys is a healthy repository, and the finding only exists in relation to what the automation now needs to do.

SSH and HTTPS are different credentials to the same repository. A job can clone over SSH with a deploy key and call the API over HTTPS with a token in the same script, and the two have nothing to do with each other. When a push fails and an API call succeeds, that is not a contradiction; it is two credentials with different capabilities. Working out which one refused you is the first useful step, and the wording of the error is what tells you.

Read the git error before reading anything else. The refusals are distinguishable in text. A key marked read only names the key directly. Write access to repository not granted can be either the key or the token depending on the protocol in use. Permission denied (publickey) is a different problem entirely — the key was not accepted at all, so it is not on this repository. And protected branch hook declined means the credential was fine and the branch refused the update. Four different repairs, all in the same corner of the same build log.

Deploy keys are also an inventory problem. The listing shows created_at and added_by, and it usually turns up keys nobody remembers, added by people who have left, for jobs that no longer run. That inventory is worth reading while you are here, especially for any key that is not read-only: a write-capable key with no rotation date on a repository that does not push is a standing grant nobody is thinking about.

The key material is never printed, and the check never pushes. The listing includes the public key itself, and this script drops that field before anything is written to a log or a JSON file: what gets reported is the id, the title, the boolean and the dates. Nor does it test the key by attempting a push. The declared capability is the finding, it is one field, and confirming it by pushing to somebody's repository would be a change made to learn something already published.

The fix, as a flow

The text classifier runs before the network call, because three of its four outcomes send the reader somewhere other than the deploy keys: a ref that refused the update, a repository that is archived, or a key that was never accepted in the first place. Only then does the one request go out, and the listing is reduced to metadata in a single place so that nothing downstream can put key material in a log or a JSON artefact. The verdict is a comparison between a boolean the API declares and one fact the API cannot know, which is whether the job on this repository pushes.

One error line, then one readkeys listed, material droppedMarked as read onlydeclared on the key object itselfProtected branch declinedthe ref refused, not the keyPermission denied publickeyauthentication, a different problemA write capable key existsread only is not what refused
Three of these four rows are somebody else's note. Only the top one is answered by the field on the key.

How to fix it

Decide which credential refused you

Paste the line from the build log into --git-error. A key marked read only, a write access not granted, a plain public-key rejection and a protected-branch refusal are four different problems with four different repairs, and the wording separates them. This step costs nothing and stops the SSH investigation before it starts.

List the deploy keys on the repository

One GET /repos/{owner}/{repo}/keys. Every key comes back with read_only, title, created_at, verified and added_by. The endpoint needs repository admin; where the token does not have it the script says the keys are unreadable rather than reporting that there are none, because those are different findings and one of them is about your token.

Compare the capability against what the job now does

Tell the script whether the automation pushes with --needs-write. A repository that pushes and has no write-capable key is the finding; a repository that only reads and has only read-only keys is correct and is reported as correct. The state that deserves a second look is the opposite one — a write-capable key on a repository whose automation only ever reads.

Read the inventory while the listing is in front of you

created_at and added_by are in the same response. Keys older than your rotation policy are listed with their age, which is usually how a key added for a migration in 2021 by somebody who has since left gets noticed. The key material itself is dropped before anything is printed: ids, titles and booleans are what a report needs.

Prefer an installation token to a wider key

If writes are genuinely required, the narrow repair is a new deploy key created with write access and the old one deleted. The better one is usually a GitHub App installation token with contents: write, which expires in an hour, is scoped to the repositories you choose, and shows up in the audit log as the App rather than as an anonymous key on a repository. The script prints both, and performs neither.

How to check it worked

After the key is replaced, or the job is moved to an installation token, the same listing answers the question without any of the SSH archaeology.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_deploy_key_capability.py \
    --repo acme/platform-api --needs-write \
    --git-error "ERROR: The key you are authenticating with has been marked as read only."
# read cost: 1 request(s) per repository against the core hourly quota
# git error -> deploy-key-read-only: the message names the key itself, so the
# refusal is the key's declared capability and not a scope, a token or SSH
# acme/platform-api: 3 deploy key(s), 0 of them write-capable
#   key 41288114 "ci-fetch" read-only created 2021-06-02 by dana-ops, 1550 day(s) old
#   key 55210987 "release-runner" read-only created 2024-11-18 by build-bot
#   key 61004422 "docs-mirror" read-only created 2025-03-04 by dana-ops
# write-needed-none-capable: this repository's automation pushes and every
# deploy key on it is read-only, which is the whole failure
# repair: create a replacement deploy key with write access and delete the old
# one, or move the job to a GitHub App installation token with contents: write,
# which is scoped, expiring and auditable
# rotation: 1 key(s) older than 365 day(s)

The full code

One request and a text classifier, which is the right shape for a failure whose difficulty is recognising it rather than finding it. The classifier goes first because it decides whether to keep reading at all: three of its four outcomes send you somewhere other than the deploy keys. The listing is then reduced to metadata before anything else touches it — the public key material is dropped in one place, so nothing downstream can leak it into a log or a JSON artefact — and the verdict is a comparison between a boolean the API declares and a fact about your job that only you can supply.

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_deploy_key_capability.py
"""Check whether a repository's deploy keys can do what its automation needs.

Read only. One GET per repository, nothing is written, and the keys are never
exercised: no push is attempted to find out whether a key can push. The
capability is declared on the key object as read_only, which is the same fact
one request earlier and without changing anybody's repository.

The point of the note: a deploy key's read_only flag is chosen when the key is
created and cannot be edited afterwards. Read-only is the default and is right
for almost every use, so a key added for a CI job that reads works perfectly
until somebody adds a push step. The refusal then arrives from Git over SSH
rather than from the API, so the diagnosis starts in the wrong tool, and no
scope, App permission or token change moves it.

The public key material is dropped before anything is printed. What this
reports is ids, titles, the boolean and the dates.

What this can and cannot see: the keys endpoint needs repository admin, so a
token without it gets a refusal, which is reported as unreadable rather than as
"no keys" -- those are different findings. Which key your SSH client actually
presents is invisible from here; the declared capability of every key on the
repository is not.

Environment:

    GITHUB_TOKEN    a token with admin read on the repository, for the keys
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone

import requests

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

API = "https://api.github.com"
UA = "github-deploy-key-capability/1.0"

# The only fields that leave this script. The listing also carries the public
# key itself; it is dropped here rather than at each print site, so nothing
# downstream can put it in a log or a JSON artefact.
SAFE_FIELDS = ("id", "title", "read_only", "created_at", "verified", "added_by")

# A deploy key older than this is worth a look during the same read.
DEFAULT_MAX_AGE_DAYS = 365


def redact(key):
    """One deploy key reduced to metadata. Pure. Never carries key material."""
    if not isinstance(key, dict):
        return {}
    out = {}
    for field in SAFE_FIELDS:
        if field in key:
            out[field] = key[field]
    return out


def redact_all(keys):
    """The whole listing, reduced. Pure."""
    return [redact(k) for k in (keys or []) if isinstance(k, dict)]


def capability(key):
    """What this key is allowed to do, as declared. Pure."""
    if not isinstance(key, dict) or "read_only" not in key:
        return "unknown"
    return "read-only" if key.get("read_only") else "read-write"


def writable_keys(keys):
    """The ids of keys that can push. Pure."""
    return [k.get("id") for k in (keys or [])
            if isinstance(k, dict) and capability(k) == "read-write"]


def verdict(status, keys, needs_write):
    """Classify one repository's deploy keys. Pure. (state, detail).

    needs_write is a fact about your job rather than about the repository, so
    it has to be supplied. The API knows what the keys can do; it does not know
    what you were going to ask them to do.
    """
    try:
        code = int(status)
    except (TypeError, ValueError):
        code = None

    if code != 200:
        if code in (403, 404):
            return ("keys-unreadable",
                    "the deploy keys endpoint needs repository admin and this "
                    "token does not have it. That is not the same as the "
                    "repository having no keys.")
        return ("keys-unreadable",
                "the deploy keys could not be listed, so nothing here is a "
                "finding about the keys.")

    rows = [k for k in (keys or []) if isinstance(k, dict)]
    writable = writable_keys(rows)

    if not rows:
        if needs_write:
            return ("no-deploy-keys",
                    "this repository has no deploy keys at all, so a push over "
                    "SSH is authenticating with something else or not at all.")
        return ("no-deploy-keys",
                "this repository has no deploy keys, which is fine if nothing "
                "clones it over SSH.")

    if needs_write and not writable:
        return ("write-needed-none-capable",
                "this repository's automation pushes and all %d deploy key(s) "
                "on it are read-only, which is the whole failure." % len(rows))
    if needs_write:
        return ("write-capable-key-present",
                "%d of %d deploy key(s) can push, so a read-only key is not "
                "what refused this write." % (len(writable), len(rows)))
    if writable:
        return ("write-capable-but-unused",
                "%d deploy key(s) can push on a repository whose automation "
                "only reads. That is a standing grant rather than a failure."
                % len(writable))
    return ("read-only-and-correct",
            "every deploy key is read-only and nothing here needs to push, "
            "which is the recommended arrangement.")


def attribute_git_error(text):
    """Work out which credential refused a push, from the message. Pure.

    Returns (state, detail). Three of the four outcomes send the reader
    somewhere other than the deploy keys, which is the point: the same corner
    of the same build log holds four different problems.
    """
    message = str(text or "").lower()
    if not message.strip():
        return ("no-message", "nothing was supplied to attribute.")
    if "marked as read only" in message or "marked as read-only" in message:
        return ("deploy-key-read-only",
                "the message names the key itself, so the refusal is the key's "
                "declared capability and not a scope, a token or SSH.")
    if "protected branch" in message or "gh006" in message:
        return ("refused-by-branch-protection",
                "the credential was accepted and the branch refused the update. "
                "That is a rule on the ref rather than a capability problem.")
    if "archived" in message:
        return ("repository-archived",
                "the repository is archived and read-only, so no credential of "
                "any kind can write to it.")
    if "permission denied (publickey)" in message:
        return ("key-not-accepted",
                "the key was not accepted at all, so it is not on this "
                "repository or the agent presented a different one. This is "
                "authentication, not capability.")
    if "write access to repository not granted" in message:
        return ("write-not-granted",
                "the write was refused without naming the key. Over SSH that is "
                "a read-only deploy key; over HTTPS it is the token or the "
                "installation. The keys listing settles it.")
    return ("unattributed",
            "the message does not name a known refusal. List the keys anyway "
            "and check which credential the remote URL implies.")


def age_days(created_at, now=None):
    """How old a key is, in whole days. Pure. None when unreadable."""
    if not created_at:
        return None
    text = str(created_at).replace("Z", "+00:00")
    try:
        when = datetime.fromisoformat(text)
    except ValueError:
        return None
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    now = now or datetime.now(timezone.utc)
    return max(0, (now - when).days)


def stale_keys(keys, max_age_days=DEFAULT_MAX_AGE_DAYS, now=None):
    """Keys older than the rotation policy. Pure. Metadata only."""
    out = []
    for key in keys or []:
        if not isinstance(key, dict):
            continue
        age = age_days(key.get("created_at"), now)
        if age is not None and age >= max_age_days:
            row = redact(key)
            row["age_days"] = age
            out.append(row)
    return out


def repair(state):
    """The sentence a reader has to act on. Pure."""
    if state in ("write-needed-none-capable", "deploy-key-read-only"):
        return ("create a replacement deploy key with write access and delete "
                "the old one, or move the job to a GitHub App installation "
                "token with contents: write, which is scoped, expiring and "
                "auditable. read_only cannot be edited on an existing key.")
    if state == "write-capable-key-present":
        return ("look elsewhere for this refusal: a key that can push exists, "
                "so check the branch rules and the repository state.")
    if state == "write-capable-but-unused":
        return ("delete the write-capable key if nothing pushes with it. A "
                "standing write grant on a repository that only gets read is "
                "the kind of thing nobody revisits.")
    if state == "read-only-and-correct":
        return ("nothing. Read-only is the recommended default and this "
                "repository matches what its automation does.")
    if state == "no-deploy-keys":
        return ("check which credential your clone actually uses. With no "
                "deploy keys, an SSH remote is authenticating as a user rather "
                "than as the repository.")
    if state == "keys-unreadable":
        return ("run this with a token that has repository admin, or an App "
                "with administration: read. Do not record the keys as absent.")
    if state == "refused-by-branch-protection":
        return ("read the branch rules rather than the credential. The push was "
                "authorised and the ref refused it.")
    if state == "repository-archived":
        return ("skip the repository. An archived repository is read-only for "
                "every credential.")
    if state == "key-not-accepted":
        return ("fix authentication first: confirm the public key is on this "
                "repository and that the agent is presenting the matching "
                "private key.")
    if state == "write-not-granted":
        return ("check the remote URL. An SSH remote points at the deploy keys, "
                "an HTTPS one points at the token or the installation.")
    return ("list the deploy keys and read read_only before investigating SSH "
            "or scopes.")


def read_cost(repos):
    """Requests this run will spend against the core quota. Pure."""
    return len(repos or [])


def list_keys(session, full_name):
    """One GET of a repository's deploy keys. Returns (status, list)."""
    r = session.get(API + "/repos/" + full_name + "/keys?per_page=100",
                    timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
                         "or revoked")
    if r.status_code == 403 and "rate limit" in r.text.lower():
        raise SystemExit("403 rate limited. GET /rate_limit reports the reset "
                         "time and does not itself consume quota")
    if r.status_code != 200:
        return r.status_code, []
    try:
        body = r.json()
    except ValueError:
        return r.status_code, []
    return r.status_code, (body if isinstance(body, list) else [])


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", action="append", required=True,
                    help="owner/name to check. Repeatable.")
    ap.add_argument("--needs-write", action="store_true",
                    help="this repository's automation pushes over SSH")
    ap.add_argument("--git-error", default="",
                    help="the line your build log recorded, to attribute the "
                         "refusal without reproducing it")
    ap.add_argument("--max-age-days", type=int, default=DEFAULT_MAX_AGE_DAYS,
                    help="rotation policy for deploy keys, in days")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (read access plus repository admin for the "
                  "keys endpoint)")
        return 2

    log.info("read cost: %d request(s) per repository against the core hourly "
             "quota", 1)
    log.info("read cost: %d request(s) in total", read_cost(args.repo))

    attributed = None
    if args.git_error:
        state, detail = attribute_git_error(args.git_error)
        log.info("git error -> %s: %s", state, detail)
        log.info("repair: %s", repair(state))
        attributed = {"state": state, "detail": detail, "repair": repair(state)}

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

    findings = []
    for name in args.repo:
        status, keys = list_keys(session, name)
        # Reduced here, once. Nothing below this line has the key material.
        rows = redact_all(keys)
        state, detail = verdict(status, rows, args.needs_write)
        stale = stale_keys(rows, args.max_age_days)

        log.info("%s: %d deploy key(s), %d of them write-capable", name,
                 len(rows), len(writable_keys(rows)))
        for row in rows:
            added_by = row.get("added_by") or "unknown"
            age = age_days(row.get("created_at"))
            log.info('  key %s "%s" %s created %s by %s%s', row.get("id"),
                     row.get("title"), capability(row),
                     str(row.get("created_at") or "")[:10], added_by,
                     ", %d day(s) old" % age if age is not None else "")
        log.info("%s: %s", state, detail)
        log.info("repair: %s", repair(state))
        if stale:
            log.info("rotation: %d key(s) older than %d day(s)", len(stale),
                     args.max_age_days)

        findings.append({
            "repository": name,
            "keys_status": status,
            "keys": rows,
            "write_capable_ids": writable_keys(rows),
            "stale_keys": stale,
            "state": state,
            "detail": detail,
            "repair": repair(state),
        })

    print(json.dumps({
        "requests_spent": read_cost(args.repo),
        "git_error": attributed,
        "findings": findings,
    }, indent=2, default=str))
    bad = {"write-needed-none-capable", "write-capable-but-unused"}
    return 1 if any(f["state"] in bad for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
github-deploy-key-capability.mjs
/**
 * Check whether a repository's deploy keys can do what its automation needs.
 *
 * Read only. One GET per repository, nothing is written, and the keys are never
 * exercised: no push is attempted to find out whether a key can push. The
 * capability is declared on the key object as read_only.
 *
 * A deploy key's read_only flag is chosen at creation and cannot be edited
 * afterwards, so a read-only key added for a CI job that reads works perfectly
 * until somebody adds a push step. The refusal then arrives from Git over SSH
 * rather than from the API, and no scope or token change moves it.
 *
 * The public key material is dropped before anything is printed.
 *
 * Environment:
 *   GITHUB_TOKEN       read access plus repository admin, for the keys endpoint
 *   GITHUB_REPOS       comma-separated owner/name values
 *   GITHUB_NEEDS_WRITE set to 1 when the automation pushes over SSH
 *   GITHUB_GIT_ERROR   the line your build log recorded
 */
const API = 'https://api.github.com';
const UA = 'github-deploy-key-capability/1.0';

/** The only fields that leave this script. The key material is not among them. */
export const SAFE_FIELDS = ['id', 'title', 'read_only', 'created_at', 'verified',
  'added_by'];

/** A deploy key older than this is worth a look during the same read. */
export const DEFAULT_MAX_AGE_DAYS = 365;

/** One deploy key reduced to metadata. Pure. Never carries key material. */
export function redact(key) {
  if (!key || typeof key !== 'object') return {};
  const out = {};
  for (const field of SAFE_FIELDS) {
    if (field in key) out[field] = key[field];
  }
  return out;
}

/** The whole listing, reduced. Pure. */
export function redactAll(keys) {
  return (keys || []).filter((k) => k && typeof k === 'object').map(redact);
}

/** What this key is allowed to do, as declared. Pure. */
export function capability(key) {
  if (!key || typeof key !== 'object' || !('read_only' in key)) return 'unknown';
  return key.read_only ? 'read-only' : 'read-write';
}

/** The ids of keys that can push. Pure. */
export function writableKeys(keys) {
  return (keys || []).filter((k) => k && typeof k === 'object'
    && capability(k) === 'read-write').map((k) => k.id);
}

/** Classify one repository's deploy keys. Pure. [state, detail]. */
export function verdict(status, keys, needsWrite) {
  const code = Number(status);

  if (code !== 200) {
    if (code === 403 || code === 404) {
      return ['keys-unreadable', 'the deploy keys endpoint needs repository '
        + 'admin and this token does not have it. That is not the same as the '
        + 'repository having no keys.'];
    }
    return ['keys-unreadable', 'the deploy keys could not be listed, so nothing '
      + 'here is a finding about the keys.'];
  }

  const rows = (keys || []).filter((k) => k && typeof k === 'object');
  const writable = writableKeys(rows);

  if (!rows.length) {
    if (needsWrite) {
      return ['no-deploy-keys', 'this repository has no deploy keys at all, so '
        + 'a push over SSH is authenticating with something else or not at all.'];
    }
    return ['no-deploy-keys', 'this repository has no deploy keys, which is fine '
      + 'if nothing clones it over SSH.'];
  }

  if (needsWrite && !writable.length) {
    return ['write-needed-none-capable', "this repository's automation pushes "
      + `and all ${rows.length} deploy key(s) on it are read-only, which is the `
      + 'whole failure.'];
  }
  if (needsWrite) {
    return ['write-capable-key-present', `${writable.length} of ${rows.length} `
      + 'deploy key(s) can push, so a read-only key is not what refused this write.'];
  }
  if (writable.length) {
    return ['write-capable-but-unused', `${writable.length} deploy key(s) can `
      + 'push on a repository whose automation only reads. That is a standing '
      + 'grant rather than a failure.'];
  }
  return ['read-only-and-correct', 'every deploy key is read-only and nothing '
    + 'here needs to push, which is the recommended arrangement.'];
}

/** Work out which credential refused a push, from the message. Pure. */
export function attributeGitError(text) {
  const message = String(text ?? '').toLowerCase();
  if (!message.trim()) return ['no-message', 'nothing was supplied to attribute.'];
  if (message.includes('marked as read only') || message.includes('marked as read-only')) {
    return ['deploy-key-read-only', 'the message names the key itself, so the '
      + "refusal is the key's declared capability and not a scope, a token or SSH."];
  }
  if (message.includes('protected branch') || message.includes('gh006')) {
    return ['refused-by-branch-protection', 'the credential was accepted and the '
      + 'branch refused the update. That is a rule on the ref rather than a '
      + 'capability problem.'];
  }
  if (message.includes('archived')) {
    return ['repository-archived', 'the repository is archived and read-only, so '
      + 'no credential of any kind can write to it.'];
  }
  if (message.includes('permission denied (publickey)')) {
    return ['key-not-accepted', 'the key was not accepted at all, so it is not on '
      + 'this repository or the agent presented a different one. This is '
      + 'authentication, not capability.'];
  }
  if (message.includes('write access to repository not granted')) {
    return ['write-not-granted', 'the write was refused without naming the key. '
      + 'Over SSH that is a read-only deploy key; over HTTPS it is the token or '
      + 'the installation. The keys listing settles it.'];
  }
  return ['unattributed', 'the message does not name a known refusal. List the '
    + 'keys anyway and check which credential the remote URL implies.'];
}

/** How old a key is, in whole days. Pure. Null when unreadable. */
export function ageDays(createdAt, now = Date.now()) {
  if (!createdAt) return null;
  const when = Date.parse(String(createdAt));
  if (!Number.isFinite(when)) return null;
  return Math.max(0, Math.floor((now - when) / 86400000));
}

/** Keys older than the rotation policy. Pure. Metadata only. */
export function staleKeys(keys, maxAgeDays = DEFAULT_MAX_AGE_DAYS, now = Date.now()) {
  const out = [];
  for (const key of keys || []) {
    if (!key || typeof key !== 'object') continue;
    const age = ageDays(key.created_at, now);
    if (age !== null && age >= maxAgeDays) out.push({ ...redact(key), age_days: age });
  }
  return out;
}

/** The sentence a reader has to act on. Pure. */
export function repair(state) {
  if (['write-needed-none-capable', 'deploy-key-read-only'].includes(state)) {
    return 'create a replacement deploy key with write access and delete the old '
      + 'one, or move the job to a GitHub App installation token with '
      + 'contents: write, which is scoped, expiring and auditable. read_only '
      + 'cannot be edited on an existing key.';
  }
  if (state === 'write-capable-key-present') {
    return 'look elsewhere for this refusal: a key that can push exists, so check '
      + 'the branch rules and the repository state.';
  }
  if (state === 'write-capable-but-unused') {
    return 'delete the write-capable key if nothing pushes with it. A standing '
      + 'write grant on a repository that only gets read is the kind of thing '
      + 'nobody revisits.';
  }
  if (state === 'read-only-and-correct') {
    return 'nothing. Read-only is the recommended default and this repository '
      + 'matches what its automation does.';
  }
  if (state === 'no-deploy-keys') {
    return 'check which credential your clone actually uses. With no deploy keys, '
      + 'an SSH remote is authenticating as a user rather than as the repository.';
  }
  if (state === 'keys-unreadable') {
    return 'run this with a token that has repository admin, or an App with '
      + 'administration: read. Do not record the keys as absent.';
  }
  if (state === 'refused-by-branch-protection') {
    return 'read the branch rules rather than the credential. The push was '
      + 'authorised and the ref refused it.';
  }
  if (state === 'repository-archived') {
    return 'skip the repository. An archived repository is read-only for every '
      + 'credential.';
  }
  if (state === 'key-not-accepted') {
    return 'fix authentication first: confirm the public key is on this '
      + 'repository and that the agent is presenting the matching private key.';
  }
  if (state === 'write-not-granted') {
    return 'check the remote URL. An SSH remote points at the deploy keys, an '
      + 'HTTPS one points at the token or the installation.';
  }
  return 'list the deploy keys and read read_only before investigating SSH or scopes.';
}

/** Requests this run will spend against the core quota. Pure. */
export function readCost(repos) {
  return (repos || []).length;
}

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 names = (process.env.GITHUB_REPOS || '').split(',')
    .map((n) => n.trim()).filter(Boolean);
  if (!token || !names.length) {
    console.error('set GITHUB_TOKEN and GITHUB_REPOS');
    process.exitCode = 2;
    return;
  }
  const needsWrite = process.env.GITHUB_NEEDS_WRITE === '1';

  console.log('read cost: 1 request(s) per repository against the core hourly quota');
  console.log(`read cost: ${readCost(names)} request(s) in total`);

  let attributed = null;
  const gitError = process.env.GITHUB_GIT_ERROR || '';
  if (gitError) {
    const [state, detail] = attributeGitError(gitError);
    console.log(`git error -> ${state}: ${detail}`);
    console.log(`repair: ${repair(state)}`);
    attributed = { state, detail, repair: repair(state) };
  }

  const findings = [];
  for (const name of names) {
    const res = await fetch(`${API}/repos/${name}/keys?per_page=100`,
      { headers: headers(token) });
    let body = null;
    try { body = await res.json(); } catch { body = null; }
    // Reduced here, once. Nothing below this line has the key material.
    const rows = redactAll(Array.isArray(body) ? body : []);
    const [state, detail] = verdict(res.status, rows, needsWrite);
    const stale = staleKeys(rows);

    console.log(`${name}: ${rows.length} deploy key(s), `
      + `${writableKeys(rows).length} of them write-capable`);
    for (const row of rows) {
      const age = ageDays(row.created_at);
      console.log(`  key ${row.id} "${row.title}" ${capability(row)} created `
        + `${String(row.created_at || '').slice(0, 10)} by ${row.added_by || 'unknown'}`
        + (age === null ? '' : `, ${age} day(s) old`));
    }
    console.log(`${state}: ${detail}`);
    console.log(`repair: ${repair(state)}`);
    if (stale.length) {
      console.log(`rotation: ${stale.length} key(s) older than ${DEFAULT_MAX_AGE_DAYS} day(s)`);
    }

    findings.push({
      repository: name,
      keys_status: res.status,
      keys: rows,
      write_capable_ids: writableKeys(rows),
      stale_keys: stale,
      state,
      detail,
      repair: repair(state),
    });
  }

  console.log(JSON.stringify({
    requests_spent: readCost(names),
    git_error: attributed,
    findings,
  }, null, 2));
  const bad = ['write-needed-none-capable', 'write-capable-but-unused'];
  process.exitCode = findings.some((f) => bad.includes(f.state)) ? 1 : 0;
}

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

Add a test

The first test is about the thing that must never happen: the key material goes in and does not come out, asserted against the serialised report rather than against a field name, because that is where a leak would actually appear. After that the error classifier gets a test per outcome, since its whole job is to send three readers out of four somewhere else, and the verdict is tested against both answers to the one question the API cannot answer — whether this repository's automation pushes. A refusal to list the keys is asserted to stay a refusal rather than becoming an empty inventory.

test_github_deploy_key_capability.py
import json

from github_deploy_key_capability import (
    DEFAULT_MAX_AGE_DAYS, SAFE_FIELDS, age_days, attribute_git_error,
    capability, read_cost, redact, redact_all, repair, stale_keys, verdict,
    writable_keys,
)

# Obviously not a key. The point of the fixture is that it never comes back out.
FAKE_MATERIAL = "ssh-ed25519 FAKE"

READ_ONLY = {"id": 41288114, "key": FAKE_MATERIAL, "title": "ci-fetch",
             "read_only": True, "created_at": "2021-06-02T11:03:00Z",
             "verified": True, "added_by": "dana-ops"}
WRITABLE = {"id": 55210987, "key": FAKE_MATERIAL, "title": "release-runner",
            "read_only": False, "created_at": "2024-11-18T08:00:00Z",
            "verified": True, "added_by": "build-bot"}


def test_the_key_material_never_leaves_the_script():
    reduced = redact(READ_ONLY)
    assert "key" not in reduced
    assert FAKE_MATERIAL not in json.dumps(reduced)
    assert FAKE_MATERIAL not in json.dumps(redact_all([READ_ONLY, WRITABLE]))
    assert set(reduced) <= set(SAFE_FIELDS)
    assert reduced["id"] == 41288114
    assert reduced["read_only"] is True


def test_redaction_survives_junk_without_leaking_it():
    assert redact(None) == {}
    assert redact("not a key") == {}
    assert redact_all(None) == []
    assert redact_all([None, "x", READ_ONLY]) == [redact(READ_ONLY)]


def test_the_capability_is_a_declared_field_not_an_experiment():
    assert capability(READ_ONLY) == "read-only"
    assert capability(WRITABLE) == "read-write"
    assert capability({"id": 1}) == "unknown"
    assert capability(None) == "unknown"
    assert writable_keys([READ_ONLY, WRITABLE]) == [55210987]
    assert writable_keys([READ_ONLY]) == []


def test_a_pushing_job_with_only_read_only_keys_is_the_finding():
    state, detail = verdict(200, [READ_ONLY, READ_ONLY], True)
    assert state == "write-needed-none-capable"
    assert "all 2 deploy key(s)" in detail
    assert "cannot be edited on an existing key" in repair(state)
    assert "contents: write" in repair(state)


def test_the_same_keys_are_correct_when_nothing_pushes():
    state, detail = verdict(200, [READ_ONLY], False)
    assert state == "read-only-and-correct"
    assert "recommended arrangement" in detail
    assert repair(state).startswith("nothing.")


def test_a_write_capable_key_is_reported_either_way():
    assert verdict(200, [READ_ONLY, WRITABLE], True)[0] == "write-capable-key-present"
    state, detail = verdict(200, [READ_ONLY, WRITABLE], False)
    assert state == "write-capable-but-unused"
    assert "standing grant" in detail


def test_a_refused_listing_is_not_an_empty_inventory():
    state, detail = verdict(403, [], True)
    assert state == "keys-unreadable"
    assert "not the same as the repository having no keys" in detail
    assert "Do not record the keys as absent" in repair(state)
    assert verdict(404, [], False)[0] == "keys-unreadable"
    assert verdict(None, [], False)[0] == "keys-unreadable"


def test_no_keys_at_all_is_its_own_answer():
    state, detail = verdict(200, [], True)
    assert state == "no-deploy-keys"
    assert "authenticating with something else" in detail
    assert "which credential your clone actually uses" in repair(state)


def test_the_read_only_message_names_the_key_itself():
    state, detail = attribute_git_error(
        "ERROR: The key you are authenticating with has been marked as read only.")
    assert state == "deploy-key-read-only"
    assert "not a scope, a token or SSH" in detail


def test_three_of_the_four_messages_send_you_somewhere_else():
    assert attribute_git_error(
        "remote: error: GH006: Protected branch update failed")[0] == (
        "refused-by-branch-protection")
    assert attribute_git_error(
        "remote: Repository was archived so is read-only.")[0] == "repository-archived"
    assert attribute_git_error(
        "git@github.com: Permission denied (publickey).")[0] == "key-not-accepted"


def test_an_unnamed_write_refusal_depends_on_the_protocol():
    state, detail = attribute_git_error(
        "remote: Write access to repository not granted.")
    assert state == "write-not-granted"
    assert "Over SSH" in detail
    assert "remote URL" in repair(state)


def test_an_unknown_or_absent_message_is_not_invented():
    assert attribute_git_error("something else entirely")[0] == "unattributed"
    assert attribute_git_error("")[0] == "no-message"
    assert attribute_git_error(None)[0] == "no-message"


def test_the_inventory_reports_age_without_reporting_material():
    from datetime import datetime, timezone
    now = datetime(2026, 8, 31, tzinfo=timezone.utc)
    assert age_days("2021-06-02T11:03:00Z", now) == 1915
    assert age_days(None) is None
    assert age_days("not a date") is None
    stale = stale_keys([READ_ONLY, WRITABLE], DEFAULT_MAX_AGE_DAYS, now)
    assert len(stale) == 2
    assert stale[0]["age_days"] == 1915
    assert FAKE_MATERIAL not in json.dumps(stale)
    assert stale_keys([READ_ONLY], 10000, now) == []


def test_the_cost_is_worked_out_before_anything_is_fetched():
    assert read_cost(["a", "b"]) == 2
    assert read_cost([]) == 0
    assert read_cost(None) == 0
    assert DEFAULT_MAX_AGE_DAYS == 365
github-deploy-key-capability.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  DEFAULT_MAX_AGE_DAYS, SAFE_FIELDS, ageDays, attributeGitError, capability,
  readCost, redact, redactAll, repair, staleKeys, verdict, writableKeys,
} from './github-deploy-key-capability.mjs';

// Obviously not a key. The point of the fixture is that it never comes back out.
const FAKE_MATERIAL = 'ssh-ed25519 FAKE';

const READ_ONLY = {
  id: 41288114,
  key: FAKE_MATERIAL,
  title: 'ci-fetch',
  read_only: true,
  created_at: '2021-06-02T11:03:00Z',
  verified: true,
  added_by: 'dana-ops',
};
const WRITABLE = {
  id: 55210987,
  key: FAKE_MATERIAL,
  title: 'release-runner',
  read_only: false,
  created_at: '2024-11-18T08:00:00Z',
  verified: true,
  added_by: 'build-bot',
};

test('the key material never leaves the script', () => {
  const reduced = redact(READ_ONLY);
  assert.ok(!('key' in reduced));
  assert.ok(!JSON.stringify(reduced).includes(FAKE_MATERIAL));
  assert.ok(!JSON.stringify(redactAll([READ_ONLY, WRITABLE])).includes(FAKE_MATERIAL));
  assert.ok(Object.keys(reduced).every((k) => SAFE_FIELDS.includes(k)));
  assert.equal(reduced.id, 41288114);
  assert.equal(reduced.read_only, true);
});

test('redaction survives junk without leaking it', () => {
  assert.deepEqual(redact(null), {});
  assert.deepEqual(redact('not a key'), {});
  assert.deepEqual(redactAll(null), []);
  assert.deepEqual(redactAll([null, 'x', READ_ONLY]), [redact(READ_ONLY)]);
});

test('the capability is a declared field not an experiment', () => {
  assert.equal(capability(READ_ONLY), 'read-only');
  assert.equal(capability(WRITABLE), 'read-write');
  assert.equal(capability({ id: 1 }), 'unknown');
  assert.equal(capability(null), 'unknown');
  assert.deepEqual(writableKeys([READ_ONLY, WRITABLE]), [55210987]);
  assert.deepEqual(writableKeys([READ_ONLY]), []);
});

test('a pushing job with only read only keys is the finding', () => {
  const [state, detail] = verdict(200, [READ_ONLY, READ_ONLY], true);
  assert.equal(state, 'write-needed-none-capable');
  assert.match(detail, /all 2 deploy key\(s\)/);
  assert.match(repair(state), /cannot be edited on an existing key/);
  assert.match(repair(state), /contents: write/);
});

test('the same keys are correct when nothing pushes', () => {
  const [state, detail] = verdict(200, [READ_ONLY], false);
  assert.equal(state, 'read-only-and-correct');
  assert.match(detail, /recommended arrangement/);
  assert.ok(repair(state).startsWith('nothing.'));
});

test('a write capable key is reported either way', () => {
  assert.equal(verdict(200, [READ_ONLY, WRITABLE], true)[0], 'write-capable-key-present');
  const [state, detail] = verdict(200, [READ_ONLY, WRITABLE], false);
  assert.equal(state, 'write-capable-but-unused');
  assert.match(detail, /standing grant/);
});

test('a refused listing is not an empty inventory', () => {
  const [state, detail] = verdict(403, [], true);
  assert.equal(state, 'keys-unreadable');
  assert.match(detail, /not the same as the repository having no keys/);
  assert.match(repair(state), /Do not record the keys as absent/);
  assert.equal(verdict(404, [], false)[0], 'keys-unreadable');
  assert.equal(verdict(null, [], false)[0], 'keys-unreadable');
});

test('no keys at all is its own answer', () => {
  const [state, detail] = verdict(200, [], true);
  assert.equal(state, 'no-deploy-keys');
  assert.match(detail, /authenticating with something else/);
  assert.match(repair(state), /which credential your clone actually uses/);
});

test('the read only message names the key itself', () => {
  const [state, detail] = attributeGitError(
    'ERROR: The key you are authenticating with has been marked as read only.');
  assert.equal(state, 'deploy-key-read-only');
  assert.match(detail, /not a scope, a token or SSH/);
});

test('three of the four messages send you somewhere else', () => {
  assert.equal(attributeGitError(
    'remote: error: GH006: Protected branch update failed')[0],
  'refused-by-branch-protection');
  assert.equal(attributeGitError(
    'remote: Repository was archived so is read-only.')[0], 'repository-archived');
  assert.equal(attributeGitError(
    'git@github.com: Permission denied (publickey).')[0], 'key-not-accepted');
});

test('an unnamed write refusal depends on the protocol', () => {
  const [state, detail] = attributeGitError(
    'remote: Write access to repository not granted.');
  assert.equal(state, 'write-not-granted');
  assert.match(detail, /Over SSH/);
  assert.match(repair(state), /remote URL/);
});

test('an unknown or absent message is not invented', () => {
  assert.equal(attributeGitError('something else entirely')[0], 'unattributed');
  assert.equal(attributeGitError('')[0], 'no-message');
  assert.equal(attributeGitError(null)[0], 'no-message');
});

test('the inventory reports age without reporting material', () => {
  const now = Date.parse('2026-08-31T00:00:00Z');
  assert.equal(ageDays('2021-06-02T11:03:00Z', now), 1915);
  assert.equal(ageDays(null), null);
  assert.equal(ageDays('not a date'), null);
  const stale = staleKeys([READ_ONLY, WRITABLE], DEFAULT_MAX_AGE_DAYS, now);
  assert.equal(stale.length, 2);
  assert.equal(stale[0].age_days, 1915);
  assert.ok(!JSON.stringify(stale).includes(FAKE_MATERIAL));
  assert.deepEqual(staleKeys([READ_ONLY], 10000, now), []);
});

test('the cost is worked out before anything is fetched', () => {
  assert.equal(readCost(['a', 'b']), 2);
  assert.equal(readCost([]), 0);
  assert.equal(readCost(null), 0);
  assert.equal(DEFAULT_MAX_AGE_DAYS, 365);
});

FAQ

Can I make an existing deploy key writable?

No. <code>read_only</code> is set when the key is created and is not editable afterwards, which is why this is a replacement rather than an adjustment. Add a new key created with write access, point the job at it, and delete the old one so nobody is left wondering which of the two is live. That is also the moment to ask whether a deploy key is still the right credential, because a key on a repository has no expiry and no identity beyond its title.

Why did widening my token change nothing?

Because the push was not using the token. A repository can be reached over SSH with a deploy key and over HTTPS with a token, and a job that does both is using two unrelated credentials with independent capabilities. If the remote is <code>git@github.com:owner/repo.git</code>, the deploy key is what authorised the push and the token is irrelevant to it. Check the remote URL first; it tells you which credential to investigate and saves the hour that usually goes into scopes.

Should we use a GitHub App instead?

Usually, yes, for anything that writes. An installation token with <code>contents: write</code> expires in about an hour, is scoped to the repositories you select, and appears in the audit log as the App that used it, whereas a write-capable deploy key is permanent, anonymous beyond its title, and sits on the repository until somebody notices it. Deploy keys remain a good answer for read-only cloning where an App would be more machinery than the job deserves.

The keys endpoint returned 403. Does that mean there are no keys?

It means you cannot see them, and the two must not be recorded the same way. Listing deploy keys needs repository admin, so a read-only auditing token gets a refusal on repositories that may have any number of keys. The script reports that as unreadable rather than as an empty inventory, because a report saying &ldquo;no deploy keys anywhere&rdquo; that was produced by a token which could not read any of them is worse than no report.

Does the script try a push to confirm the key cannot write?

No. Every script in this section is read only, and here it is not even a limitation: the capability is declared on the key object as a boolean, so pushing would establish the same fact one request later while writing to somebody's repository. The script reads the field, reports ids and titles and dates &mdash; never the key material itself &mdash; and classifies the error line you already have from the build log.

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.