Diagnostic GitHub API
The client is pointed at the wrong GitHub host entirely
The code is shared between the hosted product and the customer's own installation, and it works in one of them. In the other every endpoint answers 404, which sends everybody hunting for a permission, or the token gets a flat 401 Bad credentials despite being minted twenty minutes ago and pasted straight in. Both symptoms have the same cause and it is not on the list anybody checks: the client is talking to a different GitHub installation from the one that holds the resources. An environment variable did not get set, or an SDK fell back to its built-in default, and the requests are going somewhere the token means nothing and the repositories do not exist.
There are three host families and they are separate installations, not variations on one. github.com serves its API at https://api.github.com. GitHub Enterprise Server serves REST at https://HOSTNAME/api/v3 and GraphQL at https://HOSTNAME/api/graphql. Enterprise Cloud with data residency serves its API under https://api.SUBDOMAIN.ghe.com. A credential from one is meaningless at the others and so are the resource identifiers.
One unauthenticated read tells you which one you reached. GET {base}/meta returns an installed_version field on Enterprise Server and does not on github.com — that field is the cleanest single discriminator available. Two more readings make it robust: an HTML content type means you dropped the /api/v3 and are talking to the web interface, which answers 200 and looks alive; and GET {base}/, the root endpoint map, contains absolute URLs like current_user_url whose host is the host that actually served you, which survives a redirect that a value in a config file does not.
The problem in plain words
The reason this eats a day is that both symptoms are famously caused by something else. A 404 on this API is what a permission problem looks like, by design, so a client pointed at the wrong installation produces exactly the fingerprint of a token that cannot see anything — and it produces it on every single endpoint, which reads as a very broken token rather than as a very wrong hostname. The 401 version is worse, because a fresh credential returning Bad credentials convinces people the credential is malformed, and the next hour goes into whitespace, encoding and copy-paste.
The second reason is that nobody wrote the wrong URL. It is a default. An SDK constructed without a base URL uses github.com; a helper reads GITHUB_API_URL and falls back when the variable is absent; a container inherits the staging value into production. Nothing in the code says api.github.com, so grepping for it finds nothing, and the value only exists at runtime in a place nobody prints.
The third is the version that does not fail at all. Point a client at the Enterprise Server hostname and forget the /api/v3 suffix and you are talking to the web interface. It answers 200. It sends HTML. A client that only checks the status code, or that shrugs at a JSON parse error and returns an empty result, reports success and finds nothing — the same silent under-report an unmonitored integration can carry for months.
Why it happens
These are separate installations, not one API with different names. An Enterprise Server appliance has its own users, its own repositories, its own numeric identifiers and its own credentials. A token minted on github.com is not a weaker token there, it is not a token at all. This is why the failure is total rather than partial and why it does not respond to any change you make to permissions: there is nothing on the other end that has ever heard of your account.
installed_version is the discriminator worth building on. The Enterprise Server documentation for GET /meta lists installed_version in its response schema and the github.com one does not, so the presence of that field identifies an appliance and prints its version at the same time. It needs no token on github.com, which means this check can run at process start before any credential has been read.
The root endpoint map names the host that answered. GET / returns a map of URL templates, and they are absolute: on github.com current_user_url is https://api.github.com/user, and on an appliance it carries the appliance's hostname and /api/v3. Comparing that host against the base URL you configured catches the case the configured value cannot: a redirect, a proxy, or a load balancer sending you somewhere other than the name you dialled.
A token's shape says nothing about which host it belongs to. Prefixes tell you the credential class — fine-grained, classic, installation — and nothing about the installation that issued it. There is no local test for "is this a github.com token", so the only honest check is behavioural: call GET /user against the configured base and assert the login is the one you expect. The script takes that expected login as an argument rather than inventing a heuristic.
What this cannot tell you, and it matters. Enterprise Cloud organizations live on api.github.com alongside everything else, so /meta cannot distinguish an enterprise-managed organization from a personal account, and it should not try: that is not a host problem and nothing about it will show up here. Data residency is different, because it moves the host to api.SUBDOMAIN.ghe.com, and that one is visible in the URL.
The fix, as a flow
Three independent readings of one question, two of them free and neither needing a credential. The URL you configured is a guess about where the request lands. The host's own answer to a metadata read settles which product it is, because an appliance reports its installed version and github.com does not. The root endpoint map settles something the other two cannot: its URLs are absolute, so the host inside them is the host that actually served you, which is the only reading that survives a redirect or a proxy. The credential is checked last and by behaviour alone, because a token's prefix names its class and never the installation that issued it.
How to fix it
Print the base URL the process is actually using
Not the one in the deployment manifest, the one the client resolved at startup. This is the whole bug in most cases and it is usually one line to log. The script takes it as --base so that everything it reports is anchored to a value you have seen with your own eyes rather than assumed.
Read /meta against that base, unauthenticated
No token needed. installed_version in the response means you are on an Enterprise Server appliance and the script prints the version. Its absence, with a valid /meta document, means github.com or Enterprise Cloud. An HTML content type means the /api/v3 suffix is missing and you are talking to the web interface, which is the failure mode that returns 200 and finds nothing.
Compare the host you dialled with the host that answered
GET {base}/ returns the root endpoint map, whose URLs are absolute. The script pulls the host out of current_user_url and compares it against the host in your base URL. A mismatch is a redirect or a proxy, and it is the one thing reading your configuration cannot catch.
Assert the identity, because the token cannot be checked locally
With a token and --expect-login, the script calls GET /user and checks the login and the host inside html_url. A 401 here against a base that /meta identified as an appliance is the credential-from-the-other-installation case stated plainly, rather than as a mystery about a fresh token.
Make the check a startup assertion, not a debugging session
The repair the script prints is to set the base URL explicitly per environment and to assert at startup that the host answering matches the host expected. Two unauthenticated reads, no quota worth counting, and the class of bug where a job runs happily against the wrong installation for a week stops existing.
How to check it worked
With the base URL set for the environment, the guessed family and the observed one agree, and the host that answered is the host that was dialled.
python3 github_api_host.py --base https://github.acme.internal/api/v3 \
--expect-login dana-integration
# read cost: 3 REST request(s), 2 of them unauthenticated and free
# configured: host github.acme.internal, guessed family enterprise-server
# meta: installed_version present (3.14.2) -> enterprise-server
# served host: github.acme.internal (from current_user_url in the root map)
# agrees: the family guessed from the URL, the family the host reports, and
# the host that actually answered are all the same installation.
# identity: dana-integration, html_url host github.acme.internal — as expected.
# note: a token's prefix names its class and never its installation, so this
# assertion is the only local test there is.
# repair: nothing to change. Keep this as a startup assertion rather than a
# thing somebody runs after a week of 404s.
The full code
Two unauthenticated reads and one optional authenticated one, and everything that decides anything is string work on a URL and a lookup for one field. That is worth keeping pure because the interesting inputs are hosts you do not have: an appliance, a data-residency subdomain, a web interface answering HTML to a client that expected JSON. All three are fixtures here, and the agreement function that compares three independent readings of the same question is the part with the tests.
"""Say which GitHub installation this client is actually talking to.
Read only, and two of its three calls need no credential at all. Nothing is
configured, set or written: the repair is an environment variable and a
startup assertion, and both are printed.
The point of the note: github.com, a GitHub Enterprise Server appliance and an
Enterprise Cloud tenant with data residency are separate installations. A
credential from one is meaningless at the others, and a base URL that defaults
to the wrong one produces a 404 on every route or a flat 401 on a token minted
minutes ago.
What this can and cannot see: /meta reports installed_version on Enterprise
Server, which is the cleanest discriminator available. It cannot separate an
Enterprise Cloud organization from a personal account, because both are served
from api.github.com, and that is not a host problem.
Environment:
GITHUB_TOKEN optional. Needed only for the identity assertion.
"""
import argparse
import json
import logging
import os
import sys
from urllib.parse import urlparse
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_api_host")
UA = "github-api-host/1.0"
DOTCOM_API_HOST = "api.github.com"
DOTCOM_WEB_HOST = "github.com"
GHES_REST_SUFFIX = "/api/v3"
GHES_GRAPHQL_SUFFIX = "/api/graphql"
RESIDENCY_SUFFIX = ".ghe.com"
FAMILIES = ("dotcom", "enterprise-server", "enterprise-cloud-data-residency",
"web-host-not-api", "unknown")
def read_cost(with_identity):
"""(requests, unauthenticated ones) this run will spend. Pure.
The second number is the point: the host check needs no credential, so it
can run at process start before any secret has been read.
"""
made = 2 + (1 if with_identity else 0)
return (made, 2)
def normalise_base(url):
"""Trim a base URL to a comparable form. Pure."""
value = str(url or "").strip()
while value.endswith("/"):
value = value[:-1]
return value
def host_of(url):
"""The hostname in a URL, lowercased, or None. Pure."""
try:
parsed = urlparse(str(url or ""))
except ValueError:
return None
return (parsed.hostname or "").lower() or None
def family_from_url(base):
"""Guess the installation family from the configured URL. Pure.
A guess, explicitly. It is compared against what the host itself reports,
and the disagreement between the two is more interesting than either.
"""
value = normalise_base(base)
host = host_of(value)
if not host:
return ("unknown", "no host could be parsed out of the base URL.")
if host == DOTCOM_API_HOST:
return ("dotcom", "api.github.com is the github.com API host.")
if host == DOTCOM_WEB_HOST:
return ("web-host-not-api",
"github.com is the web interface. The API lives at "
"api.github.com, and a client pointed here will be handed HTML.")
if host.startswith("api.") and host.endswith(RESIDENCY_SUFFIX):
return ("enterprise-cloud-data-residency",
"an api.SUBDOMAIN.ghe.com host is an Enterprise Cloud tenant "
"with data residency, which is its own installation.")
if value.endswith(GHES_REST_SUFFIX):
return ("enterprise-server",
"a host with the %s suffix is an Enterprise Server appliance."
% GHES_REST_SUFFIX)
if value.endswith(GHES_GRAPHQL_SUFFIX):
return ("enterprise-server",
"this is the appliance's GraphQL path; its REST base is %s."
% GHES_REST_SUFFIX)
return ("web-host-not-api",
"this host carries no API prefix. On an appliance the REST base is "
"the hostname plus %s, and without it you are talking to the web "
"interface, which answers 200 and sends HTML." % GHES_REST_SUFFIX)
def content_is_html(content_type):
"""Did the host send a web page. Pure."""
return "html" in str(content_type or "").lower()
def family_from_meta(status, content_type, body):
"""What the host itself says it is. Pure. (family, detail).
installed_version is the discriminator: the Enterprise Server schema for
/meta carries it and the github.com one does not.
"""
if content_is_html(content_type):
return ("web-host-not-api",
"the host returned HTML rather than JSON, so this is a web "
"interface. A client checking only the status code sees a 200 "
"here and reports success.")
if int(status or 0) != 200 or not isinstance(body, dict):
return ("meta-unreadable",
"/meta did not return a readable JSON document, so the host "
"could not identify itself. On a private appliance this "
"endpoint can require authentication.")
version = body.get("installed_version")
if version:
return ("enterprise-server",
"installed_version is present (%s), which the github.com "
"schema for this endpoint does not carry." % version)
if "verifiable_password_authentication" in body or "hooks" in body:
return ("dotcom-or-enterprise-cloud",
"a valid /meta document with no installed_version. That is "
"github.com, or an Enterprise Cloud tenant, which are served "
"from the same host and cannot be separated here.")
return ("meta-unreadable",
"the document does not look like /meta, so nothing can be "
"concluded from it.")
def served_host_from_root(root):
"""The host named in the root endpoint map. Pure. (host, detail).
The URLs in that map are absolute, so this is the host that actually
answered rather than the one you dialled. It is the only reading here that
survives a redirect or a proxy.
"""
if not isinstance(root, dict) or not root:
return (None,
"the root endpoint map was not readable, so the host that "
"answered cannot be named.")
for key in ("current_user_url", "repository_url", "user_url"):
value = root.get(key)
host = host_of(value) if isinstance(value, str) else None
if host:
return (host, "taken from %s in the root map." % key)
for value in root.values():
host = host_of(value) if isinstance(value, str) else None
if host:
return (host, "taken from an absolute URL in the root map.")
return (None, "the root map carried no absolute URL to read a host from.")
def agreement(guessed, reported, configured_host, served_host):
"""Compare three independent readings. Pure. (state, detail)."""
if reported == "web-host-not-api" or guessed == "web-host-not-api":
return ("no-api-prefix",
"this is a web interface rather than an API base. On an "
"appliance append %s to the hostname; on github.com use "
"api.github.com." % GHES_REST_SUFFIX)
if served_host and configured_host and served_host != configured_host:
return ("served-elsewhere",
"you dialled %s and %s answered. A redirect or a proxy is "
"sending this client somewhere else, which reading the "
"configuration would never have caught."
% (configured_host, served_host))
if reported == "meta-unreadable":
return ("host-unidentified",
"the host did not identify itself, so the family in the URL is "
"the only evidence and it is a guess.")
if reported == "enterprise-server" and guessed != "enterprise-server":
return ("wrong-host-family",
"the URL looks like %s and the host reports itself as an "
"Enterprise Server appliance. Those are different "
"installations." % guessed)
if reported == "dotcom-or-enterprise-cloud" and guessed == "enterprise-server":
return ("wrong-host-family",
"the URL carries an appliance suffix and the host answering is "
"not an appliance. Those are different installations.")
return ("agrees",
"the family guessed from the URL, the family the host reports, and "
"the host that actually answered are all the same installation.")
def identity_check(status, login, html_url, expected_login, served_host):
"""Assert the account, because the token cannot be checked locally. Pure."""
code = int(status or 0)
if code == 0:
return ("not-checked",
"no identity call was made, so nothing confirms the credential "
"belongs to this installation.")
if code == 401:
return ("credential-not-of-this-host",
"the credential was rejected outright by this host. A token "
"minted at a different installation is not a weak token here, "
"it is not a token at all.")
if code != 200:
return ("identity-unreadable",
"HTTP %s from the identity call, so the account could not be "
"read." % status)
url_host = host_of(html_url)
if expected_login and str(login or "").lower() != str(expected_login).lower():
return ("wrong-account",
"this host knows the credential as %r and you expected %r. "
"Same shape of secret, different installation."
% (login, expected_login))
if url_host and served_host and url_host != served_host \
and not url_host.endswith(served_host) \
and not served_host.endswith(url_host):
return ("html-url-host-mismatch",
"the account's html_url points at %s while %s answered, which "
"is worth explaining before trusting either."
% (url_host, served_host))
return ("identity-as-expected",
"the account this host returns is the one you expected.")
def token_shape_is_no_evidence(token):
"""State plainly that a prefix cannot name an installation. Pure."""
value = (token or "").strip()
known = ("github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_")
if any(value.startswith(prefix) for prefix in known):
return ("class-known-host-unknown",
"the prefix names the credential class and never the "
"installation that issued it. There is no local test for which "
"host a token belongs to; the identity call is the only one.")
return ("class-unknown",
"the credential class could not be named, and it would not have "
"named the installation anyway.")
def verdict(agreement_state, identity_state):
"""The finding, in one state. Pure. (state, detail)."""
if agreement_state == "no-api-prefix":
return ("no-api-prefix",
"the base URL is a web interface, so every API call is being "
"answered with a web page.")
if agreement_state == "wrong-host-family":
return ("wrong-installation",
"the client is configured for one installation and talking to "
"another. Every 404 and every 401 follows from that.")
if agreement_state == "served-elsewhere":
return ("redirected-elsewhere",
"the host that answered is not the host that was dialled, so "
"the configuration is not the whole story.")
if identity_state in ("credential-not-of-this-host", "wrong-account"):
return ("credential-from-another-host",
"the host is reachable and the credential does not belong to "
"it, which is the same bug seen from the other side.")
if agreement_state == "host-unidentified":
return ("host-unidentified",
"the host would not identify itself, so this run narrows the "
"question rather than answering it.")
if identity_state == "html-url-host-mismatch":
return ("host-mismatch-in-payload",
"the objects this host returns point at a different hostname "
"from the one serving them.")
return ("host-as-configured",
"the base URL, the host and the account all describe the same "
"installation.")
def repair(state, base):
"""The sentence a reader has to act on. Pure. Nothing here is configured."""
if state == "no-api-prefix":
return ("set the API base URL properly: %s for github.com, the "
"appliance hostname plus %s for Enterprise Server, and "
"api.SUBDOMAIN.ghe.com for a data-residency tenant."
% ("https://" + DOTCOM_API_HOST, GHES_REST_SUFFIX))
if state in ("wrong-installation", "credential-from-another-host"):
return ("set the base URL explicitly for this environment rather than "
"letting a library default decide, and pair each base URL with "
"the credential minted at that installation. %s is not the "
"host holding these resources." % (base or "the configured base"))
if state == "redirected-elsewhere":
return ("find out what is redirecting this client. Then assert at "
"startup that the host in the root map matches the host you "
"configured, so the next one is caught in a second.")
if state == "host-unidentified":
return ("re-run with a credential this host accepts, or from a network "
"that can reach it. A private appliance can require "
"authentication even for /meta.")
return ("nothing to change. Keep this as a startup assertion rather than a "
"thing somebody runs after a week of 404s.")
def get(session, url):
"""One GET. Returns the response object, or None if the host is unreachable."""
try:
return session.get(url, timeout=30)
except requests.RequestException as err:
log.warning("%s did not answer: %s", url, err)
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default=os.environ.get("GITHUB_API_URL")
or "https://" + DOTCOM_API_HOST,
help="the base URL the client resolved at startup")
parser.add_argument("--expect-login",
help="the account this credential should be, on this host")
args = parser.parse_args()
base = normalise_base(args.base)
token = os.environ.get("GITHUB_TOKEN")
made, free = read_cost(bool(token))
log.info("read cost: %d REST request(s), %d of them unauthenticated and free",
made, free)
configured_host = host_of(base)
guessed, guessed_detail = family_from_url(base)
log.info("configured: host %s, guessed family %s. %s", configured_host,
guessed, guessed_detail)
session = requests.Session()
session.headers.update({
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub refuses requests with no User-Agent before it looks at auth.
"User-Agent": UA,
})
meta = get(session, base + "/meta")
if meta is None:
reported, reported_detail = ("meta-unreadable", "the host did not answer.")
meta_body, content_type = None, ""
else:
content_type = meta.headers.get("content-type", "")
try:
meta_body = meta.json()
except ValueError:
meta_body = None
reported, reported_detail = family_from_meta(meta.status_code,
content_type, meta_body)
log.info("meta: %s. %s", reported, reported_detail)
root = get(session, base + "/")
root_body = None
if root is not None:
try:
root_body = root.json()
except ValueError:
root_body = None
served_host, served_detail = served_host_from_root(root_body)
log.info("served host: %s (%s)", served_host or "unknown", served_detail)
agreement_state, agreement_detail = agreement(guessed, reported,
configured_host, served_host)
log.info("%s: %s", agreement_state, agreement_detail)
identity_state, identity_detail = ("not-checked", "no token supplied.")
login = None
if token:
session.headers["Authorization"] = "Bearer " + token
who = get(session, base + "/user")
if who is not None:
body = {}
try:
body = who.json() or {}
except ValueError:
body = {}
login = body.get("login")
identity_state, identity_detail = identity_check(
who.status_code, login, body.get("html_url"),
args.expect_login, served_host)
shape_state, shape_detail = token_shape_is_no_evidence(token)
log.info("%s: %s", shape_state, shape_detail)
log.info("identity: %s. %s", identity_state, identity_detail)
state, detail = verdict(agreement_state, identity_state)
log.info("%s: %s", state, detail)
fix = repair(state, base)
log.info("repair: %s", fix)
print(json.dumps({
"base": base,
"configured_host": configured_host,
"guessed_family": guessed,
"reported_family": reported,
"served_host": served_host,
"agreement_state": agreement_state,
"identity_state": identity_state,
"login": login,
"state": state,
"detail": detail,
"repair": fix,
}, indent=2, default=str))
return 1 if state != "host-as-configured" else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say which GitHub installation this client is actually talking to.
*
* Read only, and two of its three calls need no credential at all. Nothing is
* configured or written: the repair is an environment variable and a startup
* assertion, and both are printed.
*
* github.com, a GitHub Enterprise Server appliance and an Enterprise Cloud
* tenant with data residency are separate installations. A credential from one
* is meaningless at the others, and a base URL that defaults to the wrong one
* produces a 404 on every route or a flat 401 on a fresh token.
*
* Environment:
* GITHUB_API_URL the base URL the client resolved at startup
* GITHUB_TOKEN optional; needed only for the identity assertion
* GITHUB_EXPECT_LOGIN optional account this credential should be here
*/
const UA = 'github-api-host/1.0';
export const DOTCOM_API_HOST = 'api.github.com';
export const DOTCOM_WEB_HOST = 'github.com';
export const GHES_REST_SUFFIX = '/api/v3';
export const GHES_GRAPHQL_SUFFIX = '/api/graphql';
export const RESIDENCY_SUFFIX = '.ghe.com';
export const FAMILIES = ['dotcom', 'enterprise-server',
'enterprise-cloud-data-residency', 'web-host-not-api', 'unknown'];
/** [requests, unauthenticated ones] this run will spend. Pure. */
export function readCost(withIdentity) {
return [2 + (withIdentity ? 1 : 0), 2];
}
/** Trim a base URL to a comparable form. Pure. */
export function normaliseBase(url) {
let value = String(url ?? '').trim();
while (value.endsWith('/')) value = value.slice(0, -1);
return value;
}
/** The hostname in a URL, lowercased, or null. Pure. */
export function hostOf(url) {
try {
return new URL(String(url ?? '')).hostname.toLowerCase() || null;
} catch {
return null;
}
}
/** Guess the installation family from the configured URL. Pure. */
export function familyFromUrl(base) {
const value = normaliseBase(base);
const host = hostOf(value);
if (!host) return ['unknown', 'no host could be parsed out of the base URL.'];
if (host === DOTCOM_API_HOST) {
return ['dotcom', 'api.github.com is the github.com API host.'];
}
if (host === DOTCOM_WEB_HOST) {
return ['web-host-not-api', 'github.com is the web interface. The API lives '
+ 'at api.github.com, and a client pointed here will be handed HTML.'];
}
if (host.startsWith('api.') && host.endsWith(RESIDENCY_SUFFIX)) {
return ['enterprise-cloud-data-residency', 'an api.SUBDOMAIN.ghe.com host is '
+ 'an Enterprise Cloud tenant with data residency, which is its own '
+ 'installation.'];
}
if (value.endsWith(GHES_REST_SUFFIX)) {
return ['enterprise-server', `a host with the ${GHES_REST_SUFFIX} suffix is `
+ 'an Enterprise Server appliance.'];
}
if (value.endsWith(GHES_GRAPHQL_SUFFIX)) {
return ['enterprise-server', "this is the appliance's GraphQL path; its "
+ `REST base is ${GHES_REST_SUFFIX}.`];
}
return ['web-host-not-api', 'this host carries no API prefix. On an appliance '
+ `the REST base is the hostname plus ${GHES_REST_SUFFIX}, and without it `
+ 'you are talking to the web interface, which answers 200 and sends HTML.'];
}
/** Did the host send a web page. Pure. */
export function contentIsHtml(contentType) {
return String(contentType ?? '').toLowerCase().includes('html');
}
/** What the host itself says it is. Pure. [family, detail]. */
export function familyFromMeta(status, contentType, body) {
if (contentIsHtml(contentType)) {
return ['web-host-not-api', 'the host returned HTML rather than JSON, so '
+ 'this is a web interface. A client checking only the status code sees a '
+ '200 here and reports success.'];
}
if (Number(status) !== 200 || !body || typeof body !== 'object') {
return ['meta-unreadable', '/meta did not return a readable JSON document, '
+ 'so the host could not identify itself. On a private appliance this '
+ 'endpoint can require authentication.'];
}
const version = body.installed_version;
if (version) {
return ['enterprise-server', `installed_version is present (${version}), `
+ 'which the github.com schema for this endpoint does not carry.'];
}
if ('verifiable_password_authentication' in body || 'hooks' in body) {
return ['dotcom-or-enterprise-cloud', 'a valid /meta document with no '
+ 'installed_version. That is github.com, or an Enterprise Cloud tenant, '
+ 'which are served from the same host and cannot be separated here.'];
}
return ['meta-unreadable', 'the document does not look like /meta, so nothing '
+ 'can be concluded from it.'];
}
/** The host named in the root endpoint map. Pure. [host, detail]. */
export function servedHostFromRoot(root) {
if (!root || typeof root !== 'object' || Object.keys(root).length === 0) {
return [null, 'the root endpoint map was not readable, so the host that '
+ 'answered cannot be named.'];
}
for (const key of ['current_user_url', 'repository_url', 'user_url']) {
const value = root[key];
const host = typeof value === 'string' ? hostOf(value) : null;
if (host) return [host, `taken from ${key} in the root map.`];
}
for (const value of Object.values(root)) {
const host = typeof value === 'string' ? hostOf(value) : null;
if (host) return [host, 'taken from an absolute URL in the root map.'];
}
return [null, 'the root map carried no absolute URL to read a host from.'];
}
/** Compare three independent readings. Pure. [state, detail]. */
export function agreement(guessed, reported, configuredHost, servedHost) {
if (reported === 'web-host-not-api' || guessed === 'web-host-not-api') {
return ['no-api-prefix', 'this is a web interface rather than an API base. '
+ `On an appliance append ${GHES_REST_SUFFIX} to the hostname; on `
+ 'github.com use api.github.com.'];
}
if (servedHost && configuredHost && servedHost !== configuredHost) {
return ['served-elsewhere', `you dialled ${configuredHost} and ${servedHost} `
+ 'answered. A redirect or a proxy is sending this client somewhere else, '
+ 'which reading the configuration would never have caught.'];
}
if (reported === 'meta-unreadable') {
return ['host-unidentified', 'the host did not identify itself, so the '
+ 'family in the URL is the only evidence and it is a guess.'];
}
if (reported === 'enterprise-server' && guessed !== 'enterprise-server') {
return ['wrong-host-family', `the URL looks like ${guessed} and the host `
+ 'reports itself as an Enterprise Server appliance. Those are different '
+ 'installations.'];
}
if (reported === 'dotcom-or-enterprise-cloud' && guessed === 'enterprise-server') {
return ['wrong-host-family', 'the URL carries an appliance suffix and the '
+ 'host answering is not an appliance. Those are different installations.'];
}
return ['agrees', 'the family guessed from the URL, the family the host '
+ 'reports, and the host that actually answered are all the same installation.'];
}
/** Assert the account, because the token cannot be checked locally. Pure. */
export function identityCheck(status, login, htmlUrl, expectedLogin, servedHost) {
const code = Number(status) || 0;
if (code === 0) {
return ['not-checked', 'no identity call was made, so nothing confirms the '
+ 'credential belongs to this installation.'];
}
if (code === 401) {
return ['credential-not-of-this-host', 'the credential was rejected outright '
+ 'by this host. A token minted at a different installation is not a weak '
+ 'token here, it is not a token at all.'];
}
if (code !== 200) {
return ['identity-unreadable', `HTTP ${status} from the identity call, so `
+ 'the account could not be read.'];
}
const urlHost = hostOf(htmlUrl);
if (expectedLogin && String(login ?? '').toLowerCase() !== String(expectedLogin).toLowerCase()) {
return ['wrong-account', `this host knows the credential as `
+ `${JSON.stringify(login)} and you expected ${JSON.stringify(expectedLogin)}. `
+ 'Same shape of secret, different installation.'];
}
if (urlHost && servedHost && urlHost !== servedHost
&& !urlHost.endsWith(servedHost) && !servedHost.endsWith(urlHost)) {
return ['html-url-host-mismatch', `the account's html_url points at `
+ `${urlHost} while ${servedHost} answered, which is worth explaining `
+ 'before trusting either.'];
}
return ['identity-as-expected', 'the account this host returns is the one you '
+ 'expected.'];
}
/** State plainly that a prefix cannot name an installation. Pure. */
export function tokenShapeIsNoEvidence(token) {
const value = String(token ?? '').trim();
const known = ['github_pat_', 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_'];
if (known.some((prefix) => value.startsWith(prefix))) {
return ['class-known-host-unknown', 'the prefix names the credential class '
+ 'and never the installation that issued it. There is no local test for '
+ 'which host a token belongs to; the identity call is the only one.'];
}
return ['class-unknown', 'the credential class could not be named, and it '
+ 'would not have named the installation anyway.'];
}
/** The finding, in one state. Pure. [state, detail]. */
export function verdict(agreementState, identityState) {
if (agreementState === 'no-api-prefix') {
return ['no-api-prefix', 'the base URL is a web interface, so every API call '
+ 'is being answered with a web page.'];
}
if (agreementState === 'wrong-host-family') {
return ['wrong-installation', 'the client is configured for one installation '
+ 'and talking to another. Every 404 and every 401 follows from that.'];
}
if (agreementState === 'served-elsewhere') {
return ['redirected-elsewhere', 'the host that answered is not the host that '
+ 'was dialled, so the configuration is not the whole story.'];
}
if (['credential-not-of-this-host', 'wrong-account'].includes(identityState)) {
return ['credential-from-another-host', 'the host is reachable and the '
+ 'credential does not belong to it, which is the same bug seen from the '
+ 'other side.'];
}
if (agreementState === 'host-unidentified') {
return ['host-unidentified', 'the host would not identify itself, so this '
+ 'run narrows the question rather than answering it.'];
}
if (identityState === 'html-url-host-mismatch') {
return ['host-mismatch-in-payload', 'the objects this host returns point at '
+ 'a different hostname from the one serving them.'];
}
return ['host-as-configured', 'the base URL, the host and the account all '
+ 'describe the same installation.'];
}
/** The sentence a reader has to act on. Pure. Nothing here is configured. */
export function repair(state, base) {
if (state === 'no-api-prefix') {
return `set the API base URL properly: https://${DOTCOM_API_HOST} for `
+ `github.com, the appliance hostname plus ${GHES_REST_SUFFIX} for `
+ 'Enterprise Server, and api.SUBDOMAIN.ghe.com for a data-residency tenant.';
}
if (['wrong-installation', 'credential-from-another-host'].includes(state)) {
return 'set the base URL explicitly for this environment rather than letting '
+ 'a library default decide, and pair each base URL with the credential '
+ `minted at that installation. ${base || 'the configured base'} is not the `
+ 'host holding these resources.';
}
if (state === 'redirected-elsewhere') {
return 'find out what is redirecting this client. Then assert at startup '
+ 'that the host in the root map matches the host you configured, so the '
+ 'next one is caught in a second.';
}
if (state === 'host-unidentified') {
return 're-run with a credential this host accepts, or from a network that '
+ 'can reach it. A private appliance can require authentication even for /meta.';
}
return 'nothing to change. Keep this as a startup assertion rather than a '
+ 'thing somebody runs after a week of 404s.';
}
function headers(token) {
const bag = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
if (token) bag.Authorization = `Bearer ${token}`;
return bag;
}
async function safeGet(url, token) {
try {
return await fetch(url, { headers: headers(token) });
} catch (err) {
console.warn(`${url} did not answer: ${err.message}`);
return null;
}
}
async function main() {
const base = normaliseBase(process.env.GITHUB_API_URL || `https://${DOTCOM_API_HOST}`);
const token = process.env.GITHUB_TOKEN || '';
const expectLogin = process.env.GITHUB_EXPECT_LOGIN || '';
const [made, free] = readCost(Boolean(token));
console.log(`read cost: ${made} REST request(s), ${free} of them `
+ 'unauthenticated and free');
const configuredHost = hostOf(base);
const [guessed, guessedDetail] = familyFromUrl(base);
console.log(`configured: host ${configuredHost}, guessed family ${guessed}. ${guessedDetail}`);
const meta = await safeGet(`${base}/meta`, '');
let reported = 'meta-unreadable';
let reportedDetail = 'the host did not answer.';
if (meta) {
let metaBody = null;
try { metaBody = await meta.json(); } catch { metaBody = null; }
[reported, reportedDetail] = familyFromMeta(meta.status,
meta.headers.get('content-type') || '', metaBody);
}
console.log(`meta: ${reported}. ${reportedDetail}`);
const root = await safeGet(`${base}/`, '');
let rootBody = null;
if (root) {
try { rootBody = await root.json(); } catch { rootBody = null; }
}
const [servedHost, servedDetail] = servedHostFromRoot(rootBody);
console.log(`served host: ${servedHost || 'unknown'} (${servedDetail})`);
const [agreementState, agreementDetail] = agreement(guessed, reported,
configuredHost, servedHost);
console.log(`${agreementState}: ${agreementDetail}`);
let identityState = 'not-checked';
let identityDetail = 'no token supplied.';
let login = null;
if (token) {
const who = await safeGet(`${base}/user`, token);
if (who) {
let body = {};
try { body = (await who.json()) || {}; } catch { body = {}; }
login = body.login ?? null;
[identityState, identityDetail] = identityCheck(who.status, login,
body.html_url, expectLogin, servedHost);
}
const [shapeState, shapeDetail] = tokenShapeIsNoEvidence(token);
console.log(`${shapeState}: ${shapeDetail}`);
}
console.log(`identity: ${identityState}. ${identityDetail}`);
const [state, detail] = verdict(agreementState, identityState);
console.log(`${state}: ${detail}`);
const fix = repair(state, base);
console.log(`repair: ${fix}`);
console.log(JSON.stringify({
base,
configured_host: configuredHost,
guessed_family: guessed,
reported_family: reported,
served_host: servedHost,
agreement_state: agreementState,
identity_state: identityState,
login,
state,
detail,
repair: fix,
}, null, 2));
process.exitCode = state === 'host-as-configured' ? 0 : 1;
}
// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The hosts in these tests are the ones you do not have to hand: an appliance, a data-residency subdomain, and a web interface answering HTML to a client that expected JSON. The first group is the URL reader, which is a guess and is labelled as one. The second is the host's own answer, keyed on installed_version. The third is the part that earns the note: three independent readings of the same question, and the states where they disagree — a base URL for one installation and an appliance answering, a host that redirected somewhere else, and a credential from the other installation. The last group pins the honest limits: a token prefix names a class and never a host, and github.com and Enterprise Cloud cannot be told apart from here.
from github_api_host import (
DOTCOM_API_HOST, FAMILIES, GHES_REST_SUFFIX, agreement, content_is_html,
family_from_meta, family_from_url, host_of, identity_check,
normalise_base, read_cost, repair, served_host_from_root,
token_shape_is_no_evidence, verdict,
)
DOTCOM_META = {"verifiable_password_authentication": False,
"hooks": ["192.30.252.0/22"], "api": ["192.30.252.0/22"]}
GHES_META = {"verifiable_password_authentication": True,
"installed_version": "3.14.2", "hooks": ["10.0.0.0/8"]}
DOTCOM_ROOT = {"current_user_url": "https://api.github.com/user",
"repository_url": "https://api.github.com/repos/{owner}/{repo}"}
GHES_ROOT = {"current_user_url": "https://github.acme.internal/api/v3/user"}
# Obviously fake and far shorter than any real credential.
FINE = "github_pat_FAKE"
def test_the_three_host_families_are_read_off_the_url():
assert family_from_url("https://api.github.com")[0] == "dotcom"
assert family_from_url("https://github.acme.internal/api/v3")[0] == "enterprise-server"
assert family_from_url("https://api.octocorp.ghe.com")[0] == (
"enterprise-cloud-data-residency")
assert set(FAMILIES) >= {"dotcom", "enterprise-server",
"enterprise-cloud-data-residency"}
def test_the_missing_api_prefix_is_named_as_its_own_failure():
state, detail = family_from_url("https://github.acme.internal")
assert state == "web-host-not-api"
assert GHES_REST_SUFFIX in detail
state, detail = family_from_url("https://github.com")
assert state == "web-host-not-api"
assert DOTCOM_API_HOST in detail
assert family_from_url("not a url")[0] == "unknown"
def test_the_graphql_path_is_still_the_appliance():
assert family_from_url("https://github.acme.internal/api/graphql")[0] == (
"enterprise-server")
assert normalise_base("https://api.github.com///") == "https://api.github.com"
assert host_of("https://API.GitHub.com/user") == "api.github.com"
assert host_of("nonsense") is None
def test_installed_version_is_the_discriminator():
state, detail = family_from_meta(200, "application/json", GHES_META)
assert state == "enterprise-server"
assert "3.14.2" in detail
state, detail = family_from_meta(200, "application/json", DOTCOM_META)
assert state == "dotcom-or-enterprise-cloud"
assert "cannot be separated here" in detail
def test_html_from_an_api_base_is_the_silent_one():
state, detail = family_from_meta(200, "text/html; charset=utf-8", None)
assert state == "web-host-not-api"
assert "reports success" in detail
assert content_is_html("text/html") is True
assert content_is_html("application/json") is False
assert family_from_meta(401, "application/json", None)[0] == "meta-unreadable"
assert family_from_meta(200, "application/json", {"unrelated": 1})[0] == (
"meta-unreadable")
def test_the_root_map_names_the_host_that_actually_answered():
host, detail = served_host_from_root(GHES_ROOT)
assert host == "github.acme.internal"
assert "current_user_url" in detail
assert served_host_from_root(DOTCOM_ROOT)[0] == "api.github.com"
assert served_host_from_root({})[0] is None
assert served_host_from_root({"x": 1})[0] is None
def test_a_dotcom_base_against_an_appliance_is_the_headline():
state, detail = agreement("dotcom", "enterprise-server", "api.github.com",
"api.github.com")
assert state == "wrong-host-family"
assert "different installations" in detail
def test_an_appliance_base_answered_by_something_else_is_caught_too():
assert agreement("enterprise-server", "dotcom-or-enterprise-cloud",
"github.acme.internal", "github.acme.internal")[0] == (
"wrong-host-family")
def test_a_redirect_is_the_reading_configuration_cannot_give_you():
state, detail = agreement("dotcom", "dotcom-or-enterprise-cloud",
"api.github.com", "api.ghe.example")
assert state == "served-elsewhere"
assert "reading the configuration would never have caught" in detail
def test_agreement_reports_agreement():
assert agreement("dotcom", "dotcom-or-enterprise-cloud", "api.github.com",
"api.github.com")[0] == "agrees"
assert agreement("enterprise-server", "meta-unreadable",
"github.acme.internal", None)[0] == "host-unidentified"
assert agreement("web-host-not-api", "web-host-not-api", "github.com",
None)[0] == "no-api-prefix"
def test_a_credential_from_the_other_installation_is_stated_plainly():
state, detail = identity_check(401, None, None, "dana", "github.acme.internal")
assert state == "credential-not-of-this-host"
assert "it is not a token at all" in detail
state, detail = identity_check(200, "someone-else",
"https://github.acme.internal/someone-else",
"dana", "github.acme.internal")
assert state == "wrong-account"
assert "different installation" in detail
assert identity_check(0, None, None, None, None)[0] == "not-checked"
assert identity_check(503, None, None, None, None)[0] == "identity-unreadable"
def test_the_identity_passes_when_the_account_and_the_host_agree():
state, _ = identity_check(200, "dana",
"https://github.acme.internal/dana", "dana",
"github.acme.internal")
assert state == "identity-as-expected"
# api.github.com serving objects whose html_url is github.com is normal:
# one host name is a suffix of the other, so it is not a mismatch.
assert identity_check(200, "dana", "https://github.com/dana", "dana",
"api.github.com")[0] == "identity-as-expected"
def test_a_token_prefix_names_a_class_and_never_an_installation():
state, detail = token_shape_is_no_evidence(FINE)
assert state == "class-known-host-unknown"
assert "never the installation" in detail
assert token_shape_is_no_evidence("")[0] == "class-unknown"
def test_the_verdict_and_the_repair_are_about_configuration():
assert verdict("wrong-host-family", "not-checked")[0] == "wrong-installation"
assert verdict("no-api-prefix", "not-checked")[0] == "no-api-prefix"
assert verdict("served-elsewhere", "not-checked")[0] == "redirected-elsewhere"
assert verdict("agrees", "credential-not-of-this-host")[0] == (
"credential-from-another-host")
assert verdict("agrees", "identity-as-expected")[0] == "host-as-configured"
fix = repair("wrong-installation", "https://api.github.com")
assert "set the base URL explicitly" in fix
assert "letting a library default decide" in fix
assert "startup assertion" in repair("host-as-configured", "x")
def test_the_host_check_needs_no_credential():
assert read_cost(False) == (2, 2)
assert read_cost(True) == (3, 2)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DOTCOM_API_HOST, FAMILIES, GHES_REST_SUFFIX, agreement, contentIsHtml,
familyFromMeta, familyFromUrl, hostOf, identityCheck, normaliseBase, readCost,
repair, servedHostFromRoot, tokenShapeIsNoEvidence, verdict,
} from './github-api-host.mjs';
const DOTCOM_META = {
verifiable_password_authentication: false,
hooks: ['192.30.252.0/22'],
api: ['192.30.252.0/22'],
};
const GHES_META = {
verifiable_password_authentication: true,
installed_version: '3.14.2',
hooks: ['10.0.0.0/8'],
};
const DOTCOM_ROOT = {
current_user_url: 'https://api.github.com/user',
repository_url: 'https://api.github.com/repos/{owner}/{repo}',
};
const GHES_ROOT = { current_user_url: 'https://github.acme.internal/api/v3/user' };
// Obviously fake and far shorter than any real credential.
const FINE = 'github_pat_FAKE';
test('the three host families are read off the url', () => {
assert.equal(familyFromUrl('https://api.github.com')[0], 'dotcom');
assert.equal(familyFromUrl('https://github.acme.internal/api/v3')[0], 'enterprise-server');
assert.equal(familyFromUrl('https://api.octocorp.ghe.com')[0],
'enterprise-cloud-data-residency');
for (const name of ['dotcom', 'enterprise-server', 'enterprise-cloud-data-residency']) {
assert.ok(FAMILIES.includes(name));
}
});
test('the missing api prefix is named as its own failure', () => {
let [state, detail] = familyFromUrl('https://github.acme.internal');
assert.equal(state, 'web-host-not-api');
assert.ok(detail.includes(GHES_REST_SUFFIX));
[state, detail] = familyFromUrl('https://github.com');
assert.equal(state, 'web-host-not-api');
assert.ok(detail.includes(DOTCOM_API_HOST));
assert.equal(familyFromUrl('not a url')[0], 'unknown');
});
test('the graphql path is still the appliance', () => {
assert.equal(familyFromUrl('https://github.acme.internal/api/graphql')[0],
'enterprise-server');
assert.equal(normaliseBase('https://api.github.com///'), 'https://api.github.com');
assert.equal(hostOf('https://API.GitHub.com/user'), 'api.github.com');
assert.equal(hostOf('nonsense'), null);
});
test('installed_version is the discriminator', () => {
let [state, detail] = familyFromMeta(200, 'application/json', GHES_META);
assert.equal(state, 'enterprise-server');
assert.ok(detail.includes('3.14.2'));
[state, detail] = familyFromMeta(200, 'application/json', DOTCOM_META);
assert.equal(state, 'dotcom-or-enterprise-cloud');
assert.match(detail, /cannot be separated here/);
});
test('html from an api base is the silent one', () => {
const [state, detail] = familyFromMeta(200, 'text/html; charset=utf-8', null);
assert.equal(state, 'web-host-not-api');
assert.match(detail, /reports success/);
assert.equal(contentIsHtml('text/html'), true);
assert.equal(contentIsHtml('application/json'), false);
assert.equal(familyFromMeta(401, 'application/json', null)[0], 'meta-unreadable');
assert.equal(familyFromMeta(200, 'application/json', { unrelated: 1 })[0], 'meta-unreadable');
});
test('the root map names the host that actually answered', () => {
const [host, detail] = servedHostFromRoot(GHES_ROOT);
assert.equal(host, 'github.acme.internal');
assert.match(detail, /current_user_url/);
assert.equal(servedHostFromRoot(DOTCOM_ROOT)[0], 'api.github.com');
assert.equal(servedHostFromRoot({})[0], null);
assert.equal(servedHostFromRoot({ x: 1 })[0], null);
});
test('a dotcom base against an appliance is the headline', () => {
const [state, detail] = agreement('dotcom', 'enterprise-server',
'api.github.com', 'api.github.com');
assert.equal(state, 'wrong-host-family');
assert.match(detail, /different installations/);
});
test('an appliance base answered by something else is caught too', () => {
assert.equal(
agreement('enterprise-server', 'dotcom-or-enterprise-cloud',
'github.acme.internal', 'github.acme.internal')[0],
'wrong-host-family',
);
});
test('a redirect is the reading configuration cannot give you', () => {
const [state, detail] = agreement('dotcom', 'dotcom-or-enterprise-cloud',
'api.github.com', 'api.ghe.example');
assert.equal(state, 'served-elsewhere');
assert.match(detail, /reading the configuration would never have caught/);
});
test('agreement reports agreement', () => {
assert.equal(
agreement('dotcom', 'dotcom-or-enterprise-cloud', 'api.github.com', 'api.github.com')[0],
'agrees',
);
assert.equal(
agreement('enterprise-server', 'meta-unreadable', 'github.acme.internal', null)[0],
'host-unidentified',
);
assert.equal(
agreement('web-host-not-api', 'web-host-not-api', 'github.com', null)[0],
'no-api-prefix',
);
});
test('a credential from the other installation is stated plainly', () => {
let [state, detail] = identityCheck(401, null, null, 'dana', 'github.acme.internal');
assert.equal(state, 'credential-not-of-this-host');
assert.match(detail, /it is not a token at all/);
[state, detail] = identityCheck(200, 'someone-else',
'https://github.acme.internal/someone-else', 'dana', 'github.acme.internal');
assert.equal(state, 'wrong-account');
assert.match(detail, /different installation/);
assert.equal(identityCheck(0, null, null, null, null)[0], 'not-checked');
assert.equal(identityCheck(503, null, null, null, null)[0], 'identity-unreadable');
});
test('the identity passes when the account and the host agree', () => {
assert.equal(
identityCheck(200, 'dana', 'https://github.acme.internal/dana', 'dana',
'github.acme.internal')[0],
'identity-as-expected',
);
assert.equal(
identityCheck(200, 'dana', 'https://github.com/dana', 'dana', 'api.github.com')[0],
'identity-as-expected',
);
});
test('a token prefix names a class and never an installation', () => {
const [state, detail] = tokenShapeIsNoEvidence(FINE);
assert.equal(state, 'class-known-host-unknown');
assert.match(detail, /never the installation/);
assert.equal(tokenShapeIsNoEvidence('')[0], 'class-unknown');
});
test('the verdict and the repair are about configuration', () => {
assert.equal(verdict('wrong-host-family', 'not-checked')[0], 'wrong-installation');
assert.equal(verdict('no-api-prefix', 'not-checked')[0], 'no-api-prefix');
assert.equal(verdict('served-elsewhere', 'not-checked')[0], 'redirected-elsewhere');
assert.equal(verdict('agrees', 'credential-not-of-this-host')[0],
'credential-from-another-host');
assert.equal(verdict('agrees', 'identity-as-expected')[0], 'host-as-configured');
const fix = repair('wrong-installation', 'https://api.github.com');
assert.match(fix, /set the base URL explicitly/);
assert.match(fix, /letting a library default decide/);
assert.match(repair('host-as-configured', 'x'), /startup assertion/);
});
test('the host check needs no credential', () => {
assert.deepEqual(readCost(false), [2, 2]);
assert.deepEqual(readCost(true), [3, 2]);
});
FAQ
Is this not the same as a pinned API version that stopped being served?
No, and the two are easy to keep apart once you know both. A retired version pin is refused with a 400 and <code>GET /versions</code> lists what is still served, so the fix is a value in one header. This is about which installation you are talking to at all: the host is a different appliance with different users, different repositories and different identifiers, so the failure is total rather than a rejected header, and it does not respond to changing anything about the request.
Why not just check that the base URL string is right?
Because the string is only one of the three things that have to agree. The URL you configured is a guess about where the request will end up; <code>/meta</code> is what the host says about itself; and the root endpoint map is the host that actually answered. Reading the configuration cannot catch a redirect, a proxy, or an internal load balancer sending your client somewhere other than the name you dialled, and that is exactly the case where everybody trusts the config file and stops looking.
The base URL is the appliance and everything returns 200 with nothing in it.
Then the <code>/api/v3</code> is missing and you are talking to the web interface. It answers 200 and it sends HTML, so a client that checks only the status code, or that swallows a JSON parse error and returns an empty list, reports success and finds nothing. That is why the script keys on the content type as well as the body: it is the one variant of this bug that never raises an error and can run in production for months.
Can the script tell a GitHub Enterprise Cloud account from a personal one?
It cannot, and it says so rather than guessing. Enterprise Cloud organizations are served from <code>api.github.com</code> like everything else, so <code>/meta</code> has nothing to distinguish them by, and there is no host problem to find in that case. Data residency is the exception, because it genuinely moves the API to <code>api.SUBDOMAIN.ghe.com</code>, and that difference is visible in the URL before any request is made.
A brand new token gets 401. Surely the token is wrong?
It is wrong for that host. A token's prefix names its class — fine-grained, classic, installation — and carries nothing at all about the installation that issued it, so there is no local test that would have caught this. That is why the script's identity check takes an expected login and calls <code>GET /user</code> against the configured base: the only way to know a credential belongs to a host is to ask the host who it thinks the credential is.
Related field notes
- When one route 404s rather than all of them
- A pinned version that stopped being served
- The other reason a good-looking token is refused
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.
- Meta — GitHub REST API
- GitHub Enterprise Server REST API quickstart — GitHub Docs
- Getting started with data residency for GitHub Enterprise Cloud — GitHub Docs
- About the REST API — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.