Skip to content

Publishing

Two artifacts are published from this repository: the documentation site on GitHub Pages, and the package on PyPI.

Documentation

The site is built with MkDocs Material and deployed to GitHub Pages by GitHub Actions.

Locally

make install          # uv sync --group dev --group docs
make docs             # mkdocs serve, with live reload
make docs-build       # mkdocs build --strict

--strict turns warnings into errors, which is what catches a broken internal link, a missing snippet marker, and a page that exists but is absent from the navigation. Run it before pushing; CI runs exactly the same command.

The deploy workflow

.github/workflows/deploy-docs.yml runs on pushes to main that touch the documentation, and can be triggered by hand with Run workflow:

on:
  push:
    branches:
      - main
    paths:
      - 'docs/**'
      - 'examples/**'
      - 'mkdocs.yml'
      - 'pyproject.toml'
      - 'uv.lock'
      - '.github/workflows/deploy-docs.yml'
  workflow_dispatch:

examples/** is in that list because the guides embed their code from there — an example change must redeploy the site.

Two jobs. The first builds and uploads the artifact:

      - name: Build documentation
        run: uv run --group docs mkdocs build --strict
      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: site

The second deploys it:

      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

path: site matches site_dir: site in mkdocs.yml. The workflow uses a github-pages concurrency group with cancel-in-progress: false, so overlapping deploys queue rather than abort halfway.

One-time repository setup

The artifact-based deployment needs Pages configured for Actions rather than for a branch:

  1. Open the repository Settings.
  2. Go to Pages.
  3. Under Build and deployment, set Source to GitHub Actions.

Without this the workflow fails at the deploy step with a permissions error. No gh-pages branch is created or used.

The workflow already requests the permissions it needs:

permissions:
  contents: read
  pages: write
  id-token: write

Validation before deploy

.github/workflows/test.yml runs on every push and pull request, including a docs job that builds the site strictly. The doctests embedded in the pages run in the test job alongside the unit tests, on every supported Python version.

So a documentation change is checked twice before it can reach main: the transcripts must still produce their stated output, and the site must still build. See How These Docs Are Tested.

Package

The package is published to pypi.org/project/open-fsm and built with the uv_build backend. The version lives in exactly one place, pyproject.toml; open_fsm.__version__ reads it back from the installed distribution metadata.

One-time PyPI setup

Releases upload through Trusted Publishing, so no API token is stored in the repository. An owner configures the publisher once with these values:

Field Value
PyPI Project Name open-fsm
Owner open-byte
Repository open-fsm
Workflow publish.yml
Environment pypi

Where that form lives depends on whether the project already exists on PyPI:

  • Before the first release, the project has no settings page yet, so it is registered as a pending publisher at Account settings → Publishing. PyPI creates the project on the first successful upload and converts the pending publisher into a normal one.
  • Afterwards, at Manage project → Publishing.

The GitHub pypi environment must also exist, under Settings → Environments in the repository. It can be empty; the workflow only needs the name to match.

Cutting a release

uv version --bump patch      # or --bump minor / --bump major
uv lock                      # the lock records the project version too

Commit the result, merge it to main, then tag and publish a GitHub Release whose tag is the version prefixed with v (v0.0.2 for 0.0.2):

git tag v0.0.2
git push origin v0.0.2

Publishing the release triggers publish.yml, which refuses to continue when the tag and the packaged version disagree, then builds, validates the metadata with twine check, and uploads.

A version number is spent the moment it uploads

PyPI does not allow re-uploading a version, even after deleting it. That is why the workflow compares the tag against uv version --short before building — a release tagged v0.0.2 that ships 0.0.1 would burn the wrong number permanently. Rehearse on TestPyPI first.

Local verification

Build and inspect the artifacts without uploading anything:

make build
uv run --with twine twine check dist/*

Rehearsing on TestPyPI

TestPyPI is a separate site with its own account and its own tokens — a PyPI token will not work there. Create one at test.pypi.org → Account settings → API tokens.

Pass it through the environment so it never lands in shell history or a tracked file:

read -rs UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN
make publish-test

make publish-test builds and uploads to the testpypi index declared in pyproject.toml. Then install from there into a throwaway environment:

uv venv /tmp/open-fsm-smoke
VIRTUAL_ENV=/tmp/open-fsm-smoke uv pip install \
  --index-url https://test.pypi.org/simple/ \
  open-fsm
VIRTUAL_ENV=/tmp/open-fsm-smoke uv run python -c \
  "import open_fsm; print(open_fsm.__version__)"

No --extra-index-url is needed: open-fsm has no runtime dependencies, so TestPyPI can satisfy the install on its own. A project with dependencies would need PyPI as the primary index plus --index-strategy unsafe-best-match, since uv otherwise stops at the first index it consults.

TestPyPI versions are spent too

The same no-reupload rule applies. If a rehearsal burns 0.0.1 there, bump to a throwaway local version (uv version 0.0.1.dev1) for the next attempt rather than fighting it, and reset before the real release.

Supported versions

Every Python version the package advertises is executed by the test job in test.yml, which runs the matrix from 3.10 through 3.15. When requires-python or a classifier changes, add or move a cell in that matrix so the claim stays tested.