Releasing
The validate-before-merge release pipeline — RC dev tags from the PR branch, merge-commit, and the production gates that ship to PyPI, GitHub, and Docker Hub.
Cremind ships from a single git tag: the Python wheel goes to PyPI, the Electron installers (Windows / macOS / Linux) attach to a GitHub Release, and the Docker image pushes to cremind/cremind-desktop on Docker Hub. All three artifacts carry the same version number.
The model is validate-before-merge: every production tag vX.Y.Z points at a commit on main whose contributing PRs each shipped a vX.Y.Zrc<N>.dev<M> test release that was installed and exercised on a real machine. The production workflow refuses to ship a tag unless four mechanical gates pass, and it pauses for a required-reviewer approval before publishing anything. That approval is the human "I validated this" signal.
This page walks one worked example end-to-end: v0.0.2, two PRs. The canonical, always-current version is RELEASING.md in the repo.
The two roles
- Core Maintainer — owns the version line. Decides which PRs land in
0.0.2, assigns each one an RC index (rc1,rc2, …), and ultimately bumpsapp/__version__.pyand cuts the production tag. - Component Maintainer — owns the per-PR work. Cuts test releases from the PR branch tip using the assigned index, iterates dev releases until validated, and pushes fixes to the PR branch.
How v0.0.2 ships
Suppose the last shipped version is v0.0.1 and two PRs are slated for 0.0.2: PR #41 (feature X, Alice) and PR #42 (feature Y, Bob).
1. Core Maintainer assigns RC indexes
The Core Maintainer comments on each PR assigning its index — for example, PR #41 is rc1, PR #42 is rc2. This is communication only: no code change, no commit. The PR branches' app/__version__.py stays at 0.0.1 until the final production release on main.
2. Open the PRs
Each Component Maintainer opens their PR as normal. pr.yml runs its three jobs (backend, ui, smoke-build); all three must be green.
3. Cut the first dev release for each PR
Once pr.yml is green, the Component Maintainer tags from the remote PR branch tip using the assigned index:
# Alice for PR #41
git fetch origin
git tag v0.0.2rc1.dev1 origin/feat/feature-x
git push origin v0.0.2rc1.dev1# Bob for PR #42
git fetch origin
git tag v0.0.2rc2.dev1 origin/feat/feature-y
git push origin v0.0.2rc2.dev1release-rc.yml triggers for each tag. It rewrites app/__version__.py in-CI only (never committed) to the PEP 440 form — the tag with the leading v stripped — builds the wheel, uploads it to Test PyPI, pushes a Docker tag to Docker Hub, and attaches Windows / macOS / Linux Electron installers to an auto-published GitHub prerelease. Test-channel installs pick it up on their next check-for-updates poll.
app/__version__.py on the PR branch stays at 0.0.1 throughout. The in-CI rewrite means the wheel that hits Test PyPI carries the dev version, but the branch source does not.
4. Validate on a real machine
This is the human checkpoint. The Component Maintainer installs from the test channel and exercises the feature end-to-end:
.\install\install.ps1 -Channel test -Deployment localbash install/install.sh --channel test --deployment localThe installer fetches the highest-numbered dev wheel from Test PyPI. If validation surfaces a bug, push the fix to the same PR branch and cut the next dev iteration:
git tag v0.0.2rc1.dev2 origin/feat/feature-x
git push origin v0.0.2rc1.dev2Iterate dev<M+1> per bug. The PR branch's app/__version__.py still stays at 0.0.1 — only the dev counter advances.
5. Merge each PR with merge-commit
Once a PR's dev release is validated, merge it. Use merge-commit — not rebase, not squash:
gh pr merge 41 --merge
gh pr merge 42 --mergeWhy merge-commit, and not rebase or squash
Rebase-merge re-applies the feature commits with fresh committer dates, producing new SHAs. The dev tag still points at the original PR-branch commit, which is then no longer reachable from main — and verify gate 4 fails. Squash rolls the whole branch into one new commit, with the same failure mode. Merge-commit creates a merge node on top of main and keeps the original feature commits as parents, so the dev release's commit stays reachable and gate 4 passes.
PRs can merge in any order; each PR's validation is independent.
6. Core Maintainer cuts the production release
After every slated PR has shipped at least one validated dev release and been merged to main, the Core Maintainer bumps the version and tags production:
git checkout main
git pull --ff-only
# Edit app/__version__.py: __version__ = "0.0.2"
# Leave MIN_SUPPORTED_UPGRADE_FROM alone unless explicitly dropping support.
python scripts/sync_ui_version.py # copies the version into ui/package.json
git add app/__version__.py ui/package.json
git commit -m "Bump to 0.0.2"
git push origin main
git tag v0.0.2
git push origin v0.0.2The version bump is a fresh commit on main. The production tag points at that commit — not at any dev-release commit.
7. Approve the production run
release-prod.yml triggers on v0.0.2. The first job, verify, runs the four mechanical gates; if any fails, nothing is built or published. Once verify passes, the workflow pauses on the approve job. In Actions → release-prod → the running workflow, click Review deployments → prod-release → Approve and deploy. That click re-affirms that each PR's dev release was installed and validated. Rejecting cancels the run before any artifact is built.
The reviewer roster lives in repo Settings → Environments → prod-release → Required reviewers.
8. Watch the production workflow publish
After approval, the workflow runs seven jobs in order:
verify— the four mechanical gates.approve— the human-validation gate (just completed).prepare-draft— creates a draft GitHub Release with auto-generated notes from PR titles since the previous tag.wheel— builds the SPA viascripts/build_ui.sh, runshatch build, smoke-tests the wheel, publishes to PyPI, and attaches.whl+.tar.gzto the draft.electronmatrix (Ubuntu / macOS / Windows) —npm run buildinui/, then electron-builder publishes signed installers andlatest*.ymlupdate manifests to the same draft.docker— waits forwheel(PyPI must serve the new version), buildsDockerfile.desktop, and pushescremind/cremind-desktop:0.0.2+:latest.publish— promotes the draft from hidden to public, only after everything above succeeds. Half-finished releases never reach users.
gh run watch <run-id> --exit-status # optional: block until done9. Delete the feature branches
git push origin --delete feat/feature-x feat/feature-yThe four verify gates
release-prod.yml's first job is verify. It runs four mechanical checks and refuses to start the rest of the workflow unless every one passes.
- Tag format.
vX.Y.ZorvX.Y.Z.N(the hotfix form). A tag containingrcis excluded by the trigger and handled byrelease-rc.ymlinstead. - Version match. The tag's bare version equals
app/__version__.py's__version__. This is what forces the Core Maintainer's bump commit before a production tag can ship. - On main. The tagged commit must be an ancestor of
origin/main. No shipping from feature branches. - At least one validated dev release on main. Some
v<X.Y.Z>rc<N>.dev<M>tag must exist whoserelease-rc.ymlrun concluded with success and whose commit is reachable fromorigin/main. The dev tag does not have to sit at the same commit as the production tag.
After the four pass, the approve job pauses for required-reviewer approval. Without that click, no PyPI upload, Electron build, or Docker push happens.
release-rc.yml enforces one constraint of its own: the tag must match ^v(\d+\.\d+\.\d+(?:\.\d+)?)rc(\d+)\.dev(\d+)$ exactly. A legacy hyphenated form, a bare v<X.Y.Z>rc<N> without a .dev<M> counter, or a tag missing the leading v is rejected before any build job runs.
Docs-only and other no-release PRs
Most documentation changes don't trigger a release. A PR that only updates README.md, RELEASING.md, CONTRIBUTING.md, code comments, or files under docs/ lands on main like any other PR and rolls into the next feature release — there's nothing operator-facing to ship. The same applies to CI-only changes, repo metadata, and test refactors that don't touch shipped code.
Concretely: open the PR, let pr.yml go green, merge with merge-commit. No RC index, no dev release, no version bump. The next feature release's auto-generated notes pick up the doc PR's title automatically.
If a docs-only change genuinely needs its own release (a critical operator-facing correction), treat it as a single-PR release: assign rc1 for the next patch, cut v0.0.3rc1.dev1, validate by confirming the prerelease notes mention it, merge, then bump and tag v0.0.3.
Variations
Hotfix on top of a shipped release
Hotfixes use the four-segment version. To hotfix 0.0.2:
git checkout -b hotfix/v0.0.2.1 v0.0.2
# fix, commit, push, open PR; Core Maintainer assigns rc1 for v0.0.2.1
git tag v0.0.2.1rc1.dev1 origin/hotfix/v0.0.2.1
git push origin v0.0.2.1rc1.dev1
# validate, iterate dev<M+1> per fix, merge (merge-commit), then on main:
# Edit app/__version__.py: __version__ = "0.0.2.1"
python scripts/sync_ui_version.py
git add app/__version__.py ui/package.json
git commit -m "Bump to 0.0.2.1"
git push origin main
git tag v0.0.2.1
git push origin v0.0.2.1PEP 440 accepts the four-segment form, and the RC workflow's tag regex matches v0.0.2.1rc<N>.dev<M> exactly.
A release fails partway through
Because publish is the last job, a partial failure leaves the draft hidden. To retry:
gh release delete v0.0.2 --cleanup-tag --yes
# fix the underlying issue on main (run a dev-release cycle if it needs validation)
git tag v0.0.2 # re-tag the (possibly new) commit on main
git push origin v0.0.2
# approve in the Actions UIPyPI is irreversible
Once cremind==0.0.2 uploads to PyPI, you cannot re-upload the same version. If the wheel job succeeded but Electron failed, either re-tag at a higher patch version (v0.0.3), or pip uninstall on clients you control and leave the broken wheel published as a known-bad release.
Required secrets
PYPI_API_TOKEN, TEST_PYPI_API_TOKEN, DOCKERHUB_USERNAME, and DOCKERHUB_TOKEN are required — a missing secret fails the release. GITHUB_TOKEN is auto-provided.
Code-signing secrets are optional: CSC_LINK, CSC_KEY_PASSWORD, and the Apple notarization trio (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID). Missing signing secrets produce unsigned builds — the release still succeeds, but users see SmartScreen / Gatekeeper warnings.
Next
Installing via the dev channel
Run the real installer scripts against your local checkout to test install changes or bring up the full Docker bundle.
Versioning
The single source of truth in app/__version__.py, how it flows to PyPI, npm, and Docker, the PEP 440 vs SemVer forms, and the three release channels.