11 Keyless Package-Registry APIs, and Why You Can't Pin `latest`


Four packages from npm, one minute apart, July 22, 2026:

typescript   dist-tags.latest = 7.0.2      newest published = 7.1.0-dev.20260721.1
react        dist-tags.latest = 19.2.8      newest published = 0.0.0-experimental-...20260721
next         dist-tags.latest = 16.2.11     newest published = 16.3.0-canary.93
vue          dist-tags.latest = 3.5.40      newest published = 3.6.0-rc.1

In every one of those, the version labeled latest is not the newest thing published. It is not the highest version number either. On next there are 16 different dist-tags, and latest is just one of them. typescript has published 3,776 versions total, most of them nightly dev builds, and latest points at exactly one of those by hand.

Then the row that keeps me honest. Same query, same minute, eslint:

eslint       dist-tags.latest = 10.7.0      newest published = 10.7.0

Identical. So latest is not a liar. It is a pointer a maintainer moves with npm dist-tag add, and it sometimes lands on the newest upload and sometimes does not. Nothing in the field tells you which case you are in.

That is the whole post. Finding a package registry with a free API is the easy ten percent. The trap is that latest, across every registry I checked, means a different thing computed by a different rule, and none of those rules is stable enough to read inside a build.

The one idea

A keyless package-registry API answers questions about published packages and versions with no API key, no signup, no card. I verified eleven with a live curl on July 22, 2026. The table is one screen down.

Under it is the part I care about. Every registry exposes some notion of latest, and it is a mutable, editorial pointer, not a measurement. A maintainer sets it, a version can be yanked out from under it, a Docker tag can be re-pushed to a new digest. If your build reads latest, you resolve different bytes on a different day and call it reproducible. The fix is to never dereference the alias. Pin an immutable coordinate: an exact version plus an integrity hash.

The 11 endpoints, verified

Every row is a live check on July 22, 2026, around 01:35Z. I ran negative controls first, so I know the probe can report failure: a bogus .invalidtld host returned 000, a nonexistent PyPI package returned 404, a nonexistent npm package returned 404, crates.io with no User-Agent returned 403, a nonexistent RubyGems gem returned 404, and a nonexistent Go module returned 404. The probe can tell live from dead.

#EndpointWhat it answerskeylessHTTP
1registry.npmjs.org/<pkg>full packument, versions, dist-tagsyes200
2pypi.org/pypi/<pkg>/jsonreleases, files, yank stateyes200
3crates.io/api/v1/crates/<c>crate metadata, three version fieldsyes (needs UA)200
4hub.docker.com/v2/repositories/library/<img>/tags/image tags, digests, sizesyes200
5api.github.com/repos/<o>/<r>/releases/latestthe release flagged latestyes (60/h unauth)200
6proxy.golang.org/<mod>/@latestresolved version + commit hashyes200
7rubygems.org/api/v1/gems/<g>.jsongem version + dependenciesyes200
8repo.packagist.org/p2/<vendor>/<pkg>.jsonComposer package versionsyes200
9api.nuget.org/v3/registration5-gz-semver2/<id>/index.json.NET registration, version pagesyes200
10formulae.brew.sh/api/formula/<f>.jsonHomebrew formula + bottle infoyes200
11hex.pm/api/packages/<p>Elixir/Erlang package releasesyes200

Two operational notes, both of which cost people time.

crates.io returns 403 with no User-Agent. The body says so in plain text: it asks you to set a UA with a contact address. Any non-empty string works, but a bare curl looks like a live check and is not one. That is exactly the kind of row that makes a table look verified when it is not.

The GitHub row is rate-limited hard when unauthenticated. 60 requests per hour from an IP, shared across everything you do against api.github.com. Fine for a lookup, useless for a sweep, and the 403 you get when you exhaust it looks nothing like a 404.

Five registries, five definitions of latest

This is the deterministic part. I cannot show you a pointer moving inside a single run, because that takes time to happen. What I can show you, right now, is that the same word is computed five incompatible ways across five registries. Here is each rule, measured July 22, 2026.

npm: latest is a hand-set dist-tag. Not a maximum, not the newest upload. It is a named pointer, one of several, that the publisher assigns. It lagged the newest upload on typescript, react, next and vue, and matched it on eslint. Same field, opposite behavior, no flag to distinguish them.

PyPI: info.version is the highest non-yanked, non-prerelease release under PEP 440. requests reports 2.34.2, out of 163 releases, and two of those releases, 2.32.0 and 2.32.1, are fully yanked and silently excluded from that field. PyPI still serves them. Ask for the exact version and you get a 200, with the registry’s own reason attached:

requests 2.32.0  ->  HTTP 200
yanked_reason = "Yanked due to conflicts with CVE-2024-35195 mitigation"

Yank support is defined in PEP 592, written by Donald Stufft. A yank removes a release from range resolution but leaves it installable by exact pin, on purpose, so that an existing lockfile does not break. Which means info.version is a view, not the truth: the set of real releases is larger than the field admits.

crates.io ships three version fields in one struct, and they are genuinely different concepts: newest_version (most recently published by time), max_version (highest by semver), and max_stable_version (highest non-prerelease). On tokio, serde, clap and reqwest all three agreed. On three others they did not:

wgpu   newest=29.0.4   max=30.0.0   max_stable=30.0.0
rand   newest=0.9.5    max=0.10.2   max_stable=0.10.2
num    newest=0.1.43   max=0.4.3    max_stable=0.4.3

Read that carefully. On wgpu, the most recently published version is 29.0.4, a patch backported to an older major line, while the highest version is 30.0.0. “Newest” and “highest” are different sorts, and the registry exposes both because it knows they diverge. If your code reads newest_version and calls it latest, you would install a nine-major-versions-old line for num.

Docker Hub: latest is a bare string with no ordering. I pulled the 25 most recently updated tags for library/python. latest was not among them. It exists, it was last updated July 20, 2026, but it is a tag someone chose to push there and nothing enforces that it tracks the newest image, or any image at all. A tag is a mutable ref: the same python:latest can point at a different digest tomorrow.

GitHub: /releases/latest is filtered by a prerelease flag the publisher sets. For psf/requests it returned v2.34.2. But /tags on the same repo lists v2.34.0.dev1, a git tag that never shows up as a release because a human ticked a box. “Latest release” and “newest tag” are different questions with different answers, and the difference is an editorial choice, not a computation.

Five registries. Five rules. The word latest carries none of that context when you read it.

The good citizen: Go’s module proxy

One registry gets this right, and it is worth showing because it proves the rest could. Ask the Go module proxy for @latest and it does not hand you a mutable label. It resolves the request and hands back an immutable coordinate plus a commit hash. Verbatim, July 22, 2026:

GET https://proxy.golang.org/github.com/gorilla/mux/@latest

{"Version":"v1.8.1","Time":"2023-10-18T11:23:00Z",
 "Origin":{"VCS":"git","URL":"https://github.com/gorilla/mux",
 "Ref":"refs/tags/v1.8.1",
 "Hash":"b4617d0b9670ad14039b2739167fd35a60f557c5"}}

@latest is a query, and the answer is a resolved thing: v1.8.1, pinned to git commit b4617d0b9670. The Go toolchain then records that version and a content hash in go.sum, and re-verifies it on every build. The request can say “latest” all it wants; what comes back is something you can pin. testify resolved to v1.11.1, golang.org/x/text to v0.40.0, each with its own hash. That is the shape every other registry is missing.

Read it yourself

This is the whole finding in about twenty lines. Runnable, keyless, no dependencies beyond the standard library.

# runnable, keyless. crates.io needs any non-empty User-Agent.
import json, urllib.request

def get(url, hdr=None):
    req = urllib.request.Request(url, headers=hdr or {})
    return json.load(urllib.request.urlopen(req, timeout=20))

# npm: 'latest' is a hand-set dist-tag, not a max and not the newest upload
npm = get("https://registry.npmjs.org/typescript")
tag = npm["dist-tags"]["latest"]
uploads = {k: v for k, v in npm["time"].items() if k not in ("created", "modified")}
newest = max(uploads, key=uploads.get)
print("npm      latest-tag=%s  newest-upload=%s" % (tag, newest))

# PyPI: 'version' is highest non-yanked, non-prerelease (PEP 440)
print("PyPI     info.version=%s" %
      get("https://pypi.org/pypi/requests/json")["info"]["version"])

# crates.io: three fields, one struct
cr = get("https://crates.io/api/v1/crates/wgpu",
         {"User-Agent": "you@example.com"})["crate"]
print("crates   newest=%s  max=%s  max_stable=%s" %
      (cr["newest_version"], cr["max_version"], cr["max_stable_version"]))

# Go: @latest resolves to an immutable version + commit hash
go = get("https://proxy.golang.org/github.com/gorilla/mux/@latest")
print("go       @latest=%s  hash=%s" % (go["Version"], go["Origin"]["Hash"][:12]))

Output on my machine, July 22, 2026:

npm      latest-tag=7.0.2  newest-upload=7.1.0-dev.20260721.1
PyPI     info.version=2.34.2
crates   newest=29.0.4  max=30.0.0  max_stable=30.0.0
go       @latest=v1.8.1  hash=b4617d0b9670

Four registries, four meanings of one word. Your version numbers will differ from mine, which is the point: run it next week and npm’s line will have moved on its own.

The fix is to pin, and to pin a hash

The remediation is not “read a better field.” It is: do not dereference the alias in anything that has to be reproducible. Pin an exact version, and pin a hash of the artifact so that “this version” cannot quietly mean different bytes.

Every serious lockfile already does exactly this. Here is what npm records once you install a pinned version, hash and all:

"typescript": {
  "version": "7.0.2",
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
  "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="
}

The word latest appears nowhere in that lockfile, by design. Same on PyPI, where pip will refuse to install anything whose hash you did not name once you pin one:

requests==2.34.2 \
  --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0

That is the pattern for all eleven. Resolve the alias once, record the version and the hash, verify the hash on every subsequent install. Go’s go.sum does it automatically. npm’s integrity, pip’s --hash, Cargo’s Cargo.lock checksums, all the same move. The registry stays free to move its pointers around; your build stops caring, because it stopped reading them.

Where this comes from, and what I did not measure

I run scrapers and data pipelines in production, 32 published actors and something over 2,000 runs. The recurring lesson is boring and expensive: a run is reproducible exactly when every input is pinned to an immutable coordinate, and reading a mutable alias like latest, or an unpinned version range, is a category of nondeterminism I now design out before it ships. Package metadata is one of those inputs. So is a version resolver.

I want to be precise about what I did not do. I did not pull our own logs to tell you “N runs broke because of a yank or a moved tag.” I could invent a number and I would rather not. The honest artifact is the one above: a live probe you can rerun, showing five registries disagree about latest today. If you see a specific failure count in a post like this with no method attached, ask where it came from.

Honest limits

  • Time-mutability is shown by design, not by demonstration. I cannot make a dist-tag move inside one afternoon. The cross-registry rule divergence is measured and deterministic; the “it will move next week” claim rests on npm dist-tags being assignments, Docker tags being mutable refs, and yanks being retroactive. All three are documented behavior, none is a live before-and-after here.
  • I did not fully close the crates.io case. I found three crates where the three version fields carry two distinct values (wgpu, rand, num). I did not find one where all three fields are mutually distinct, which would need a crate whose newest publish is a live pre-release above its highest stable. It exists; I did not catch one in this sweep.
  • Rate limits and keyless status are a snapshot. GitHub unauth is 60/h, crates.io requires a UA, Docker Hub anonymous pulls are rate-limited. All eleven were keyless on July 22, 2026. That is all I can promise.
  • One machine, one network, one afternoon. Byte counts and version numbers drift. Read them as evidence of shape and divergence, not as constants.

The open question

I can pin a version and a hash, and I trust that. What I have not solved is the supply-chain layer above it: a pinned hash proves the bytes did not change, but it says nothing about whether the version I pinned was compromised at publish time, before anyone hashed it. A yank tells me after the fact, like requests 2.32.0 getting pulled over a CVE-2024-35195 conflict, but by then it is in a thousand lockfiles.

If you pin aggressively, how do you decide when to unpin and move forward, without either chasing latest blindly or freezing on a version that quietly went bad? I have half-answers involving yank feeds and advisory databases and I would rather hear a real one. 👇


Written with AI assistance. Every version number, status code and command output above is from my own live checks on July 22, 2026, and the negative controls are printed as they came out.