From 7688e7fd104a874d9272c30aa94b210837c441b9 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 22 Sep 2026 14:27:07 -0700 Subject: [PATCH] Publish desktop update manifests from pnpm release, keeping each Gitea release a draft until every platform is attached and signing and notarizing the macOS build. --- README.md | 115 +++++++++++++-------------- electron/electron-builder.config.mjs | 1 - scripts/release.mjs | 101 +++++++++++++++++++---- scripts/release/gitea.mjs | 9 ++- 4 files changed, 149 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 238380c2..6633dcf8 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and workflow. ### Desktop development (Linux) -The Electron target and its unsigned packages are for development and testing. Release publishing -and production signing remain prerequisites for public distribution. Desktop secrets are encrypted -using the OS keyring or keychain. -If protected storage is unavailable or unreadable, the app warns and keeps secrets only in -memory until it closes, leaving the saved file untouched. Unlock or configure the OS keyring -and restart to restore persistence. Linux’s insecure `basic_text` backend is never used. +Desktop secrets are encrypted with the OS keyring or keychain. If protected storage is unavailable +or unreadable, the app warns, keeps secrets in memory until it closes, and leaves the saved file +untouched. Unlocking or configuring the keyring and restarting restores persistence. The app never +uses Linux's insecure `basic_text` backend. -**Use disposable accounts only with unsigned development packages.** Production distribution -still requires release signing and publishing configuration. +**Use only disposable accounts with unsigned development packages.** The Electron subproject installs separately, so ordinary web and mobile installs don't download Electron: @@ -112,16 +109,12 @@ pnpm run start:desktop `build:desktop` builds the frontend without PWA/service-worker registration, synchronizes the Electron platform, and compiles its TypeScript entrypoint. It uses the same branding environment as the web build, and does not synchronize Android or iOS. `start:desktop` opens the last build without -Vite, so rerun `build:desktop` after frontend changes. The development URL goes only to the desktop -run process. Capawesome records it in ignored generated configuration, and production -synchronization removes it. +Vite, so rerun `build:desktop` after frontend changes. Run `pnpm run test:desktop` after building to check the Linux desktop window. On a headless Linux -runner, use `xvfb-run -a pnpm run test:desktop`. Such a runner also needs `libgtk-3-0t64`, since -Playwright's chromium dependencies do not cover the GTK libraries Electron links against. The test -drops Chromium's sandbox when it runs as root, because Chromium refuses to start that way. This -smoke suite does not start a web dev server, and does not run in CI. It verifies nothing about -Windows or macOS. +runner, use `xvfb-run -a pnpm run test:desktop`, and install `libgtk-3-0t64`, which Playwright's +Chromium dependencies leave out. The test drops Chromium's sandbox when it runs as root. It does not +start a web dev server, does not run in CI, and verifies nothing about Windows or macOS. ### Desktop packaging @@ -132,20 +125,22 @@ pnpm run package:desktop:macos ``` Each command rebuilds production assets, copies and updates Capacitor, compiles Electron, vendors -its runtime and plugins, and invokes electron-builder without publishing or signing. Outputs land in -`electron/dist/`: a Linux x64 AppImage, a Windows x64 NSIS installer, and separate macOS x64 and -arm64 DMGs and ZIPs. The root package version is authoritative, the Capacitor app ID stays stable, and -`VITE_PLATFORM_NAME` supplies the product name. Vite's `.env.local` overrides apply, and explicit -`VITE_*` environment values take precedence. Use production branding values when building artifacts -for others. `VITE_PLATFORM_LOGO` can be a local or HTTPS image, which packaging resizes to 1024×1024 -and stages in ignored output. +its runtime and plugins, and runs electron-builder without publishing. Packages land in +`electron/dist/`: a Linux x64 AppImage, a Windows x64 NSIS installer, and macOS x64 and arm64 DMGs +and ZIPs. The version comes from the root `package.json`, the product name from +`VITE_PLATFORM_NAME`, and the Capacitor app ID stays fixed. Vite's `.env.local` overrides apply and +explicit `VITE_*` environment values take precedence, so use production branding when building for +others. `VITE_PLATFORM_LOGO` can be a local or HTTPS image, and packaging resizes it to 1024×1024. -Window and Dock icons use the bundled branding image, including during local runs. The default -`VITE_PLATFORM_LOGO=static/logo.png` is the official logo; custom branding remains supported. -On Linux, packaged startup registers a hidden desktop entry and a persistent icon under `XDG_DATA_HOME` -(normally `~/.local/share`) so Wayland docks can identify the app. Existing user or system launchers -are preserved, and development runs never write an entry. The generated entry follows the AppImage -path, including immediately after an update renames it. Windows uses the executable icon resources. +macOS packages are signed and notarized when `CSC_NAME` names a Developer ID Application certificate +in the keychain and `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER` are set. All other +packages are unsigned. + +Window and Dock icons use the branding image, including in local runs. On Linux, a packaged app +registers a hidden desktop entry and an icon under `XDG_DATA_HOME` (normally `~/.local/share`) so +Wayland docks can identify it. It leaves existing user and system launchers alone, never writes an +entry from a development run, and updates the entry when an update renames the AppImage. Windows +uses the executable's icon resources. Linux packaging requires Linux. Windows packaging from Linux uses the pinned official `electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared @@ -162,32 +157,25 @@ FLOTILLA_DESKTOP_EXECUTABLE="/absolute/path/to/application" pnpm run test:deskto Use the AppImage or installed executable rather than the installer. This checks packaged metadata, local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and -uninstall still require target-OS testing. +uninstall need testing on the target OS. ### Desktop updates -Packaged apps check once at startup, download available updates, and install them on normal quit. -There is no update notification or custom updater UI. Unpackaged development runs do not check. -Update errors are logged and leave the app usable. The generic feed is configured in -`electron/electron-builder.config.mjs`; electron-builder generates `app-update.yml` and the -`latest*.yml` files alongside the installers, including when packaging with `--publish never`. - -Before public desktop auto-updates ship, the release pipeline must guarantee that **every stable -release selected by Gitea's latest route includes all desktop updater metadata and matching -artifacts**. A newer mobile-only release would break the desktop feed. Production acceptance also -requires platform signing and native Windows/macOS testing. -The existing release task does not yet upload updater metadata or macOS ZIPs; that is follow-up work. -Until it does, the public feed returns 404 for channel metadata and packaged startup logs an update -check failure. This PR provides packaging and runtime support, not an operational public update feed. -Do not publish desktop builds to users until the publishing follow-up is complete. +Packaged apps check for an update once at startup, download it, and install it on a normal quit. +There is no update notification or updater UI, and development runs never check. Update errors are +logged and leave the app usable. The feed is Gitea's latest release, set in +`electron/electron-builder.config.mjs`. [Releasing](#releasing) covers how a release fills it. +electron-builder writes `app-update.yml` and the `latest*.yml` manifests beside the installers, even +with `--publish never`. macOS only installs updates to a signed app. Windows and Linux packages +update unsigned. For local updater QA, use disposable copies with temporary A/B versions and an isolated user-data directory. In those copies only, point the builder's generic feed at a local HTTP server. Package both versions and serve B's generated metadata and artifacts. Run AppImage A, wait for B to download, quit normally, and relaunch the installed AppImage to verify its version and saved state. Check that preferences, protected secrets, tray controls, and notification activation survive. -Also exercise unavailable/missing files, interrupted downloads, invalid checksums/versions, and -an already-current version; failed updates must not install. Never bypass integrity checks. +Also exercise missing files, interrupted downloads, invalid checksums and versions, and an +already-current version. A failed update must not install. Never bypass integrity checks. On macOS, build both architectures together and verify the single generated manifest references both ZIPs with matching hashes. Do not hand-create or merge updater manifests. @@ -196,8 +184,8 @@ both ZIPs with matching hashes. Do not hand-create or merge updater manifests. `pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects, the signed APK on gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect, and the desktop packages. It checks the tag, the changelog section, every credential and every -tool up front, and refuses to start if one of them is missing rather than getting halfway. What's -left — rolling out on Play, submitting for review — comes back as a list when it finishes. +tool up front, and refuses to start if any is missing. It finishes with a list of what's left to do +by hand, such as rolling out on Play and submitting for review. ```sh pnpm bump minor # or patch, major, or an explicit x.y.z @@ -208,7 +196,7 @@ pnpm release ``` `pnpm release --check` runs those checks and reports the plan without building anything. Naming -steps runs a subset — `pnpm release ios`, or `pnpm release apk gitea`. A step that fails stops the +steps runs a subset, such as `pnpm release ios` or `pnpm release apk gitea`. A step that fails stops the run and prints the command to pick up from there. | step | what it does | @@ -218,19 +206,25 @@ run and prints the command to pick up from there. | `fdroid` | reruns F-Droid's own preparation and build against the tag in a throwaway worktree | | `play` | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft | | `ios` | `cap build ios` to an archive and IPA, uploaded with `altool` | -| `desktop` | `package:desktop:*` for this OS | -| `gitea` | creates the release for the tag from the changelog, attaches the APK and any desktop packages | +| `desktop` | `package:desktop:*` for this OS: Linux and Windows from Linux, signed and notarized macOS from a Mac | +| `gitea` | creates a draft release from the changelog, attaches the APK, desktop packages and update manifests, and publishes it once every platform is there | | `zapstore` | `zsp publish zapstore.yaml` | +Linux builds the Linux and Windows packages and a Mac builds the macOS ones, so a release takes a +run on each, both ending in `gitea`. Gitea's latest release is the desktop update feed, so the release stays a +draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml` +manifests. Each manifest is uploaded after the files it lists. A mobile-only release can't be +published, so package the desktop apps for every release. + Release notes come from the `CHANGELOG.md` section matching `package.json`'s version, so every store shows the same text. The APK and zapstore share one artifact, whose path lives in `zapstore.yaml`. -F-Droid has nothing to upload — their servers build from the tag themselves — so the `fdroid` step -is a gate instead: it runs [their preparation and build](fdroid/README.md) against the tag in a -throwaway git worktree, and fails the release before anything is published if that build no longer -works. Preparation patches source with exact-match replacements, so it breaks quietly when the -files it rewrites change. Expect it to take a while; it installs and builds from scratch. +F-Droid builds from the tag on its own servers, so the `fdroid` step uploads nothing. It runs +[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree and +fails the release before anything is published if that build breaks. Preparation patches source +with exact-match replacements, so it breaks quietly when the files it rewrites change. The step is +slow because it installs and builds from scratch. ### Credentials @@ -243,15 +237,16 @@ along with how to get them. | `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates | | `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key | | `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access | -| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | App Store Connect API key with the App Manager role, from Users and Access → Integrations | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | App Store Connect API key with the App Manager role, from Users and Access → Integrations; also notarizes the macOS app | +| `CSC_NAME` | the Developer ID Application certificate in the keychain that signs the macOS app, without its prefix | | `SIGN_WITH` | nostr key for zapstore: an nsec, a `bunker://` url, or `browser` | Add `_ALIAS_PASSWORD` to either keystore prefix when the alias has its own password. `PLAY_TRACK` (default `production`) and `PLAY_STATUS` (default `draft`) choose where a Play upload lands. Keystores and API keys belong outside the repository; only their paths go in `.env.local`. -Gradle signs from those variables, so Android Studio still opens and builds the project without -them — it just produces an unsigned release build. +Without the keystore variables, Android Studio still builds the project and produces an unsigned +release build. ### Obtainium @@ -265,7 +260,7 @@ Obtainium reports the git tag as the version. ## Deployment -To run your own Flotilla, it's as simple as: +To run your own Flotilla: ```sh pnpm install diff --git a/electron/electron-builder.config.mjs b/electron/electron-builder.config.mjs index 01228bc1..bf502980 100644 --- a/electron/electron-builder.config.mjs +++ b/electron/electron-builder.config.mjs @@ -49,7 +49,6 @@ export default { {target: "zip", arch: ["x64", "arm64"]}, ], category: "public.app-category.social-networking", - notarize: false, }, dmg: {sign: false}, } diff --git a/scripts/release.mjs b/scripts/release.mjs index 2133ce90..07e7842c 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -108,14 +108,35 @@ const apkMetadata = async () => { } const desktopTargets = {darwin: ["macos"], linux: ["linux", "windows"], win32: []} -const desktopTarget = desktopTargets[process.platform]?.[0] +const hostTargets = desktopTargets[process.platform] ?? [] -const packagedDesktop = async () => - existsSync(desktopDist) - ? (await readdir(desktopDist)).filter( - file => file.includes(version) && /\.(dmg|AppImage|exe)$/.test(file), - ) - : [] +// Gitea's latest release is the desktop update feed, so it only goes public once every platform's +// manifest is on it +const desktopManifests = ["latest.yml", "latest-linux.yml", "latest-mac.yml"] + +const packagedDesktop = async () => { + const files = existsSync(desktopDist) ? await readdir(desktopDist) : [] + const manifests = [] + + // Manifest names carry no version, so a leftover from an older build is told apart by its contents + for (const file of files.filter(file => desktopManifests.includes(file))) { + const text = await readFile(join(desktopDist, file), "utf-8") + + if (text.match(/^version: '?([^'\s]+)'?$/m)?.[1] === version) { + manifests.push({ + file, + urls: [...text.matchAll(/^\s*- url: '?(.+?)'?$/gm)].map(match => match[1]), + }) + } + } + + return { + artifacts: files.filter( + file => file.includes(version) && /\.(dmg|zip|AppImage|exe|blockmap)$/.test(file), + ), + manifests, + } +} const followUps = [] @@ -269,22 +290,42 @@ const steps = [ name: "desktop", title: "Package the desktop app", missing: () => [ - ...(desktopTarget ? [] : [`desktop packaging on ${process.platform}`]), + ...(hostTargets.length > 0 ? [] : [`desktop packaging on ${process.platform}`]), ...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]), + ...(hostTargets.includes("windows") && !installed("docker") ? ["docker"] : []), + ...(hostTargets.includes("macos") + ? missingEnv("CSC_NAME", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH") + : []), ], setup: [ "Run npm ci --prefix electron. Each platform's packages have to be built on that platform,", "so run pnpm release desktop gitea on the others to add theirs to the same release.", + "Linux also cross-builds the Windows installer, which needs docker.", + "macOS only installs updates to a signed app, so macOS packages are signed and notarized:", + "set CSC_NAME to the name of the Developer ID Application certificate in your keychain,", + "without its prefix, and the ASC_* key the ios step uses notarizes them.", ], run: async () => { - await run("pnpm", ["run", `package:desktop:${desktopTarget}`], {cwd: root}) + for (const target of hostTargets) { + await run("pnpm", ["run", `package:desktop:${target}`], { + cwd: root, + env: { + ...process.env, + ...(target === "macos" && { + APPLE_API_KEY: resolve(root, process.env.ASC_KEY_PATH), + APPLE_API_KEY_ID: process.env.ASC_KEY_ID, + APPLE_API_ISSUER: process.env.ASC_ISSUER_ID, + }), + }, + }) + } const elsewhere = Object.values(desktopTargets) .flat() - .filter(target => !desktopTargets[process.platform].includes(target)) + .filter(target => !hostTargets.includes(target)) followUps.push( - `Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release gitea`, + `Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release desktop gitea`, ) }, }, @@ -307,19 +348,49 @@ const steps = [ await apkMetadata() } + const apkName = `${name}-${version}.apk` + const {artifacts, manifests} = await packagedDesktop() const files = [ - ...(existsSync(apk) ? [[apk, `${name}-${version}.apk`]] : []), - ...(await packagedDesktop()).map(file => [join(desktopDist, file), file]), + ...(existsSync(apk) ? [[apk, apkName]] : []), + ...artifacts.map(file => [join(desktopDist, file), file]), ] - if (files.length === 0) { + if (files.length + manifests.length === 0) { throw new Error("Nothing to attach; build the apk or the desktop packages first") } const release = await api.upsertRelease(version, notes) + const attach = async (path, filename) => + console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`)) for (const [path, filename] of files) { - console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`)) + await attach(path, filename) + } + + // An updater acts on a manifest the moment it can read one, so what it points to goes up first + const attached = await api.assetNames(release.id) + + for (const {file, urls} of manifests) { + const absent = urls.filter(url => !attached.includes(url)) + + if (absent.length > 0) { + throw new Error(`${file} points to ${absent.join(", ")}, which the release doesn't have`) + } + + await attach(join(desktopDist, file), file) + } + + if (release.draft) { + const names = await api.assetNames(release.id) + const missing = [apkName, ...desktopManifests].filter(file => !names.includes(file)) + + if (missing.length > 0) { + followUps.push( + `Gitea: ${version} stays a draft until it has ${missing.join(", ")}; build them and run pnpm release gitea to publish it`, + ) + } else { + await api.publish(release.id) + } } }, }, diff --git a/scripts/release/gitea.mjs b/scripts/release/gitea.mjs index 845fb456..5b4bd1e2 100644 --- a/scripts/release/gitea.mjs +++ b/scripts/release/gitea.mjs @@ -28,7 +28,14 @@ export const gitea = ({repository, token}) => { upsertRelease: async (tag, notes) => (await api("GET", `/releases/tags/${tag}`, {allow404: true})) ?? - (await api("POST", "/releases", {body: {tag_name: tag, name: tag, body: notes}})), + (await api("POST", "/releases", { + body: {tag_name: tag, name: tag, body: notes, draft: true}, + })), + + assetNames: async releaseId => + ((await api("GET", `/releases/${releaseId}`)).assets ?? []).map(asset => asset.name), + + publish: releaseId => api("PATCH", `/releases/${releaseId}`, {body: {draft: false}}), attach: async (releaseId, filename, data) => { const {assets} = await api("GET", `/releases/${releaseId}`)