Release Process
Release Process
This document describes how Health Assistant releases and release candidates are cut, versioned, and tracked. The goal is a single, low-ceremony workflow where the scope of the next release is captured as work lands, not assembled retroactively by diffing branches.
Principles
CHANGELOG.mdis the single source of truth for release scope. No separate scope file, nodev/-side drafts. The## [Unreleased]section is the live scope of the next release.- Write changelog entries at commit time, not at release time. Every
PR/commit that changes user-visible behavior adds one line under
## [Unreleased]in the same commit. - Git tags +
git logare the source of truth for what changed between releases. Never store diff manifests manually — usegit log vA..vB --oneline/git diff vA..vB --stat. backend/app/core/config.py(VERSION: str) is the single version source. Thescripts/version_manager.pyCLI propagates that string to all user-facing surfaces (README badge,package.json,package-lock.json,docs/INSTALL.md,AboutPage.tsx). The version is not set via.env—.envholds deployment configuration, not code version.
⚠️ Push policy — local-first by default
The version manager's
--gitflag stages, commits, and tags locally only. That is the default stop point for every release. Do not add--pushunless you explicitly intend to publish to the online repository.
--pushpushes the commit and tag to every configured remote, which triggers CI/CD: Docker image builds (docker-publish.yml) + a public GitHub Release (release.yml). A mistaken push publishes artifacts that are hard to un-publish. When in doubt, ask before pushing.
Goal Command Cut a release locally (default) version_manager.py bump <type> --gitPublish to remotes + trigger CI (opt-in) version_manager.py release --git --push
Changelog format
CHANGELOG.md follows Keep a Changelog.
Group every entry under one of these headings inside ## [Unreleased]:
| Heading | When to use it |
|---|---|
Added | New features, endpoints, UI, config options. |
Changed | Changes in existing functionality, refactors with user impact. |
Deprecated | Features scheduled for removal (announce the sunset). |
Removed | Deleted features, dead code, dropped endpoints. |
Fixed | Bug fixes, crash fixes, correctness fixes. |
Security | Vulnerability fixes, hardening, auth/authz tightening. |
Optional sub-headings (e.g. ### DB foundation, ### P0 stabilization pass)
are fine for grouping a large batch — keep them under the top-level section
headings so the structure stays scannable.
Entry style
- One bullet per change. Reference the audit item / issue / PR when useful
(e.g.
(audit B7),(#42)). - Past tense, imperative mood: "Fixed
list_observationsfilter...", not "Fixes..." or "This fixes...". - Call out breaking changes explicitly with a Breaking changes note at the top of the release section.
- If the change needs operators to act at deploy time, add a numbered step
under an
### Operational notes for deploysub-section.
Versioning
MAJOR— incompatible API / data-model changes.MINOR— new features, backward-compatible.PATCH— bug fixes only, backward-compatible.- Suffix
-rc.N— release candidate (0.3.0-rc.1,0.3.0-rc.2, ...). Promoting an rc to the same version with no suffix is the final release.
The version string currently lives in backend/app/core/config.py as
VERSION: str = "X.Y.Z[-suffix]". It is read by FastAPI (app.main) and the
FHIR R4 CapabilityStatement (fhir_facade_service.py). The version is not
sourced from .env — operators should not override the code version per
deployment.
Cutting a release candidate
# 1. Review what's accumulated under ## [Unreleased]
git log v0.3.0-rc.1..HEAD --oneline # commits since last tag
git diff v0.3.0-rc.1..HEAD --stat # files touched
# 2. In CHANGELOG.md: rename ## [Unreleased] → ## [vX.Y.Z-rc.N] - YYYY-MM-DD
# and insert a fresh ## [Unreleased] block above it.
# 3. Bump the version string everywhere via the manager (local commit + tag — DEFAULT)
python3 scripts/version_manager.py bump rc --git
# (or set explicitly:) python3 scripts/version_manager.py set X.Y.Z-rc.N --git
# 4. Confirm the tag exists locally
git tag --list 'vX.Y.Z*'
# 5. ONLY when you explicitly want to publish to the online repository:
# python3 scripts/version_manager.py release --git --pushversion_manager.py updates: backend/app/core/config.py,
frontend/package.json, frontend/package-lock.json, docs/INSTALL.md,
README.md badge, frontend/src/pages/About/AboutPage.tsx, CHANGELOG.md,
docs/RELEASE_PROCESS.md. With --git it commits
(chore(release): bump version to X.Y.Z-rc.N) and creates an annotated git
tag vX.Y.Z-rc.N locally. Adding --push pushes the commit + tag to
every configured remote (see GitHub Release automation
below for what happens next).
Promoting a release candidate to a final release
# 1. In CHANGELOG.md: rename ## [vX.Y.Z-rc.N] → ## [vX.Y.Z] - YYYY-MM-DD
# (drop the -rc.N suffix). Leave the body intact.
# 2. Bump patch → promotes rc to final (strips the -rc.N suffix), local commit + tag
python3 scripts/version_manager.py bump patch --git
# 3. ONLY when you explicitly want to publish:
# python3 scripts/version_manager.py release --git --pushCatch-up: the release subcommand
If you ran set/bump without --git, or edited CHANGELOG.md
after the version bump, run:
python3 scripts/version_manager.py release --gitThis reads the version already recorded in config.py, stages the version
files + release docs, commits, and tags vX.Y.Z locally — catching up to a
fully released state without re-bumping the version. Add --push only when
you explicitly want to publish to the online repository.
GitHub Release automation
Pushing a v* tag triggers two GitHub workflows (in
.github/workflows/):
docker-publish.yml— builds and publishes backend + frontend Docker images toghcr.io/health-assistant-io/health-assistant. Tags follow semver (X.Y.Z,X.Y,lateston main).release.yml— creates a GitHub Release attached to the tag with:- Release notes auto-extracted from the matching
## [vX.Y.Z]section inCHANGELOG.md. - Prerelease flag set automatically when the version suffix matches
-rc.*,-beta,-alpha,-pre, or-dev. Final releases (no suffix) are published as full releases.
- Release notes auto-extracted from the matching
So the complete release flow is: edit CHANGELOG.md → run
version_manager.py ... --git (local commit + tag) → stop. Only when you
explicitly want to publish: add --push → both workflows fire → Docker images
land on GHCR + GitHub Release appears with notes, marked as prerelease for
RCs. No manual GitHub UI action needed (when pushing).
The release workflow can also be triggered manually from the GitHub Actions
UI (workflow_dispatch) with a tag name input — useful for backfilling a
release for an older tag that predates the workflow.
Diffing between releases
Never store "files changed between releases" manually — git already tracks it:
git log v0.2.0..v0.3.0-rc.1 --oneline # commits between two tags
git diff v0.2.0..v0.3.0-rc.1 --stat # files changed, +/- counts
git diff v0.2.0..v0.3.0-rc.1 -- backend/app # scope to a subtree
git log --stat v0.2.0..v0.3.0-rc.1 # per-commit file listsThe CHANGELOG narrative + these git commands together give the full picture.
What dev/ is for
dev/ is gitignored (.gitignore:1) and holds ephemeral, personal working
state only:
- Audit drafts (
dev/audits/AUDIT-YYYY-MM-DD.md) - Plan drafts (
dev/plans/*.md) - Scratch notes (
dev/notes-*,dev/last-question.md) - Sample files, ad-hoc fix scripts
Release scope never lives in dev/. If you draft release notes there
while working, promote the text into CHANGELOG.md's ## [Unreleased]
section before the commit lands. Anything left in dev/ is invisible to git
history, is lost across machines, and cannot be diffed against tags.
Quick checklist (per commit/PR with user-visible impact)
- Added a bullet under
## [Unreleased]→ correct heading inCHANGELOG.md. - Referenced audit item / issue if applicable.
- Breaking change flagged with a Breaking changes note.
- Deploy-time action listed under
### Operational notes for deploy.
Quick checklist (cutting a release)
- Reviewed
git log <last-tag>..HEAD --onelineagainst## [Unreleased]. - Renamed
## [Unreleased]→## [vX.Y.Z(-rc.N)?] - YYYY-MM-DD. - Added a fresh
## [Unreleased]block above it. - Ran
python3 scripts/version_manager.py bump {rc|patch|minor|major} --git. - Confirmed the new tag exists:
git tag --list 'vX.Y.Z*'. - (If you forgot
--git) Ranpython3 scripts/version_manager.py release --git. - Did NOT push unless explicitly publishing to the online repository.
- (Only if publishing) Ran
python3 scripts/version_manager.py release --git --push. - (Only if pushed) Confirmed the GitHub Release appeared (Actions tab → "Create GitHub Release" workflow).
- (Only if pushed) Confirmed Docker images published (Packages tab on GitHub).