ghr-pypi
Documentation: ghr-pypi.readthedocs.io — deployment tutorials, how-to guides, and the full configuration and CLI reference.
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.
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]
ghr-pypi extract-meta PATH...
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). 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
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
mirror: false # optional — see "Mirroring assets"
metadata: true # optional — see "Dependency metadata"
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.
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 (default) |
download and hash the file |
no-fragment |
link it without a #sha256= fragment (pip skips integrity verification) |
omit |
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 mirror: true (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 mirror: true 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.
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. -
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.
- The GitHub API returns at most 100 releases per page and the tool reads one page.
- 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ghr_pypi-2026.8.8.tar.gz.
File metadata
- Download URL: ghr_pypi-2026.8.8.tar.gz
- Upload date:
- Size: 329.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
40554fc848491638ba22d4d4a113ca76010d425dbf9af6709274bc95ec70d1ae
|
|
| MD5 |
ea921e9829ef8f860d9abbfebc1a9e39
|
|
| BLAKE2b-256 |
0ec9ea128229878ee2fff3e28d5c3d6787bdfb4c4621af47e720639b29810a0a
|
Provenance
The following attestation bundles were made for ghr_pypi-2026.8.8.tar.gz:
Publisher:
release.yml on bckohan/ghr-pypi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ghr_pypi-2026.8.8.tar.gz -
Subject digest:
40554fc848491638ba22d4d4a113ca76010d425dbf9af6709274bc95ec70d1ae - Sigstore transparency entry: 2381044109
- Sigstore integration time:
-
Permalink:
bckohan/ghr-pypi@39de44f08f5d827d85b8d05b36770201c4c34126 -
Branch / Tag:
refs/tags/v2026.8.8 - Owner: https://github.com/bckohan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@39de44f08f5d827d85b8d05b36770201c4c34126 -
Trigger Event:
push
-
Statement type:
File details
Details for the file ghr_pypi-2026.8.8-py3-none-any.whl.
File metadata
- Download URL: ghr_pypi-2026.8.8-py3-none-any.whl
- Upload date:
- Size: 23.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dde2ef1f5e7af45e1fe38e3c90bcc4506af819bce730f7253faee917ca3f18d5
|
|
| MD5 |
3c4af73d6108e18ca42a1200aa7218c9
|
|
| BLAKE2b-256 |
12acc41dd44471dc4a78a6ff5dfee248fa2fcab0be00b8e74cda0eed73946686
|
Provenance
The following attestation bundles were made for ghr_pypi-2026.8.8-py3-none-any.whl:
Publisher:
release.yml on bckohan/ghr-pypi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ghr_pypi-2026.8.8-py3-none-any.whl -
Subject digest:
dde2ef1f5e7af45e1fe38e3c90bcc4506af819bce730f7253faee917ca3f18d5 - Sigstore transparency entry: 2381044250
- Sigstore integration time:
-
Permalink:
bckohan/ghr-pypi@39de44f08f5d827d85b8d05b36770201c4c34126 -
Branch / Tag:
refs/tags/v2026.8.8 - Owner: https://github.com/bckohan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@39de44f08f5d827d85b8d05b36770201c4c34126 -
Trigger Event:
push
-
Statement type: