Skip to main content

ghr-pypi

License: MIT Ruff PyPI version PyPI pyversions PyPI status Documentation Status Code Cov Test Status Lint Status

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

  1. In repo Settings → Pages, set Source to GitHub Actions.

  2. Publish your packages' wheels/sdists as GitHub Release assets (see the github-release job in release.yml for a tag-triggered example).

  3. 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.yml for the full workflow (triggers, permissions, deploy job). Note: releases created by workflows with GITHUB_TOKEN don't fire release events, 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>.metadata beside 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 METADATA and upload it next to the wheel (see the "Extract PEP 658 metadata from wheels" step in release.yml for 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-url over --index-url if 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-url for 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-pypi itself: 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

ghr_pypi-2026.8.6.tar.gz (286.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ghr_pypi-2026.8.6-py3-none-any.whl (19.1 kB view details)

Uploaded Python 3

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

Hashes for ghr_pypi-2026.8.6.tar.gz
Algorithm Hash digest
SHA256 f1750cb25b62c9907f647548f0987c693b622d4ef548c68a0745ece8566cbebb
MD5 902c4ad5cb9c37ff37c799a1f6cd57af
BLAKE2b-256 94c0779f2266a9d2533c218e3410a7a327f596a3cc90aaf155ed0c9ffeafc84f

See more details on using hashes here.

Provenance

The following attestation bundles were made for ghr_pypi-2026.8.6.tar.gz:

Publisher: release.yml on bckohan/ghr-pypi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for ghr_pypi-2026.8.6-py3-none-any.whl
Algorithm Hash digest
SHA256 6701ef059e61eb430189501c1d5bb664b42cfde6ffc4564acfd90ec20b3fa990
MD5 6c2636d5250c9a20dd9bfd3ac453507c
BLAKE2b-256 6a69a899f42f3ea612464b60aa67cd0feb5fcf7ff3d5221d83f08db4d00341e3

See more details on using hashes here.

Provenance

The following attestation bundles were made for ghr_pypi-2026.8.6-py3-none-any.whl:

Publisher: release.yml on bckohan/ghr-pypi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page