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

  1. CHANGELOG.md is the single source of truth for release scope. No separate scope file, no dev/-side drafts. The ## [Unreleased] section is the live scope of the next release.
  2. 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.
  3. Git tags + git log are the source of truth for what changed between releases. Never store diff manifests manually — use git log vA..vB --oneline / git diff vA..vB --stat.
  4. backend/app/core/config.py (VERSION: str) is the single version source. The scripts/version_manager.py CLI 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.env holds deployment configuration, not code version.

⚠️ Push policy — local-first by default

The version manager's --git flag stages, commits, and tags locally only. That is the default stop point for every release. Do not add --push unless you explicitly intend to publish to the online repository.

--push pushes 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.

GoalCommand
Cut a release locally (default)version_manager.py bump <type> --git
Publish 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]:

HeadingWhen to use it
AddedNew features, endpoints, UI, config options.
ChangedChanges in existing functionality, refactors with user impact.
DeprecatedFeatures scheduled for removal (announce the sunset).
RemovedDeleted features, dead code, dropped endpoints.
FixedBug fixes, crash fixes, correctness fixes.
SecurityVulnerability 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_observations filter...", 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 deploy sub-section.

Versioning

Semantic Versioning 2.0.0:

  • 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 --push

version_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 --push

Catch-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 --git

This 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/):

  1. docker-publish.yml — builds and publishes backend + frontend Docker images to ghcr.io/health-assistant-io/health-assistant. Tags follow semver (X.Y.Z, X.Y, latest on main).
  2. release.yml — creates a GitHub Release attached to the tag with:
    • Release notes auto-extracted from the matching ## [vX.Y.Z] section in CHANGELOG.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.

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 lists

The 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 in CHANGELOG.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 --oneline against ## [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) Ran python3 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).