ghr-pypi¶
Tools for creating Python package indexes from GitHub release assets. No index server, nothing published to pypi.org — just static PEP 503 HTML and PEP 691 JSON, servable from GitHub Pages, a CDN or your own webserver, and rebuilt automatically on every release. Optional deployment targets write the host artifacts that go with it — Cloudflare Pages cache headers, an nginx snippet — through a plugin interface you can register your own host against.
This repository is both the tool and its own demo: the index at bckohan.github.io/ghr-pypi is built by this tool from this repo’s releases.
Installation¶
pip install ghr-pypi
# or run it without installing:
uvx ghr-pypi --help
Usage¶
ghr-pypi index [OWNER/REPO...] [--out DIRECTORY] [--token TOKEN]
[--target NAME] [--target-out DIRECTORY]
ghr-pypi extract-meta PATH...
ghr-pypi webhook --index-repo OWNER/REPO [--out DIRECTORY]
index builds the package index; extract-meta writes a wheel’s PEP 658
core metadata beside it for upload as a release asset (see “Dependency
metadata” below); webhook writes a Cloudflare Worker that rebuilds the
index when another repository publishes a release — see the “How do I
rebuild when another repository releases?” guide in the documentation, which
also covers the two ways that need no receiver at all. Bare ghr-pypi prints
help and exits non-zero.
index reads every (non-draft) release of each OWNER/REPO via the GitHub API
(--token defaults to $GITHUB_TOKEN), collects the wheel/sdist assets,
takes each file’s sha256 from the API’s asset digest (downloading and
hashing only files that lack one), and writes a static
PEP 503 index to --out (default
_site/):
_site/index.html → human landing page
_site/simple/ → lists every project
_site/simple/<project>/ → file links with #sha256= fragments
_site/simple/**/index.json → PEP 691 JSON Simple API (see below)
With no repository argument it indexes $GITHUB_REPOSITORY, so inside a
GitHub Actions workflow ghr-pypi index on its own is the whole command.
It refuses to build an empty index (non-zero exit) so a misconfigured CI run can never deploy a blank package index.
Using it in your own repo¶
In repo Settings → Pages, set Source to GitHub Actions.
Publish your packages’ wheels/sdists as GitHub Release assets (see the
github-releasejob inrelease.ymlfor a tag-triggered example).Add a Pages workflow that runs the tool — the core of it:
- uses: astral-sh/setup-uv@v5 - name: Build the package index env: GITHUB_TOKEN: ${{ github.token }} run: uvx ghr-pypi index - uses: actions/upload-pages-artifact@v5
With no further arguments
indexbuilds$GITHUB_REPOSITORYinto_site, which is also whatupload-pages-artifactuploads by default.See
pages.ymlfor the full workflow (triggers, permissions, deploy job). Note: releases created by workflows withGITHUB_TOKENdon’t firereleaseevents, so a release workflow must dispatch the Pages workflow explicitly (ours does).
Your index appears at https://<owner>.github.io/<repo>/simple/.
Aggregating multiple repositories¶
To serve one index built from several repositories’ releases, pass a YAML config instead of a repository:
# index.yml
repositories:
- yourorg/lib-one
- yourorg/lib-two
- yourorg/tools-* # optional — an fnmatch pattern in the
# name half stands for every matching
# repo the token can read
exclude_repositories: # optional — subtracts from pattern
- yourorg/tools-legacy # expansions only; a repo named
# explicitly above is always indexed
title: yourorg package index # optional
url: https://yourorg.github.io/pypi/ # optional — enables the absolute
# --extra-index-url example on the
# landing page
missing_digest: download # optional — see below
formats: [html, json] # optional — default: both
assets: link # optional — or `mirror`, or
# `redirect`; see below
target: static # optional — or `cloudflare`/`nginx`
metadata: true # optional — see "Dependency metadata"
# NOTE: `missing_metadata` is deliberately absent — it is rejected unless
# `assets` is `redirect`, even when set to its own default. Under
# `assets: redirect` it would be valid here:
#
# missing_metadata: extract # or `warn`
yanked: # optional — PEP 592 yanks, keyed by
yourpkg: # project then (quoted) version
"1.0.1": broken sdist, use 1.0.2 # reason string, or `true`
exclude: # optional — versions dropped from the
yourpkg: # index entirely
- "0.0.1" # see below
ghr-pypi index --config index.yml --out site
Any wheel or sdist attached to any (non-draft) release on any configured repository is included. If two repositories publish the same filename, the first repository in the list wins and a warning is printed. A pattern’s matches are sorted and spliced in where the pattern stands, so an entry listed above it still wins that tie-break.
GitHub’s API supplies a sha256 digest for release assets uploaded since
mid-2025, which the builder uses directly — those files are never
downloaded. missing_digest controls what happens to older assets that
lack a digest:
value |
behavior |
|---|---|
|
download and hash the file |
|
link it without a |
|
leave it out of the index, with a warning |
Duplicate filenames are resolved before the policy applies, so if the first repository’s copy lacks a digest, a later copy’s digest is not consulted.
JSON Simple API¶
With json in formats (the default), the builder also writes a
PEP 691 JSON index — simple/index.json
and simple/<project>/index.json, api-version 1.1 with
PEP 700 versions, size, and
upload-time fields (uv’s --exclude-newer uses upload-time).
On a full webserver you can serve the JSON at the canonical URLs via
Accept-header content negotiation (application/vnd.pypi.simple.v1+json);
on static hosts the files sit alongside the HTML. The JSON shape is
spec-defined and is NOT affected by template overrides.
formats: [json] emits a JSON-only, headless index (no landing page);
formats: [html] reproduces today’s HTML-only output.
Mirroring assets¶
With assets: mirror (or --mirror on the command line form), the
builder downloads every asset into site/files/<project>/ and the index
links to those local copies with relative URLs — the site is fully
self-contained and relocatable, and GitHub is out of the serving path.
This is also the way to index private repositories: downloads go
through GitHub’s authenticated asset API using your --token, and the
resulting site can be served behind whatever auth your host provides
(pip and uv understand basic auth and netrc). Direct links to a private
repo’s assets would not be fetchable by pip.
ghr-pypi index yourorg/private-repo --out site --token $TOKEN --mirror
When the mirrored site will be hosted somewhere other than GitHub Pages,
prefer a config file with assets: mirror and set url to the real host (or
omit it — with a config file, omitting url means no install example at all,
unless $GITHUB_REPOSITORY is set, which supplies the building repository’s
Pages URL). The bare command line form instead assumes a Pages URL whenever it
can derive one. The CLI reference documents exactly when each applies.
Every mirrored file is hashed while downloading; when GitHub’s API
advertises a digest it is verified and a mismatch fails the build
(downloads are staged to a temporary file, so a failed or interrupted
build never corrupts previously mirrored files). The
missing_digest option does not apply (and is rejected) under mirroring —
every file gets a real hash. Files already present in site/files/ with
the right hash are not re-downloaded, so repeat builds only fetch new
assets. In GitHub Actions, persist them between runs:
- uses: actions/cache@v4
with:
path: site/files
key: mirrored-assets-${{ github.run_id }}
restore-keys: mirrored-assets-
Note: files removed from releases (and their extracted .metadata
siblings) are not pruned from site/files/ — clear the directory (or the
cache) to drop them.
Private packages without mirroring¶
assets: redirect is the other way to serve a private repository: no
package bytes are copied into the site at all. Every link points at
_assets/<asset-id>/<filename> on your own site, and a token-holding
redirector — installed by the target — turns each request into GitHub’s
short-lived signed URL, which pip can follow. It is also the only way to
serve PEP 658 metadata for a private repository without mirroring its
wheels: link mode cannot host the sidecars, and mirror mode extracts them
only because it already copied every wheel into the site.
assets: redirect
target: cloudflare # or nginx — both built-ins ship a redirector
The cloudflare target writes site/_worker.js plus a wrangler.toml
and a deploy checklist into --target-out. Deploying means creating the
Pages project, binding three secrets to it with wrangler pages secret put — GHR_PYPI_USER, GHR_PYPI_PASSWORD (the credentials pip
presents) and GITHUB_TOKEN (never leaves the Worker) — and only then
running wrangler pages deploy. All three are required, and the order
matters: a Pages deployment binds its environment when it is created.
The Worker gates the whole site, not just _assets/, and serves only the
assets listed in the generated _assets/manifest.json — which records
each file’s source repository, so it discloses more than the index does
and must be gated with it. missing_metadata (extract, the default, or
warn) decides whether the build downloads a sidecar-less wheel once to
extract its metadata.
The nginx target does the same job on stock nginx — no njs, no
auth_request, no module to install. It writes ghr-pypi.conf with an
/_assets/ location behind auth_basic that proxies to GitHub’s asset
API and hands the 302 straight back, plus ghr-pypi-assets.conf, the
allow-list as a generated map. There are two files because map is
only valid in http context, and the split is why they are included at
two different levels. Neither holds a credential: the token and the
htpasswd are paths the build names but refuses to write, because this
output lands in a build directory and plausibly a git repository.
The “How do I serve private packages without mirroring them?” guide in the documentation is the full version: the deploy order and why, the failure symptoms, and the security notes.
Deployment targets¶
Most hosts want a little configuration alongside the index — cache rules,
a MIME type for the .metadata sidecars. A target writes it for you:
ghr-pypi index yourorg/yourrepo --out site --target cloudflare
target: cloudflare # with a config file it is a key
target |
writes |
contents |
|---|---|---|
|
nothing |
the default |
|
|
Pages cache rules for |
|
|
snippet to |
Under assets: mirror the cloudflare target adds two more rules: immutable
caching for /files/, and a content type for the .metadata sidecars. In
link mode there is no files/ directory to describe, so neither is written.
Under assets: redirect it writes the redirector instead and no
_headers at all — Cloudflare does not apply that file to a Worker’s
responses, and there every response is one. The nginx snippet sets root,
directory URLs and the .metadata type, and gains the /_assets/
redirector under assets: redirect; it sets no caching directives at all.
A target never changes the index or rewrites a URL — that is what assets
decides — so the same index can be deployed anywhere.
Note where each one writes. Anything under --out is published, so a
server config would be served to anyone who can reach the index; the nginx
snippet therefore goes to --target-out, which defaults to the working
directory. Every file a target writes is echoed on stdout. --target is
refused with --config (set target: in the file); --target-out is a
path, like --out, and is accepted with both.
Other hosts can register their own: a class with a name and an emit
method, published under the ghr_pypi.targets entry point group. The
“Deployment targets” reference and the “How do I write a target for my own
host?” guide in the documentation cover the full contract.
Dependency metadata (PEP 658)¶
Resolvers can read a wheel’s dependencies without downloading the wheel when the index serves its core metadata (PEP 658/714) — uv in particular resolves dramatically faster against large indexes.
Mirror mode: metadata is extracted from every mirrored wheel automatically and served as
<filename>.metadatabeside it — no configuration needed.Redirect mode: a release’s own
.metadataasset is served through the redirector; for wheels that have none,missing_metadatachooses between extracting it (the default) and warning about the gap.Link mode: the index can only advertise metadata files that live next to the wheel’s own URL, so they must be uploaded as release assets named
<wheel-filename>.metadata. The builder warns per repository when wheels lack them:warning: yourorg/lib-one: 3 of 4 wheels have no .metadata asset; ...
To publish metadata assets from your release workflow, run
ghr-pypi extract-metaover your built wheels and upload the sidecars next to them (see the “Extract PEP 658 metadata from wheels” step inrelease.ymlfor the full version):- uses: astral-sh/setup-uv@v5 - name: Extract PEP 658 metadata run: uvx ghr-pypi extract-meta dist/ - run: gh release upload "$GITHUB_REF_NAME" dist/*.whl.metadata
Set metadata: false to disable extraction, advertising, and warnings.
If you replace project.html wholesale, copy the built-in’s
data-core-metadata handling to keep advertising metadata.
Yanking and excluding releases¶
A bad release does not have to be deleted. yanked marks it
PEP 592 yanked: the file stays in the
index (and in the PEP 700 versions list, and in the mirror) carrying a
data-yanked attribute and a "yanked" JSON key, so pip and uv stop
selecting it — but still install it, and print your reason, when a
requirement pins that exact version. Existing pinned installs keep working.
exclude is the harder edge: the listed versions never enter the index at
all — no link, no JSON entry, no versions entry, nothing mirrored — and
anything pinned to them stops resolving. Use it when the release must not be
installable by anyone.
yanked:
yourpkg:
"1.0.1": broken sdist, use 1.0.2 # or `true` for no reason
exclude:
yourpkg:
- "0.0.1"
Both are keyed by project (PEP 503-normalized) then version, and versions
match by PEP 440 equivalence, so "1.0" matches a 1.0.0 file — but
"1.0.0" does not match 1.0.0+local; write local versions out in full.
Neither key touches GitHub: removing the entry restores the files on the
next build. If you replace project.html wholesale, copy the built-in’s
data-yanked conditional too, or yanked files will be published as if they
were fine.
Customizing templates¶
Add a templates: directory to the config (resolved relative to the config
file) to override the built-in pages:
templates: ./templates
A file named landing.html, project.html, or simple_root.html in that
directory replaces the built-in template wholesale. To change just part of a
page, extend the built-in under the builtin/ prefix and override blocks:
{% extends "builtin/landing.html" %}
{% block footer %}<footer>© yourorg</footer>{% endblock %}
Always extend via the builtin/ prefix — an override that does
{% extends "landing.html" %} resolves to itself and fails with a recursion
error.
landing.html and project.html define blocks title, head, header,
content, and footer. simple_root.html defines only head — its body is
the PEP 503 anchor list that pip parses, so extend it with care.
If you replace project.html wholesale, guard the hash fragment with
{% if file.sha256 %} as the built-in does — with missing_digest: no-fragment, file.sha256 can be None, and an unconditional
#sha256={{ file.sha256 }} renders a link pip will refuse to verify.
The live demo¶
Two tiny packages live in packages/:
demo-lib (a one-function library) and
demo-app (depends on it, installs a demo-app CLI).
Install them from this repo’s Pages index:
pip install --index-url https://bckohan.github.io/ghr-pypi/simple/ ghr-pypi-demo-app
demo-app
# Hello, world! (served from GitHub Pages)
Resolving demo-app’s dependency on demo-lib from the same index proves
dependency resolution works end to end.
Cut a new release (CalVer-stamps every package — the tool and both demos — runs the full check suite, tests, commits, tags, pushes; the workflows do the rest):
just release
Caveats¶
Prefer
--extra-index-urlover--index-urlif you still want pypi.org for everything else — but pip may consult both indexes, so a name squatted on pypi.org could shadow yours (dependency confusion). Use names that don’t exist on pypi.org, or--index-urlfor your index only.Release assets on public repos are public; this is not a private index.
A
yourorg/*pattern on a personal account finds public repositories only — GitHub has no endpoint that lists someone’s private ones. List private repositories explicitly.This repo’s own index also lists
ghr-pypiitself: the PyPI release workflow attaches the tool’s wheels to GitHub Releases, and the index builder indexes every non-draft release — deliberate dogfooding.
Development¶
just setup # create the uv venv + pre-commit hooks
just install # sync all dependency groups
just test # run the test suite
just check # lint, format, types, package, docs
Cut a release with just release — it pushes a signed v* tag that triggers
release.yml.
Why this design¶
Why a static index instead of an index server¶
An index server — devpi, pypiserver, or a hosted equivalent — is a service: something to deploy, keep patched, give a certificate, back up, and hand credentials to. PEP 503 and PEP 691 describe the Simple API as a set of documents, not as behavior. Installing needs nothing dynamic; only uploading does, and uploading is exactly the part this project does not do.
So ghr-pypi writes the documents and stops. The output is a directory: HTML anchor lists,
JSON payloads, and (under assets: mirror) the files themselves. Any static host will serve
it —
GitHub Pages, a CDN, an S3 bucket, an nginx root. Every response is cacheable, there is no
origin process to compromise, no API token sits on the server, and the build is idempotent:
re-running it produces the same site, and rolling back means redeploying a previous one.
Why GitHub release assets are a reasonable package store¶
The alternative to a package store is another package store. Release assets are one you almost certainly already have: durable, versioned alongside the tag that produced them, served from GitHub’s CDN with the repository’s own availability, and access-controlled by the repository’s own permissions. Publishing to them is one step in a release workflow you have already written.
They are also cheap to index. Since mid-2025 the GitHub API reports a sha256 digest for
each asset, so the builder can advertise a hash without transferring the file. A full rebuild
is a handful of API calls and no package bytes at all — which is why regenerating the whole
index on every release is practical.
The trust model¶
Every link the index emits carries a #sha256= fragment — unless missing_digest is set
to no-fragment — and pip and uv refuse a download whose hash does not match. That covers
corruption and tampering between the host and the installer.
The digest itself comes from GitHub’s API, so in link mode you are trusting GitHub for integrity. Mirror mode narrows that: each file is downloaded and hashed locally, the computed hash is compared against the advertised digest, and a mismatch fails the build — the mirrored copy is pinned to what was true at build time.
What the index does not do: it does not sign anything or attest to who built a wheel; it
does not protect you from a malicious release on a repository you chose to aggregate; and it
does not make a public repository’s assets private — they are world-readable whatever the
index says. And missing_digest: no-fragment deliberately gives verification up for
pre-digest assets, so use it knowingly.
When not to use it¶
Private repositories under the default
assets: link. Direct asset URLs require anAuthorizationheader that pip will not send, so the links are useless. Two modes fix it, and either way the site itself must sit behind whatever authentication your host offers:assets: mirrorcopies the files into the site (How do I serve packages from a private repository?), andassets: redirectleaves them on GitHub and has a token-holding redirector fetch each one on demand (How do I serve private packages without mirroring them?).Anything needing an upload API. There is no
twine uploadand no delete. Yanking and excluding are configuration, not API calls — the only way to change the index is to rebuild and redeploy it.
Contents:
- Tutorials
- How-To Guides
- How do I serve packages from a private repository?
- How do I serve private packages without mirroring them?
- How do I aggregate several repositories into one index?
- How do I index a whole organization?
- How do I customize the look of the index pages?
- How do I rebuild when another repository releases?
- How do I make rebuilds fast for a large index?
- How do I publish PEP 658 metadata for my wheels?
- How do I serve the JSON API at the canonical URLs?
- How do I stop pip from resolving my package from pypi.org instead?
- How do I yank a bad release?
- What happens when a release or file is deleted?
- Why is my index empty, or why did the build fail?
- How do I host the index somewhere other than GitHub Pages?
- How do I write a target for my own host?
- Reference
- Changelog