Packaging and distribution¶
How github-agent is built, installed, published and consumed. The project has one runtime
contract that the packaging exists to protect: zero third-party dependencies. The wheel is
the CLI plus its library, the sdist is the complete pack, and the CLI itself proves both.
Measured when this page was first written (1.2.2, CPython 3.14.7, pip 26.2.1) and re-measured on
1.3.0 (CPython 3.13.15, pip 25.3); the wheel layout, the sdist contents and the zero-dependency
check are the same under both. Commands are written for a POSIX shell; the PowerShell equivalents
are in scripts/build_and_check.ps1.
Contents¶
- What ships
- Four names, one project
- One version, one source
- Build locally
- Automated build check
- Install
- From source
- Wheel and sdist contents
- GitHub Action
- Docker image
- pre-commit hooks
- Release procedure
- PyPI publication
- Homebrew and Scoop
- Static analysis: ruff and mypy
- Failure modes
What ships¶
| Artefact | Built from | Contains | Audience |
|---|---|---|---|
Wheel github_agent_cli-1.3.0-py3-none-any.whl |
pyproject.toml | ghagent.py, ghalib/, dist-info |
pip, uv, pipx users |
sdist github_agent_cli-1.3.0.tar.gz |
the same tree | the whole pack: tools/, tests/, skills/, reference/, docs/, wiki/, assets/, .github/, packaging files |
pack installation, reproducible source |
| Composite Action action.yml | the tagged repository | installs the action checkout with pip, then runs ghagent |
CI workflows |
| Container image Dockerfile | the build context | the wheel in python:3.13-slim, ENTRYPOINT ghagent |
local containers |
| pre-commit hooks .pre-commit-hooks.yaml | the tagged repository | four hooks, language: python |
contributors |
requires-python is >=3.11; CI runs the self-tests on 3.11 and 3.13. The only optional
dependency is the png extra (Pillow) and it changes one thing: PNG output of
ghagent assets render.
Four names, one project¶
The distribution name is not the product name. A user meets four different identifiers:
| Name | Value | Where it is used |
|---|---|---|
| Distribution | github-agent-cli |
PyPI URL, pip install, wheel and sdist filenames |
| Console script | ghagent |
the command in a shell, in CI, in pre-commit hooks |
| Library package | ghalib |
from ghalib import ..., ghagent.py imports |
| Repository and product | github-agent |
GitHub, README, ghagent --version prints github-agent X.Y.Z |
Why they differ: github-agent on PyPI belongs to an unrelated project ("GitHub Agent for MCP",
version 2.4.9 at the time of writing) and ghagent belongs to another one ("A GitHub Explorer",
0.0.1). A name is claimed once and cannot be reused, so this project publishes under
github-agent-cli and keeps the product, the command and the library names unchanged. This is a
distribution fact, not a rename of the project: CHANGELOG.md records it under 1.2.1.
One version, one source¶
tools/ghalib/util.py holds the only version literal in the tree:
pyproject.toml declares dynamic = ["version"] and never repeats the number. To release a
version, change PACK_VERSION once; the wheel filename, wheel metadata, the sdist name and
ghagent --version all follow. A captured mismatch is a packaging defect, so the build check
compares the artefact version against the file instead of trusting either side.
Build locally¶
python -m venv .venv-build
source .venv-build/bin/activate
python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*
Measured output of the last verified build:
Successfully built github_agent_cli-1.3.0.tar.gz and github_agent_cli-1.3.0-py3-none-any.whl
...
Checking dist/github_agent_cli-1.3.0-py3-none-any.whl: PASSED
Checking dist/github_agent_cli-1.3.0.tar.gz: PASSED
python -m build uses build isolation: it downloads hatchling>=1.27 (the declared build
backend) from PyPI. On a machine without network, install hatchling first and pass
--no-isolation. dist/, build/ and virtual environments are in .gitignore; no build
output is committed.
The wheel is built from the sdist by python -m build, so a broken sdist cannot hide behind
a good wheel: the same file list that is published as source is the one that produces the
installed package.
Automated build check¶
bash scripts/build_and_check.sh # Linux, macOS, WSL
pwsh -NoProfile -File scripts/build_and_check.ps1 # Windows
Both scripts create their own virtual environments under the system temporary directory and remove them afterwards; they do not touch the working tree. Each run asserts, in order:
- the sdist and the wheel build from this tree;
twine checkaccepts both;- the artefact version equals
PACK_VERSION; - the wheel contains top-level
ghagent.pyandghalib/, and notools/package; Requires-Distholds nothing except thepngextra;- a clean virtual environment installs the wheel,
ghagent --versionprints the expected string, and the installed CLI runsdocandauditagainst this checkout.
The same assertions run in the package job of CI, so a pull
request that breaks packaging is red before it merges.
Install¶
uv tool install github-agent-cli # isolated CLI, recommended
pipx install github-agent-cli # isolated CLI, alternative
pip install github-agent-cli # into the current environment
pip install "github-agent-cli[png]" # plus Pillow, for PNG banner output
ghagent --version # github-agent 1.3.0
These commands resolve from PyPI after the first publication; until then install the wheel built in Build locally or use the source checkout below. The last verified clean installation, measured in a fresh virtual environment:
Nothing else is installed: the package has no runtime dependency, and the only Requires-Dist
line in the wheel metadata is pillow>=10.0; extra == 'png'.
uv and pipx were not installed in the verification environment, so those two commands are
documented client forms and were not executed here; the measured path is pip install of the
wheel and pip install <repository directory>, both of which produce the same console script.
The wheel ships the CLI only. ghagent selftest and the install family need the source tree
(tests/, tools/); from an installed copy they exit 2 and say so instead of pretending to
have verified anything. The next section is that path.
From source¶
git clone https://github.com/paulmann/github-agent.git
cd github-agent
python tools/ghagent.py --version # github-agent 1.3.0
python tools/ghagent.py selftest # must print PASS
python tools/ghagent.py install --profile harness --write
Source mode is the only mode that can self-test the pack or copy it into an agent skill root:
the sdist is the same tree, so an unpacked sdist behaves the same way. An editable install
(pip install -e .) also resolves ghagent to tools/ghagent.py, so ghagent selftest keeps
working from an editable copy; a regular wheel installation cannot do either.
Wheel and sdist contents¶
The wheel has 17 entries and no other top-level name:
| Path | Why |
|---|---|
ghagent.py |
the console-script target (ghagent = "ghagent:main") |
ghalib/ |
11 library modules, one small file each, standard library only |
github_agent_cli-1.3.0.dist-info/ |
METADATA, WHEEL, entry_points.txt, licenses/LICENSE, RECORD |
only-include and sources in pyproject.toml strip the tools/ prefix, so site-packages
gets ghagent.py and ghalib/ and never a generic tools package. The verification checks
that absence explicitly.
Key metadata lines from github_agent_cli-1.3.0.dist-info/METADATA:
Metadata-Version: 2.5
Name: github-agent
Version: 1.3.0
Requires-Python: >=3.11
License-Expression: MIT
License-File: LICENSE
Provides-Extra: png
Requires-Dist: pillow>=10.0; extra == 'png'
The sdist carries the complete pack: all top-level documents and translations, tools/,
tests/, skills/, reference/, docs/, wiki/, assets/, examples/, .github/,
scripts/, action.yml, Dockerfile, .dockerignore, .pre-commit-hooks.yaml and
pyproject.toml. That is what ghagent install copies, so
pip download --no-binary :all: github-agent followed by an unpack and
python tools/ghagent.py selftest reproduces a verifiable pack.
GitHub Action¶
name: Repository hygiene
on: [push, pull_request]
permissions:
contents: read
jobs:
ghagent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: paulmann/github-agent@v1.3.0
with:
command: audit . --min-score 80
The action is runs: using: composite. It installs the checked-out action repository into the
runner's Python with pip install "$GITHUB_ACTION_PATH", so it does not need a PyPI release
and works as soon as the tag exists. It uses actions/setup-python@v7 and needs network only
for the build backend and Python.
| Input | Default | Effect |
|---|---|---|
command |
audit . --min-score 80 |
argument string passed to ghagent |
min-score |
80 |
appended when command runs audit without its own --min-score |
working-directory |
. |
directory under the workspace to run in |
python-version |
3.13 |
Python used for the run |
github-token |
${{ github.token }} |
exported as GITHUB_TOKEN for remote metadata calls |
The action declares no outputs. Its result is the step exit code: 0 clean, 1 findings,
2 usage or environment problem. For a command other than audit, command is executed as
given.
Docker image¶
docker build -t ghagent:1.3.0 .
docker run --rm ghagent:1.3.0 --version
docker run --rm -v "$PWD:/repo" ghagent:1.3.0 audit . --min-score 80
Two stages, both python:3.13-slim. The build stage runs pip wheel against the build
context (no PyPI copy of github-agent); the runtime stage installs that wheel with
--no-index --find-links and copies nothing else. The image sets WORKDIR /repo, so mounting
a repository at /repo needs no path argument, ENTRYPOINT is ghagent, and the default
command is --help.
Docker was not installed in the verification environment: the image was not built or run here, and no registry image is published by this repository. Treat the commands above as the supported local path, not as a public image reference.
pre-commit hooks¶
repos:
- repo: https://github.com/paulmann/github-agent
rev: v1.3.0
hooks:
- id: ghagent-doc
- id: ghagent-audit
- id: ghagent-privacy
- id: ghagent-changelog
rev must be a released tag: language: python makes pre-commit build the wheel from the
tagged tree and expose the ghagent console script. All four hooks set pass_filenames: false
because each command examines a tree, not a list of changed files.
| Hook | Runs | Trigger |
|---|---|---|
ghagent-doc |
ghagent doc --root . |
any markdown file changed (types_or: [markdown]) |
ghagent-audit |
ghagent audit . --min-score 80 |
every commit (always_run) |
ghagent-privacy |
ghagent privacy --root . |
every commit (always_run) |
ghagent-changelog |
ghagent changelog check |
CHANGELOG.md changed |
Release procedure¶
- Set
PACK_VERSIONintools/ghalib/util.py; change nothing else for the version. - Add
## [X.Y.Z] - YYYY-MM-DDtoCHANGELOG.mdandCHANGELOG.ru.md, update the link blocks in both, then runpython tools/ghagent.py changelog check. - Run the six repository checks and the build check:
python tools/ghagent.py selftest,doc --root .,audit .,changelog check,assets check,privacy --root ., thenbash scripts/build_and_check.sh. - Commit on
main, push, and watch CI until green; never tag a red commit. - Create an annotated tag, then a GitHub release whose notes come from
python tools/ghagent.py changelog notes X.Y.Z; attachdist/github_agent_cli-X.Y.Z.tar.gz,dist/github_agent_cli-X.Y.Z-py3-none-any.whlandSHA256SUMS.txt. - Download the assets again and compare their SHA-256 values with
SHA256SUMS.txt; publish only after the comparison matches. - Upload to PyPI deliberately, through the Publish workflow (next section), and verify the project page.
- Optionally publish the wiki and refresh repository metadata; both are separate commands with their own verification.
The release body is generated, the publish button is not¶
.github/workflows/release-draft.yml runs ghagent changelog notes <version> and creates a
draft release from the tagged tree. A draft does not fire publish.yml, which listens for
release: published, so the irreversible step (PyPI) stays with a human who reads the draft and
presses Publish. Signed commits are a repository setting — a key and git commit -S — and are
not something a workflow can grant; they remain a requirement of the procedure, not a step in it.
The release assets are the durable artefact: PyPI forbids re-uploading the same version, so a mistake is a new patch version, never a silently replaced file.
PyPI publication¶
github-agent-cli 1.3.0 was published on 2026-11-01. Measured facts:
| Fact | Value |
|---|---|
| Project page | https://pypi.org/project/github-agent-cli/ |
| Wheel on PyPI | github_agent_cli-1.3.0-py3-none-any.whl, 71 318 bytes, sha256 f62fe090fe9267e749e76b6e1fd0d8b291115729ff139941068a986c147983fc |
| sdist on PyPI | github_agent_cli-1.3.0.tar.gz, 317 289 bytes, sha256 763595eb57df20b7e12711946680e528d100794d3fc0addf8fb4c9b06d33cf26 |
| Installed check | the published wheel, unpacked and run with CPython 3.13.15: ghagent --version prints github-agent 1.3.0, ghagent audit . --min-score 90 scores 100.0% and exits 0 |
The PyPI files are built by the Publish workflow on the runner, so their hashes differ from the
GitHub release assets, which are built locally from the tagged tree. Both are legitimate builds of
the same commit; if you need bit-identical files, download the release asset or the PyPI file and
compare it against its own hash, not against the other one.
The first upload used the PYPI_API_TOKEN repository secret because the local network reset the
connection to upload.pypi.org while the GitHub runner could reach it. The workflow therefore
passes password: ${{ secrets.PYPI_API_TOKEN }} and sets skip-existing: true, which makes a
retry after a partial transfer idempotent.
Moving to OIDC (recommended, no long-lived secret)¶
The project now exists, so a trusted publisher can be attached to it instead of using a token:
- PyPI → the
github-agent-cliproject → Publishing → Add a trusted publisher with owner (your PyPI username), repositorygithub-agent, workflowpublish.yml, environmentpypi. - In publish.yml delete the
password:line; keepenvironment: pypiandid-token: write. - Delete the
PYPI_API_TOKENsecret (gh secret delete PYPI_API_TOKEN -R owner/repo) and revoke the token on PyPI. - Run the workflow once to prove the OIDC exchange works, then extend the trigger with
release: types: [published]and drop theif:guard, so a published release becomes the only publication event.
Homebrew and Scoop¶
Both manifests are real now: they point at the published 1.3.0 files and CI job manifests
downloads each URL and compares its SHA-256 before a pull request can merge.
| Package manager | File | Install |
|---|---|---|
| Homebrew (macOS, Linux) | packaging/homebrew/github-agent-cli.rb | brew install paulmann/homebrew-github-agent-cli/github-agent-cli |
| Scoop (Windows) | packaging/scoop/github-agent-cli.json | scoop bucket add github-agent-cli https://github.com/paulmann/scoop-github-agent-cli then scoop install github-agent-cli |
The formula installs the PyPI sdist
(github_agent_cli-1.3.0.tar.gz, sha256 763595eb57df20b7e12711946680e528d100794d3fc0addf8fb4c9b06d33cf26)
into a virtualenv with python@3.13, so brew users need no pip. The Scoop manifest installs the
Windows bundle github-agent-cli-1.3.0-windows-x64.zip (sha256
cd88d4858ad183799a71dd8065092cc84c3813dc11ffe75e319ccc052e5077fe): ghagent.cmd,
lib/ with the wheel contents, and depends: python for the interpreter. The bundle was executed
on Windows before upload — ghagent.cmd --version printed github-agent 1.3.0 and an audit scored
100.0%.
The canonical copies live in the tap (paulmann/homebrew-github-agent-cli) and bucket
(paulmann/scoop-github-agent-cli) repositories; the files in packaging/ are the reviewable
source, and packaging/README.md carries the bump procedure. Both manifests
auto-detect a new version from the PyPI JSON API; the hashes still have to be recomputed after each
release, which is exactly what the manifests job checks.
Static analysis: ruff and mypy¶
Both are now enabled, in a separate lint job, with an explicit configuration in pyproject.toml
and a clean baseline — which is what the earlier "deferred" note asked for:
| Tool | Configuration | Why exactly these rules |
|---|---|---|
ruff |
select = ["E4", "E7", "E9", "F", "I"], line-length = 120 |
E501 is off on purpose: the prose in this pack is written in paragraphs that wrap for a reader, not for a 79-column counter. E4/E7/E9 and F catch the defects that matter (unused and undefined names, broken syntax); I keeps imports sorted so two files stop fighting about the same block. |
mypy |
files = ["tools/ghalib", "tools/ghagent.py"], check_untyped_defs, no_implicit_optional, warn_unused_ignores, warn_redundant_casts |
Annotations are partial by design; the checker still reads untyped bodies and refuses silent Optional. |
Neither tool is imported by the pack and neither ships in the wheel: the runtime dependency count
stays zero, and pip install "github-agent-cli[png]" remains the only extra. The baseline was
established the way the note required — configuration first, then ruff check . and mypy green
on the same commit that enabled them.
Failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
ghagent selftest exits 2 after pip install |
the wheel ships the CLI only | clone the repository or unpack the sdist |
python -m build fails without network |
build isolation downloads hatchling |
install hatchling and pass --no-isolation |
wheel contains tools/ |
sources/only-include lost from pyproject.toml |
restore them; the build check fails on the layout |
| version in the filename is stale | PACK_VERSION was not changed |
change it in tools/ghalib/util.py only |
twine check warns about metadata |
an invalid license or readme declaration | keep license = "MIT" and license-files = ["LICENSE"] |
the Action cannot find ghagent |
pip install "$GITHUB_ACTION_PATH" did not run |
check the composite step order and its exit code |
| pre-commit cannot find the revision | rev is a branch or a missing tag |
use a released tag such as v1.3.0 |
| a publish run fails on permissions | Trusted Publishing not configured, or no pypi environment |
complete the setup above before uploading |