Skip to content

Releasing TraceLens

TraceLens uses tag-driven releases. The package version comes from the git tag, and CI publishes only from a release tag. The pipeline creates the tag: the "Release on merge" workflow after CI passes on main, the "Release tag" workflow when a release pull request is merged, or a maintainer pushes one by hand.

This avoids hand-edited version numbers, release loops, and PyPI's immutable version constraint.

Release Model

  • Every pull request and every main commit runs tests, lint, typecheck, and package build validation.
  • A tag named vX.Y.Z builds package version X.Y.Z.
  • Pushing that tag triggers .github/workflows/release.yml.
  • After CI passes on a push to main, the "Release on merge" workflow releases whatever the changelog has queued under [Unreleased]: it picks the version, moves the entries into a dated section, commits release: vX.Y.Z to main, tags it, and starts the release workflow (Automatic Releases).
  • For a version you name (a pre-release, or a jump the rules would not make), the "Release prepare" workflow turns [Unreleased] into a dated section and opens a release pull request; merging it tags the merge commit (the "Release tag" workflow).
  • The release workflow builds the package, verifies the tag matches the built version, renders the release notes from the changelog's dated section, publishes to PyPI using trusted publishing through the release environment (a reviewer required there gates every path), and then creates the GitHub Release for the tag with those notes and the built files attached.
  • Nothing is published unless the build, the version check, and the notes succeed first, and no GitHub Release is created unless publication succeeded. Tags whose version is not a final X.Y.Z (rc, a, b, dev, post) are marked as pre-releases on GitHub.

One-Time Setup

TraceLens already has a PyPI project and trusted publishing configured. Re-run this section only if the repository, workflow name, or PyPI ownership changes, when the "Release prepare" workflow reports that it may not open pull requests, or when "Release on merge" reports that main rejected its push.

  1. Confirm the package metadata:
python -m pip index versions tracelens
  1. In PyPI, add a trusted publisher for this repository:

  2. Owner: ssf0409

  3. Repository: tracelens
  4. Workflow: release.yml
  5. Environment: release

  6. In GitHub, create the release environment under repository settings. Add required reviewers if you want a manual approval gate before publishing.

  7. Let the "Release prepare" workflow open pull requests: Settings → Actions → General → Workflow permissions → enable "Allow GitHub Actions to create and approve pull requests". Without it the workflow pushes the release branch, fails to open the pull request, removes the branch again, and says so.

  8. Let the "Release on merge" workflow push to main. Without branch protection nothing is needed. If main requires pull requests, allow GitHub Actions to bypass that rule; otherwise the workflow cannot land its release commit and falls back to opening the release pull request through "Release prepare", which you then merge.

No PyPI API token is required when trusted publishing is configured correctly.

Automatic Releases

Every merge to main that leaves entries under [Unreleased] in CHANGELOG.md becomes a release once CI is green on that commit. The "Release on merge" workflow (.github/workflows/release-auto.yml) runs when the CI workflow completes for a push to main, and:

  1. Skips a red or cancelled CI run; a release commit itself (release: vX.Y.Z, or the merge of a release/vX.Y.Z pull request: the "Release tag" workflow owns those); a commit whose title carries [release: skip]; an empty [Unreleased] section; and a latest tag that is a pre-release (v1.0.0rc1: finish it with "Release prepare"). The job summary says which.
  2. Picks the version (scripts/next_version.py) from the latest vX.Y.Z tag and the commit title:
  3. [release: X.Y.Z] names the version outright;
  4. [release: major], [release: minor], or [release: patch] names the bump;
  5. otherwise entries under ### Added, ### Changed, ### Removed, or ### Deprecated make a minor release, and entries only under ### Fixed (or ### Security) a patch release. While 0.x, breaking changes are minor bumps; use [release: major] when it is time for 1.0.0.

Only the first line of the merge commit message is read (the pull request title for a squash merge), so a pull request description can discuss the markers freely. 3. Releases: scripts/prepare_release.py moves the entries into ## [X.Y.Z] - <today>, the notes are rendered to check the section, the result is committed to main as release: vX.Y.Z, tagged vX.Y.Z, and the release workflow is started with publish=true (PyPI through the release environment, then the GitHub Release). The commit and the tag are pushed with the workflow token, which raises no events: CI does not run again and nothing loops.

Preview the decision for the commit you are about to merge:

git log -1 --format=%B > /tmp/msg.txt        # or write the intended title
python scripts/next_version.py --message-file /tmp/msg.txt

A release freezes the section it creates. A pull request updated after a release must keep its entries under [Unreleased] (a merge or rebase can leave them inside the section that was just released); CI's changelog check (scripts/check_changelog.py, run by the lint job on every pull request) refuses entries added to a released section. The contributor side is in CONTRIBUTING.md.

To batch several pull requests into one release, put [release: skip] in every merge title but the last. To stop automatic releases altogether, disable the "Release on merge" workflow under Actions; the other two paths keep working.

If main moves on before the workflow can push (two merges in quick succession), the run for the older commit stops and the CI run of the newer commit releases everything queued by then. If main rejects the push (branch protection against GitHub Actions), the workflow starts "Release prepare" with the same version, which opens the release pull request for you to merge, and says so in its job summary.

Cut A Release By Hand

For a pre-release, a version the rules above would not pick, or when the automatic path is off, the manual path is two clicks and one review.

  1. Run the "Release prepare" workflow (Actions → Release prepare → Run workflow) with the version, for example 0.5.0, and an optional one-paragraph summary. It checks that the version is well-formed, not older than the latest tag, and unused; moves the changelog's [Unreleased] entries into ## [0.5.0] - <today> (refusing an empty section); pushes release/v0.5.0; opens the pull request release: v0.5.0 with the rendered notes as its description; and starts CI on the branch. Preview the notes locally at any time:
python scripts/prepare_release.py --version 0.5.0 --check
python scripts/release_notes.py --version 0.5.0   # after the section exists
  1. Review and merge the release pull request. Edit CHANGELOG.md on the branch if the wording needs work; the pull request description is a preview, the changelog section is the source. Its checks are the ci.yml run the workflow started on the branch (pull requests opened by a workflow do not trigger pull_request workflows themselves). Merge it with any merge method: the "Release tag" workflow reads the version from the commit that lands on main (release: v0.5.0 after a squash or rebase merge, the release/v0.5.0 branch name in a merge commit), tags that commit v0.5.0, and starts the release workflow with publish=true, which publishes to PyPI (the release environment and any reviewer required there still apply) and creates the GitHub Release.

  2. Verify that every public channel tells the same story:

git ls-remote --tags origin "vX.Y.Z"           # the tag exists on GitHub
python -m pip index versions tracelens          # PyPI lists X.Y.Z
gh release view vX.Y.Z --json isPrerelease,assets,body   # the GitHub Release exists,
                                                # is (not) a pre-release, has the wheel
                                                # and sdist, and its body is the
                                                # changelog section
python scripts/release_notes.py --version X.Y.Z # same notes locally
mkdocs build --strict                           # docs still build
  1. Smoke test from a clean environment once PyPI lists the version:
python -m venv /tmp/tracelens-release-smoke
/tmp/tracelens-release-smoke/bin/python -m pip install tracelens
/tmp/tracelens-release-smoke/bin/tracelens --help

Whichever path creates it, the tag is new, the changelog has the dated section for its version, and only a tag is ever published: the "Release tag" workflow tags only the merge of a release/vX.Y.Z pull request, and "Release on merge" only its own release: vX.Y.Z commit.

Manual fallback

If the workflows are unavailable, the tag-driven path still works on its own:

  1. Move the [Unreleased] entries into ## [X.Y.Z] - YYYY-MM-DD (or run python scripts/prepare_release.py --version X.Y.Z) and commit.
  2. Run the verification gate (make verify, make docs) and, for packaging or dependency changes, the built-wheel smoke in Contributor Testing.
  3. git tag vX.Y.Z && git push origin vX.Y.Z. The pushed tag triggers the release workflow directly, exactly as the automatic path does.

If A Release Fails

  • Build, version check, or release notes fail. Nothing was published. Fix the cause (usually a missing changelog section or a tag that does not match the version), delete the tag locally and remotely, and push it again.
  • PyPI publication fails. No GitHub Release is created. PyPI versions are immutable, so if the failure happened after upload, do not retag; if it happened before, fix and re-run the workflow.
  • PyPI succeeded but the GitHub Release step failed. Re-run the workflow (either "Re-run failed jobs" or "Re-run all jobs"): publishing is skipped for files PyPI already has (skip-existing), and the release step updates an existing release instead of failing on "already exists". Re-running never uploads a different file under an already-published version.
  • Something else needs checking first. Use "Run workflow" (workflow_dispatch) on the release workflow with publish left off, on a branch or a tag: it builds, verifies, and shows the rendered notes in the job summary without publishing anything.
  • "Release prepare" fails with "GitHub Actions is not permitted to create or approve pull requests". Enable the setting in step 4 of One-Time Setup and run the workflow again; it removed the release branch it had pushed, so nothing needs cleaning up.
  • A release pull request must be abandoned. Close it and delete the release/vX.Y.Z branch; nothing was tagged or published. Run "Release prepare" again later, with the same version if it is still right.
  • The tag was created but the release workflow never ran (the dispatch in "Release tag" failed). Re-run the "Release tag" job: it keeps a tag that already points at the release commit and only dispatches again. Or run the release workflow by hand on the tag with publish=true; both are safe to repeat.
  • "Release on merge" did not release a merge you expected it to. Its job summary says why: CI was red or cancelled, [Unreleased] was empty, the title said [release: skip], the latest tag is a pre-release, or main moved on before it could push (the next green commit releases everything queued). Fix the cause and merge again, or use "Release prepare".
  • The release: vX.Y.Z commit is on main but the tag or the publish run is missing. Re-run the "Release on merge" job: it recognises its own release commit on top of the tested commit, keeps a tag that already points there, and dispatches the release again. Or push the tag by hand (git tag -a vX.Y.Z -m "release: vX.Y.Z" <sha> && git push origin vX.Y.Z), which triggers the release workflow directly.
  • "Release on merge" fails with "CHANGELOG.md already has a section for X.Y.Z but there is no tag vX.Y.Z". A release commit landed without its tag. Re-run the "Release tag" job for that commit (or "Release on merge", or tag by hand as above); the next merge then releases normally.
  • "Release on merge" opened a release pull request instead of releasing. main rejected its push. Merge that pull request to publish, and see step 5 of One-Time Setup to make the next release automatic.

Dependency Guidance

Downstream projects should depend on TraceLens from PyPI:

dependencies = [
    "tracelens>=0.3.0",
]

Public GitHub or PyPI dependencies do not need a CI secret. A secret is only needed when a downstream CI job checks out or installs a private repository.

For local pre-release checks, prefer the built-wheel and downstream smoke guidance in Contributor Testing. TestPyPI is optional and mainly useful when changing the publishing workflow itself.