Diagnostic GitHub API
a pinned X-GitHub-Api-Version stopped being supported
Nothing was deployed. No token was rotated, no permission changed, no dependency was upgraded. On a Tuesday morning every request to the GitHub API starts coming back refused, with a message about an API version, and the only thing that moved is the date. The header responsible was copied out of a documentation sample in 2022 and has not been looked at since.
GET /versions returns the list of date-based API versions GitHub currently serves. It needs no token and costs nothing worth counting. Compare the value your client puts in X-GitHub-Api-Version against that list: if it is not on it, the pin is the cause and nothing about your credentials or your code is.
The interesting states are the ones either side of the failure. A pin that is still supported but several versions behind is the same problem before it becomes an incident, and it is the only moment at which you can fix it calmly. A pin that is not a valid date at all is a typo, which behaves differently again. And no pin is not automatically safe: an unpinned client rides GitHub's default, which is itself a version with a lifetime.
The problem in plain words
The failure has no author. Every debugging instinct starts from what changed, and the honest answer is nothing: the same container image that worked on Monday fails on Tuesday. So the search goes to the places where things usually change — credentials, network, GitHub's status page — and a rotation gets done for no reason, which adds a second variable to an incident that had one.
The message does say what is wrong, and it is still missed, because a whole-integration outage does not look like a header problem. Everything fails at once, including the health check, so it reads like an authentication failure or a service outage. Nobody diffs a request header when the entire client is down; they look for something big, and the cause is eleven characters that nobody has read since they were pasted.
Underneath that is the reason it will happen again: nothing in the running system is watching the list. The version pin is a string in a constant file, the retirement is announced in a changelog, and the two are connected by somebody remembering. The gap between a version leaving the supported list and starting to answer with a refusal is months of warning that goes to an inbox rather than to a monitor.
Why it happens
Versions are dates, and dates retire. X-GitHub-Api-Version takes a value like 2022-11-28. New versions are published when a breaking change lands, old ones are supported for a period and then withdrawn, and a request pinned to a withdrawn one is refused outright rather than quietly upgraded. That refusal is deliberate: silently serving a newer version to a client that asked for an older one would break it in a subtler way.
Sending no header is not the same as being safe. A request with no version header gets GitHub's current default, which today is 2022-11-28. That is a real version with a real lifetime, so an unpinned client is pinned to whatever the server decides and will move under you when the default moves. Neither choice is free; pinning trades a surprise upgrade for a scheduled one.
A refusal keys on the message, not the number. The status code you get for a bad version is not the interesting part — reports have it in the 400 family and as a 410 Gone depending on the surface — and treating one particular number as the signature is how a check stops working. The message names the version, so that is what the script matches on.
An invalid version is a different bug from a retired one. 2022-11-38 is not a date, and a value that was never a version behaves unlike one that was withdrawn. Both fail, but only one of them has a migration to plan; the other has a typo to fix, and the nearest supported version is almost certainly what was meant.
Moving the pin is not a one-line change. Every version in between yours and the target exists because something breaking happened, so the repair is to read those notes and then move, rather than to jump to the newest value and find out at runtime. The script names the versions in between for exactly that reason.
The fix, as a flow
One unauthenticated GET carries the whole check. The list of versions GitHub still serves is public, so the comparison costs nothing and can run on a schedule rather than during an incident. The pinned value is a string your own client owns, which makes this the rare failure that is knowable months before it happens: a version drops off the list first and starts answering 410 later.
How to fix it
Find the value your client actually sends
It is a constant somewhere, or a default inside a client library, and the two can disagree. Grep for X-GitHub-Api-Version across the repository and its lockfile-pinned dependencies. If nothing sets it, you are unpinned, which is its own state rather than a clean bill of health.
Ask GitHub what it still serves
GET /versions is unauthenticated and answers with a plain list of date strings. That makes this the rare check that can run from anywhere, including a monitoring job with no credential at all — though anonymous requests share the sixty-per-hour cap, so it is a check to run hourly rather than per request.
Sort the pin into one of five states rather than two
Current, behind, retired, never-a-version, unpinned. Only one of those is an incident, but three of them are worth an alert, and collapsing them into ok and not ok throws away the months of warning that make this problem cheap to fix.
Read the notes for every version in between
Each published version marks a breaking change. Moving from 2022-11-28 to a version three releases newer means three sets of notes, and the changes are usually small and specific — a field that stopped being returned, a parameter that became required. Reading them takes an afternoon; discovering them in production takes longer.
Put the comparison in the monitor, not in the incident
The whole point of a public list of supported versions is that this failure is knowable in advance. Run the same script on a schedule and alert on supported-behind, so the version that disappears from the list gives you a ticket rather than an outage.
How to check it worked
Run it with the pin your client uses. A healthy answer names the version and says it is the current one; a warning names the versions you are behind by.
python3 github_api_version_pin.py --pinned 2022-11-28
# supported: 2022-11-28
# supported-current: 2022-11-28 is the newest version GitHub serves.
python3 github_api_version_pin.py --pinned 2021-04-01
# retired: 2021-04-01 is older than every supported version. Requests pinned
# to it are refused.
# repair: move the pin to 2022-11-28, reading the notes for 0 version(s) in
# between first
The full code
One unauthenticated GET carries the check, and a second optional one confirms it live against the path that was failing. Everything that decides anything is pure and works on strings: a validator that knows a version is a date, a classifier with five states rather than two, a nearest-neighbour search that turns a typo into a suggestion, and a message matcher that keys on the words GitHub uses rather than on a status code that has been reported as more than one number.
"""Check the X-GitHub-Api-Version your client pins against the versions GitHub serves.
Read only, and mostly unauthenticated: GET /versions needs no credential and
returns the list of date-based REST API versions currently supported. Nothing
here changes a header, a deployment or a pin. The repair is printed.
The point of running this on a schedule rather than during an incident is that
a version leaves the supported list before it starts refusing requests. The
state worth alerting on is "supported but behind", which is the same problem
with months of notice attached.
"""
import argparse
import json
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_api_version_pin")
API = "https://api.github.com"
UA = "github-api-version-pin/1.0"
# The version a request gets when it carries no X-GitHub-Api-Version header.
# Unpinned is not unversioned: this value has a lifetime like any other.
SERVER_DEFAULT = "2022-11-28"
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# What a refusal about the version looks like in prose. Matched on words rather
# than on a status code, which has been reported as more than one number and is
# not something a check should depend on.
VERSION_WORDS = re.compile(
r"api version|x-github-api-version|version.*(not supported|no longer)",
re.IGNORECASE)
def is_version(value):
"""Whether a string is shaped like an API version. Pure.
A date shape is necessary and not sufficient: 2022-11-38 passes the regex
and is not a date, so the day and month are range checked too. A typo that
looks like a version fails differently from one that does not.
"""
text = str(value or "").strip()
if not DATE.match(text):
return False
year, month, day = (int(p) for p in text.split("-"))
return 2000 <= year <= 2999 and 1 <= month <= 12 and 1 <= day <= 31
def supported(body):
"""Parse the GET /versions body into a sorted list of versions. Pure.
Anything that is not version-shaped is dropped rather than carried, because
a junk entry that sorts to the end would make every real pin look retired.
Date strings sort correctly as text, which is the one convenience of this
format.
"""
if not isinstance(body, list):
return []
return sorted({str(v).strip() for v in body if is_version(v)})
def behind(pin, versions):
"""Supported versions strictly newer than the pin. Pure.
These are the breaking-change notes to read before moving, so the count
matters as much as the target.
"""
return [v for v in versions or [] if v > str(pin or "")]
def nearest(pin, versions):
"""The supported version closest to a pin, for the typo case. Pure.
Compared as digit strings rather than parsed as dates, which is enough to
order them and avoids pretending 2022-11-38 is a calendar date in order to
say it is not one.
"""
if not versions:
return None
target = re.sub(r"\D", "", str(pin or "")) or "0"
return min(versions, key=lambda v: (abs(int(re.sub(r"\D", "", v)) - int(target)), v))
def classify(pin, versions):
"""Sort a pinned value into one of six states. Pure.
Five of them are about the pin. The sixth is about not being able to judge
at all, which is a real outcome when GET /versions is what is unreachable.
"""
if not versions:
return ("no-versions-list",
"GET /versions returned nothing version-shaped, so the pin "
"cannot be judged. That is a failure of the check rather than "
"a finding about the pin.")
newest = versions[-1]
if pin is None or not str(pin).strip():
state = "unpinned"
detail = ("no X-GitHub-Api-Version header is sent, so requests get "
"GitHub's default of %s. That is a real version with a real "
"lifetime: unpinned means pinned by the server, and it moves "
"without asking." % SERVER_DEFAULT)
if SERVER_DEFAULT not in versions:
detail += (" The default this script knows about is not on the "
"served list any more, so check what the current one is.")
return state, detail
pin = str(pin).strip()
if not is_version(pin):
return ("malformed-pin",
"%r is not shaped like an API version. It was never valid, so "
"this is a typo rather than a retirement; the closest served "
"version is %s." % (pin, nearest(pin, versions)))
if pin in versions:
newer = behind(pin, versions)
if not newer:
return ("supported-current",
"%s is the newest version GitHub serves." % pin)
return ("supported-behind",
"%s is still served, and %d newer version(s) exist: %s. This "
"is the state to alert on, because it is this problem with "
"notice attached." % (pin, len(newer), ", ".join(newer)))
if pin < versions[0]:
return ("retired",
"%s is older than every supported version. Requests pinned to "
"it are refused, and the oldest one still served is %s."
% (pin, versions[0]))
if pin > newest:
return ("not-yet-supported",
"%s is newer than every supported version, so it names a "
"version that does not exist yet. Almost always a typo; the "
"closest served version is %s." % (pin, nearest(pin, versions)))
return ("unknown-version",
"%s is a valid date and was never a published version. The "
"closest served version is %s." % (pin, nearest(pin, versions)))
def confirms_version_refusal(status, message):
"""Whether a live response blames the API version. Pure.
Keyed on the words rather than the status code. A refusal about the version
is unambiguous in prose and has been reported under more than one number,
so matching the number is how a check quietly stops working.
"""
if status is None or status < 400:
return False
return bool(VERSION_WORDS.search(str(message or "")))
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
r = session.get(API + path, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--pinned", default=os.environ.get("GITHUB_API_VERSION"),
help="the value your client sends in X-GitHub-Api-Version; "
"omit it to check the unpinned case")
ap.add_argument("--path", default="/meta",
help="a path to re-send with the pin, to confirm the "
"verdict live")
args = ap.parse_args()
session = requests.Session()
session.headers.update({
"Accept": "application/vnd.github+json",
"User-Agent": UA,
})
# Optional. GET /versions is public; a token only raises the rate limit
# this check shares with every other anonymous caller on the address.
token = os.environ.get("GITHUB_TOKEN")
if token:
session.headers["Authorization"] = "Bearer " + token
status, body = get(session, "/versions")
if status != 200:
log.error("GET /versions returned %d, so there is no list to compare "
"against", status)
return 2
versions = supported(body)
log.info("supported: %s", ", ".join(versions) or "nothing version-shaped")
state, detail = classify(args.pinned, versions)
log.info("%s: %s", state, detail)
# Confirmation, not diagnosis: send the pin at a cheap path and see whether
# the response blames it.
if args.pinned:
session.headers["X-GitHub-Api-Version"] = str(args.pinned)
live_status, live_body = get(session, args.path)
message = live_body.get("message") if isinstance(live_body, dict) else None
log.info("%s with the pin returned %d", args.path, live_status)
if confirms_version_refusal(live_status, message):
log.info("confirmed live: the response blames the version")
elif live_status >= 400:
log.info("the %d does not mention the version, so it has another "
"cause", live_status)
if state in ("retired", "unknown-version", "not-yet-supported",
"malformed-pin"):
target = versions[-1]
log.info("repair: move the pin to %s, reading the notes for %d "
"version(s) in between first", target,
len(behind(args.pinned or "", versions)) - 1
if args.pinned else 0)
if state == "supported-behind":
log.info("repair: schedule the move to %s; nothing is failing yet, "
"which is the only good time to do it", versions[-1])
print(json.dumps({"pinned": args.pinned, "supported": versions,
"behind_by": len(behind(args.pinned or "", versions)),
"state": state}, indent=2))
return 1 if state in ("retired", "unknown-version", "not-yet-supported",
"malformed-pin") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Check the X-GitHub-Api-Version your client pins against the versions GitHub serves.
*
* Read only, and mostly unauthenticated: GET /versions needs no credential.
* Nothing here changes a header, a deployment or a pin; the repair is printed.
*
* The state worth alerting on is "supported but behind", which is this problem
* with months of notice attached.
*/
const API = 'https://api.github.com';
const UA = 'github-api-version-pin/1.0';
/** The version a request gets when it sends no header. Not unversioned. */
export const SERVER_DEFAULT = '2022-11-28';
const DATE = /^\d{4}-\d{2}-\d{2}$/;
/** What a refusal about the version looks like in prose, not as a number. */
const VERSION_WORDS = /api version|x-github-api-version|version.*(not supported|no longer)/i;
/** Whether a string is shaped like an API version. Pure. */
export function isVersion(value) {
const text = String(value ?? '').trim();
if (!DATE.test(text)) return false;
const [year, month, day] = text.split('-').map(Number);
return year >= 2000 && year <= 2999 && month >= 1 && month <= 12 &&
day >= 1 && day <= 31;
}
/** Parse the GET /versions body into a sorted list of versions. Pure. */
export function supported(body) {
if (!Array.isArray(body)) return [];
return [...new Set(body.map((v) => String(v).trim()).filter(isVersion))].sort();
}
/** Supported versions strictly newer than the pin. Pure. */
export function behind(pin, versions) {
return (versions ?? []).filter((v) => v > String(pin ?? ''));
}
/** The supported version closest to a pin, for the typo case. Pure. */
export function nearest(pin, versions) {
if (!versions || !versions.length) return null;
const digits = (v) => Number(String(v ?? '').replace(/\D/g, '') || '0');
const target = digits(pin);
return [...versions].sort((a, b) => (Math.abs(digits(a) - target) -
Math.abs(digits(b) - target)) || a.localeCompare(b))[0];
}
/** Sort a pinned value into one of six states. Pure. */
export function classify(pin, versions) {
if (!versions || !versions.length) {
return ['no-versions-list',
'GET /versions returned nothing version-shaped, so the pin cannot be ' +
'judged. That is a failure of the check rather than a finding about the pin.'];
}
const newest = versions[versions.length - 1];
if (pin === null || pin === undefined || !String(pin).trim()) {
let detail = 'no X-GitHub-Api-Version header is sent, so requests get ' +
`GitHub's default of ${SERVER_DEFAULT}. That is a real version with a ` +
'real lifetime: unpinned means pinned by the server, and it moves ' +
'without asking.';
if (!versions.includes(SERVER_DEFAULT)) {
detail += ' The default this script knows about is not on the served ' +
'list any more, so check what the current one is.';
}
return ['unpinned', detail];
}
const value = String(pin).trim();
if (!isVersion(value)) {
return ['malformed-pin',
`'${value}' is not shaped like an API version. It was never valid, so ` +
'this is a typo rather than a retirement; the closest served version ' +
`is ${nearest(value, versions)}.`];
}
if (versions.includes(value)) {
const newer = behind(value, versions);
if (!newer.length) {
return ['supported-current', `${value} is the newest version GitHub serves.`];
}
return ['supported-behind',
`${value} is still served, and ${newer.length} newer version(s) exist: ` +
`${newer.join(', ')}. This is the state to alert on, because it is this ` +
'problem with notice attached.'];
}
if (value < versions[0]) {
return ['retired',
`${value} is older than every supported version. Requests pinned to it ` +
`are refused, and the oldest one still served is ${versions[0]}.`];
}
if (value > newest) {
return ['not-yet-supported',
`${value} is newer than every supported version, so it names a version ` +
'that does not exist yet. Almost always a typo; the closest served ' +
`version is ${nearest(value, versions)}.`];
}
return ['unknown-version',
`${value} is a valid date and was never a published version. The closest ` +
`served version is ${nearest(value, versions)}.`];
}
/** Whether a live response blames the API version. Pure. */
export function confirmsVersionRefusal(status, message) {
if (status === null || status === undefined || status < 400) return false;
return VERSION_WORDS.test(String(message ?? ''));
}
async function get(path, headers) {
const res = await fetch(API + path, { headers });
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const pinned = process.argv[2] ?? process.env.GITHUB_API_VERSION ?? null;
const path = process.argv[3] ?? '/meta';
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': UA,
};
// Optional: a token only raises the rate limit this public check shares
// with every other anonymous caller on the address.
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const list = await get('/versions', headers);
if (list.status !== 200) {
console.error(`GET /versions returned ${list.status}, so there is no list ` +
'to compare against');
process.exitCode = 2;
return;
}
const versions = supported(list.body);
console.log(`supported: ${versions.join(', ') || 'nothing version-shaped'}`);
const [state, detail] = classify(pinned, versions);
console.log(`${state}: ${detail}`);
if (pinned) {
const live = await get(path, { ...headers, 'X-GitHub-Api-Version': String(pinned) });
const message = live.body && typeof live.body === 'object' ? live.body.message : null;
console.log(`${path} with the pin returned ${live.status}`);
if (confirmsVersionRefusal(live.status, message)) {
console.log('confirmed live: the response blames the version');
} else if (live.status >= 400) {
console.log(`the ${live.status} does not mention the version, so it has ` +
'another cause');
}
}
const broken = ['retired', 'unknown-version', 'not-yet-supported', 'malformed-pin'];
if (broken.includes(state)) {
const inBetween = pinned ? Math.max(behind(pinned, versions).length - 1, 0) : 0;
console.log(`repair: move the pin to ${versions[versions.length - 1]}, ` +
`reading the notes for ${inBetween} version(s) in between first`);
}
if (state === 'supported-behind') {
console.log(`repair: schedule the move to ${versions[versions.length - 1]}; ` +
'nothing is failing yet, which is the only good time to do it');
}
console.log(JSON.stringify({
pinned,
supported: versions,
behind_by: behind(pinned ?? '', versions).length,
state,
}, null, 2));
process.exitCode = broken.includes(state) ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and set an exit code that fails a passing suite.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Every case here is a string against a list, which is the whole argument for keeping the classifier away from the network: a retirement that will happen in eighteen months can be tested today. The ones that earn their place are the near-misses — a date that was never a version, a date shape that is not a date, and a pin that is supported and still worth an alert — plus the refusal matcher, which must not key on a status code.
from github_api_version_pin import (
behind, classify, confirms_version_refusal, is_version, nearest, supported,
)
SERVED = ["2022-11-28", "2024-06-10", "2025-04-01"]
def test_a_version_is_a_real_date_and_not_just_a_date_shape():
assert is_version("2022-11-28")
assert not is_version("2022-11-38")
assert not is_version("2022-13-01")
assert not is_version("latest")
assert not is_version(None)
def test_the_versions_body_is_sorted_and_junk_is_dropped():
assert supported(["2024-06-10", "2022-11-28", "latest", ""]) == [
"2022-11-28", "2024-06-10"]
assert supported(None) == []
assert supported({"versions": []}) == []
def test_being_behind_is_counted_as_the_notes_still_to_read():
assert behind("2022-11-28", SERVED) == ["2024-06-10", "2025-04-01"]
assert behind("2025-04-01", SERVED) == []
def test_the_nearest_served_version_is_offered_for_a_typo():
assert nearest("2024-06-01", SERVED) == "2024-06-10"
assert nearest("2022-11-38", SERVED) == "2022-11-28"
assert nearest("2022-11-28", []) is None
def test_the_current_pin_is_the_quiet_state():
state, detail = classify("2025-04-01", SERVED)
assert state == "supported-current"
assert "newest version" in detail
def test_a_supported_but_behind_pin_is_the_one_to_alert_on():
state, detail = classify("2022-11-28", SERVED)
assert state == "supported-behind"
assert "2 newer version(s)" in detail
assert "notice attached" in detail
def test_a_retired_pin_is_named_as_older_than_everything_served():
state, detail = classify("2021-04-01", SERVED)
assert state == "retired"
assert "2022-11-28" in detail
def test_a_date_that_was_never_a_version_is_its_own_state():
state, detail = classify("2024-06-11", SERVED)
assert state == "unknown-version"
assert "2024-06-10" in detail
def test_a_future_date_is_a_typo_rather_than_a_retirement():
assert classify("2099-01-01", SERVED)[0] == "not-yet-supported"
def test_a_value_that_is_not_a_date_is_a_typo_and_says_so():
state, detail = classify("2022-11-38", SERVED)
assert state == "malformed-pin"
assert "never valid" in detail
def test_sending_no_header_is_a_state_rather_than_a_pass():
state, detail = classify(None, SERVED)
assert state == "unpinned"
assert "pinned by the server" in detail
assert classify("", SERVED)[0] == "unpinned"
def test_an_unpinned_client_is_warned_when_the_known_default_is_gone():
_, detail = classify(None, ["2025-04-01"])
assert "not on the served list" in detail
def test_an_empty_versions_list_is_a_failure_of_the_check_not_a_finding():
state, detail = classify("2022-11-28", [])
assert state == "no-versions-list"
assert "failure of the check" in detail
def test_a_refusal_is_matched_on_words_and_not_on_a_status_code():
assert confirms_version_refusal(410, "The API version is no longer supported")
assert confirms_version_refusal(400, "X-GitHub-Api-Version is not supported")
assert not confirms_version_refusal(200, "The API version is no longer supported")
assert not confirms_version_refusal(403, "Resource not accessible by integration")
assert not confirms_version_refusal(None, None)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
behind, classify, confirmsVersionRefusal, isVersion, nearest, supported,
} from './github-api-version-pin.mjs';
const SERVED = ['2022-11-28', '2024-06-10', '2025-04-01'];
test('a version is a real date and not just a date shape', () => {
assert.ok(isVersion('2022-11-28'));
assert.ok(!isVersion('2022-11-38'));
assert.ok(!isVersion('2022-13-01'));
assert.ok(!isVersion('latest'));
assert.ok(!isVersion(null));
});
test('the versions body is sorted and junk is dropped', () => {
assert.deepEqual(supported(['2024-06-10', '2022-11-28', 'latest', '']),
['2022-11-28', '2024-06-10']);
assert.deepEqual(supported(null), []);
assert.deepEqual(supported({ versions: [] }), []);
});
test('being behind is counted as the notes still to read', () => {
assert.deepEqual(behind('2022-11-28', SERVED), ['2024-06-10', '2025-04-01']);
assert.deepEqual(behind('2025-04-01', SERVED), []);
});
test('the nearest served version is offered for a typo', () => {
assert.equal(nearest('2024-06-01', SERVED), '2024-06-10');
assert.equal(nearest('2022-11-38', SERVED), '2022-11-28');
assert.equal(nearest('2022-11-28', []), null);
});
test('the current pin is the quiet state', () => {
const [state, detail] = classify('2025-04-01', SERVED);
assert.equal(state, 'supported-current');
assert.match(detail, /newest version/);
});
test('a supported but behind pin is the one to alert on', () => {
const [state, detail] = classify('2022-11-28', SERVED);
assert.equal(state, 'supported-behind');
assert.match(detail, /2 newer version\(s\)/);
assert.match(detail, /notice attached/);
});
test('a retired pin is named as older than everything served', () => {
const [state, detail] = classify('2021-04-01', SERVED);
assert.equal(state, 'retired');
assert.match(detail, /2022-11-28/);
});
test('a date that was never a version is its own state', () => {
const [state, detail] = classify('2024-06-11', SERVED);
assert.equal(state, 'unknown-version');
assert.match(detail, /2024-06-10/);
});
test('a future date is a typo rather than a retirement', () => {
assert.equal(classify('2099-01-01', SERVED)[0], 'not-yet-supported');
});
test('a value that is not a date is a typo and says so', () => {
const [state, detail] = classify('2022-11-38', SERVED);
assert.equal(state, 'malformed-pin');
assert.match(detail, /never valid/);
});
test('sending no header is a state rather than a pass', () => {
const [state, detail] = classify(null, SERVED);
assert.equal(state, 'unpinned');
assert.match(detail, /pinned by the server/);
assert.equal(classify('', SERVED)[0], 'unpinned');
});
test('an unpinned client is warned when the known default is gone', () => {
const [, detail] = classify(null, ['2025-04-01']);
assert.match(detail, /not on the served list/);
});
test('an empty versions list is a failure of the check not a finding', () => {
const [state, detail] = classify('2022-11-28', []);
assert.equal(state, 'no-versions-list');
assert.match(detail, /failure of the check/);
});
test('a refusal is matched on words and not on a status code', () => {
assert.ok(confirmsVersionRefusal(410, 'The API version is no longer supported'));
assert.ok(confirmsVersionRefusal(400, 'X-GitHub-Api-Version is not supported'));
assert.ok(!confirmsVersionRefusal(200, 'The API version is no longer supported'));
assert.ok(!confirmsVersionRefusal(403, 'Resource not accessible by integration'));
assert.ok(!confirmsVersionRefusal(null, null));
});
FAQ
Should I pin a version at all, or leave the header off?
Pin it. Leaving the header off does not opt out of versioning; it opts into whatever GitHub currently defaults to, which today is 2022-11-28 and will one day be something else. An unpinned client therefore gets a breaking change delivered on GitHub's schedule with no signal beforehand, while a pinned one gets a retirement it can see coming on a public list. The cost of pinning is that somebody has to move it, which is exactly the work the scheduled check turns into a ticket.
How much warning do I get before a version stops working?
Enough to plan, if anything is watching. Versions are announced when they are published and retired after a supported period, and the practical signal is that a version stops appearing in GET /versions. That list is public and unauthenticated, so a daily job comparing your pin against it converts the whole problem into a ticket with a date on it. Waiting for the refusal instead means finding out during an outage, with no useful information in the error beyond the version string you already knew.
Does the version pin affect GraphQL as well?
No. X-GitHub-Api-Version applies to the REST API only; the GraphQL API is versioned through schema evolution and deprecation notices rather than through date-stamped versions, so a GraphQL client has nothing to pin and nothing to retire. If part of your integration is GraphQL and part is REST, only the REST half is exposed to this, which is worth knowing when half your calls fail and half do not.
What is actually in a version change?
Something breaking, by definition, which is why a new version exists at all. In practice they are narrow: a response field that stops being returned, a parameter that becomes required, a default that changes, an endpoint whose shape moves. Each published version has its own set of notes, and the reason to read every version between your pin and your target rather than only the target's is that the changes accumulate rather than replace one another.
My requests fail and the message says nothing about a version. Is this still it?
Probably not. The refusal for a bad version names the version, and a script that keys on a status code alone will mistake an unrelated 4xx for this problem. If everything is failing and the message says Bad credentials, the cause is the credential rather than the header, and that has its own triage: the two 401 messages GitHub uses mean opposite things and are worth telling apart before anything else is touched.
Related field notes
- 401 Bad credentials on every endpoint
- Unauthenticated and capped at 60 an hour
- A route that refuses a valid installation token
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.
- API Versions — GitHub REST API
- Breaking changes — GitHub REST API
- Meta — GitHub REST API
- Getting started with 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.