main. Commit subjects must follow
Conventional Commits –
check-semantic-pr.yml already enforces that on every PR, so this should already be true.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.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.cliff.toml), then puts both in the job
summary. A dry run stops here – nothing else has happened.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.latest.PYPI_PUBLISH_ENABLED,
unset by default, and each waits on its environment’s reviewer. A release candidate reaches
TestPyPI but not PyPI.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.
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.
0.5.0 becomes 0.5.0rc1, and running
it again gives 0.5.0rc2. You don’t pass version, and you don’t need force – the guard
that catches a mistyped version compares the base, which is unchanged.0.5.0rc1, never 0.5.0-rc1. hatchling names the
sdist from the normalized version, and the workflow asserts the name matches the tag – a
hyphenated tag fails the release on purpose (see _expected_asset_name in
src/sorter/update/updater.py).cliff.toml’s tag_pattern is anchored at both
ends, so an rc tag is not a release boundary: the eventual 0.5.0 is still computed from the
last stable tag, and its notes still span everything since that tag rather than starting at the
last candidate. Without that anchor git-cliff hands back the rc tag itself as the “next”
version – pinned in tests/integration/test_cliff_config.py.pip needs --pre to see a prerelease anyway.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.
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.
docs.yml is called by release.yml right after the promote step,
building from the release’s tag – not from the branch the release was dispatched from, so
the docs at version N are the docs that shipped in N.
Because it is called, its deploy job’s permissions: block and the one on release.yml’s
docs job have to agree: a called job may not request more than the caller grants it, and
asking for more makes GitHub reject release.yml as an invalid workflow file before any job
runs – so a mismatch surfaces as a release that will not start, naming the wrong file.
actionlint does not check across the call; tests/unit/test_workflow_permissions.py does, for
every uses: ./.github/workflows/... in the tree.latest. Candidates share one rolling
prerelease entry in the dropdown (URL /prerelease/, title showing the actual tag) –
always the newest candidate’s docs, never an accumulating rc1/rc2/… list – and the slot is
retired when the stable that ends the cycle publishes. latest/ and the site root keep
pointing at the newest stable release throughout, same rule /releases/latest follows.<version>/pdf/ai-case-sorter-docs.pdf,
and the same PDF is attached to the GitHub release as
ai-case-sorter-docs-<version>.pdf (the manual next to the sdist). It is
linked from the landing page, rendered by mkdocs-with-pdf/WeasyPrint during the deploy. The
PDF machinery lives in mkdocs-pdf.yml (used with -F by the workflow), NOT in
mkdocs.yml: the plugin’s dormant-notice and its WeasyPrint CSS complaints would fail
--strict, and a laptop’s mkdocs serve needs none of it (WeasyPrint wants system pango).docs/guide/GUIDE.md is also the in-app F1 guide (sorter/ui/help_viewer.py loads
that exact file into a QTextBrowser). It must stay at that path and stay plain Markdown –
Material-only syntax would render as source text at the user. Its heading anchors are
GitHub’s, and MkDocs slugs them identically, so a deep link into the published site and one
into the in-app guide resolve to the same section.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.
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:
ai_case_sorter-<tag>.tar.gz – the project’s own sdist,
the same file uv build already produces for every push to main, not a separately built
artifact. hatch-vcs’s build hook stamps src/sorter/_version.py into it automatically.
sorter/update/updater.py looks for that asset by exact name.git clone that was never built falls back to 0.0.0+unknown. Expected – it’s
a contributor path, not a release path.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.
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.
src/-layout releaseCall 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.
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.