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
+32 -30
View File
@@ -24,24 +24,26 @@ Releases are driven by git tags of the form `healthchat-v<semver>` 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-<version>.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/<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.** 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-<version>.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/<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.
@@ -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/<version>/`), **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=<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/<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
@@ -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-<version>.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/<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>`
@@ -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/<version>/`, 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
+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 }}
+11 -7
View File
@@ -48,10 +48,12 @@ feeds it into build_desktop_prepare.yaml (`config: element.io/healthchat`,
`webapp-artifact: healthchat-webapp`) and the per-platform build_desktop_{macos,windows}.yaml
reusables. It runs on every `healthchat-v*` tag (alongside the web build) and via
workflow_dispatch. The reusables are pointed at THIS fork (`repository`/`ref` inputs) so the
Electron shell, icons and the healthchat:// deep-link handler are fork-branded. On a tag push the
`release` job attaches the staged update feed as split healthchat-desktop-feed-<ver>.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/<ver>/ (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-<ver>.dmg + HealthChat.dmg (stable "latest")
install/win32/<arch>/HealthChat-Setup-<ver>.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/<ver>/ (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