diff --git a/.github/workflows/build-healthchat-desktop.yaml b/.github/workflows/build-healthchat-desktop.yaml index 54476614ae..66a8a30120 100644 --- a/.github/workflows/build-healthchat-desktop.yaml +++ b/.github/workflows/build-healthchat-desktop.yaml @@ -10,6 +10,10 @@ 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 the installers and a staged update-feed +# tarball to the GitHub Release; the live auto-update feed at healthchat.ch/packages/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 @@ -269,9 +273,11 @@ jobs: gh release upload "$TAG" release-files/* --clobber # The GitHub Release above is private (internal fork), so it can't serve end users or the - # Squirrel auto-updater. Mirror the update feed + public installers to healthchat.ch over - # FTPS so installed apps self-update (update_base_url -> .../packages/desktop/update/) and - # users can download from a public URL (.../packages/desktop/install/). + # Squirrel auto-updater. Those are served from healthchat.ch/packages/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. - name: Stage update feed + public installers run: | set -euo pipefail @@ -328,31 +334,21 @@ jobs: echo "--- staged feed tree ---" find feed -type f | sort - - name: Install lftp - run: sudo apt-get update && sudo apt-get install -y lftp - - - name: Publish to healthchat.ch over FTPS + # 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 env: - 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 }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} run: | set -euo pipefail - : "${FTP_SERVER:?FTP_SERVER secret not set}" - : "${FTP_USERNAME:?FTP_USERNAME secret not set}" - : "${LFTP_PASSWORD:?FTP_PASSWORD secret not set}" - # mirror -R (reverse) uploads the local tree; default is no-delete, so prior - # *.nupkg are preserved (Squirrel.Windows delta history) and only the staged files - # are written/overwritten. If the host's FTPS cert is untrusted, set - # ssl:verify-certificate to no. - 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/healthchat.ch/packages/desktop - mirror -R --parallel=4 --verbose feed/ /public_html/healthchat.ch/packages/desktop/ - bye - LFTP + 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. + tar -czf "$TARBALL" feed + ls -lh "$TARBALL" + gh release upload "$TAG" "$TARBALL" --clobber + echo "::notice::Update feed attached as ${TARBALL}. The live feed at healthchat.ch is UNCHANGED — run the 'Deploy HealthChat Desktop' workflow to publish it." diff --git a/.github/workflows/deploy-healthchat-desktop.yaml b/.github/workflows/deploy-healthchat-desktop.yaml new file mode 100644 index 0000000000..2d71b607f1 --- /dev/null +++ b/.github/workflows/deploy-healthchat-desktop.yaml @@ -0,0 +1,157 @@ +name: Deploy HealthChat Desktop + +# Publishes an already-released desktop build to healthchat.ch/packages/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. +# +# Why that split matters: the feed under .../packages/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. + +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." + required: true + type: string + dry_run: + description: "Dry run — list what would be uploaded without writing to the server." + required: false + type: boolean + default: false + +permissions: + contents: read # download the Release asset + +concurrency: + # Never let two runs mirror onto the same FTP tree at once. + group: deploy-healthchat-desktop + cancel-in-progress: false + +jobs: + deploy: + name: Publish update feed to healthchat.ch + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Resolve version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + VERSION="$INPUT_VERSION" + # Same regex as the release workflows, so -rc1/-hotfix1 builds can be published + # deliberately (e.g. to a test machine) but never by accident. + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid version '$VERSION' — expected e.g. 1.5.0 or 1.5.0-rc1" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=healthchat-v$VERSION" >> "$GITHUB_OUTPUT" + echo "Publishing HealthChat Desktop $VERSION (tag healthchat-v$VERSION)" + + - name: Download update feed from the Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.version.outputs.tag }} + 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" + exit 1 + fi + if ! gh release download "$TAG" --pattern "$TARBALL" --clobber; then + echo "::error::Release $TAG has no asset '$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 + tar -xzf "$TARBALL" + test -d feed || { echo "::error::Tarball did not contain a feed/ directory"; exit 1; } + echo "--- feed to publish ---" + find feed -type f | sort + + - name: Sanity-check the feed before publishing + run: | + set -euo pipefail + # A partial feed is worse than none: Squirrel clients poll these paths, and a + # missing manifest breaks auto-update for everyone already installed. + fail=0 + for f in feed/update/macos/releases.json \ + feed/update/win32/x64/RELEASES \ + feed/update/win32/arm64/RELEASES; do + if [[ ! -s "$f" ]]; then echo "::error::missing or empty: $f"; fail=1; fi + done + # The macOS updater fetches the zip named in releases.json; make sure it is present. + if [[ -s feed/update/macos/releases.json ]]; then + if ! ls feed/update/macos/*-mac.zip >/dev/null 2>&1; then + echo "::error::releases.json present but no *-mac.zip payload alongside it"; fail=1 + fi + fi + exit $fail + + - name: Install lftp + run: sudo apt-get update && sudo apt-get install -y lftp + + - name: Publish to healthchat.ch over FTPS + env: + 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 }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + : "${FTP_SERVER:?FTP_SERVER secret not set}" + : "${FTP_USERNAME:?FTP_USERNAME secret not set}" + : "${LFTP_PASSWORD:?FTP_PASSWORD secret not set}" + MIRROR_OPTS="--parallel=4 --verbose" + if [[ "$DRY_RUN" == "true" ]]; then + echo "::notice::DRY RUN — no files will be written to the server." + MIRROR_OPTS="$MIRROR_OPTS --dry-run" + fi + # mirror -R (reverse) uploads the local tree; default is no-delete, so prior + # *.nupkg are preserved (Squirrel.Windows delta history) and only the staged files + # are written/overwritten. If the host's FTPS cert is untrusted, set + # ssl:verify-certificate to no. + lftp -u "$FTP_USERNAME" --env-password "$FTP_SERVER" </dev/null || echo "000") + if [[ "$code" == "200" ]]; then echo "ok $code $url"; else echo "FAIL $code $url"; fail=1; fi + done + if [[ $fail -ne 0 ]]; then + echo "::error::One or more feed URLs are not reachable — auto-update may be broken." + exit 1 + fi + echo "::notice::Update feed for ${{ steps.version.outputs.version }} is live. Installed apps will pick it up on their next update check."