Files
healthchat-web/.claude/skills/releasing-healthchat/SKILL.md
T

21 KiB

name, description
name description
releasing-healthchat Cut a versioned HealthChat release of element-web by tagging release/healthchat. Use when the user asks to release, publish, tag, or cut a new HealthChat version, or asks about the release/deployment process for HealthChat element-web.

Releasing HealthChat (element-web)

Releases are driven by git tags of the form healthchat-v<semver> pushed to origin. The tag triggers two workflows in parallel:

  • .github/workflows/build-healthchat.yaml (web) — builds the HealthChat Docker image, publishes it to ghcr.io under healthchat-web:<version>, :latest, and :<sha>, and creates a GitHub Release whose body is the matching section of HEALTHCHAT_CHANGES.md.

    :latest is the tag production deploys from. It only moves for a clean X.Y.Z tag push — never for -rc1/-hotfix1/-dryrun1 tags, and never for a manual workflow_dispatch run. Those builds still publish :<version> and :<sha>, so you can deploy them deliberately by overriding the image ref (see "Deploying the release"), but they can never become production by accident.

  • .github/workflows/build-healthchat-desktop.yaml (desktop) — builds the Electron app for macOS (universal, signed+notarised) and Windows (x64/arm64, signed) and publishes to the same GitHub Release as the web workflow (both create-if-missing, so they race safely). (Linux is intentionally not built — the upstream Linux reusable pulls a build-env Docker image keyed by commit SHA that only exists in the upstream element-hq registry, so it cannot succeed in this fork.) The release job stages the Squirrel update feed and public installers and uploads them to the versioned archive at https://packages.healthchat.ch/desktop-releases/<version>/ — but it does not publish them to the live feed. Nothing binary is attached to the GitHub Release (the compressed feed outgrew GitHub's 2 GiB per-asset cap at 1.6.0); the Release notes get a "## Downloads" section linking the installers' public URLs on packages.healthchat.ch, which resolve once the version is deployed. See "Deploying the desktop release" below.

  • .github/workflows/deploy-healthchat-desktop.yaml (desktop deploy) — manual dispatch only. Fetches the staged archive for the requested version and mirrors it to the live feed at https://packages.healthchat.ch/desktop/ over FTPS (the private GitHub Release can serve neither end users nor the auto-updater). The pre-1.6.0 feed at https://healthchat.ch/packages/desktop/ is retired and frozen at 1.5.0 — installs that old must reinstall once from the new download URL. This is what makes installed apps self-update; see apps/desktop/element.io/healthchat/README for the feed layout and the two caveats (first install is manual; Windows auto-update needs the Setup.exe path, not the .msi).

Publish target: ghcr.io Docker image + versioned feed archive on packages.healthchat.ch + GitHub Release (web bundle + installer download links). No npm, no mobile fan-out.

Release ≠ deploy

Cutting a tag never changes what any user is running. Both halves of the deployment are separate, manual, and need no code change:

Component Released by Deployed by
Web (ghcr.io image) healthchat-v* tag push → :latest moves gh workflow run deploy.yml on the ansible repo
Desktop (update feed) healthchat-v* tag push → feed tarball on the Release gh workflow run deploy-healthchat-desktop.yaml

Writing the desktop feed pushes an update to every installed client, so it is a deployment, not a publication step — hence the same manual gate as the server side. Deploy the two together unless you have a reason not to: a desktop client talks to the web deployment, so leaving one behind means users run a client built against a different release.

The desktop app version is taken from apps/desktop/package.json (the per-job version input cannot set it — Windows squirrel mangles it into a nightly string, and on macOS it only feeds the update manifest). So apps/desktop/package.json must be bumped to the release version in the release commit to keep the About dialog and installer filenames matching the tag. This is no longer optional: the desktop workflow's "Assert desktop app version matches release" step fails the build if apps/desktop/package.json drifts from the tag's MAJOR.MINOR.PATCH (any -rc1/-hotfix1 suffix on the tag is ignored — squirrel needs a clean numeric version, so set 1.4.0 even when tagging healthchat-v1.4.0-rc1).

Preflight checklist

Verify each item before tagging. If any fail, fix and commit first.

  • On branch release/healthchat with a clean working tree (git status).
  • HEALTHCHAT_CHANGES.md has a section ## Changes in <new-version> (<date>) at the top of the file. The pipeline's awk extractor matches this exact heading format.
  • apps/desktop/package.json version is set to the release's MAJOR.MINOR.PATCH (no suffix). Required — the desktop build asserts this and fails otherwise. This is what the desktop apps report as their version.
  • .healthchat-workspace-version points at the healthchat-workspace tag or ref that should ship with this release. Ask the user whether to bump it to the latest plugin release or keep the currently pinned ref (see "Choosing the plugin version" below). Commit any bump in the same commit as the changelog entry.
  • Root package.json version can optionally be bumped to match (cosmetic — the web bundle version is injected from the tag at build time, and the git tag is the source of truth for the build).
  • No uncommitted changes to .github/workflows/build-healthchat.yaml, Dockerfile, or docker/ that haven't been manually sanity-checked.

Choosing the plugin version

The HealthChat plugin (healthchat-workspace) ref that ships with the release is pinned in .healthchat-workspace-version (a single line, e.g. v0.5.0-rc.4). Before tagging, always ask the user which they want:

  1. Update to the latest plugin release — check the available releases:

    gh release list --repo Novaloop-AG/healthchat-workspace --limit 10
    

    If they choose a newer ref, write it to .healthchat-workspace-version and commit it alongside the changelog entry.

  2. Keep the currently pinned ref — leave .healthchat-workspace-version untouched (show them the current value so the choice is informed).

Do not silently change the plugin ref; the user decides per release. The workspace_ref workflow_dispatch input can override it for a one-off build without committing, but the committed file is the source of truth for tag builds.

Cutting the release (normal path)

# from release/healthchat, fully committed:
git tag healthchat-v<MAJOR>.<MINOR>.<PATCH>
git push origin healthchat-v<MAJOR>.<MINOR>.<PATCH>

Examples:

  • git tag healthchat-v1.0.0
  • git tag healthchat-v1.4.0-rc1
  • git tag healthchat-v1.4.0-hotfix1

The workflow triggers automatically. Watch it via gh run watch or GitHub Actions UI.

Deploying the release

Publishing the image does not deploy it. Deployment is a separate manual dispatch against the ansible repo — but it needs no code change anywhere: the inventory pins matrix_client_element_container_image to ghcr.io/novaloop-ag/element-web/healthchat-web:latest, and the ansible role pulls with pull: always, so it picks up whatever :latest now points at and restarts the container only if the digest actually moved.

Wait for the release workflow to go green first (the deploy pulls whatever is in the registry at that moment), then:

gh workflow run deploy.yml \
  --repo Novaloop-AG/matrix-docker-ansible-deploy \
  --ref release/healthchat \
  -f command="install-service client-element"

Watch it: gh run watch --repo Novaloop-AG/matrix-docker-ansible-deploy.

Or via the UI: GitHub → Novaloop-AG/matrix-docker-ansible-deploy → Actions → "Deploy HealthChat (Synapse, Web app etc.)" → Run workflow → command: install-service client-element.

Add -f check_mode=true for a dry run (--check --diff).

Deploying a specific version instead of :latest

RCs, hotfix tags and dispatch builds do not move :latest. To ship one — or to roll back — pass an explicit image ref through extra_args, which the workflow forwards verbatim to ansible-playbook. No inventory edit, no commit:

gh workflow run deploy.yml \
  --repo Novaloop-AG/matrix-docker-ansible-deploy \
  --ref release/healthchat \
  -f command="install-service client-element" \
  -f extra_args="-e matrix_client_element_container_image=ghcr.io/novaloop-ag/element-web/healthchat-web:1.5.0"

Note this is a one-shot override: the next plain deploy returns to :latest. For a sustained hold, pin the version in the inventory instead.

Confirming what actually shipped

Because the inventory tracks :latest, git no longer records the deployed version — check the running app, not the repo:

curl -fsS https://app.healthchat.ch/version

That serves the build version baked in by scripts/docker-package.sh (e.g. healthchat-v1.5.0). If it still reports the old version, the deploy either did not run or :latest had not moved yet.

Deploying the desktop release

The desktop build stages the feed at https://packages.healthchat.ch/desktop-releases/<version>/ but leaves the live feed at https://packages.healthchat.ch/desktop/ untouched. Until you run this, installed apps keep whatever version they have and the public download links still serve the old installers.

Wait for "Build HealthChat Desktop" to go green, then:

gh workflow run deploy-healthchat-desktop.yaml \
  --ref release/healthchat \
  -f version=1.5.0

Or: GitHub → Actions → "Deploy HealthChat Desktop" → Run workflow → version: 1.5.0.

Dry run first if you want to see what would be written without touching the server:

gh workflow run deploy-healthchat-desktop.yaml \
  --ref release/healthchat \
  -f version=1.5.0 -f dry_run=true

The workflow refuses to publish a partial feed — it fails before contacting the server if releases.json, either arch's RELEASES, or the macOS *-mac.zip payload is missing. A half-written feed breaks auto-update for everyone already installed, which is much worse than not deploying.

On success it curls the five load-bearing URLs and fails if any is not 200, so a green run means the feed is genuinely live. Installed apps pick it up on their next update check.

Deploying an RC or rolling back

version accepts any released version, including 1.5.0-rc1. Because staged feeds persist in the versioned archive (desktop-releases/<version>/), re-running this workflow with an older version republishes that feed — no rebuild, no re-signing. That is the desktop rollback path:

gh workflow run deploy-healthchat-desktop.yaml --ref release/healthchat -f version=1.4.1

Caveat: mirror -R does not delete, so rolling back rewrites the manifests but leaves the newer *.nupkg files in place. That is intentional — Squirrel.Windows needs the delta history — and harmless, since clients follow RELEASES/releases.json, not directory listings.

Versions with no staged archive (cut before the archive design) fail the deploy with a pointer; dispatch "Build HealthChat Desktop" with version=<version> to rebuild and stage that version, then re-run the deploy.

Manual / retry path (workflow_dispatch)

Use when retrying a failed run, testing the pipeline, or rebuilding an image without moving tags.

  1. GitHub → Actions → "Release HealthChat" → Run workflow.
  2. Fill in:
    • version (required): e.g. 1.4.0. Same regex validation as tag path.
    • workspace_ref (optional): overrides .healthchat-workspace-version.
    • release_notes_override (optional): replaces the HEALTHCHAT_CHANGES.md lookup.
  3. Run. Docker image is pushed. No GitHub Release is created on the dispatch path — only the tag-push path creates Releases (keeps manual retries from clobbering the release history). :latest is not moved either, for the same reason: a retry or pipeline test must not become production. The build still publishes :<version> and :<sha>; deploy it with the explicit-version override in "Deploying the release" if that is what you want.

The desktop workflow ("Build HealthChat Desktop") has its own workflow_dispatch with the same inputs. Its release job also runs on dispatch to (re)stage the feed archive at desktop-releases/<version>/ — that is the recovery path when a version's archive is missing — but the GitHub Release steps (create + Downloads links) stay tag-only, so a dispatch can never touch release history.

Tag format

healthchat-v<MAJOR>.<MINOR>.<PATCH>[-<suffix>]

Suffix conventions (informational; regex accepts any [0-9A-Za-z.-]+):

  • -rc1, -rc2 — release candidates
  • -hotfix1 — post-release fixes on an existing minor
  • -dryrun1, -renew-test — one-off pipeline tests

The healthchat- prefix keeps these tags distinct from upstream element-hq v<semver> tags if they are ever fetched into the repo.

Regex used for validation (must match): ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$

Release notes

HEALTHCHAT_CHANGES.md is the single source. The workflow extracts the section matching the version being released. Format:

## Changes in 1.4.0 (2026-04-21)

Short summary line (optional).

🙌 Improvements

- Bullet one.
- Bullet two.

If no matching section exists, the pipeline falls back to HealthChat <version> — automated build from <ref>. and emits a workflow warning. Prefer fixing the changelog over relying on the fallback.

After the release

  • Workflow run is green in GitHub Actions.

  • docker pull ghcr.io/novaloop-ag/element-web/healthchat-web:<version> succeeds. (Note the image path contains the repo segment — the workflow builds it as format('{0}/healthchat-web', github.repository).)

  • GitHub Release exists at https://github.com/<owner>/<repo>/releases/tag/healthchat-v<version> with the correct body.

  • The :latest tag on ghcr.io now points at the new digest, and matches :<version>:

    ```bash
    docker buildx imagetools inspect ghcr.io/novaloop-ag/element-web/healthchat-web:latest
    docker buildx imagetools inspect ghcr.io/novaloop-ag/element-web/healthchat-web:<version>
    ```
    
    For an `-rc`/`-hotfix`/`-dryrun` tag the opposite is expected: `:latest` must
    be **unchanged**.
    
  • The desktop workflow run ("Build HealthChat Desktop") is green, the GitHub Release notes carry the "## Downloads" section, and the staged archive exists: curl -fsS https://packages.healthchat.ch/desktop-releases/<version>/update/macos/releases.json. Without that archive the desktop deploy has nothing to publish.

  • Confirm the live feed is still on the previous version — releasing must not have moved it. curl -fsS https://packages.healthchat.ch/desktop/update/macos/releases.json

  • Smoke-check signing before shipping to users: macOS spctl -a -vvv -t install <app> is accepted; Windows installer shows a valid publisher (no SmartScreen block).

After the deployment

Two independent deploys — do both (see "Deploying the release" and "Deploying the desktop release"):

  • Web deployed and curl -fsS https://app.healthchat.ch/version reports the new version.
  • Desktop feed deployed: the "Deploy HealthChat Desktop" run is green. Its own verify step already asserts the feed URLs return 200, so a green run is the check.
  • releases.json now advertises the new version: curl -fsS https://packages.healthchat.ch/desktop/update/macos/releases.json | jq -r '.releases[0].updateTo.version'
  • Spot-check auto-update on a real installed client (or at least confirm the app's update check reaches the feed) before announcing.

Required secrets

Configured in repo Settings → Secrets and variables → Actions:

Secret Purpose
HEALTHCHAT_WORKSPACE_TOKEN PAT with read access to Novaloop-AG/healthchat-workspace. Needed by actions/checkout for the private plugin repo.
GITHUB_TOKEN Auto-provided. Used for ghcr.io login and GitHub Release creation. No manual setup required.
FTP_SERVER / FTP_USERNAME / FTP_PASSWORD Credentials for the healthchat.ch FTP(S) account (docroot of packages.healthchat.ch is /public_html/packages). Used by the release job of build-healthchat-desktop.yaml to stage the feed archive under /public_html/packages/desktop-releases/<version>/, and by deploy-healthchat-desktop.yaml to publish the live feed under /public_html/packages/desktop/. FTP_SERVER is the hostname only (explicit FTPS / AUTH TLS).

Desktop signing additionally requires two GitHub environments (Settings → Environments). Without them the desktop builds still run but produce unsigned/untrusted output:

Environment Secrets / variables
Desktop Apple secrets APPLE_ID, APPLE_ID_PASSWORD (app-specific), APPLE_CSC_LINK (base64 .p12), APPLE_CSC_KEY_PASSWORD; variable APPLE_TEAM_ID

Windows code-signing reuses the org-level SSL.com eSigner secrets shared with the other Novaloop apps (ESIGNER_USERNAME, ESIGNER_PASSWORD, ESIGNER_TOTP_SECRET); the windows job in build-healthchat-desktop.yaml maps them to the CKA names the reusable expects (no credential_id needed). Set variable ESIGNER_MODE=prod for a trusted signature. These org secrets are scoped to private/internal repos, so the fork must stay internal (not public). Add branch/environment protection so only release/healthchat + healthchat-v* runs can reach the signing credentials.

Rollback

  • Bad image already deployed → redeploy the previous version immediately using the explicit-version override in "Deploying the release" (no commit, no inventory edit). That restores service; then fix forward with a hotfix tag. Remember the override is one-shot — the next plain deploy goes back to :latest, so land the real fix before anyone runs setup-all.
  • Bad image, notes fine → fix the cause, commit, tag healthchat-v<ver>-hotfix1. Note a -hotfix1 tag does not move :latest; once you have verified it, either deploy it via the explicit-version override or cut a clean X.Y.Z tag to promote it.
  • Bad desktop build already deployed to the feed → re-run "Deploy HealthChat Desktop" with the previous version (see "Deploying an RC or rolling back"). Clients that already auto-updated stay on the bad version until the next release — the feed only controls what is offered next, it cannot recall an installed build. That asymmetry is exactly why the desktop deploy is gated behind a manual dispatch.
  • Bad desktop build caught before deploying → nothing to undo. Do not run the deploy workflow; fix forward and cut a new tag.
  • Bad release notes → edit HEALTHCHAT_CHANGES.md and the GitHub Release body manually (GitHub UI). The ghcr.io tag is immutable, don't try to move it. Note the desktop feed bakes the notes in at build time (into releases.json and notes.txt), so a post-hoc edit to HEALTHCHAT_CHANGES.md does not reach the update dialog — rebuild that version if the notes matter to desktop users.
  • Completely wrong tag → delete the GitHub Release (UI). Do not delete the ghcr.io image tag — treat it as burned. Re-tag with a new version.

Known branch divergence on rebase

These files are HealthChat-only and will conflict cleanly when rebasing release/healthchat onto upstream. Always keep the HealthChat side (--ours during rebase):

  • .github/workflows/build-healthchat.yaml
  • .github/workflows/build-healthchat-desktop.yaml
  • .github/workflows/deploy-healthchat-desktop.yaml
  • HEALTHCHAT_CHANGES.md
  • .healthchat-workspace-version
  • .claude/skills/releasing-healthchat/SKILL.md
  • apps/desktop/build/icon.png, apps/desktop/build/icon.ico, apps/desktop/build/icon.icon/ (HealthChat icons)

The four shared desktop reusables carry small HealthChat additions (a repository input + product-name-agnostic asserts). These should merge cleanly, but if they conflict on rebase, re-apply the HealthChat changes rather than blindly taking either side:

  • .github/workflows/build_desktop_prepare.yaml
  • .github/workflows/build_desktop_linux.yaml (still in the tree, but no longer invoked by build-healthchat-desktop.yaml — the fork doesn't build Linux)
  • .github/workflows/build_desktop_macos.yaml
  • .github/workflows/build_desktop_windows.yaml