diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index f58abca..0cefb8a 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -7,10 +7,12 @@ # * builds the desktop packages and attaches them (plus the update # metadata) to the Gitea release tagged `desktop-v`. # -# The live update feed (deploy/data/desktop, served by the frontend nginx at -# /desktop/) is not touched here: it is runtime state on the deploy host and -# is still published with `deploy/push_desktop.sh --no-build` from a machine -# that can reach it. +# The desktop build also attaches the update metadata (latest*.yml) to the +# release; that is the desktop updater's feed, resolved through the Gitea API +# at check time (see desktop/README.md). The older website feed +# (deploy/data/desktop, served at /desktop/) is runtime state on the deploy +# host and only needed for installs before 0.1.2; it is refreshed with +# `deploy/push_desktop.sh` from a machine that can reach the deploy host. # # Registry login uses a repo PAT with the minimal write:package scope (the # Gitea registry rejects the automatic job token, go-gitea/gitea#23642); diff --git a/AGENTS.md b/AGENTS.md index 03a7749..0c395ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,9 +56,10 @@ Project constraints (do not regress): `desktop-v`). Jobs run on the user-scoped runners: `ubuntu-latest` on nitro-ci, `desktop` on msi-mortar-ci. Do not add actions/cache (`cache: pip`/`npm`) to these workflows: Gitea's cache service hangs the job - on restore/save. The live desktop update feed (deploy/data/desktop) is still - published with `deploy/push_desktop.sh --no-build` from a machine with SSH - to the deploy host — CI has no key for that. + on restore/save. Desktop updates read the release assets (latest*.yml) from + the Gitea API at check time; the older website feed (deploy/data/desktop) + only matters for installs before 0.1.2 and is refreshed with + `deploy/push_desktop.sh` from a machine with SSH to the deploy host. - Security/permission tests live in backend/apps/core/tests and need a one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; diff --git a/deploy/README.md b/deploy/README.md index 67eea9b..456abe5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -145,9 +145,12 @@ Build the desktop installers without publishing anything: ``` Hand the files out or attach them to a Gitea release manually — the script -prints sizes and SHA-256 sums for the release notes. +prints sizes and SHA-256 sums for the release notes. The release assets are +also the desktop update feed; the app resolves the newest `desktop-v*` +release on Gitea at check time (see `desktop/README.md`). -Push the desktop builds and their update metadata to the frontend's feed: +The frontend's `/desktop/` feed is optional now — kept for manual downloads +and for installs older than 0.1.2. To publish it: ```bash ./push_desktop.sh # build Linux packages + copy the feed to jakerasp @@ -160,19 +163,13 @@ The remote copy defaults to `jakerasp:/home/jake/servers/J621`, or `$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`, which the frontend nginx mounts read-only and serves at `/desktop/`. The remote copy uses rsync when both ends have it, tar over ssh when the server -does not. The desktop app's "Check for updates…" menu item reads -`latest-linux.yml` / `latest.yml` from there (see `desktop/README.md`). -Backend-only composes have no frontend, so no feed. +does not. Backend-only composes have no frontend, so no website feed. The manual **CD** workflow (Actions tab) builds the desktop packages on the runner and attaches them plus the update metadata to the Gitea release -`desktop-v`; it does not touch the live feed, because that is -runtime state on the deploy host and CI has no SSH key for it. After a CD run, -publish the feed from a machine that can reach the deploy checkout: - -```bash -./push_desktop.sh --no-build --local # or without --local to also copy it -``` +`desktop-v`; that is what the desktop updater reads. The website feed +is not touched by CI (runtime state on the deploy host, no SSH key there); use +`push_desktop.sh` when it needs refreshing for old installs. ## Scheduled jobs diff --git a/deploy/push_desktop.sh b/deploy/push_desktop.sh index cbd29b5..1acd695 100755 --- a/deploy/push_desktop.sh +++ b/deploy/push_desktop.sh @@ -1,11 +1,12 @@ #!/bin/bash -# Build the J621 desktop packages and publish them to the update feed. +# Build the J621 desktop packages, optionally publish them to the website +# feed. # -# Locally the feed is deploy/data/desktop, which the frontend nginx mounts -# read-only and serves at /desktop/. With --host the same directory is also -# copied to a remote deploy checkout (rsync, or tar over ssh when the server -# has no rsync). electron-updater reads latest-linux.yml / latest.yml from -# there; the feed URL comes from desktop/electron-builder.yml. +# Desktop updates no longer depend on this: the app resolves the newest +# `desktop-v*` release on Gitea at check time (see desktop/README.md). This +# script builds the packages and can copy them to deploy/data/desktop, which +# the frontend nginx mounts read-only and serves at /desktop/ for manual +# downloads and for pre-0.1.2 installs. # # Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path] # --win also cross-build the Windows NSIS installer (needs wine) diff --git a/desktop/README.md b/desktop/README.md index b589baf..7d2e696 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -49,7 +49,8 @@ npm run dist:all `deploy/build_desktop.sh` wraps the same commands, installs dependencies on first run and prints sizes plus SHA-256 sums for release notes. Nothing is -published by it; `deploy/push_desktop.sh` is the one that feeds auto-updates. +published by it; the CD workflow attaches the artifacts to the Gitea release, +which is also the update feed. The Arch package can be installed and removed with pacman: @@ -68,19 +69,29 @@ will warn, and it has not been smoke-tested on real Windows. The app checks only when asked (**J621 → Check for updates…** in the menu): Linux packages install through pacman/dpkg, which needs administrator rights, -and the Windows build is unsigned, so nothing installs silently. The check -reads `latest-linux.yml` / `latest.yml` from the feed configured in -`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set -`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this). +and the Windows build is unsigned, so nothing installs silently. + +The check resolves the feed itself: it asks the Gitea API for the newest +non-draft `desktop-v*` release (`J621_UPDATE_REPO`, default +`https://gitea.rainbow-herring.ts.net/jakebreath/j621` — lowercase on purpose, +the API path is case-sensitive), picks the `latest-linux.yml` / `latest.yml` +asset for the platform and uses that release as an electron-updater generic +feed. The baked `publish.url` in `electron-builder.yml` is metadata only. +`J621_UPDATE_URL` overrides the whole lookup (the smoke test uses this). Publishing a release: 1. Bump `version` in `desktop/package.json` — that is what the updater compares. -2. `./deploy/push_desktop.sh --win` builds deb/pacman/NSIS and copies the - artifacts plus both channel files into `deploy/data/desktop/`, which the - frontend nginx serves read-only at `/desktop/`. +2. Run the manual CD workflow, which builds the packages and attaches them + plus both channel files to the Gitea release `desktop-v`. + `./deploy/push_desktop.sh --win` does the same build locally (and can also + copy the files to the website feed, which is optional now). 3. Existing installs find the new version on their next manual check. +Note for the 0.1.1 → 0.1.2 step: 0.1.1 only knows the old `/desktop/` feed, so +publish 0.1.2 there once (`./deploy/push_desktop.sh --no-build`, or install it +manually). From 0.1.2 on, updates come from Gitea. + `package-type` in the app resources tells electron-updater whether to run `pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install updates without elevation. diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 7c9e2cd..d870a20 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -2,12 +2,13 @@ appId: io.j621.desktop productName: J621 copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence -# Update feed served by the frontend nginx (deploy/data/desktop, published -# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override -# at runtime with J621_UPDATE_URL for a fork or a test feed. +# Update feed: desktop/src/main.ts resolves the newest desktop-v* release on +# Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder +# to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL +# overrides the feed for a fork or a test. publish: provider: generic - url: https://j621.rainbow-herring.ts.net/desktop + url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases directories: output: release diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 4c0a845..98e3620 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -238,8 +238,9 @@ function isDownloadNavigation(url: URL): boolean { /** * Updates are manual by design: Linux packages install through pacman/dpkg * (pkexec/sudo) and the Windows build is unsigned, so the app asks before - * downloading and again before installing. The feed comes from `publish` in - * electron-builder.yml and can be overridden with J621_UPDATE_URL. + * downloading and again before installing. The feed is resolved at check time + * from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`); + * `J621_UPDATE_URL` overrides it for forks and the smoke test. */ type UpdateEvent = | { state: "available"; version: string } @@ -250,9 +251,84 @@ type UpdateEvent = let updateReporter: ((event: UpdateEvent) => void) | null = null; let updateCheckRunning = false; -function setUpUpdates(win: BrowserWindow): void { +const UPDATE_REPO = + process.env.J621_UPDATE_REPO?.trim() || + "https://gitea.rainbow-herring.ts.net/jakebreath/j621"; + +interface ReleaseAsset { + name: string; + browser_download_url: string; +} + +interface Release { + tag_name: string; + draft: boolean; + prerelease: boolean; + assets?: ReleaseAsset[]; +} + +function releaseVersion(tag: string): [number, number, number] | null { + const match = /^desktop-v(\d+)\.(\d+)\.(\d+)$/.exec(tag); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareVersions( + a: [number, number, number], + b: [number, number, number], +): number { + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] - b[index]; + } + return 0; +} + +/** + * Resolve the generic feed base electron-updater should use. + * + * Gitea's API path is case-sensitive (owner/repo must match the login), while + * the asset URLs it returns are canonical, so the base is derived from the + * platform's metadata asset (`latest-linux.yml` / `latest.yml`). + */ +async function resolveReleaseFeed(): Promise { const override = process.env.J621_UPDATE_URL?.trim(); - if (override) autoUpdater.setFeedURL({ provider: "generic", url: override }); + if (override) return override; + const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)\/?$/.exec(UPDATE_REPO); + if (!match) { + throw new Error( + `J621_UPDATE_REPO must be // (got ${UPDATE_REPO}).`, + ); + } + const [, origin, owner, repo] = match; + const response = await fetch( + `${origin}/api/v1/repos/${owner}/${repo}/releases?limit=50`, + { headers: { Accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error(`Release lookup on Gitea failed (HTTP ${response.status}).`); + } + const releases = (await response.json()) as Release[]; + const assetName = + process.platform === "win32" ? "latest.yml" : "latest-linux.yml"; + let best: { version: [number, number, number]; asset: ReleaseAsset } | null = + null; + for (const release of releases) { + if (release.draft || release.prerelease) continue; + const version = releaseVersion(release.tag_name); + if (!version) continue; + const asset = release.assets?.find((entry) => entry.name === assetName); + if (!asset) continue; + if (!best || compareVersions(version, best.version) > 0) { + best = { version, asset }; + } + } + if (!best) { + throw new Error(`No ${assetName} asset found in ${UPDATE_REPO} releases.`); + } + return best.asset.browser_download_url.replace(/\/[^/]*$/, ""); +} + +function setUpUpdates(win: BrowserWindow): void { if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; @@ -336,6 +412,8 @@ async function checkForUpdates(win: BrowserWindow): Promise { if (updateCheckRunning) return; updateCheckRunning = true; try { + const feed = await resolveReleaseFeed(); + autoUpdater.setFeedURL({ provider: "generic", url: feed }); await autoUpdater.checkForUpdates(); } catch (error) { const message = error instanceof Error ? error.message : String(error);