R. Raushan
Featuredpythonpypipackaginggithub actionsopen source

How I Published My First Python Package on PyPI

Publishing agentic-graph-composer taught me two things worth writing down: skip API tokens for Trusted Publishing, and if your CLI ships a frontend, bundle it into the wheel instead of making users install Node.

July 18, 20266 min

pip install agentic-graph-composer now does exactly what it looks like it should do: it drops a working agc CLI on your machine, and agc canvas schema.yaml opens a real browser UI for editing your agent graph, with zero mention of npm anywhere. Getting to that one-liner took longer than writing the compiler underneath it, and almost none of that time went where I expected. The actual code - a hatchling build hook - is maybe forty lines. The two decisions behind those forty lines are the part worth writing about.

Skip the API token

The obvious way to publish to PyPI from CI is twine upload with a PYPI_API_TOKEN sitting in a repo secret. That works, and for years it was the only option. It also means a long-lived credential, scoped to your whole project, sitting in GitHub's secret store - readable by any workflow with the right permissions, exfiltratable by any action you add later that turns out to be less trustworthy than you assumed, and yours to rotate manually if it ever leaks.

PyPI now supports Trusted Publishing: OIDC-based, short-lived tokens minted per workflow run, with no secret stored anywhere.

flowchart LR
    subgraph token["API token (old way)"]
        A1[Long-lived PYPI_API_TOKEN] --> A2[Stored as a repo secret]
        A2 --> A3[Every workflow run reuses it]
    end
    subgraph oidc["Trusted Publishing (OIDC)"]
        B1[GitHub Actions requests a short-lived OIDC token] --> B2[PyPI verifies repo + workflow file match]
        B2 --> B3[PyPI mints a one-time upload token]
        B3 --> B4[Token expires after the run]
    end

Setting it up is a one-time registration on PyPI's side (Project settings -> Publishing -> add a trusted publisher, pointing at the exact GitHub org/repo/workflow filename), and then the workflow itself needs nothing but a permission grant:

on:
  release:
    types: [published]
 
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write   # no PYPI_API_TOKEN secret needed
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install build && python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

No secret to create, no secret to rotate, no secret that outlives the ten seconds it takes GitHub and PyPI to agree this specific workflow run, from this specific repo, is allowed to publish this specific package. The trust boundary is "this exact workflow file in this exact repo," not "whoever has this string." I set it up expecting a bigger ceremony and mostly just found a form.

The one habit worth keeping from the token era: rehearse the publish before it's real. I wired a second workflow, manually triggered, that runs the identical build steps against TestPyPI instead - same OIDC exchange, same build hook, same artifact, different repository-url. It's cheap insurance: if the wheel is somehow malformed or the trusted-publisher config is wrong, you find out against a throwaway index instead of burning the one shot you get at a real version number (PyPI doesn't let you re-upload a version once it's published, even a broken one). Mine ran clean both times, which is exactly what a dry run is supposed to feel like - unremarkable.

Bundling a frontend into a Python wheel

The more interesting problem wasn't PyPI-specific at all. agc canvas opens a React Flow UI for editing agent graphs, built with Vite and living in its own canvas/ directory with its own package.json. That's fine for me, developing against a git checkout with Node installed. It's not fine for someone who just ran pip install agentic-graph-composer and has no reason to have Node on their machine at all - there's no canvas/ source tree in a wheel for them to cd into and build.

The first design I sketched published the canvas separately, as its own npm package, launched via npx agc-canvas --api-base <url>. I actually like that shape in the abstract - independent versioning, no coupling between a Python release and a frontend release. I unwound it before writing any code for it, because I checked what comparable tools actually do: Streamlit, Jupyter Lab, MLflow, Gradio, Arize Phoenix - every local-first Python tool with a companion web UI I could think of ships the frontend's prebuilt static assets inside the Python package itself. One pip install, one working UI, no second install command. Nothing was going to consume my canvas as a standalone JS package - there was no real second consumer to design independence for, just the abstract appeal of decoupling.

So the wheel bundles it. A Hatchling build hook runs at build time:

class CanvasBuildHook(BuildHookInterface):
    def initialize(self, version, build_data):
        if os.environ.get("AGC_SKIP_CANVAS_BUILD"):
            return
        canvas_dir = Path(self.root) / "canvas"
        dist_dir = canvas_dir / "dist"
        target_dir = Path(self.root) / "src" / "agc" / "canvas_static"
        if not dist_dir.is_dir():
            npm = shutil.which("npm")
            if npm is None:
                return  # no Node available - agc canvas degrades gracefully
            subprocess.run([npm, "ci"], cwd=canvas_dir, check=True)
            subprocess.run([npm, "run", "build"], cwd=canvas_dir, check=True)
        shutil.copytree(dist_dir, target_dir)

and pyproject.toml force-includes the output as a wheel artifact, since it's generated at build time rather than tracked in git:

[tool.hatch.build.targets.wheel]
packages = ["src/agc"]
artifacts = ["src/agc/canvas_static/**/*"]

The asymmetry is the whole point: only whoever builds and publishes the wheel needs Node and npm on their machine. The publish workflow gets actions/setup-node right next to actions/setup-python. Everyone downstream of that - every pip install agentic-graph-composer - gets a working canvas with no Node in sight, because the JavaScript already got compiled down to static HTML/JS/CSS before it ever left CI. A contributor without Node installed still gets a working Python package too; the hook just checks shutil.which("npm") first and skips the canvas build entirely if it's missing, rather than failing the whole install.

One wrinkle fell out of this that I hadn't anticipated: Vite bakes environment variables into the build at build time, but agc canvas picks a random free port every run, so there's no single API base URL to bake in. The fix is a synthetic route - the local server serves GET /agc-config.js, which injects window.__AGC_API_BASE__ = "" (empty string meaning "same origin as whatever's serving this page") - and the frontend checks !== undefined rather than truthiness, because an empty string here is a real configured value, not an absence of one. It's a small thing, but it's the kind of small thing you only discover once you've committed to shipping a prebuilt asset instead of a dev server, which is exactly why I'd rather learn it from a design doc than from a support issue after the fact.

Cutting the actual release, once both of those pieces existed, was almost anticlimactic: tag a version, publish a GitHub Release, watch the Actions tab, and a minute later agentic-graph-composer existed on PyPI with no secret ever having touched the repo and no Node.js requirement ever reaching an end user's terminal.