Publish To PyPI

Use this runbook to publish Vexcalibur from the automated release workflow. Do not upload Vexcalibur with a local API token or password.

Prerequisites

  • You are on main, and main contains the commit to release.

  • The release tag uses vMAJOR.MINOR.PATCH, for example v0.1.0.

  • The vexcalibur-dev organization has the AUTOMATION_CLIENT_ID variable and AUTOMATION_SECRET secret available to this repository. These identify a GitHub App named vexcalibur-dev-automation, installed on vexcalibur-dev/vexcalibur with Contents read/write permission so the release workflow can create tags and GitHub Releases. PyPI publishing verifies that GitHub Releases are authored by vexcalibur-dev-automation[bot].

  • Anyone who can manually dispatch the Release workflow is trusted to publish a GitHub Release and trigger the PyPI publishing workflow.

  • If the GitHub pypi environment is protected, you can approve or request the required approval for that environment.

  • You have PyPI maintainer or owner rights for configuring Trusted Publishing and yanking a bad release.

  • The PyPI project or pending publisher is configured for:

    • project vexcalibur

    • repository vexcalibur-dev/vexcalibur

    • workflow pypi.yml

    • environment pypi

  • The GitHub pypi environment exists. It should not contain a PyPI token; the workflow uses PyPI Trusted Publishing.

  • Local release preflight requires uv, actionlint, and shellcheck on PATH. Their versions are pinned in .tool-versions; install or activate them with a .tool-versions-compatible tool manager such as mise or asdf, or with direct package installs that provide the pinned versions.

PyPI can create a project from a pending Trusted Publisher on the first publish. A pending publisher does not reserve the project name until the first upload succeeds.

Preflight

Start from a clean, current checkout:

git fetch origin main --tags
git switch main
git pull --ff-only origin main
git status --short

Run the local gates:

actionlint -version
shellcheck --version
uv lock --check
uv sync --frozen --extra docs
uv run --frozen ruff format --check src tests scripts/*.py docs/conf.py
uv run --frozen ruff check src tests scripts/*.py docs/conf.py
uv run --frozen mypy src
make workflow-lint
uv run --frozen pytest -m "not live" --cov-fail-under=75
make docs
uv run --frozen pip-audit --cache-dir /tmp/vexcalibur-pip-audit-cache
make secrets

Build and inspect the release artifacts with a temporary local tag. Delete the temporary tag before creating the real release tag. Use the release script output so the local preflight checks the same version the workflow will publish.

release_metadata="$(scripts/next-release-tag.sh)"
printf '%s\n' "$release_metadata"

RELEASE_SKIP="$(printf '%s\n' "$release_metadata" | awk -F= '$1 == "skip" { print $2 }')"
RELEASE_TAG="$(printf '%s\n' "$release_metadata" | awk -F= '$1 == "tag" { print $2 }')"

if [ "$RELEASE_SKIP" = "true" ]; then
  echo "The release workflow will skip this commit."
  exit 0
fi

RELEASE_VERSION=${RELEASE_TAG#v}
DIST_DIR=/tmp/vexcalibur-dist

if git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null &&
  [ "$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}")" != "$(git rev-parse HEAD)" ]; then
  echo "Local tag already exists: $RELEASE_TAG" >&2
  exit 1
fi

if git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then
  TEMP_TAG_CREATED=false
else
  git tag "$RELEASE_TAG"
  TEMP_TAG_CREATED=true
  trap 'git tag --delete "$RELEASE_TAG" >/dev/null 2>&1 || true' EXIT
fi

uv build --clear --no-create-gitignore --no-sources --out-dir "$DIST_DIR"
python scripts/verify-dist-metadata.py "$DIST_DIR" \
  --expected-name vexcalibur \
  --expected-version "$RELEASE_VERSION"
uv run --frozen twine check "$DIST_DIR"/*
VEXCALIBUR_WHEEL="$(find "$DIST_DIR" -maxdepth 1 -type f -name '*.whl')" \
  VEXCALIBUR_EXPECTED_VERSION="$RELEASE_VERSION" \
  make installed-cli-check
if [ "$TEMP_TAG_CREATED" = "true" ]; then
  git tag --delete "$RELEASE_TAG"
  trap - EXIT
fi

If the release tag already exists on HEAD, this preflight checks that tag instead of recreating it. That matches the automation recovery path for an existing tag whose GitHub Release was not created. Do not continue if any command fails or if git status --short shows an unexpected tracked change.

Publish

The preferred path is the Release workflow. It runs after pushes to main, computes the next vMAJOR.MINOR.PATCH tag from Conventional Commits, runs release quality gates against the exact release commit, creates an annotated tag with the automation GitHub App, and publishes a GitHub Release. The first automatic release is v0.1.0.

The workflow can also be started manually from GitHub Actions. Leave version empty to compute the next version, or provide an explicit MAJOR.MINOR.PATCH version such as 0.1.0.

Automatic version bumps use these rules:

  • BREAKING CHANGE: or a ! marker in the Conventional Commit type creates a major release.

  • feat: creates a minor release.

  • fix:, perf:, refactor:, deps:, build(deps):, and chore(deps): create a patch release.

  • docs:, test:, ci:, and ordinary chore: commits do not create a release by themselves after the first tag exists.

  • [skip release] and [release skip] in the head commit skip automatic release creation.

The release workflow publishes the GitHub Release with generated release notes after scanning those notes with detect-secrets. That release triggers .github/workflows/pypi.yml. The PyPI workflow validates that the release was created by the vexcalibur-dev-automation GitHub App, validates the tag format, confirms the GitHub Release is not marked as a prerelease, confirms the tag points at current origin/main, re-runs the quality, workflow lint, shell lint, security, test, documentation, package build, and installed-wheel gates, verifies both distribution metadata records match the tag, and publishes through the pypi environment.

Pushing a tag alone does not publish to PyPI. Manually creating a GitHub Release does not publish to PyPI because the publishing workflow rejects releases not created by the automation app.

Verify

After the PyPI workflow succeeds:

RELEASE_VERSION=0.1.0
python -m venv /tmp/vexcalibur-release-check
/tmp/vexcalibur-release-check/bin/python -m pip install "vexcalibur==$RELEASE_VERSION"
/tmp/vexcalibur-release-check/bin/python - <<'PY'
import importlib.metadata
import vexcalibur

print(importlib.metadata.version("vexcalibur"))
print(vexcalibur.__version__)
PY

Both printed versions must match the released version. Also check the PyPI project page and the GitHub Release assets or workflow artifacts before announcing the release.

Mitigate A Bad Release

If a bad release was uploaded, prefer yanking the release instead of deleting it. Yanking is PyPI’s non-destructive mitigation for broken, incompatible, or vulnerable releases.

  1. Open https://pypi.org/manage/project/vexcalibur/releases/.

  2. Open the bad version’s Options menu.

  3. Select Yank.

  4. Enter a reason that downstream users can act on.

  5. Confirm the release shows as yanked on PyPI.

  6. Fix the problem and publish a higher version.