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 # * builds the desktop packages and attaches them (plus the update
# metadata) to the Gitea release tagged `desktop-v<package.json version>`. # metadata) to the Gitea release tagged `desktop-v<package.json version>`.
# #
# The live update feed (deploy/data/desktop, served by the frontend nginx at # The desktop build also attaches the update metadata (latest*.yml) to the
# /desktop/) is not touched here: it is runtime state on the deploy host and # release; that is the desktop updater's feed, resolved through the Gitea API
# is still published with `deploy/push_desktop.sh --no-build` from a machine # at check time (see desktop/README.md). The older website feed
# that can reach it. # (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 # Registry login uses a repo PAT with the minimal write:package scope (the
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642); # 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` `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 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 (`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 on restore/save. Desktop updates read the release assets (latest*.yml) from
published with `deploy/push_desktop.sh --no-build` from a machine with SSH the Gitea API at check time; the older website feed (deploy/data/desktop)
to the deploy host — CI has no key for that. 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 - Security/permission tests live in backend/apps/core/tests and need a
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; 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 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 ```bash
./push_desktop.sh # build Linux packages + copy the feed to jakerasp ./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/`, `$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`,
which the frontend nginx mounts read-only and serves at `/desktop/`. The 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 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 does not. Backend-only composes have no frontend, so no website feed.
`latest-linux.yml` / `latest.yml` from there (see `desktop/README.md`).
Backend-only composes have no frontend, so no feed.
The manual **CD** workflow (Actions tab) builds the desktop packages on the The manual **CD** workflow (Actions tab) builds the desktop packages on the
runner and attaches them plus the update metadata to the Gitea release 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 `desktop-v<version>`; that is what the desktop updater reads. The website feed
runtime state on the deploy host and CI has no SSH key for it. After a CD run, is not touched by CI (runtime state on the deploy host, no SSH key there); use
publish the feed from a machine that can reach the deploy checkout: `push_desktop.sh` when it needs refreshing for old installs.
```bash
./push_desktop.sh --no-build --local # or without --local to also copy it
```
## Scheduled jobs ## Scheduled jobs
+7 -6
View File
@@ -1,11 +1,12 @@
#!/bin/bash #!/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 # Desktop updates no longer depend on this: the app resolves the newest
# read-only and serves at /desktop/. With --host the same directory is also # `desktop-v*` release on Gitea at check time (see desktop/README.md). This
# copied to a remote deploy checkout (rsync, or tar over ssh when the server # script builds the packages and can copy them to deploy/data/desktop, which
# has no rsync). electron-updater reads latest-linux.yml / latest.yml from # the frontend nginx mounts read-only and serves at /desktop/ for manual
# there; the feed URL comes from desktop/electron-builder.yml. # downloads and for pre-0.1.2 installs.
# #
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path] # Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
# --win also cross-build the Windows NSIS installer (needs wine) # --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 `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 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: 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): The app checks only when asked (**J621 → Check for updates…** in the menu):
Linux packages install through pacman/dpkg, which needs administrator rights, Linux packages install through pacman/dpkg, which needs administrator rights,
and the Windows build is unsigned, so nothing installs silently. The check and the Windows build is unsigned, so nothing installs silently.
reads `latest-linux.yml` / `latest.yml` from the feed configured in
`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set The check resolves the feed itself: it asks the Gitea API for the newest
`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this). 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: Publishing a release:
1. Bump `version` in `desktop/package.json` — that is what the updater compares. 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 2. Run the manual CD workflow, which builds the packages and attaches them
artifacts plus both channel files into `deploy/data/desktop/`, which the plus both channel files to the Gitea release `desktop-v<version>`.
frontend nginx serves read-only at `/desktop/`. `./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. 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 `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 `pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
updates without elevation. updates without elevation.
+5 -4
View File
@@ -2,12 +2,13 @@ appId: io.j621.desktop
productName: J621 productName: J621
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
# Update feed served by the frontend nginx (deploy/data/desktop, published # Update feed: desktop/src/main.ts resolves the newest desktop-v* release on
# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override # Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder
# at runtime with J621_UPDATE_URL for a fork or a test feed. # to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL
# overrides the feed for a fork or a test.
publish: publish:
provider: generic provider: generic
url: https://j621.rainbow-herring.ts.net/desktop url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases
directories: directories:
output: release 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 * Updates are manual by design: Linux packages install through pacman/dpkg
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before * (pkexec/sudo) and the Windows build is unsigned, so the app asks before
* downloading and again before installing. The feed comes from `publish` in * downloading and again before installing. The feed is resolved at check time
* electron-builder.yml and can be overridden with J621_UPDATE_URL. * from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`);
* `J621_UPDATE_URL` overrides it for forks and the smoke test.
*/ */
type UpdateEvent = type UpdateEvent =
| { state: "available"; version: string } | { state: "available"; version: string }
@@ -250,9 +251,84 @@ type UpdateEvent =
let updateReporter: ((event: UpdateEvent) => void) | null = null; let updateReporter: ((event: UpdateEvent) => void) | null = null;
let updateCheckRunning = false; 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(); 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; if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
@@ -336,6 +412,8 @@ async function checkForUpdates(win: BrowserWindow): Promise<void> {
if (updateCheckRunning) return; if (updateCheckRunning) return;
updateCheckRunning = true; updateCheckRunning = true;
try { try {
const feed = await resolveReleaseFeed();
autoUpdater.setFeedURL({ provider: "generic", url: feed });
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);