Point desktop updates at the Gitea release feed
CI / Backend tests (push) Successful in 2m41s
CI / Frontend build & lint (push) Successful in 25s

The updater now resolves the newest non-draft desktop-v* release through the
Gitea API at check time (J621_UPDATE_REPO, lowercase because the API path is
case-sensitive), picks the platform's latest*.yml asset and uses that release
as a generic electron-updater feed; J621_UPDATE_URL still overrides
everything. Verified against the live API: release picked, yml fetched,
artifact HEAD 200.

electron-builder's publish.url is now metadata only (still needed so the
build emits latest*.yml). Docs updated: CD release assets are the feed, the
website /desktop/ feed only matters for installs before 0.1.2.
This commit is contained in:
2026-09-23 22:00:49 -05:00
parent 5f237aa2e3
commit 7ca84f4fea
7 changed files with 132 additions and 41 deletions
+6 -4
View File
@@ -7,10 +7,12 @@
# * builds the desktop packages and attaches them (plus the update
# metadata) to the Gitea release tagged `desktop-v<package.json version>`.
#
# 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);
+4 -3
View File
@@ -56,9 +56,10 @@ Project constraints (do not regress):
`desktop-v<version>`). 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'@'%';
+9 -12
View File
@@ -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<version>`; 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<version>`; 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
+7 -6
View File
@@ -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)
+19 -8
View File
@@ -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<version>`.
`./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.
+5 -4
View File
@@ -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
+82 -4
View File
@@ -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<string> {
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 <origin>/<owner>/<repo> (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<void> {
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);