flopscope.
Development

Release Process

Use this page when cutting a new release of flopscope.

You will learn:

  • How commit types map to version bumps under semver
  • How cz bump updates versions across the three packages and tags
  • What happens after git push --follow-tags
  • The one-time PyPI Trusted Publisher setup per project

Version bumps

Versions follow Semantic Versioning. While at 0.x, the project is API-unstable — breaking changes bump the minor digit, not the major. Commit types map to bumps as follows:

Commit type0.x bump≥1.0 bump
fix:patch (0.2.00.2.1)patch
feat:minor (0.2.00.3.0)minor
feat!: / BREAKING CHANGE:minor (0.2.00.3.0)major (1.42.0)
Other types (chore, docs, …)no releaseno release

Commitizen handles this automatically via major_version_zero = true in the [tool.commitizen] block of pyproject.toml. Flip that to false when we cut v1.0.0.

End-to-end flow

We release with commitizen. End-to-end flow:

# 1. Make sure local main is at origin/main
git checkout main && git pull origin main

# 2. Preview the next version + changelog (no writes)
uv run cz bump --dry-run

# 3. Write the new version into every version location and prepend the
#    CHANGELOG.md section — no commit or tag is created yet.
uv run cz bump --files-only --changelog

# 4. Review the new CHANGELOG.md section; hand-edit it if the release
#    needs callouts the generated bullet list can't express (e.g. a
#    BREAKING CHANGE section). The publish workflow copies this section
#    verbatim into the GitHub Release body.

# 5. Refresh the three uv.lock files. Each lockfile records its own
#    package's version, so all three are stale after every bump — and
#    CI's check-lock job fails the push to main if they are.
make relock

# 6. Verify version lockstep and lockfile consistency before committing.
make check-sync-versions && make check-lock

# 7. Commit everything as the bump commit, then tag it (annotated, like
#    every existing release tag).
git add -A
git commit -m "chore(release): bump version <old> to <new>"
git tag -a v<new> -m "v<new>"

# 8. Push the commit + tag. Tag push triggers the PyPI publish workflow.
git push --follow-tags

Why not plain uv run cz bump? It commits and tags in one shot, before the lockfiles can be refreshed — so the bump commit ships stale uv.lock files and check-lock fails on main. If you ran it anyway: make relock, then stage the three lockfiles (git add uv.lock flopscope-server/uv.lock flopscope-client/uv.lock) — an unstaged --amend would silently exclude them — then git commit --amend --no-edit, and re-point the tag with git tag -fa v<new> -m "v<new>" before pushing.

Pre-releases follow the same files-only flow with --prerelease alpha added in step 3 (uv run cz bump --files-only --changelog --prerelease alpha); the relock, verify, commit, and tag steps are identical, with the prerelease version to tag (e.g. v0.3.0a0) shown in the bump output.

Multi-package lockstep

flopscope, flopscope-server, and flopscope-client are released in lockstep at the same version. cz bump updates all version locations in one invocation via the version_files config in [tool.commitizen]:

  • All three pyproject.toml [project].version fields
  • All three __version__ strings in package __init__.py files
  • Both cross-package pins (flopscope-server==X.Y.Z in flopscope's server extra, and flopscope==X.Y.Z in flopscope-server's dependencies)

Before tagging, check-sync-versions asserts that all eight version locations agree. Run locally:

make check-sync-versions

CI runs this in the client-server-sync job on every PR, so drift introduced by hand-edits is caught before merge.

What happens after git push --follow-tags

The tag push triggers .github/workflows/pypi-publish.yml, which:

  1. Builds wheels in parallel via a matrix job — one per published package.
  2. Pauses for approval in the pypi GitHub environment (manual gate). A single approval click covers all matrix-publish jobs.
  3. Publishes the packages to PyPI in parallel via Trusted Publishing (OIDC; no API token stored in repo secrets).
  4. Creates one GitHub Release whose body is the matching CHANGELOG section for that tag.

End result: pip install <package>==X.Y.Z works ~2 minutes after a maintainer clicks "approve" on the pypi environment gate.

First-time PyPI setup (one-time per project)

Before the first release will succeed, configure two things outside the repo. The Trusted Publisher steps must be repeated for each PyPI project name being published.

On PyPI — add a Trusted Publisher (do this for each project name):

  1. Go to https://pypi.org/manage/account/publishing/
  2. Click "Add a new pending publisher"
  3. Fill in (repeat with each project name in turn):
    • PyPI Project Name: flopscope, then flopscope-server, then flopscope-client
    • Owner: AIcrowd
    • Repository name: flopscope
    • Workflow name: pypi-publish.yml
    • Environment name: pypi
  4. Save each one.

On GitHub — create the pypi environment with required reviewers (do this once; it is shared across all matrix-publish jobs):

  1. Go to https://github.com/AIcrowd/flopscope/settings/environments
  2. Click "New environment", name it pypi
  3. Under "Deployment protection rules" → "Required reviewers", add the release maintainers
  4. Save

After all configured Trusted Publishers and the shared environment are set up, the next v* tag push will trigger the publish flow end-to-end — and a single approval click covers all matrix publishes.

Manual publish from a maintainer's machine (fallback)

The normal path is the matrix workflow described above. Use this section only when GitHub Actions is unavailable or won't fire (Actions outage, repo policy glitch, etc.) and you need to ship a release right now. This path bypasses Trusted Publishing, the pypi environment approval gate, and the auto-created GitHub Release.

One-time setup

  1. Install twine as a uv-managed tool (keeps it out of project dependencies):

    uv tool install twine
  2. Generate three project-scoped PyPI API tokens, one per project. For each of flopscope, flopscope-server, flopscope-client:

    • Go to https://pypi.org/manage/account/token/
    • Click "Add API token"
    • Token name: something self-descriptive (e.g. flopscope-manual-2026)
    • Scope: "Project: flopscope" (scope to a single project — not "Entire account")
    • Copy the pypi-... token immediately; PyPI will not show it again
  3. Add all three tokens to ~/.pypirc (chmod 600 the file). Per-project sections let twine pick the right one based on the wheel's distribution name:

    [distutils]
    index-servers =
        flopscope
        flopscope-server
        flopscope-client
    
    [flopscope]
    repository = https://upload.pypi.org/legacy/
    username = __token__
    password = pypi-AgEIcHlwaS5vcmcCJ...  # token scoped to flopscope
    
    [flopscope-server]
    repository = https://upload.pypi.org/legacy/
    username = __token__
    password = pypi-AgEIcHlwaS5vcmcCJ...  # token scoped to flopscope-server
    
    [flopscope-client]
    repository = https://upload.pypi.org/legacy/
    username = __token__
    password = pypi-AgEIcHlwaS5vcmcCJ...  # token scoped to flopscope-client
    chmod 600 ~/.pypirc

Publishing v<X.Y.Z> manually

Assumes the bump commit + vX.Y.Z tag already exist on main (produce them with the End-to-end flow above as usual; the difference is only that the GH workflow won't run).

  1. Build all three packages from the tagged commit:

    git checkout vX.Y.Z
    rm -rf dist flopscope-server/dist flopscope-client/dist
    uv build
    (cd flopscope-server && uv build)
    (cd flopscope-client && uv build)
  2. Verify each wheel's metadata is what you expect — particularly the Requires-Dist cross-pin in flopscope-server and the Provides-Extra: server in flopscope:

    unzip -p dist/flopscope-X.Y.Z-py3-none-any.whl flopscope-X.Y.Z.dist-info/METADATA \
      | grep -E "^(Name|Version|Requires-Dist|Provides-Extra):"
    unzip -p flopscope-server/dist/flopscope_server-X.Y.Z-py3-none-any.whl \
      flopscope_server-X.Y.Z.dist-info/METADATA \
      | grep -E "^(Name|Version|Requires-Dist):"
    unzip -p flopscope-client/dist/flopscope_client-X.Y.Z-py3-none-any.whl \
      flopscope_client-X.Y.Z.dist-info/METADATA \
      | grep -E "^(Name|Version|Requires-Dist):"
  3. Upload each wheel + sdist with twine, pointing at the per-project section of ~/.pypirc:

    uv tool run twine upload --repository flopscope dist/*
    uv tool run twine upload --repository flopscope-server flopscope-server/dist/*
    uv tool run twine upload --repository flopscope-client flopscope-client/dist/*

    Twine prints a https://pypi.org/project/.../ URL after each successful upload. If a previous attempt already uploaded any of the files, add --skip-existing to make the command idempotent.

  4. Create the GitHub Release manually (the workflow would normally do this):

    version=X.Y.Z
    awk -v ver="v$version" '
      $1 == "##" && $2 == ver { capture = 1; next }
      $1 == "##" && capture { exit }
      capture { print }
    ' CHANGELOG.md > /tmp/release-notes.md
    
    gh release create "v$version" --title "v$version" --notes-file /tmp/release-notes.md

Trade-offs vs the workflow

The manual path skips four safety nets the workflow gives you:

  • Environment approval gate. Anyone with the API tokens can publish; there is no second-pair-of-eyes click. Scope tokens narrowly and rotate them after use if leaked or no longer needed.
  • Trusted Publishing audit trail. PyPI records "uploaded by API token X" instead of "uploaded by GH workflow run #..." — harder to trace.
  • Auto-generated GitHub Release with notes from CHANGELOG. Step 4 above is the manual replacement.
  • Matrix isolation. Network/IO failures during upload could leave a partial publish (e.g., flopscope uploaded but flopscope-server failed). In that case re-run only the failed twine upload with --skip-existing.

Once Actions is healthy again, switch back to the workflow path — delete the API tokens you created for the manual publish, and the next vX.Y.Z tag push will go through Trusted Publishing as normal.

On this page