ci: stage desktop feed on packages.healthchat.ch archive

This commit is contained in:
2026-07-29 23:28:39 +02:00
parent 09e66dba47
commit 30bb2bfca3
4 changed files with 143 additions and 103 deletions
+64 -32
View File
@@ -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/<version>/ 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/<version>/), 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/<version>/ (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" <<LFTP
set cmd:fail-exit true
set ftp:ssl-force true
set ftp:ssl-protect-data true
set ssl:verify-certificate true
mkdir -pf /public_html/packages/desktop-releases/$VERSION
mirror -R --delete --parallel=4 --verbose feed/ /public_html/packages/desktop-releases/$VERSION/
bye
LFTP
echo "::notice::Feed staged at https://packages.healthchat.ch/desktop-releases/$VERSION/ — the live feed is UNCHANGED. Run 'Deploy HealthChat Desktop' to publish it."
# The Release carries no installer binaries (see "Ensure GitHub Release exists"), so
# point readers at the public download URLs instead. The links are derived from the
# staged feed/install tree, so they stay correct if electron-builder ever renames the
# artifacts. They resolve once "Deploy HealthChat Desktop" has published this version.
- name: Link public downloads in the Release notes
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
@@ -3,23 +3,24 @@ name: Deploy HealthChat Desktop
# Publishes an already-released desktop build to packages.healthchat.ch/desktop/ over FTPS.
#
# This is the DEPLOY half of the desktop pipeline, deliberately split from the release half
# (build-healthchat-desktop.yaml). Cutting a `healthchat-v*` tag builds, signs and attaches the
# installers plus a staged update-feed tarball to the GitHub Release, but never touches the live
# feed. Nothing reaches installed apps until this workflow is dispatched by hand.
# (build-healthchat-desktop.yaml). Cutting a `healthchat-v*` tag builds, signs and stages the
# update feed + public installers at packages.healthchat.ch/desktop-releases/<version>/, 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<version> Release carrying a healthchat-desktop-feed-<version>.tar.gz asset."
description: "Version to publish (e.g. 1.5.0). Must have a staged feed at packages.healthchat.ch/desktop-releases/<version>/ (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" <<LFTP
set cmd:fail-exit true
set ftp:ssl-force true
set ftp:ssl-protect-data true
set ssl:verify-certificate true
mirror --parallel=4 --verbose /public_html/packages/desktop-releases/$VERSION/ feed/
bye
LFTP
test -d feed || { echo "::error::Archive fetch produced no feed/ directory"; exit 1; }
echo "--- feed to publish ---"
find feed -type f | sort
@@ -108,10 +113,7 @@ jobs:
fi
exit $fail
- name: Install lftp
run: sudo apt-get update && sudo apt-get install -y lftp
- name: Publish to healthchat.ch over FTPS
- name: Publish to packages.healthchat.ch over FTPS
env:
FTP_SERVER: ${{ secrets.FTP_SERVER }}
FTP_USERNAME: ${{ secrets.FTP_USERNAME }}