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 OWNER/REPO --out site [--token TOKEN]
Reads every (non-draft) release of 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 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)
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 "$GITHUB_REPOSITORY" --out site - uses: actions/upload-pages-artifact@v3 with: path: site
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"
ghr-pypi --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 single-repository 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 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) — the single-repository form assumes a Pages URL for the landing
page's install example.
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, extract each wheel's
METADATAand upload it next to the wheel (see the "Extract PEP 658 metadata from wheels" step inrelease.ymlfor the full version):- name: Extract PEP 658 metadata run: | python3 -c " import pathlib, zipfile for w in pathlib.Path('dist').glob('*.whl'): m = [n for n in zipfile.ZipFile(w).namelist() if n.endswith('.dist-info/METADATA') and n.count('/') == 1] w.with_name(w.name + '.metadata').write_bytes( zipfile.ZipFile(w).read(m[0])) " - 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.
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.6.tar.gz.
File metadata
- Download URL: ghr_pypi-2026.8.6.tar.gz
- Upload date:
- Size: 286.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f1750cb25b62c9907f647548f0987c693b622d4ef548c68a0745ece8566cbebb
|
|
| MD5 |
902c4ad5cb9c37ff37c799a1f6cd57af
|
|
| BLAKE2b-256 |
94c0779f2266a9d2533c218e3410a7a327f596a3cc90aaf155ed0c9ffeafc84f
|
Provenance
The following attestation bundles were made for ghr_pypi-2026.8.6.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.6.tar.gz -
Subject digest:
f1750cb25b62c9907f647548f0987c693b622d4ef548c68a0745ece8566cbebb - Sigstore transparency entry: 2365891066
- Sigstore integration time:
-
Permalink:
bckohan/ghr-pypi@1e38d4da9111bfa23523c858b6c3a17d016feff7 -
Branch / Tag:
refs/tags/v2026.8.6 - Owner: https://github.com/bckohan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1e38d4da9111bfa23523c858b6c3a17d016feff7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file ghr_pypi-2026.8.6-py3-none-any.whl.
File metadata
- Download URL: ghr_pypi-2026.8.6-py3-none-any.whl
- Upload date:
- Size: 19.1 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 |
6701ef059e61eb430189501c1d5bb664b42cfde6ffc4564acfd90ec20b3fa990
|
|
| MD5 |
6c2636d5250c9a20dd9bfd3ac453507c
|
|
| BLAKE2b-256 |
6a69a899f42f3ea612464b60aa67cd0feb5fcf7ff3d5221d83f08db4d00341e3
|
Provenance
The following attestation bundles were made for ghr_pypi-2026.8.6-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.6-py3-none-any.whl -
Subject digest:
6701ef059e61eb430189501c1d5bb664b42cfde6ffc4564acfd90ec20b3fa990 - Sigstore transparency entry: 2365892115
- Sigstore integration time:
-
Permalink:
bckohan/ghr-pypi@1e38d4da9111bfa23523c858b6c3a17d016feff7 -
Branch / Tag:
refs/tags/v2026.8.6 - Owner: https://github.com/bckohan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1e38d4da9111bfa23523c858b6c3a17d016feff7 -
Trigger Event:
push
-
Statement type: