From 30bb2bfca328f6423d610a9dd02c361aac81138a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl?= Date: Wed, 29 Jul 2026 23:28:39 +0200 Subject: [PATCH] ci: stage desktop feed on packages.healthchat.ch archive --- .claude/skills/releasing-healthchat/SKILL.md | 62 ++++++------ .../workflows/build-healthchat-desktop.yaml | 96 ++++++++++++------- .../workflows/deploy-healthchat-desktop.yaml | 70 +++++++------- apps/desktop/element.io/healthchat/README | 18 ++-- 4 files changed, 143 insertions(+), 103 deletions(-) diff --git a/.claude/skills/releasing-healthchat/SKILL.md b/.claude/skills/releasing-healthchat/SKILL.md index 46f690f7ae..a69a3d1f16 100644 --- a/.claude/skills/releasing-healthchat/SKILL.md +++ b/.claude/skills/releasing-healthchat/SKILL.md @@ -24,24 +24,26 @@ Releases are driven by git tags of the form `healthchat-v` pushed to 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 attaches them to the Release as - split `healthchat-desktop-feed-.tar.gz.part*` assets (the compressed feed - exceeds GitHub's 2 GiB per-asset cap) — but it **does not publish them**. Installer - binaries are not attached individually; the Release notes get a "## Downloads" - section linking their public URLs on packages.healthchat.ch, which resolve once the - version is deployed. See "Deploying the desktop release" below. + the Squirrel update feed and public installers and uploads them to the versioned + archive at `https://packages.healthchat.ch/desktop-releases//` — 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.** Downloads and reassembles those feed parts and mirrors the feed to - `https://packages.healthchat.ch/desktop/` over FTPS (the private GitHub Release can - serve neither end users nor the auto-updater), plus the legacy + 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), plus the legacy `https://healthchat.ch/packages/desktop/` path while clients built <= 1.5.0 are still in the field. 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 + GitHub Release (web bundle + desktop feed -parts, with installer download links).** No npm, no mobile fan-out. +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 @@ -189,9 +191,9 @@ run or `:latest` had not moved yet. ## Deploying the desktop release -The desktop build attaches the split `healthchat-desktop-feed-.tar.gz.part*` -assets to the Release but -leaves `https://packages.healthchat.ch/desktop/` untouched. Until you run this, installed +The desktop build stages the feed at +`https://packages.healthchat.ch/desktop-releases//` 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. @@ -223,10 +225,10 @@ means the feed is genuinely live. Installed apps pick it up on their next update ### Deploying an RC or rolling back -`version` accepts any released version, including `1.5.0-rc1`. Because the feed lives on -the Release (assets never expire, unlike workflow artifacts), **re-running this workflow -with an older version republishes that feed** — no rebuild, no re-signing. That is the -desktop rollback path: +`version` accepts any released version, including `1.5.0-rc1`. Because staged feeds +persist in the versioned archive (`desktop-releases//`), **re-running this +workflow with an older version republishes that feed** — no rebuild, no re-signing. +That is the desktop rollback path: ```bash gh workflow run deploy-healthchat-desktop.yaml --ref release/healthchat -f version=1.4.1 @@ -237,9 +239,9 @@ newer `*.nupkg` files in place. That is intentional — Squirrel.Windows needs t history — and harmless, since clients follow `RELEASES`/`releases.json`, not directory listings. -Releases cut **before** this release/deploy split carry no feed tarball. The workflow says -so explicitly and lists the assets it did find; re-run "Build HealthChat Desktop" for that -version to produce one. +Versions with no staged archive (cut before the archive design) fail the deploy with a +pointer; dispatch "Build HealthChat Desktop" with `version=` to rebuild and +stage that version, then re-run the deploy. ## Manual / retry path (workflow_dispatch) @@ -260,9 +262,10 @@ without moving tags. 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; like the web path, its `release` job only publishes on a -real tag push, so a manual dispatch builds and uploads run artifacts but does not -attach anything to a Release. +with the same inputs. Its `release` job **also runs on dispatch** to (re)stage the +feed archive at `desktop-releases//` — 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 @@ -322,11 +325,10 @@ warning. Prefer fixing the changelog over relying on the fallback. For an `-rc`/`-hotfix`/`-dryrun` tag the opposite is expected: `:latest` must be **unchanged**. -- [ ] The desktop workflow run ("Build HealthChat Desktop") is green and the - GitHub Release carries the split feed assets - `healthchat-desktop-feed-.tar.gz.part*` plus a "## Downloads" section - in its notes linking the public installers. Without those parts the desktop - deploy has nothing to publish. +- [ ] 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//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 ` @@ -354,7 +356,7 @@ Configured in repo Settings → Secrets and variables → Actions: |---|---| | `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. Used **only** by `deploy-healthchat-desktop.yaml` to publish the update feed + public installers under `/public_html/packages/desktop/` (docroot of packages.healthchat.ch) plus, during the URL migration, the legacy `/public_html/healthchat.ch/packages/desktop/`. `FTP_SERVER` is the hostname only (explicit FTPS / AUTH TLS). The release workflows never touch FTP, so a missing/expired credential breaks the deploy but never the release. | +| `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//`, and by `deploy-healthchat-desktop.yaml` to publish the live feed under `/public_html/packages/desktop/` plus, during the URL migration, the legacy `/public_html/healthchat.ch/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 diff --git a/.github/workflows/build-healthchat-desktop.yaml b/.github/workflows/build-healthchat-desktop.yaml index b1e92ef4ce..d8e2d48f9d 100644 --- a/.github/workflows/build-healthchat-desktop.yaml +++ b/.github/workflows/build-healthchat-desktop.yaml @@ -10,11 +10,12 @@ name: Build HealthChat Desktop # (via `webapp-artifact`) and the per-platform build_desktop_{macos,windows}.yaml reusables. # # Notes: -# - This workflow RELEASES but does not DEPLOY. It attaches a staged update-feed tarball (split -# into <2 GiB parts) to the GitHub Release and links the public installer URLs in the Release -# notes; the live auto-update feed at packages.healthchat.ch/desktop/ is only touched by the -# manually dispatched deploy-healthchat-desktop.yaml. So cutting a tag never pushes an update -# to already-installed apps until a human decides to ship it. +# - This workflow RELEASES but does not DEPLOY. It stages the update feed + public installers +# to the versioned archive at packages.healthchat.ch/desktop-releases// and links +# the public installer URLs in the Release notes (nothing binary is attached to the GitHub +# Release); the live auto-update feed at packages.healthchat.ch/desktop/ is only touched by +# the manually dispatched deploy-healthchat-desktop.yaml. So cutting a tag never pushes an +# update to already-installed apps until a human decides to ship it. # - The reusable build workflows are pointed at THIS fork (repository/ref inputs) so the Electron # shell, icons (apps/desktop/build/icon.*) and the healthchat:// deep-link handler # (apps/desktop/src/protocol.ts) are all fork-branded. Branding values come from @@ -228,10 +229,13 @@ jobs: targets: "squirrel msi" release: - name: Publish to GitHub Release + name: Stage feed archive + Release notes needs: [macos, windows] - # Only publish on a real tag push; manual workflow_dispatch runs build but do not publish. - if: startsWith(github.ref, 'refs/tags/') + # Runs on tag pushes AND manual dispatches: the archive upload is idempotent per version + # (mirror --delete into desktop-releases//), and re-running it via dispatch is + # the recovery path for a version whose archive is missing. The GitHub Release steps + # below stay tag-only, so a dispatch can never touch release history. + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -242,17 +246,31 @@ jobs: with: persist-credentials: false + - name: Resolve version + id: relversion + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + VERSION="${GITHUB_REF_NAME#healthchat-v}" + else + VERSION="${INPUT_VERSION:?No version input on workflow_dispatch}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Download all build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist-artifacts - # Installers are NOT attached to the Release: they ship inside the feed tarball and are + # Installers are NOT attached to the Release: they ship inside the staged feed and are # served from packages.healthchat.ch after the deploy — the Release body links to them # there (see "Link public downloads in the Release notes" below). Attaching them here # would duplicate ~1 GiB per release and the x64/arm64 Setup.exe share a filename, so # they'd clobber each other as flat Release assets anyway. - name: Ensure GitHub Release exists + if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -265,13 +283,16 @@ jobs: gh release view "$TAG" >/dev/null 2>&1 || \ gh release create "$TAG" --title "$TAG" --notes "HealthChat $TAG" - # The GitHub Release above is private (internal fork), so it can't serve end users or the + # The GitHub Release is private (internal fork), so it can't serve end users or the # Squirrel auto-updater. Those are served from packages.healthchat.ch/desktop/, which is # published by the SEPARATE, MANUALLY DISPATCHED "Deploy HealthChat Desktop" workflow # (deploy-healthchat-desktop.yaml) — releasing never touches the live update feed. - # This job only *stages* that feed and attaches it to the Release as a tarball, so the - # deploy workflow can publish any past version without rebuilding or re-signing. + # This job only *stages* that feed and uploads it to the versioned archive at + # packages.healthchat.ch/desktop-releases// (the archive persists, so the + # deploy workflow can publish any staged version without rebuilding or re-signing). - name: Stage update feed + public installers + env: + VERSION: ${{ steps.relversion.outputs.version }} run: | set -euo pipefail # --- Release notes: extract this version's section from HEALTHCHAT_CHANGES.md --- @@ -279,7 +300,6 @@ jobs: # auto-update "See changes" dialog: macOS reads it from releases.json's `notes` # field natively, Windows fetches notes.txt (Squirrel.Windows can't carry notes # through the update-downloaded event — only releaseName is available). - VERSION="${GITHUB_REF_NAME#healthchat-v}" NOTES=$(awk -v ver="$VERSION" ' /^## Changes in / { if (found) { exit } @@ -327,34 +347,46 @@ jobs: echo "--- staged feed tree ---" find feed -type f | sort - # Attach the staged feed to the Release instead of publishing it. "Deploy HealthChat - # Desktop" (deploy-healthchat-desktop.yaml) downloads this tarball and mirrors it to - # healthchat.ch when a human decides to ship. Release assets do not expire, so any past - # version stays redeployable long after the workflow artifacts are gone. - - name: Attach update feed to the GitHub Release + # Upload the staged feed to the versioned archive instead of publishing it. "Deploy + # HealthChat Desktop" (deploy-healthchat-desktop.yaml) mirrors this archive to the + # live feed when a human decides to ship. The archive directories persist on the + # server, so any staged version stays redeployable without rebuilding or re-signing. + # (The feed used to be attached to the GitHub Release as a tarball, but the + # compressed feed outgrew GitHub's 2 GiB per-asset cap at 1.6.0.) + - name: Publish staged feed to the versioned archive env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} + FTP_SERVER: ${{ secrets.FTP_SERVER }} + FTP_USERNAME: ${{ secrets.FTP_USERNAME }} + # Passed to lftp via LFTP_PASSWORD (--env-password) so it never appears on the + # command line / process list. + LFTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} + VERSION: ${{ steps.relversion.outputs.version }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#healthchat-v}" - TARBALL="healthchat-desktop-feed-${VERSION}.tar.gz" - # Store paths relative to feed/ so the deploy side extracts a bare feed/ tree. - # GitHub caps Release assets at 2 GiB and the compressed feed exceeds that - # (first hit by 1.6.0), so stream the tarball through split and attach the - # parts; the deploy workflow concatenates ${TARBALL}.part* in glob order - # before extracting. - tar -czf - feed | split -b 1536m -d - "${TARBALL}.part" - ls -lh "${TARBALL}".part* - gh release upload "$TAG" "${TARBALL}".part* --clobber - echo "::notice::Update feed attached as ${TARBALL}.part*. The live feed at packages.healthchat.ch is UNCHANGED — run the 'Deploy HealthChat Desktop' workflow to publish it." + : "${FTP_SERVER:?FTP_SERVER secret not set}" + : "${FTP_USERNAME:?FTP_USERNAME secret not set}" + : "${LFTP_PASSWORD:?FTP_PASSWORD secret not set}" + sudo apt-get update && sudo apt-get install -y lftp + # The archive dir is owned by this version's build: --delete makes re-runs + # idempotent (a rebuild fully replaces the staged tree). The live feed under + # /public_html/packages/desktop is never touched here. + lftp -u "$FTP_USERNAME" --env-password "$FTP_SERVER" </, but +# never touches the live feed. Nothing reaches installed apps until this workflow is dispatched +# by hand. # # Why that split matters: the feed under .../desktop/update/ is what the Squirrel # auto-updater polls. Writing to it pushes an update to every installed client, so it is a # deployment, not a publication step — the same reason the ansible playbook's deploy is a manual # dispatch rather than a release trigger. # -# The tarball is a GitHub Release asset (they do not expire, unlike workflow artifacts), so any -# past version can be re-published without rebuilding or re-signing — that is the rollback path. +# The versioned archive directories persist on the server, so any staged version can be +# re-published without rebuilding or re-signing — that is the rollback path. on: workflow_dispatch: inputs: version: - description: "Version to publish (e.g. 1.5.0). Must have a healthchat-v Release carrying a healthchat-desktop-feed-.tar.gz asset." + description: "Version to publish (e.g. 1.5.0). Must have a staged feed at packages.healthchat.ch/desktop-releases// (produced by Build HealthChat Desktop)." required: true type: string dry_run: @@ -28,8 +29,7 @@ on: type: boolean default: false -permissions: - contents: read # download the Release asset +permissions: {} # the feed comes from the packages.healthchat.ch archive, not from GitHub concurrency: # Never let two runs mirror onto the same FTP tree at once. @@ -40,7 +40,8 @@ jobs: deploy: name: Publish update feed to packages.healthchat.ch runs-on: ubuntu-latest - timeout-minutes: 30 + # Fetches ~2 GiB from the archive and uploads it twice (canonical + legacy path). + timeout-minutes: 60 steps: - name: Resolve version id: version @@ -59,33 +60,37 @@ jobs: echo "tag=healthchat-v$VERSION" >> "$GITHUB_OUTPUT" echo "Publishing HealthChat Desktop $VERSION (tag healthchat-v$VERSION)" - - name: Download update feed from the Release + - name: Install lftp + run: sudo apt-get update && sudo apt-get install -y lftp + + - name: Fetch staged feed from the archive env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ steps.version.outputs.tag }} + FTP_SERVER: ${{ secrets.FTP_SERVER }} + FTP_USERNAME: ${{ secrets.FTP_USERNAME }} + # Passed to lftp via LFTP_PASSWORD (--env-password) so it never appears on the + # command line / process list. + LFTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - TARBALL="healthchat-desktop-feed-${VERSION}.tar.gz" - if ! gh release view "$TAG" >/dev/null 2>&1; then - echo "::error::No GitHub Release found for tag $TAG" + : "${FTP_SERVER:?FTP_SERVER secret not set}" + : "${FTP_USERNAME:?FTP_USERNAME secret not set}" + : "${LFTP_PASSWORD:?FTP_PASSWORD secret not set}" + # The archive is public — probe it over HTTPS first for a clear error before + # opening the FTP session. + if ! curl -fsSI "https://packages.healthchat.ch/desktop-releases/$VERSION/update/macos/releases.json" >/dev/null 2>&1; then + echo "::error::No staged feed at https://packages.healthchat.ch/desktop-releases/$VERSION/ — run 'Build HealthChat Desktop' (workflow_dispatch, version=$VERSION) to build and stage this version, then re-run the deploy." exit 1 fi - # The feed is attached as split ${TARBALL}.part* files (the compressed feed - # outgrew GitHub's 2 GiB per-asset cap at 1.6.0); older releases may carry a - # single-file tarball instead. Handle both. - if ! gh release download "$TAG" --pattern "${TARBALL}*" --clobber; then - echo "::error::Release $TAG has no asset matching '${TARBALL}*'. Releases cut before the deploy split do not carry one — re-run 'Build HealthChat Desktop' for this version to produce it." - gh release view "$TAG" --json assets --jq '.assets[].name' || true - exit 1 - fi - if compgen -G "${TARBALL}.part*" > /dev/null; then - cat "${TARBALL}".part* | tar -xzf - - else - tar -xzf "$TARBALL" - fi - test -d feed || { echo "::error::Tarball did not contain a feed/ directory"; exit 1; } + lftp -u "$FTP_USERNAME" --env-password "$FTP_SERVER" <.tar.gz.part* -assets (the compressed feed exceeds GitHub's 2 GiB per-asset cap) and links the public installer -URLs on packages.healthchat.ch in the Release notes — installer binaries are not attached. +Electron shell, icons and the healthchat:// deep-link handler are fork-branded. The `release` +job uploads the staged update feed + installers to the versioned archive at +packages.healthchat.ch/desktop-releases// (also on workflow_dispatch — the recovery path +for a missing archive) and, on tag pushes only, links the public installer URLs in the Release +notes. Nothing binary is attached to the GitHub Release (the compressed feed outgrew GitHub's +2 GiB per-asset cap at 1.6.0). (Linux is not built: the upstream build_desktop_linux.yaml needs a build-env Docker image keyed by commit SHA that only exists in the upstream element-hq registry, so it can't run here.) @@ -115,9 +117,11 @@ subdomain's docroot on the healthchat.ch FTP account is /public_html/packages): install/macos/HealthChat-.dmg + HealthChat.dmg (stable "latest") install/win32//HealthChat-Setup-.exe + HealthChat-Setup.exe (stable) + *.msi -The `release` job in build-healthchat-desktop.yaml stages these trees; the manually dispatched -deploy-healthchat-desktop.yaml uploads them via lftp/FTPS (mirror -R, no-delete so prior nupkgs -survive for delta history). Add these repo secrets: +The `release` job in build-healthchat-desktop.yaml stages these trees and uploads them to the +versioned archive desktop-releases// (sibling of desktop/ under /public_html/packages); +the manually dispatched deploy-healthchat-desktop.yaml mirrors that archive onto the live +desktop/ tree via lftp/FTPS (mirror -R, no-delete so prior nupkgs survive for delta history). +Add these repo secrets: FTP_SERVER hostname only (explicit FTPS / AUTH TLS on port 21; if the host cert is untrusted, flip ssl:verify-certificate to no in the workflow) FTP_USERNAME