Skip to content

Diagnostic GitHub API

the repository is archived so every write returns 403

The labelling bot has been failing on one repository since March. Everything it reads comes back perfectly: issues, labels, the repository object itself, all 200. The moment it tries to add a label it gets 403, so the on-call runbook says permissions, so somebody widens the token, and it still gets 403. The token was never the problem. Somebody archived the repository seven months ago, which makes it read-only for everyone and every credential, and the bot has been retrying a request that will not be accepted by anyone until it is unarchived.

Read-only token Python and Node.js Tests included
Holding a card and phone
Photo by Nathana Reboucas on Unsplash
The short answer

Archiving a repository makes it read-only. The API keeps serving every read, which is why the failure looks selective, and refuses every write with 403 and a message about the repository being archived — regardless of the token, the scopes, the App permissions or the caller's role.

One field settles it before any write is attempted: GET /repos/{owner}/{repo} returns archived, alongside disabled. For a whole organisation, GET /orgs/{org}/repos?type=all&per_page=100 returns the same boolean per repository. Filter archived: true out at the top of any write-path loop and treat it as a permanent skip rather than a retryable error. Nothing in your integration can fix it; only unarchiving can, and that is a decision about whether the repository is still in use.

The problem in plain words

Everything about this points at credentials. A 403 is the permission-denied status, the failure is on one repository out of many, and the same token works everywhere else — which is the exact signature of a narrowed grant. So the investigation goes where that signature leads: check the scopes, check the App installation, check whether the repository was left off a selective installation, mint a wider token, try again. Each of those is an hour, each of them is reasonable, and none of them changes the answer.

The read path making it worse is the cruel part. If the repository had gone away entirely, the bot would have got 404s on everything and somebody would have looked at the repository first. Instead the repository is completely healthy from the outside: it lists, it reads, its issues are all there, its metadata is current. A resource that answers every question except one looks like an authorisation problem, because normally that is what it is.

Then the retry logic quietly makes it expensive. A 403 is usually transient enough to be worth retrying — rate limits arrive as 403 — so most clients retry it, some with backoff and some without. A daily job with three retries against forty archived repositories is a few hundred requests a day spent on a refusal that has been permanent since March, and because the failures are caught and logged the job still reports success. Nobody sees the number until somebody asks why the quota chart has a floor under it.

Bot writestwice a dayone label perissue403 on everywritereads all stillfineToken widenedtwicescopes, then anAppStill 403,since Marchnothing changed atallRetried on aschedule864 requests a dayand the credential is blamed again
Every read succeeds throughout. A resource that answers every question except one looks exactly like a permissions problem.

Why it happens

Read-only is the whole feature. Archiving exists to preserve a repository as it was: readable, searchable, cloneable, and frozen. The API implements exactly that, so a read and a write to the same path get different answers by design. The response says so — the message names the repository as archived — but it arrives as a 403 alongside every other kind of refusal, and most clients bucket by status code long before they read a message.

No credential can override it. This is the unusual part and the reason it wastes so much time. Every other 403 in this section is answered by changing what the caller holds: a scope that was never granted, a permission the endpoint wanted, an installation that was narrowed. Here the owner of the repository, with admin and a token that can delete organisations, gets the same 403. The state belongs to the repository, so no amount of work on the credential moves it.

It is permanent until a human decides otherwise, so it is a skip and not a retry. Retrying is the correct response to a rate limit, a 502 and a timeout, and it is the wrong response to this. The classification a client needs is not “is this a 403” but “can this ever succeed”, and archived answers that in advance, before the request is sent, for free, on a field the client is usually already fetching.

Filter at the top of the loop, not in the error handler. The reason to read archived up front rather than parse the 403 afterwards is that the up-front version costs nothing and covers everything. One organisation listing at per_page=100 gives you the boolean for every repository you were about to touch, in one request per hundred repositories, and the write loop simply never visits them. Handling it in the error path means you pay for the failed request every time, for every repository, forever.

archived and disabled are different states and sit next to each other. They arrive in the same response and are easy to conflate, and they should not be: an archived repository is fully readable and was frozen deliberately by someone on your side, while a disabled one is not fully readable and was disabled by GitHub over billing or a terms problem. The remedies have different owners, so the report keeps them apart.

This script never attempts the write. It reads archived and reports it, and where you paste in a refusal you already recorded it attributes that message. Sending a write to an archived repository to watch the 403 arrive would prove the same fact one request later, and it would be a write — against a repository somebody deliberately froze, which is the last place to be experimenting.

The fix, as a flow

The detection is one field, so the script is mostly about what to do with it. One read per repository, or one per hundred across an organisation, gives archived and disabled for everything the write loop was about to touch. The classification afterwards turns those two booleans into the output a client actually needs, which is a policy rather than a status code: permanent skip or retry. Beside it sits arithmetic that converts a retry rate into requests an hour and a share of the quota, and an attribution of a 403 you already recorded, done from its message rather than by sending it again.

One repository objectarchived and disabled, read oncearchived truepermanent skip, never a retrydisabled truea different note and a different ownerBoth trueunarchiving alone changes nothingNeitherthe refusal is about something else
One read, two booleans, four answers. Only the last one leaves the credential as a suspect.

How to fix it

Read the boolean before the loop, not the error after it

One GET /repos/{owner}/{repo} per repository, or one GET /orgs/{org}/repos?type=all&per_page=100 per hundred repositories for a whole organisation. archived comes back on every one of them. This is the entire detection and it happens before a single write is contemplated.

Separate archived from disabled while you are there

The same response carries disabled. They mean different things, have different owners and need different reports, so the script classifies into four states rather than two and hands the disabled ones to their own note. A repository can be both, and that is reported as both.

Classify the state as permanent or retryable

The output your client needs is not the status code, it is the policy: permanent skip for archived and disabled, retry for the transient failures, unknown where the repository could not be read. Wire that verdict into the write path and the retry loop stops existing for these repositories rather than being tuned.

Price the retries you have already spent

Give the script --attempts-per-hour and it multiplies out what a retrying client has been spending against the hourly core quota on requests that could never have been accepted. This is usually the number that gets the fix prioritised, because it converts “some errors in the log” into a share of the quota that everything else on that token is competing for.

Attribute a 403 you already recorded, without reproducing it

Paste the message from your logs into --failure-message and the script names the cause rather than leaving you to infer it: an archived repository, a rate limit, a credential refusal or something else. It does not reproduce the failure to check. The repository object already said archived: true, and a second opinion obtained by writing to somebody's frozen repository is not worth having.

How to check it worked

After the filter is at the top of the loop, the archived repositories are skipped rather than attempted, and the requests they were consuming come back.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_archived_repo_guard.py \
    --org acme --attempts-per-hour 12 \
    --failure-message "Repository was archived so is read-only."
# read cost: 1 request(s) per 100 repositories in an org sweep
# acme: 212 repository(ies) read in 3 request(s)
# acme/legacy-billing: archived
#   permanent-skip: archiving makes a repository read-only, so no write will
#   ever be accepted here regardless of the token
#   last push 214 day(s) ago
#   repair: filter archived repositories out at the top of the write loop and
#   treat this as a permanent skip. Unarchive only if it is genuinely in use.
# acme/legacy-reports: archived
# acme/vendor-mirror: archived
#
# recorded failure -> archived-refusal: the message names the repository as
# archived, which is a property of the repository and not of your credential
# retry cost: 12 attempt(s)/hour against 3 archived repository(ies) is 36
# request(s)/hour, 864 a day, 17% of a 5000/hour quota
# summary: 212 repositories, 3 archived, 0 disabled, 209 writable

The full code

The detection is one field, so the code around it is about what to do with the field rather than how to obtain it. The classification is pure and has four states because archived and disabled can each be true independently; the retry policy is a separate function from the state because it is the part that goes into your client; and the arithmetic exists to turn a log line into a number of requests an hour. The one thing the script will not do is attempt the write, so the sweep and the attribution both work from readable state and from a message you already have.

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_archived_repo_guard.py
"""Find archived repositories before a write loop discovers them the hard way.

Read only. One GET per repository, or one per hundred in an organisation
sweep, and nothing is written. In particular no write is attempted against an
archived repository to confirm the 403: the archived boolean on the repository
object is the finding, it arrives before any write would be sent, and the
repository was deliberately frozen by somebody who would rather it stayed that
way.

The point of the note: archiving makes a repository read-only. Reads keep
working, which is why the failure looks selective and looks like permissions,
and every write is refused with 403 regardless of the token, the scopes, the
App permissions or the caller's role. No credential change fixes it.

What this can and cannot see: whether your own client retries a 403 is
invisible from here, so the retry cost is computed from a rate you supply. The
archived boolean itself is exact.

Environment:

    GITHUB_TOKEN    a token with read access to the repositories
"""
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_archived_repo_guard")

API = "https://api.github.com"
UA = "github-archived-repo-guard/1.0"

# The core hourly quota a retrying client is spending these requests out of.
CORE_QUOTA_PER_HOUR = 5000

# One listing request covers this many repositories.
ORG_PAGE_SIZE = 100

# Words that identify an archived repository in a refusal you already recorded.
ARCHIVED_WORDS = ("archived", "read-only", "read only")


def lifecycle(repo):
    """Which platform state this repository is in. Pure.

    Four states rather than two: archived and disabled are separate booleans
    that arrive in the same response, they can both be set, and they have
    different owners and different remedies.
    """
    if not isinstance(repo, dict):
        return "unknown"
    archived = bool(repo.get("archived"))
    disabled = bool(repo.get("disabled"))
    if archived and disabled:
        return "archived-and-disabled"
    if archived:
        return "archived"
    if disabled:
        return "disabled"
    return "active"


def accepts_writes(state):
    """Whether a write to this repository can ever be accepted. Pure."""
    if state in ("archived", "disabled", "archived-and-disabled"):
        return False
    if state == "active":
        return True
    return None


def retry_policy(state):
    """What a client should do with a failure here. Pure.

    This is the output that belongs in the write path. A 403 from a rate limit
    is worth retrying and a 403 from an archived repository never is, and the
    status code cannot tell them apart.
    """
    if accepts_writes(state) is False:
        return "permanent-skip"
    if state == "active":
        return "retry"
    return "unknown"


def explain(state):
    """Why this repository refuses writes, in one sentence. Pure."""
    if state == "archived":
        return ("archiving makes a repository read-only, so no write will ever "
                "be accepted here regardless of the token.")
    if state == "disabled":
        return ("the repository is disabled, which is a different state with a "
                "different owner: see the disabled repository note.")
    if state == "archived-and-disabled":
        return ("the repository is both archived and disabled. Unarchiving it "
                "would still leave it disabled, so the disabled state is the "
                "one to resolve first.")
    if state == "active":
        return "the repository accepts writes; this refusal is about something else."
    return "the repository could not be read, so its state is unknown."


def classify_failure(status, message):
    """Attribute a refusal you already recorded. Pure. (state, detail).

    Nothing is sent to produce this. The message comes out of your logs, and
    the repository object has already answered the same question independently.
    """
    text = str(message or "").lower()
    try:
        code = int(status)
    except (TypeError, ValueError):
        code = None

    if any(word in text for word in ARCHIVED_WORDS):
        return ("archived-refusal",
                "the message names the repository as archived, which is a "
                "property of the repository and not of your credential.")
    if "rate limit" in text:
        return ("rate-limited",
                "a rate limit, which is a transient 403 and the one kind worth "
                "retrying. That is a different note.")
    if "not accessible" in text or "integration" in text or "personal access token" in text:
        return ("credential-refusal",
                "the message blames the credential rather than the repository, "
                "so this is a permissions problem and widening the grant may "
                "actually help.")
    if code == 404:
        return ("not-found",
                "404 rather than 403, which means several things at once and "
                "needs its own triage.")
    if code == 403:
        return ("forbidden-unattributed",
                "a 403 whose message names neither the repository state nor a "
                "rate limit. Read the repository object to settle it.")
    return ("no-failure", "nothing here names a refusal.")


def days_since(timestamp, now=None):
    """Whole days between an ISO 8601 timestamp and now. Pure. None if absent."""
    if not timestamp:
        return None
    text = str(timestamp).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 wasted_requests(attempts_per_hour, repositories, hours=1):
    """Requests a retrying client spends on refusals that cannot succeed. Pure."""
    try:
        rate = max(0, int(attempts_per_hour or 0))
        count = max(0, int(repositories or 0))
        span = max(0, int(hours or 0))
    except (TypeError, ValueError):
        return 0
    return rate * count * span


def quota_share(requests_per_hour, quota=CORE_QUOTA_PER_HOUR):
    """That spend as a whole-number percentage of the hourly quota. Pure."""
    try:
        spend = max(0, int(requests_per_hour or 0))
    except (TypeError, ValueError):
        return 0
    if not quota:
        return 0
    return int(round(100.0 * spend / quota))


def skip_list(rows):
    """The repositories a write loop should never visit. Pure and sorted."""
    names = []
    for row in rows or []:
        if not isinstance(row, dict):
            continue
        if accepts_writes(row.get("state")) is False and row.get("full_name"):
            names.append(str(row["full_name"]))
    return sorted(set(names))


def summarise(rows):
    """Counts for the bottom of the report. Pure."""
    counts = {"total": 0, "archived": 0, "disabled": 0, "writable": 0,
              "unknown": 0}
    for row in rows or []:
        state = (row or {}).get("state")
        counts["total"] += 1
        if state in ("archived", "archived-and-disabled"):
            counts["archived"] += 1
        if state in ("disabled", "archived-and-disabled"):
            counts["disabled"] += 1
        if state == "active":
            counts["writable"] += 1
        if state == "unknown":
            counts["unknown"] += 1
    return counts


def repair(state):
    """The sentence a reader has to act on. Pure."""
    if state == "archived":
        return ("filter archived repositories out at the top of the write loop "
                "and treat this as a permanent skip. Unarchive only if the "
                "repository is genuinely still in use.")
    if state == "disabled":
        return ("see /github/repo-disabled/ -- a disabled repository is a "
                "different state with a different owner, usually billing or a "
                "terms problem rather than a decision on your side.")
    if state == "archived-and-disabled":
        return ("resolve the disabled state with GitHub first; unarchiving on "
                "its own will not make this repository writable.")
    if state == "active":
        return ("nothing here. This repository accepts writes, so a refusal "
                "against it is about the credential or the branch.")
    if state == "archived-refusal":
        return ("stop retrying and skip. No token, scope or App permission "
                "makes an archived repository writable.")
    if state == "rate-limited":
        return ("honour retry-after and slow down. This one really is worth "
                "retrying.")
    if state == "credential-refusal":
        return ("triage the credential: the message blames the token or the "
                "integration rather than the repository state.")
    return ("read the repository object and use the archived and disabled "
            "booleans rather than inferring state from a status code.")


def read_cost_for_repos(repos):
    """Requests a per-repository run will spend. Pure."""
    return len(repos or [])


def pages_for(count, page_size=ORG_PAGE_SIZE):
    """Listing requests an organisation of this size needs. Pure."""
    try:
        total = max(0, int(count or 0))
    except (TypeError, ValueError):
        return 0
    if not total:
        return 0
    return (total + page_size - 1) // page_size


def parse_link(header):
    """The Link header as {rel: url}. Pure.

    Scanned rather than split on commas, because a URL may contain one and a
    naive split turns the next page into two unusable halves.
    """
    text = str(header or "")
    links, i = {}, 0
    while True:
        start = text.find("<", i)
        if start < 0:
            break
        end = text.find(">", start)
        if end < 0:
            break
        url = text[start + 1:end]
        tail = text[end + 1:]
        stop = tail.find("<")
        segment = tail if stop < 0 else tail[:stop]
        rel = ""
        for bit in segment.split(";"):
            bit = bit.strip()
            if bit.startswith("rel="):
                rel = bit[4:].strip().strip(",").strip('"')
        if rel:
            links[rel] = url
        i = end + 1
    return links


def row_for(repo):
    """One report row from one repository object. Pure."""
    state = lifecycle(repo)
    return {
        "full_name": (repo or {}).get("full_name"),
        "state": state,
        "accepts_writes": accepts_writes(state),
        "retry_policy": retry_policy(state),
        "explanation": explain(state),
        "days_since_last_push": days_since((repo or {}).get("pushed_at")),
        "repair": repair(state),
    }


def get_repo(session, full_name):
    """One GET of a repository object. Returns a dict or None."""
    r = session.get(API + "/repos/" + full_name, 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 None
    try:
        body = r.json()
    except ValueError:
        return None
    return body if isinstance(body, dict) else None


def list_org_repos(session, org, max_pages=20):
    """Every repository in an organisation. Returns (repos, requests_spent)."""
    url = "%s/orgs/%s/repos?type=all&per_page=%d" % (API, org, ORG_PAGE_SIZE)
    repos, spent = [], 0
    while url and spent < max_pages:
        r = session.get(url, timeout=30)
        spent += 1
        if r.status_code != 200:
            break
        try:
            page = r.json()
        except ValueError:
            break
        if not isinstance(page, list):
            break
        repos.extend(item for item in page if isinstance(item, dict))
        url = parse_link(r.headers.get("Link")).get("next")
    return repos, spent


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", action="append", default=[],
                    help="owner/name to check. Repeatable.")
    ap.add_argument("--org", help="sweep every repository in an organisation")
    ap.add_argument("--attempts-per-hour", type=int, default=0,
                    help="how often your write loop retries a failing "
                         "repository, so the waste can be stated in requests")
    ap.add_argument("--failure-message", default="",
                    help="a refusal you already recorded, to attribute it "
                         "without reproducing it")
    ap.add_argument("--failure-status", default="",
                    help="the status code recorded alongside it")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2
    if not args.repo and not args.org:
        log.error("give at least one --repo or an --org to sweep")
        return 2

    if args.repo:
        log.info("read cost: %d request(s) against the core hourly quota",
                 read_cost_for_repos(args.repo))
    if args.org:
        log.info("read cost: 1 request(s) per %d repositories in an org sweep",
                 ORG_PAGE_SIZE)

    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,
    })

    rows = []
    if args.org:
        repos, spent = list_org_repos(session, args.org)
        log.info("%s: %d repository(ies) read in %d request(s)", args.org,
                 len(repos), spent)
        rows.extend(row_for(repo) for repo in repos)
    for name in args.repo:
        repo = get_repo(session, name)
        if repo is None:
            rows.append({"full_name": name, "state": "unknown",
                         "accepts_writes": None, "retry_policy": "unknown",
                         "explanation": explain("unknown"),
                         "days_since_last_push": None,
                         "repair": repair("unknown")})
        else:
            rows.append(row_for(repo))

    frozen = [row for row in rows if row["accepts_writes"] is False]
    for row in frozen:
        log.info("%s: %s", row["full_name"], row["state"])
        log.info("  %s: %s", row["retry_policy"], row["explanation"])
        if row["days_since_last_push"] is not None:
            log.info("  last push %d day(s) ago", row["days_since_last_push"])
        log.info("  repair: %s", row["repair"])

    recorded = None
    if args.failure_message or args.failure_status:
        state, detail = classify_failure(args.failure_status, args.failure_message)
        log.info("recorded failure -> %s: %s", state, detail)
        log.info("repair: %s", repair(state))
        recorded = {"state": state, "detail": detail}

    spend = wasted_requests(args.attempts_per_hour, len(frozen))
    if spend:
        log.info("retry cost: %d attempt(s)/hour against %d frozen "
                 "repository(ies) is %d request(s)/hour, %d a day, %d%% of a "
                 "%d/hour quota", args.attempts_per_hour, len(frozen), spend,
                 spend * 24, quota_share(spend), CORE_QUOTA_PER_HOUR)

    counts = summarise(rows)
    log.info("summary: %d repositories, %d archived, %d disabled, %d writable",
             counts["total"], counts["archived"], counts["disabled"],
             counts["writable"])

    print(json.dumps({
        "counts": counts,
        "skip_list": skip_list(rows),
        "wasted_requests_per_hour": spend,
        "quota_share_percent": quota_share(spend),
        "recorded_failure": recorded,
        "repositories": rows,
    }, indent=2, default=str))
    return 1 if frozen else 0


if __name__ == "__main__":
    sys.exit(main())
github-archived-repo-guard.mjs
/**
 * Find archived repositories before a write loop discovers them the hard way.
 *
 * Read only. One GET per repository, or one per hundred in an organisation
 * sweep, and nothing is written. No write is attempted against an archived
 * repository to confirm the 403: the archived boolean is the finding and it
 * arrives before any write would be sent.
 *
 * Archiving makes a repository read-only. Reads keep working, every write is
 * refused with 403, and no token, scope or App permission changes that.
 *
 * Environment:
 *   GITHUB_TOKEN      a token with read access to the repositories
 *   GITHUB_REPOS      comma-separated owner/name values
 *   GITHUB_ORG        an organisation to sweep instead
 *   GITHUB_ATTEMPTS   retries an hour your write loop makes
 *   GITHUB_FAILURE    a refusal message you already recorded
 */
const API = 'https://api.github.com';
const UA = 'github-archived-repo-guard/1.0';

/** The core hourly quota a retrying client spends these requests out of. */
export const CORE_QUOTA_PER_HOUR = 5000;

/** One listing request covers this many repositories. */
export const ORG_PAGE_SIZE = 100;

/** Words that identify an archived repository in a recorded refusal. */
export const ARCHIVED_WORDS = ['archived', 'read-only', 'read only'];

/** Which platform state this repository is in. Pure. */
export function lifecycle(repo) {
  if (!repo || typeof repo !== 'object') return 'unknown';
  const archived = Boolean(repo.archived);
  const disabled = Boolean(repo.disabled);
  if (archived && disabled) return 'archived-and-disabled';
  if (archived) return 'archived';
  if (disabled) return 'disabled';
  return 'active';
}

/** Whether a write to this repository can ever be accepted. Pure. */
export function acceptsWrites(state) {
  if (['archived', 'disabled', 'archived-and-disabled'].includes(state)) return false;
  if (state === 'active') return true;
  return null;
}

/** What a client should do with a failure here. Pure. */
export function retryPolicy(state) {
  if (acceptsWrites(state) === false) return 'permanent-skip';
  if (state === 'active') return 'retry';
  return 'unknown';
}

/** Why this repository refuses writes, in one sentence. Pure. */
export function explain(state) {
  if (state === 'archived') {
    return 'archiving makes a repository read-only, so no write will ever be '
      + 'accepted here regardless of the token.';
  }
  if (state === 'disabled') {
    return 'the repository is disabled, which is a different state with a '
      + 'different owner: see the disabled repository note.';
  }
  if (state === 'archived-and-disabled') {
    return 'the repository is both archived and disabled. Unarchiving it would '
      + 'still leave it disabled, so the disabled state is the one to resolve first.';
  }
  if (state === 'active') {
    return 'the repository accepts writes; this refusal is about something else.';
  }
  return 'the repository could not be read, so its state is unknown.';
}

/** Attribute a refusal you already recorded. Pure. [state, detail]. */
export function classifyFailure(status, message) {
  const text = String(message ?? '').toLowerCase();
  const code = Number(status);

  if (ARCHIVED_WORDS.some((word) => text.includes(word))) {
    return ['archived-refusal', 'the message names the repository as archived, '
      + 'which is a property of the repository and not of your credential.'];
  }
  if (text.includes('rate limit')) {
    return ['rate-limited', 'a rate limit, which is a transient 403 and the one '
      + 'kind worth retrying. That is a different note.'];
  }
  if (text.includes('not accessible') || text.includes('integration')
    || text.includes('personal access token')) {
    return ['credential-refusal', 'the message blames the credential rather '
      + 'than the repository, so this is a permissions problem and widening the '
      + 'grant may actually help.'];
  }
  if (code === 404) {
    return ['not-found', '404 rather than 403, which means several things at '
      + 'once and needs its own triage.'];
  }
  if (code === 403) {
    return ['forbidden-unattributed', 'a 403 whose message names neither the '
      + 'repository state nor a rate limit. Read the repository object to settle it.'];
  }
  return ['no-failure', 'nothing here names a refusal.'];
}

/** Whole days between an ISO 8601 timestamp and now. Pure. Null if absent. */
export function daysSince(timestamp, now = Date.now()) {
  if (!timestamp) return null;
  const when = Date.parse(String(timestamp));
  if (!Number.isFinite(when)) return null;
  return Math.max(0, Math.floor((now - when) / 86400000));
}

/** Requests a retrying client spends on refusals that cannot succeed. Pure. */
export function wastedRequests(attemptsPerHour, repositories, hours = 1) {
  const rate = Math.max(0, Math.trunc(Number(attemptsPerHour) || 0));
  const count = Math.max(0, Math.trunc(Number(repositories) || 0));
  const span = Math.max(0, Math.trunc(Number(hours) || 0));
  return rate * count * span;
}

/** That spend as a whole-number percentage of the hourly quota. Pure. */
export function quotaShare(requestsPerHour, quota = CORE_QUOTA_PER_HOUR) {
  const spend = Math.max(0, Math.trunc(Number(requestsPerHour) || 0));
  if (!quota) return 0;
  return Math.round((100 * spend) / quota);
}

/** The repositories a write loop should never visit. Pure and sorted. */
export function skipList(rows) {
  const names = new Set();
  for (const row of rows || []) {
    if (!row || typeof row !== 'object') continue;
    if (acceptsWrites(row.state) === false && row.full_name) {
      names.add(String(row.full_name));
    }
  }
  return [...names].sort();
}

/** Counts for the bottom of the report. Pure. */
export function summarise(rows) {
  const counts = {
    total: 0, archived: 0, disabled: 0, writable: 0, unknown: 0,
  };
  for (const row of rows || []) {
    const state = (row || {}).state;
    counts.total += 1;
    if (['archived', 'archived-and-disabled'].includes(state)) counts.archived += 1;
    if (['disabled', 'archived-and-disabled'].includes(state)) counts.disabled += 1;
    if (state === 'active') counts.writable += 1;
    if (state === 'unknown') counts.unknown += 1;
  }
  return counts;
}

/** The sentence a reader has to act on. Pure. */
export function repair(state) {
  if (state === 'archived') {
    return 'filter archived repositories out at the top of the write loop and '
      + 'treat this as a permanent skip. Unarchive only if the repository is '
      + 'genuinely still in use.';
  }
  if (state === 'disabled') {
    return 'see /github/repo-disabled/ -- a disabled repository is a different '
      + 'state with a different owner, usually billing or a terms problem '
      + 'rather than a decision on your side.';
  }
  if (state === 'archived-and-disabled') {
    return 'resolve the disabled state with GitHub first; unarchiving on its '
      + 'own will not make this repository writable.';
  }
  if (state === 'active') {
    return 'nothing here. This repository accepts writes, so a refusal against '
      + 'it is about the credential or the branch.';
  }
  if (state === 'archived-refusal') {
    return 'stop retrying and skip. No token, scope or App permission makes an '
      + 'archived repository writable.';
  }
  if (state === 'rate-limited') {
    return 'honour retry-after and slow down. This one really is worth retrying.';
  }
  if (state === 'credential-refusal') {
    return 'triage the credential: the message blames the token or the '
      + 'integration rather than the repository state.';
  }
  return 'read the repository object and use the archived and disabled booleans '
    + 'rather than inferring state from a status code.';
}

/** Requests a per-repository run will spend. Pure. */
export function readCostForRepos(repos) {
  return (repos || []).length;
}

/** Listing requests an organisation of this size needs. Pure. */
export function pagesFor(count, pageSize = ORG_PAGE_SIZE) {
  const total = Math.max(0, Math.trunc(Number(count) || 0));
  if (!total) return 0;
  return Math.ceil(total / pageSize);
}

/** The Link header as {rel: url}. Pure. Comma-safe. */
export function parseLink(header) {
  const text = String(header ?? '');
  const links = {};
  let i = 0;
  for (;;) {
    const start = text.indexOf('<', i);
    if (start < 0) break;
    const end = text.indexOf('>', start);
    if (end < 0) break;
    const url = text.slice(start + 1, end);
    const tail = text.slice(end + 1);
    const stop = tail.indexOf('<');
    const segment = stop < 0 ? tail : tail.slice(0, stop);
    let rel = '';
    for (const raw of segment.split(';')) {
      const bit = raw.trim();
      if (bit.startsWith('rel=')) rel = bit.slice(4).trim().replace(/[",]/g, '');
    }
    if (rel) links[rel] = url;
    i = end + 1;
  }
  return links;
}

/** One report row from one repository object. Pure. */
export function rowFor(repo) {
  const state = lifecycle(repo);
  return {
    full_name: (repo || {}).full_name,
    state,
    accepts_writes: acceptsWrites(state),
    retry_policy: retryPolicy(state),
    explanation: explain(state),
    days_since_last_push: daysSince((repo || {}).pushed_at),
    repair: repair(state),
  };
}

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

async function listOrgRepos(token, org, maxPages = 20) {
  let url = `${API}/orgs/${org}/repos?type=all&per_page=${ORG_PAGE_SIZE}`;
  const repos = [];
  let spent = 0;
  while (url && spent < maxPages) {
    const res = await fetch(url, { headers: headers(token) });
    spent += 1;
    if (!res.ok) break;
    let page = null;
    try { page = await res.json(); } catch { page = null; }
    if (!Array.isArray(page)) break;
    repos.push(...page.filter((item) => item && typeof item === 'object'));
    url = parseLink(res.headers.get('link')).next;
  }
  return { repos, spent };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const org = process.env.GITHUB_ORG;
  const names = (process.env.GITHUB_REPOS || '').split(',')
    .map((n) => n.trim()).filter(Boolean);
  if (!token || (!org && !names.length)) {
    console.error('set GITHUB_TOKEN and either GITHUB_ORG or GITHUB_REPOS');
    process.exitCode = 2;
    return;
  }

  if (names.length) {
    console.log(`read cost: ${readCostForRepos(names)} request(s) against the `
      + 'core hourly quota');
  }
  if (org) {
    console.log(`read cost: 1 request(s) per ${ORG_PAGE_SIZE} repositories in `
      + 'an org sweep');
  }

  const rows = [];
  if (org) {
    const { repos, spent } = await listOrgRepos(token, org);
    console.log(`${org}: ${repos.length} repository(ies) read in ${spent} request(s)`);
    rows.push(...repos.map(rowFor));
  }
  for (const name of names) {
    const res = await fetch(`${API}/repos/${name}`, { headers: headers(token) });
    let body = null;
    try { body = await res.json(); } catch { body = null; }
    rows.push(res.status === 200 && body ? rowFor(body) : {
      full_name: name,
      state: 'unknown',
      accepts_writes: null,
      retry_policy: 'unknown',
      explanation: explain('unknown'),
      days_since_last_push: null,
      repair: repair('unknown'),
    });
  }

  const frozen = rows.filter((row) => row.accepts_writes === false);
  for (const row of frozen) {
    console.log(`${row.full_name}: ${row.state}`);
    console.log(`  ${row.retry_policy}: ${row.explanation}`);
    if (row.days_since_last_push !== null) {
      console.log(`  last push ${row.days_since_last_push} day(s) ago`);
    }
    console.log(`  repair: ${row.repair}`);
  }

  let recorded = null;
  const message = process.env.GITHUB_FAILURE || '';
  if (message) {
    const [state, detail] = classifyFailure('', message);
    console.log(`recorded failure -> ${state}: ${detail}`);
    console.log(`repair: ${repair(state)}`);
    recorded = { state, detail };
  }

  const attempts = Number(process.env.GITHUB_ATTEMPTS || 0);
  const spend = wastedRequests(attempts, frozen.length);
  if (spend) {
    console.log(`retry cost: ${attempts} attempt(s)/hour against ${frozen.length} `
      + `frozen repository(ies) is ${spend} request(s)/hour, ${spend * 24} a day, `
      + `${quotaShare(spend)}% of a ${CORE_QUOTA_PER_HOUR}/hour quota`);
  }

  const counts = summarise(rows);
  console.log(`summary: ${counts.total} repositories, ${counts.archived} `
    + `archived, ${counts.disabled} disabled, ${counts.writable} writable`);

  console.log(JSON.stringify({
    counts,
    skip_list: skipList(rows),
    wasted_requests_per_hour: spend,
    quota_share_percent: quotaShare(spend),
    recorded_failure: recorded,
    repositories: rows,
  }, null, 2));
  process.exitCode = frozen.length ? 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 state machine is asserted first, including the case people forget: a repository can be archived and disabled at once, and unarchiving it would still leave it refusing. Then the part that actually changes a client's behaviour — the retry policy, which has to come out as a permanent skip rather than as a status code — and the attribution of a refusal already in your logs, which is tested to blame the repository when the message names it and the credential when the message names that instead. The arithmetic and the Link parsing are pinned last, the latter with a comma inside a URL because that is what breaks a naive sweep on page two.

test_github_archived_repo_guard.py
from github_archived_repo_guard import (
    CORE_QUOTA_PER_HOUR, ORG_PAGE_SIZE, accepts_writes, classify_failure,
    days_since, explain, lifecycle, pages_for, parse_link, quota_share,
    read_cost_for_repos, repair, retry_policy, row_for, skip_list, summarise,
    wasted_requests,
)

ARCHIVED = {"full_name": "acme/legacy-billing", "archived": True,
            "disabled": False, "pushed_at": "2025-01-27T09:14:00Z"}
ACTIVE = {"full_name": "acme/platform-api", "archived": False,
          "disabled": False, "pushed_at": "2026-08-20T09:14:00Z"}
DISABLED = {"full_name": "acme/suspended-thing", "archived": False,
            "disabled": True}
BOTH = {"full_name": "acme/frozen-and-gone", "archived": True, "disabled": True}


def test_the_two_booleans_make_four_states():
    assert lifecycle(ARCHIVED) == "archived"
    assert lifecycle(ACTIVE) == "active"
    assert lifecycle(DISABLED) == "disabled"
    assert lifecycle(BOTH) == "archived-and-disabled"
    assert lifecycle(None) == "unknown"
    assert lifecycle("not a repo") == "unknown"


def test_an_archived_repository_can_never_accept_a_write():
    assert accepts_writes("archived") is False
    assert accepts_writes("archived-and-disabled") is False
    assert accepts_writes("disabled") is False
    assert accepts_writes("active") is True
    assert accepts_writes("unknown") is None


def test_the_output_a_client_needs_is_a_policy_not_a_status_code():
    assert retry_policy("archived") == "permanent-skip"
    assert retry_policy("disabled") == "permanent-skip"
    assert retry_policy("active") == "retry"
    assert retry_policy("unknown") == "unknown"


def test_the_explanation_says_the_token_is_irrelevant():
    assert "regardless of the token" in explain("archived")
    assert "different owner" in explain("disabled")
    assert "would still leave it disabled" in explain("archived-and-disabled")
    assert "unknown" in explain("nonsense")


def test_a_recorded_refusal_is_attributed_without_being_reproduced():
    state, detail = classify_failure(403, "Repository was archived so is read-only.")
    assert state == "archived-refusal"
    assert "not of your credential" in detail
    assert "No token, scope or App permission" in repair(state)


def test_a_rate_limit_is_the_one_403_worth_retrying():
    state, _ = classify_failure(403, "API rate limit exceeded for user ID 1")
    assert state == "rate-limited"
    assert "retry-after" in repair(state)


def test_a_credential_refusal_is_handed_back_to_the_credential():
    state, detail = classify_failure(403, "Resource not accessible by integration")
    assert state == "credential-refusal"
    assert "blames the credential" in detail
    assert classify_failure(404, "Not Found")[0] == "not-found"
    assert classify_failure(403, "Forbidden")[0] == "forbidden-unattributed"
    assert classify_failure("", "")[0] == "no-failure"


def test_the_retry_waste_is_stated_in_requests_and_in_quota():
    assert wasted_requests(12, 3) == 36
    assert wasted_requests(12, 3, 24) == 864
    assert wasted_requests(0, 3) == 0
    assert wasted_requests(None, None) == 0
    assert quota_share(864) == 17
    assert quota_share(0) == 0
    assert CORE_QUOTA_PER_HOUR == 5000


def test_the_skip_list_holds_everything_that_cannot_be_written_to():
    rows = [row_for(ARCHIVED), row_for(ACTIVE), row_for(DISABLED), row_for(BOTH)]
    assert skip_list(rows) == ["acme/frozen-and-gone", "acme/legacy-billing",
                               "acme/suspended-thing"]
    assert skip_list([]) == []
    assert skip_list(None) == []


def test_the_summary_counts_a_repository_in_both_columns_when_it_is_both():
    counts = summarise([row_for(ARCHIVED), row_for(ACTIVE), row_for(BOTH)])
    assert counts == {"total": 3, "archived": 2, "disabled": 1, "writable": 1,
                      "unknown": 0}


def test_a_row_carries_the_policy_and_the_repair_together():
    row = row_for(ARCHIVED)
    assert row["state"] == "archived"
    assert row["retry_policy"] == "permanent-skip"
    assert row["accepts_writes"] is False
    assert "top of the write loop" in row["repair"]
    assert row["days_since_last_push"] is not None


def test_an_age_is_read_from_the_timestamp_or_left_alone():
    from datetime import datetime, timezone
    now = datetime(2026, 8, 31, tzinfo=timezone.utc)
    assert days_since("2026-08-01T00:00:00Z", now) == 30
    assert days_since("2027-01-01T00:00:00Z", now) == 0
    assert days_since(None) is None
    assert days_since("not a date") is None


def test_the_cost_is_worked_out_before_anything_is_fetched():
    assert read_cost_for_repos(["a", "b", "c"]) == 3
    assert read_cost_for_repos([]) == 0
    assert ORG_PAGE_SIZE == 100
    assert pages_for(212) == 3
    assert pages_for(100) == 1
    assert pages_for(0) == 0


def test_the_link_header_survives_a_comma_inside_a_url():
    header = ('<https://api.github.com/orgs/acme/repos?type=all,public&page=2>; '
              'rel="next", <https://api.github.com/orgs/acme/repos?page=3>; rel="last"')
    links = parse_link(header)
    assert links["next"].endswith("page=2")
    assert links["last"].endswith("page=3")
    assert parse_link("") == {}
    assert parse_link(None) == {}
github-archived-repo-guard.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  CORE_QUOTA_PER_HOUR, ORG_PAGE_SIZE, acceptsWrites, classifyFailure, daysSince,
  explain, lifecycle, pagesFor, parseLink, quotaShare, readCostForRepos, repair,
  retryPolicy, rowFor, skipList, summarise, wastedRequests,
} from './github-archived-repo-guard.mjs';

const ARCHIVED = {
  full_name: 'acme/legacy-billing',
  archived: true,
  disabled: false,
  pushed_at: '2025-01-27T09:14:00Z',
};
const ACTIVE = {
  full_name: 'acme/platform-api',
  archived: false,
  disabled: false,
  pushed_at: '2026-08-20T09:14:00Z',
};
const DISABLED = {
  full_name: 'acme/suspended-thing', archived: false, disabled: true,
};
const BOTH = { full_name: 'acme/frozen-and-gone', archived: true, disabled: true };

test('the two booleans make four states', () => {
  assert.equal(lifecycle(ARCHIVED), 'archived');
  assert.equal(lifecycle(ACTIVE), 'active');
  assert.equal(lifecycle(DISABLED), 'disabled');
  assert.equal(lifecycle(BOTH), 'archived-and-disabled');
  assert.equal(lifecycle(null), 'unknown');
  assert.equal(lifecycle('not a repo'), 'unknown');
});

test('an archived repository can never accept a write', () => {
  assert.equal(acceptsWrites('archived'), false);
  assert.equal(acceptsWrites('archived-and-disabled'), false);
  assert.equal(acceptsWrites('disabled'), false);
  assert.equal(acceptsWrites('active'), true);
  assert.equal(acceptsWrites('unknown'), null);
});

test('the output a client needs is a policy not a status code', () => {
  assert.equal(retryPolicy('archived'), 'permanent-skip');
  assert.equal(retryPolicy('disabled'), 'permanent-skip');
  assert.equal(retryPolicy('active'), 'retry');
  assert.equal(retryPolicy('unknown'), 'unknown');
});

test('the explanation says the token is irrelevant', () => {
  assert.match(explain('archived'), /regardless of the token/);
  assert.match(explain('disabled'), /different owner/);
  assert.match(explain('archived-and-disabled'), /would still leave it disabled/);
  assert.match(explain('nonsense'), /unknown/);
});

test('a recorded refusal is attributed without being reproduced', () => {
  const [state, detail] = classifyFailure(403,
    'Repository was archived so is read-only.');
  assert.equal(state, 'archived-refusal');
  assert.match(detail, /not of your credential/);
  assert.match(repair(state), /No token, scope or App permission/);
});

test('a rate limit is the one 403 worth retrying', () => {
  assert.equal(classifyFailure(403, 'API rate limit exceeded for user ID 1')[0],
    'rate-limited');
  assert.match(repair('rate-limited'), /retry-after/);
});

test('a credential refusal is handed back to the credential', () => {
  const [state, detail] = classifyFailure(403, 'Resource not accessible by integration');
  assert.equal(state, 'credential-refusal');
  assert.match(detail, /blames the credential/);
  assert.equal(classifyFailure(404, 'Not Found')[0], 'not-found');
  assert.equal(classifyFailure(403, 'Forbidden')[0], 'forbidden-unattributed');
  assert.equal(classifyFailure('', '')[0], 'no-failure');
});

test('the retry waste is stated in requests and in quota', () => {
  assert.equal(wastedRequests(12, 3), 36);
  assert.equal(wastedRequests(12, 3, 24), 864);
  assert.equal(wastedRequests(0, 3), 0);
  assert.equal(wastedRequests(null, null), 0);
  assert.equal(quotaShare(864), 17);
  assert.equal(quotaShare(0), 0);
  assert.equal(CORE_QUOTA_PER_HOUR, 5000);
});

test('the skip list holds everything that cannot be written to', () => {
  const rows = [rowFor(ARCHIVED), rowFor(ACTIVE), rowFor(DISABLED), rowFor(BOTH)];
  assert.deepEqual(skipList(rows), ['acme/frozen-and-gone', 'acme/legacy-billing',
    'acme/suspended-thing']);
  assert.deepEqual(skipList([]), []);
  assert.deepEqual(skipList(null), []);
});

test('the summary counts a repository in both columns when it is both', () => {
  assert.deepEqual(summarise([rowFor(ARCHIVED), rowFor(ACTIVE), rowFor(BOTH)]), {
    total: 3, archived: 2, disabled: 1, writable: 1, unknown: 0,
  });
});

test('a row carries the policy and the repair together', () => {
  const row = rowFor(ARCHIVED);
  assert.equal(row.state, 'archived');
  assert.equal(row.retry_policy, 'permanent-skip');
  assert.equal(row.accepts_writes, false);
  assert.match(row.repair, /top of the write loop/);
  assert.notEqual(row.days_since_last_push, null);
});

test('an age is read from the timestamp or left alone', () => {
  const now = Date.parse('2026-08-31T00:00:00Z');
  assert.equal(daysSince('2026-08-01T00:00:00Z', now), 30);
  assert.equal(daysSince('2027-01-01T00:00:00Z', now), 0);
  assert.equal(daysSince(null), null);
  assert.equal(daysSince('not a date'), null);
});

test('the cost is worked out before anything is fetched', () => {
  assert.equal(readCostForRepos(['a', 'b', 'c']), 3);
  assert.equal(readCostForRepos([]), 0);
  assert.equal(ORG_PAGE_SIZE, 100);
  assert.equal(pagesFor(212), 3);
  assert.equal(pagesFor(100), 1);
  assert.equal(pagesFor(0), 0);
});

test('the link header survives a comma inside a url', () => {
  const header = '<https://api.github.com/orgs/acme/repos?type=all,public&page=2>; '
    + 'rel="next", <https://api.github.com/orgs/acme/repos?page=3>; rel="last"';
  const links = parseLink(header);
  assert.ok(links.next.endsWith('page=2'));
  assert.ok(links.last.endsWith('page=3'));
  assert.deepEqual(parseLink(''), {});
  assert.deepEqual(parseLink(null), {});
});

FAQ

Can I widen the token so writes work again?

No, and this is the fact that saves the afternoon. Archiving is a property of the repository rather than of your access to it, so the organisation owner with full admin gets exactly the same 403 as a narrow read-only token. Every other 403 in this section is answered by changing what the caller holds; this one is answered only by unarchiving, which is a decision about whether the repository is still in use rather than a permissions task. If a wider token was the first thing you tried, that instinct was reasonable and it cannot work here.

Why do reads keep working?

Because that is the entire point of archiving. The repository is preserved exactly as it was and stays readable, searchable and cloneable indefinitely; only mutation is refused. From an integration's point of view this is the worst possible shape of failure, since a resource that answers every question except one looks like an authorisation problem rather than a lifecycle one. If the repository had been deleted you would have got 404s everywhere and looked at the repository first.

Should my client retry the 403?

Not this one. Retrying is right for a rate limit, a 502 and a timeout, and wrong for a state that will not change until a person changes it. The useful classification is not the status code but whether the request can ever succeed, and <code>archived</code> answers that before the request is sent. Treat it as a permanent skip: filter those repositories out at the top of the loop, and if you want the number that makes the case for doing it, the script multiplies your retry rate by the count and reports it as a share of the hourly quota.

How do I sweep a whole organisation cheaply?

<code>GET /orgs/{org}/repos?type=all&amp;per_page=100</code> returns <code>archived</code> and <code>disabled</code> on every repository, so an organisation of two hundred costs three requests and gives you the complete skip list. Follow the <code>Link</code> header for the remaining pages. That is cheaper than discovering the same thing one failed write at a time, and it happens before the write loop rather than inside its error handler.

Does the script write to an archived repository to prove the 403?

It does not, and it will not. Every script in this section is read only, and this is the case where that discipline matters most: the repository was frozen deliberately, so it is the last place to run an experiment. The proof is a boolean the API hands you for free on a read you were probably making anyway. If you have a 403 in your logs, paste the message in and the script attributes it &mdash; archived repository, rate limit, or credential &mdash; from the text and the repository object together.

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.