AI-Case-Sorter-Py

Releasing AI Case Sorter

How it works

  1. Merge whatever should be in the release to main. Commit subjects must follow Conventional Commitscheck-semantic-pr.yml already enforces that on every PR, so this should already be true.
  2. Preview by running Release with dry-run left on (it defaults to on). It resolves the version, generates the changelog, and shows both in the job summary alongside the raw commits they were derived from – without pushing a tag or creating anything. That is the sanity check: anything in the commit list but missing from the notes was dropped for a reason (an unconventional subject, or a skipped ci:/build: type), which is how a mistyped commit type gets caught before it becomes a wrong version bump.
  3. When ready, run the same Release workflow with dry-run unchecked (Actions tab -> Release -> Run workflow):
    • version: leave empty to auto-detect. git-cliff computes the next version from the Conventional Commits since the last tag – a feat: bumps the minor, a fix: the patch, a !/BREAKING CHANGE: the major. While the project is pre-1.0 a breaking change bumps the minor instead (0.1.0 + feat!: -> 0.2.0), so no commit message can push the project to 1.0.0 on its own – see CONTRIBUTING.md. Passing a version explicitly always overrides auto-detection.
    • ref: branch to release from. Empty means the branch you dispatched on. Must be main, hotfix/*, or release/* — anything else is rejected before a tag is created. A raw commit SHA is rejected for the same reason: release from the branch containing it instead, so the release is traceable to a branch.
    • force: only needed when you pass a version that disagrees with the auto-detected one. Without it, a mismatch is a hard error naming both numbers – that’s the guard that catches “meant 0.3.0, typed 0.2.0” before it becomes a tag.
    • prerelease: leave at none for a real release. See Cutting a release candidate below.
    • dry-run: defaults to on. Shows the resolved version and generated notes in the job summary without pushing a tag or creating anything. Uncheck it to actually release.
  4. The workflow resolves the version and generates changelog notes from commits since the last tag via git-cliff (cliff.toml), then puts both in the job summary. A dry run stops here – nothing else has happened.
  5. Lint and the full test matrix run, so what you approve next is already green.
  6. The run pauses for approval. The job that tags is bound to the stable environment, so it sits pending until a required reviewer approves it on the run page. There is no draft to publish by hand – this is the confirmation step.
  7. On approval it tags the ref, builds, checks the artifacts (built version matches the tag; the sdist is named and stamped as the updater expects), pushes the tag, and creates the GitHub Release as a prerelease with its artifacts already attached.
  8. The Windows installer is run for real against that exact tag, and the resulting install has to carry that exact version.
  9. Only then is the prerelease promoted to the latest release – one flag flip, assets already in place. A release candidate stops here instead, staying a prerelease.
  10. The documentation site is published for that tag – see The documentation site. A release candidate is published too, under its own version; it just doesn’t become latest.
  11. Finally TestPyPI, then PyPI. Both are gated on the repo variable PYPI_PUBLISH_ENABLED, unset by default, and each waits on its environment’s reviewer. A release candidate reaches TestPyPI but not PyPI.

Why a prerelease rather than a draft

The release has to be verifiable before anyone can reach it, and those two pull in opposite directions. A draft is invisible to the verification too: /releases/tags/<tag> returns 404 to the unauthenticated fetch the installer makes, so there would be nothing to install.

A prerelease is readable by exact tag, but /releases/latest excludes it – and that is the endpoint both sorter/update/updater.py and install-windows.ps1 use. So the release is fully testable while staying invisible to every real client until step 9.

If verification fails the release stays a prerelease: nobody was served it, and PyPI never ran. The tag and prerelease are left in place for a human to delete or supersede – fixing forward with a patch version is usually cleaner than deleting a pushed tag.

This also closes a window that used to exist: artifacts were attached after publication, so for a short time (26 seconds, measured on 1.0.0) /releases/latest returned a release with no matching sdist, and clients silently fell back to the source archive and installed something reporting 0.0.0+unknown.

Cutting a release candidate

Set prerelease to rc (or b/a) and run the workflow as normal. It does everything a real release does – tags, builds, asserts the artifacts, publishes with assets attached, and installs the result on Windows for real – and then stops before step 9. The release stays a prerelease permanently rather than as a staging state.

How a tester installs one. /releases/latest still points at the last stable release, so nothing is offered automatically – that’s the point. In the app: the status-bar Check for updates button opens the dialog even when there’s nothing new, then “Choose a different version…” → tick “Show prereleases” → pick the rc. For a fresh Windows machine, install-windows.ps1 -Version 0.5.0rc1.

Shipping the real release afterwards is just the workflow again with prerelease: none. The candidates’ tags stay where they are; nothing needs deleting.

The documentation site

https://sjseth.github.io/AI-Case-Sorter-Py/ – MkDocs Material over the repo’s own docs/ folder, published one directory per release by mike onto the gh-pages branch. The header dropdown is mike’s versions.json, so an old release’s docs stay reachable forever at their own URL.

Editing the docs, and checking them before pushing:

uv run --group docs mkdocs serve                # live preview on localhost:8000
uv run --group docs mkdocs build --strict       # what CI would fail on

--strict is the real gate: mkdocs.yml turns unrecognised links and unresolved intra-page anchors into warnings, and --strict turns warnings into errors. The guide’s table of contents is 22 same-page links; a broken one there breaks the in-app navigation identically.

Note that either command syncs the venv to exactly the default groups plus docs, so it uninstalls the ml extra if you had it. uv sync --extra ml puts it back.

Re-publishing by hand is Actions -> Docs -> Run workflow, with the tag as version. Doing it from a laptop is the same thing the workflow runs, and needs push rights to gh-pages:

uv run --group docs mike deploy -F mkdocs-pdf.yml --push --update-aliases 1.2.0 latest  # a release
uv run --group docs mike deploy -F mkdocs-pdf.yml --push --title "1.2.0rc1 (pre-release)" prerelease  # a candidate
uv run --group docs mike set-default --push latest                     # the site-root redirect
uv run --group docs mike list                                          # what is published now

(-F mkdocs-pdf.yml builds the per-version PDF too; dropping it publishes without one.)

One-time setup is automated: GitHub Pages is not something .github/settings.yml can manage – see the block in that file – but the Docs workflow enables it itself: after the first deploy pushes gh-pages into existence, an idempotent API call switches Pages on in branch mode (gh-pages, / (root)). mike also creates the branch itself on that first push, so a fresh repo (this fork or upstream) needs no pre-created branch and no clicking. Only if a policy denies the workflow token does it fall back to printing the manual step as a warning.

Versioning

There is no version to bump by hand. The tag is the single source of truth: hatch-vcs derives the version from it at build time (pyproject.toml’s [tool.hatch.version] source = "vcs"), writing src/sorter/_version.py, which src/sorter/__init__.py reads. Don’t edit a version string anywhere – there isn’t one to edit.

That’s the point of the setup: the old arrangement had a static __version__ that had to be bumped in the same commit as the tag, and it drifted in practice. When this was written, the app’s window title said v2.0.1 while __version__ said 0.1.0 and no git tag existed at all – three different answers to “what version is this?”, one of them shown to users.

How the version reaches a user who never has .git:

See CLAUDE.md §7 for why bootstrap.py passes --no-install-project/--no-sync: without them, a launch from a git-less release would silently overwrite the correct baked version.

Getting to 1.0.0

Auto-detection will never propose it. While the project is at 0.x, cliff.toml’s breaking_always_bump_major = false makes a !/BREAKING CHANGE: commit bump the minor (0.1.0 -> 0.2.0) rather than jumping to 1.0.0 – otherwise a routine breaking change during pre-1.0 development would declare the API stable as a side effect of a commit message.

So 1.0.0 is cut deliberately: run the Release workflow with version: 1.0.0 and force: true (force is required precisely because the value disagrees with what git-cliff computed – that guard is what makes this an explicit decision rather than a typo). From the first 1.x tag onward the mapping is ordinary semver again, with no config change needed.

One-off: the first src/-layout release

Call this out in that release’s notes, because it cannot be fixed in code:

Running 1.0.0 or 1.0.1? Update to 1.1.0 first (use “Choose a version” in the update dialog), then update again. Updating straight to this release reports “The downloaded archive does not look like the app”.

An in-app update is validated by the copy already installed. Accepting the src/ layout shipped in 1.1.0 (#62); 1.0.x only knows the flat main.py + sorter/__init__.py set and rejects anything else. There is no way to patch an installed updater after the fact, so the one-time manual step is the whole remedy.

Installs at 1.1.0 or later need nothing: the archive carries a root main.py (force-included from src/sorter/_legacy_entry.py), so the launch that applies the update still finds an entry point where it expects one — see CLAUDE.md §2.

Commit-type -> changelog section mapping

Set by cliff.toml, matching the types check-semantic-pr.yml enforces:

Commit type Changelog section
feat Features
fix Bug Fixes
perf Performance
refactor Refactoring
chore Miscellaneous
docs Documentation
test Testing
security Security
style Style
revert Reverts
ci, build (omitted from the changelog – internal tooling, not user-facing)

Commits that don’t parse as Conventional Commits are skipped entirely (filter_unconventional = true), which is why every commit on this repo needs a properly-typed subject line.

Each line also carries by @user, and in #N when it resolves: remote.username comes from the commit’s author/committer email and works regardless of how a PR was merged, but remote.pr_number only ever resolves for a squash merge – git-cliff matches a commit’s SHA against a closed PR’s merge_commit_sha, which a real merge commit’s constituent commits never equal. See the comment above body in cliff.toml for the mechanism.