diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index f715247788..25d620ecb1 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -179,14 +179,17 @@ from the squash message first, or use a plain merge.
| `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/layout-new.tsx` | Dropped the ` ` mount and its import from both layouts (added upstream in v1.17.18; since v1.18.3 upstream itself removed the sibling `HelpButton`, so nothing is imported from `help-button` anymore) | `TabsInfoPopup` is a dev-channel-only "Introducing Tabs" promo whose `muted autoplay loop` `` mounts on every layout render. The fork's Windows e2e runs on GPU-less GitHub-hosted runners (software GPU — see the runner-swap row), where continuous video decode wedges the GPU command buffer (`kTransientFailure`), making the whole app-e2e suite flaky and ~3–5× slower. It never ships (release builds use a non-`dev` `CODEV_CHANNEL`), so unmounting it costs users nothing. | Keep ours: don't re-add the ` ` mount or restore it to the import. |
| `packages/app/src/components/help-button.tsx` | Deleted (as of the v1.18.3 sync). The fork had removed the `TabsInfoPopup` component and its `introducing-tabs.mp4` import, keeping the rebranded `HelpButton`; upstream v1.18.3 deleted `HelpButton` itself, leaving only the popup — so nothing remains and the file is gone. | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the rows around it retire. |
| `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. |
-| `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code"; "OpenCode Documentation" item → "CoDev Code Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev Code menu" (×2) | Visible menu branding, incl. screen-reader labels. | Keep our labels; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. |
+| `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code"; "OpenCode Documentation" item → "CoDev Code Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev Code menu" (×2). Plus a **"Check for Updates..." item added to the Help menu**, it and a preceding separator both `platforms: ["windows"]`; `desktop-menu.ts` gains an exported `desktopMenuItemDisabled(item, {updaterStatus, commandDisabled})` — sibling to the existing `desktopMenuVisible` — which `WindowsAppMenu` now calls to honour `enabled: "updater"`. | Upstream only ever exposes the update check from the macOS app menu (`platforms: ["macos"]`), and `createMenu` early-returns on non-darwin while `WindowsAppMenu` filters on `"windows"` — so on Windows and Linux there was no menu path to it at all, leaving Settings → General as the only manual entry point. `WindowsAppMenu` renders for both Windows and Linux (`titlebar.tsx` gates on `windows() \|\| linux()`) but filters with the `"windows"` key, so `platforms: ["windows"]` reaches both without widening `DesktopMenuPlatform`. The separator carries the same restriction or macOS's Help menu ends on a stray divider. `enabled: "updater"` was silently ignored in this component — only `command` entries could disable — so a dev build would have shown an item that errors on click; the updater's own `disabled` status is the renderer-side equivalent of the main process's `UPDATER_ENABLED`. | Keep our labels, the Help entry, and `entryDisabled`; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. If upstream adds its own non-macOS update entry, drop ours for theirs. Covered by `packages/app/src/desktop-menu.test.ts` ("offers an update check on every platform exactly once"), which will fail on a duplicate rather than let both ship, plus the `item disabled state` block over `desktopMenuItemDisabled`. Keep the disabled logic in `desktop-menu.ts` rather than inlining it back into the component — it is unreachable from a test there, since the app package has no component-render harness. |
| Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev Code" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. |
| **Universal macOS builds**: `packages/desktop/electron.vite.config.ts`, `packages/desktop/electron-builder.config.ts`, `packages/desktop/package.json` | Three changes that together make `electron-builder --mac --universal` (and `--x64`) produce a working app. (1) The `codev:node-pty-narrower` plugin and its `nodePtyPkg` constant are gone; the main build externalises `"@lydell/node-pty"` itself. (2) `@lydell/node-pty` moves from `devDependencies` to `dependencies`, so the wrapper is actually packed. (3) `mac.x64ArchFiles` is set to `**/app.asar.unpacked/node_modules/**`. | (1) Upstream rewrote the import to `@lydell/node-pty-${process.platform}-${process.arch}` — the **build host's** arch, resolved once, while `electron-vite build` runs before `electron-builder` and emits one bundle for whatever is packaged after it. On Apple Silicon that baked `darwin-arm64` in, so an `--x64` or `--universal` build shipped an Electron shell that starts and then cannot load its terminal backend. The wrapper's own entry does the same platform/arch lookup at run time, so externalising it picks the right binary on the machine that runs the app. (2) The wrapper was a devDependency and therefore never shipped: the old narrowing worked only because it pointed at the *platform* packages, which are `optionalDependencies`. Verified against a packaged build — `asar list` showed `@lydell/node-pty-darwin-*` present and `@lydell/node-pty/index.js` absent — so `require("@lydell/node-pty")` could not resolve. The bundled server (`packages/opencode/dist/node/node.js`) already required the bare wrapper, so this was a live bug in packaged builds before the vite change, not one it introduced. (3) All six platform packages ship in both the x64 and arm64 halves, so eight Mach-O files (node-pty's `pty.node` and its extension-less `spawn-helper`, `@parcel/watcher`, `msgpackr-extract`) are byte-identical across them; `@electron/universal` reads that as a mistake and refuses to merge. They are prebuilt binaries chosen at run time, not per-arch build output. | Keep all three. On conflict take upstream's files and re-apply. Do not reintroduce host-arch narrowing to silence a bundler warning, and do not move `@lydell/node-pty` back to `devDependencies` — the two are load-bearing together. Verify a packaged build rather than trusting the build log: `grep -o "@lydell/node-pty[a-z0-9-]*" out/main/index.js` must print the bare wrapper with no platform suffix, `asar list` must contain `/node_modules/@lydell/node-pty/index.js`, and the app must load its `pty.node` from inside the bundle when run from **outside** this repo — inside it, Node's module walk-up finds the repo's `node_modules` and masks a missing wrapper. |
| Desktop app text + identifier rebrand: `packages/desktop/**` (README, `package.json`, `src/renderer/index.html`, `src/renderer/i18n/{en,es}.ts`, `src/main/{index,windows,server,logging}.ts`, `src/main/wsl/{sidecar,startup,servers,servers.test}.ts`, `src/main/install-state.test.ts`, `scripts/copy-metainfo.ts`, `electron-builder.config.ts`) plus the WSL/deep-link surfaces it shares with `packages/app` (`src/wsl/{types,settings-model,settings-model.test,settings,dialog-add-server}.ts(x)`, `src/i18n/{en,es}.ts`, `src/pages/layout/{deep-links,helpers.test}.ts`, `src/app.tsx`, `src/index.ts`) and `packages/desktop/src/{preload,renderer}` | Every user-visible "OpenCode" the desktop app renders → "CoDev Code": crash dialogs, window ``, updater/CLI-installed i18n (en+es, with the CLI-command hint `'opencode'`→`'codev'`), the unpackaged app-name fallback `"OpenCode Dev"`, the AppStream metainfo (product name, description, `developer` → `CoDev ` with the upstream `id="ly.anoma"` dropped, the three URLs repointed to the fork repo, upstream's lander screenshot removed), the OS deep-link handler display name, README, and `package.json` (`homepage` → the fork repo, `author` → `CoDev `; both were briefly deleted outright, which broke `deb`/`rpm` packaging — electron-builder derives the Debian `Maintainer` field from `author.email` and refuses to build fpm targets without `homepage`, so the two fields have to carry fork values rather than be absent. `AppImage`/`nsis`/`dmg` do not use fpm and never noticed. Values match the AppStream metainfo `scripts/copy-metainfo.ts` already emits: `CoDev ` and ``). Codebase identifiers renamed `Opencode`→`Codev`/`opencode`→`codev` (`WslOpencodeCheck`, `resolveWslOpencode`, `installWslOpencode`, `expectOpencodeVersion`, `releaseOpencodeResolve`, `checkOpencode`/`refreshOpencodeCheck*`/`setOpencodeCheck`, `opencodeChecks`, `wslOpencodeAction`, …) together with the internal wire values whose producer and consumer both ship here (`virtual:opencode-server`, the `opencode:{node-pty,virtual-server,copy-server}` vite plugin names, the `wsl-servers-install-opencode` IPC channel, the `install-opencode` job kind, the `__OPENCODE__` deep-link bridge global, the `opencode:deep-link` renderer event, the `opencode-onboarding-` temp dir, the `wsl.onboarding.installOpencode*` i18n keys), and the server-log collector path `/opencode/log` → `/codev/log` (a real bug fix — the fork CLI writes to `/codev/log`, `packages/core/src/global.ts` `app = "codev"`, so debug-log export was silently collecting nothing). Two stale product-named locals renamed to what they hold (`opencode`→`binary` in wsl/sidecar.ts, `opencode`→`checked` in the servers test). | The fork ships the desktop app, so every surface it shows and every identifier a maintainer reads should say codev. | Keep our strings + identifiers; take upstream's surrounding changes. PRESERVED deliberately, do not rename: `@opencode-ai/*` package ids, the `ai.opencode.desktop*` appIds and `opencode://` scheme + `schemes:["opencode"]` (identity plumbing), the `packageName:"opencode"` Linux package names and `opencode-desktop.desktop` legacy launcher, the persisted on-disk store names (`opencode.settings`, `opencode.{draft,workspace,global,window,updater}.*`, the `opencode` data dir in install-state), the basic-auth username `"opencode"` (must match the server default in `packages/server/src/auth.ts`), and the `../opencode/dist` build paths. |
| `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev Code" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. |
| Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. |
| `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. |
-| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` targets repointed to the fork: beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` both → `quickbeard/codev-code` (upstream has no fork-side beta repo, so the two channels share one). | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and publish targets; take upstream's other config. Two known follow-ups, both moot while the fork ships no desktop build: beta and prod now write the same `latest.yml` in one repo (splitting them needs `publish.channel` plus the hardcoded `autoUpdater.channel = "latest"` in `src/main/updater.ts`), and `codev-code` is private, which electron-updater cannot read without a token. |
+| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. |
+| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
+| `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. |
+| `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. |
| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ` `. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. |
| `packages/ui/src/assets/favicon/` (favicon-v3.svg, favicon-v3.ico, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: the blocky "C" in brand red `#ee0033` on a white tile, generated from the same geometry as the app icon by `packages/desktop/scripts/generate-icons.ts` (4x5 grid, one-unit stroke, unit = 120/1024, corner radius 229/1024); `favicon-v3.svg` is the hand-written 512-unit source of that geometry. The tab and `.ico` icons keep the rounded tile so they read as an app icon; `apple-touch-icon-v3.png` and the two maskable `web-app-manifest-*` icons are square and full-bleed, because iOS and PWA launchers apply their own mask and a pre-rounded icon leaves transparent corners that render black. Manifest name "CoDev Code"/"codev"; its `theme_color`/`background_color` stay `#080808` — those describe the PWA's chrome and splash, not the icon. **Was `#00cdcd` on a dark tile** until this row was rewritten: PR #10 pinned the favicon to the then-cyan TUI banner accent, that banner later moved to the navy/red wordmark, and the favicon was left behind as the only cyan in the tree — visibly so on desktop, where the dock icon was red while the window and notification icons were cyan. | Every surface that draws the mark should draw the same mark: the dock icon, the tab, notifications, the `codev --mini` badge and the landing page icon are now one geometry in one red. Cyan on `#131010` was the higher-contrast tile at 16px (~9.4:1 versus red's ~4.2:1) — a real cost, accepted so the brand reads consistently. | Keep our assets (binary: take ours wholesale). Regenerate with the script rather than hand-editing, and edit `icons/icon.svg` + `favicon-v3.svg` together if the geometry ever changes. |
| `AGENTS.md` | Default-branch note: `dev` → `main` (single merged line; upstream has two lines saying local `main` may not exist); added fork bullets requiring every upstream-file change to get a `DIVERGENCES.md` row (and matching en + es doc updates) in the same PR, and pointing agents at this file's Divergence policy (prefer config/plugins, then `packages/codev-*`, upstream edits last) and Syncing with upstream sections | The fork's default branch is `main` (see the workflow-triggers row); agents kept basing diffs on a nonexistent `dev`. Agents edit upstream files without recording the divergence — and don't apply the placement policy or sync runbook — unless the instructions file says to. | Keep our lines; take upstream's other guidance. |
diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx
index 016ff086c9..3953701d76 100644
--- a/packages/app/src/components/windows-app-menu.tsx
+++ b/packages/app/src/components/windows-app-menu.tsx
@@ -6,7 +6,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command"
-import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
+import {
+ DESKTOP_MENU,
+ desktopMenuItemDisabled,
+ desktopMenuVisible,
+ type DesktopMenuAction,
+ type DesktopMenuEntry,
+} from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
@@ -89,7 +95,10 @@ export function WindowsAppMenu(props: {
runEntry(entry)}
/>
),
diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts
index c7e36a7cb7..5b11bdef40 100644
--- a/packages/app/src/desktop-menu.test.ts
+++ b/packages/app/src/desktop-menu.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { DESKTOP_MENU } from "./desktop-menu"
+import { DESKTOP_MENU, desktopMenuItemDisabled, desktopMenuVisible, type DesktopMenuItem } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
@@ -10,4 +10,61 @@ describe("desktop menu", () => {
expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
})
+
+ test("offers an update check on every platform exactly once", () => {
+ for (const platform of ["macos", "windows"] as const) {
+ const items = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, platform)).flatMap((menu) =>
+ (menu.items ?? [])
+ .filter((item) => desktopMenuVisible(item, platform))
+ .filter((item) => item.type === "item" && item.action === "app.checkForUpdates"),
+ )
+
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({ enabled: "updater" })
+ }
+ })
+
+ describe("item disabled state", () => {
+ const updateCheck = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).find(
+ (item): item is DesktopMenuItem => item.type === "item" && item.action === "app.checkForUpdates",
+ )!
+ const never = () => false
+
+ test("greys out the update check while the updater reports itself disabled", () => {
+ expect(desktopMenuItemDisabled(updateCheck, { updaterStatus: "disabled", commandDisabled: never })).toBe(true)
+ })
+
+ test("greys out the update check when there is no updater at all", () => {
+ expect(desktopMenuItemDisabled(updateCheck, { commandDisabled: never })).toBe(true)
+ })
+
+ for (const status of ["idle", "checking", "downloading", "ready", "up-to-date", "installing", "error"] as const) {
+ test(`enables the update check while the updater is ${status}`, () => {
+ expect(desktopMenuItemDisabled(updateCheck, { updaterStatus: status, commandDisabled: never })).toBe(false)
+ })
+ }
+
+ test("defers to the command registry for command entries", () => {
+ const entry = { type: "item", label: "Export Logs...", command: "logs.export" } as const
+
+ expect(desktopMenuItemDisabled(entry, { commandDisabled: (id) => id === "logs.export" })).toBe(true)
+ expect(desktopMenuItemDisabled(entry, { commandDisabled: never })).toBe(false)
+ })
+
+ test("leaves link and role entries alone", () => {
+ expect(
+ desktopMenuItemDisabled({ type: "item", label: "Report a Bug", href: "https://example.test" }, {
+ commandDisabled: never,
+ }),
+ ).toBe(false)
+ expect(desktopMenuItemDisabled({ type: "item", role: "quit" }, { commandDisabled: never })).toBe(false)
+ })
+ })
+
+ test("leaves the macOS help menu without a trailing separator", () => {
+ const help = DESKTOP_MENU.find((menu) => menu.id === "help")!
+ const visible = help.items!.filter((item) => desktopMenuVisible(item, "macos"))
+
+ expect(visible.at(-1)?.type).toBe("item")
+ })
})
diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts
index 58e2ac6f63..4eeb47d04d 100644
--- a/packages/app/src/desktop-menu.ts
+++ b/packages/app/src/desktop-menu.ts
@@ -1,4 +1,5 @@
import { DOCS_URL } from "@codev/brand"
+import type { UpdaterState } from "./updater"
export type DesktopMenuPlatform = "macos" | "windows"
@@ -215,6 +216,17 @@ export const DESKTOP_MENU: DesktopMenu[] = [
label: "Report a Bug",
href: "https://github.com/quickbeard/codev-code/issues/new?template=bug-report.yml",
},
+ // macOS has this in the app menu, which the Windows/Linux menu does not
+ // render. Both the item and its separator are windows-only so macOS does
+ // not get a duplicate entry and a trailing separator.
+ { type: "separator", platforms: ["windows"] },
+ {
+ type: "item",
+ label: "Check for Updates...",
+ action: "app.checkForUpdates",
+ enabled: "updater",
+ platforms: ["windows"],
+ },
],
},
]
@@ -222,3 +234,18 @@ export const DESKTOP_MENU: DesktopMenu[] = [
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
+
+/**
+ * Whether a menu item renders greyed out. The native macOS menu asks the main
+ * process for `UPDATER_ENABLED` directly; in the renderer the equivalent signal
+ * is the updater reporting itself disabled, which it does for unpackaged and
+ * dev-channel builds — and for a missing updater, as on the web.
+ */
+export function desktopMenuItemDisabled(
+ item: DesktopMenuItem,
+ input: { updaterStatus?: UpdaterState["status"]; commandDisabled: (id: string) => boolean },
+) {
+ if (item.enabled === "updater") return (input.updaterStatus ?? "disabled") === "disabled"
+ if (item.command) return input.commandDisabled(item.command)
+ return false
+}
diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts
index 5a57b30856..c6031a08ff 100644
--- a/packages/desktop/electron-builder.config.test.ts
+++ b/packages/desktop/electron-builder.config.test.ts
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import type { Configuration } from "electron-builder"
+import type { GenericServerOptions } from "builder-util-runtime"
+import { GenericProvider } from "electron-updater/out/providers/GenericProvider"
const legacyDesktopEntry = "resources/linux/opencode-desktop.desktop"
@@ -27,6 +29,77 @@ for (const channel of channels) {
})
}
+for (const channel of ["beta", "prod"] as const) {
+ test(`points ${channel} at the bucket update feed`, async () => {
+ const previous = process.env.CODEV_CHANNEL
+ process.env.CODEV_CHANNEL = channel
+
+ const module = await import(`./electron-builder.config.ts?publish=${channel}`)
+ const config = module.default as Configuration
+
+ if (previous === undefined) delete process.env.CODEV_CHANNEL
+ else process.env.CODEV_CHANNEL = previous
+
+ const publish = config.publish as GenericServerOptions
+ expect(publish.provider).toBe("generic")
+ expect(publish.url).toStartWith("https://")
+ // Without an explicit channel, detectUpdateChannel derives one from the
+ // version's first prerelease component ("8" for 1.18.4-8) and names the
+ // metadata 8.yml, which no client ever requests.
+ expect(publish.channel).toBe("latest")
+ // MinIO answers a multipart range request with the whole file.
+ expect(publish.useMultipleRangeRequest).toBe(false)
+ })
+}
+
+// The bucket prefix has no trailing slash, and `new URL(name, base)` would drop
+// its last segment without one — every artifact would resolve a directory too
+// high and 404. electron-updater normalises that in newBaseUrl, which is
+// internal, so drive the real provider instead of restating the rule.
+test("resolves artifacts against the bucket prefix, not its parent", async () => {
+ const previous = process.env.CODEV_CHANNEL
+ process.env.CODEV_CHANNEL = "prod"
+
+ const module = await import("./electron-builder.config.ts?feed=prod")
+ const config = module.default as Configuration
+
+ if (previous === undefined) delete process.env.CODEV_CHANNEL
+ else process.env.CODEV_CHANNEL = previous
+
+ // resolveFiles touches neither the updater nor the executor; it only reads the
+ // base URL the constructor derived from our publish config.
+ const provider = new GenericProvider(config.publish as GenericServerOptions, undefined as never, {
+ platform: "darwin",
+ isUseMultipleRangeRequest: false,
+ executor: undefined as never,
+ })
+
+ const resolved = provider.resolveFiles({
+ version: "1.18.4-9",
+ files: [{ url: "codev-code-desktop-mac-universal.zip", sha512: "x", size: 1 }],
+ path: "codev-code-desktop-mac-universal.zip",
+ sha512: "x",
+ releaseDate: "2026-08-11T00:00:00.000Z",
+ })
+
+ expect(resolved[0]?.url.href).toBe(
+ "https://netmind.viettel.vn/codev-storage/codev-code/codev-code-desktop-mac-universal.zip",
+ )
+})
+
+test("leaves dev builds without an update feed", async () => {
+ const previous = process.env.CODEV_CHANNEL
+ process.env.CODEV_CHANNEL = "dev"
+
+ const module = await import("./electron-builder.config.ts?publish=dev")
+ const config = module.default as Configuration
+
+ if (previous === undefined) delete process.env.CODEV_CHANNEL
+ else process.env.CODEV_CHANNEL = previous
+
+ expect(config.publish).toBeUndefined()
+})
+
test("keeps a hidden prod launcher for old Linux pins", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "prod"
diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts
index e54f19c91f..3d621264ba 100644
--- a/packages/desktop/electron-builder.config.ts
+++ b/packages/desktop/electron-builder.config.ts
@@ -38,6 +38,29 @@ const APP_IDS = {
prod: "ai.opencode.desktop",
} as const
+// Base64 (like packages/codev-brand/src/index.ts) to keep the host out of
+// casual greps, not as a secret — the decoded URL ships in every packaged
+// build's app-update.yml.
+const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldi1jb2Rl")
+
+// electron-builder bakes this into app-update.yml; the installers are uploaded
+// to the bucket by hand, so `generic` (which has no publisher) is the whole
+// story on the build side.
+const publish = {
+ provider: "generic",
+ url: process.env.CODEV_UPDATE_URL ?? UPDATE_URL,
+ // Required. Without it `detectUpdateChannel` derives the channel from the
+ // first semver prerelease component of the app version — "8" for 1.18.4-8 —
+ // and electron-builder writes 8.yml/8-mac.yml/8-linux.yml while the app asks
+ // for latest.yml (autoUpdater.channel in src/main/updater.ts).
+ channel: "latest",
+ // MinIO does not serve multipart range requests, which breaks differential
+ // downloads. electron-updater hardcodes the same false for its s3 provider
+ // (electron-updater/out/providerFactory.js) citing minio/minio#5285, but
+ // defaults it to true for generic URLs.
+ useMultipleRangeRequest: false,
+} as const
+
const getBase = (appId: string): Configuration => ({
artifactName: "codev-code-desktop-${os}-${arch}.${ext}",
directories: {
@@ -135,7 +158,11 @@ function getConfig() {
appId,
productName: "CoDev Code Beta",
protocols: { name: "CoDev Code Beta", schemes: ["opencode"] },
- publish: { provider: "github", owner: "quickbeard", repo: "codev-code", channel: "latest" },
+ // The fork ships no beta installers, but UPDATER_ENABLED covers this
+ // channel, and a build with no publish config gets no app-update.yml at
+ // all — every check would fail with ENOENT. Sharing prod's feed keeps
+ // the unused channel functional rather than broken.
+ publish,
rpm: { packageName: "opencode-beta" },
}
}
@@ -145,7 +172,7 @@ function getConfig() {
appId,
productName: "CoDev Code",
protocols: { name: "CoDev Code", schemes: ["opencode"] },
- publish: { provider: "github", owner: "quickbeard", repo: "codev-code", channel: "latest" },
+ publish,
deb: { fpm: [legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [legacyDesktopEntryFpm] },
}
diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts
deleted file mode 100644
index 5c4af23ed6..0000000000
--- a/packages/desktop/scripts/finalize-latest-json.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-#!/usr/bin/env bun
-
-import { $ } from "bun"
-import path from "node:path"
-import { parseArgs } from "node:util"
-
-const { values } = parseArgs({
- args: Bun.argv.slice(2),
- options: {
- "dry-run": { type: "boolean", default: false },
- },
-})
-
-const dryRun = values["dry-run"]
-
-const repo = process.env.GH_REPO
-if (!repo) throw new Error("GH_REPO is required")
-
-const releaseId = process.env.CODEV_RELEASE
-if (!releaseId) throw new Error("CODEV_RELEASE is required")
-
-const version = process.env.CODEV_VERSION
-if (!version) throw new Error("CODEV_VERSION is required")
-
-const dir = process.env.LATEST_YML_DIR
-if (!dir) throw new Error("LATEST_YML_DIR is required")
-const root = dir
-
-const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
-if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
-
-const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
- headers: {
- Authorization: `token ${token}`,
- Accept: "application/vnd.github+json",
- },
-})
-
-if (!rel.ok) {
- throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
-}
-
-type Asset = {
- name: string
- url: string
-}
-
-type Release = {
- assets?: Asset[]
-}
-
-const assets = ((await rel.json()) as Release).assets ?? []
-const amap = new Map(assets.map((item) => [item.name, item]))
-
-type Item = {
- url: string
-}
-
-type Yml = {
- version: string
- files: Item[]
-}
-
-function parse(text: string): Yml {
- const lines = text.split("\n")
- let version = ""
- const files: Item[] = []
- let url = ""
-
- const flush = () => {
- if (!url) return
- files.push({ url })
- url = ""
- }
-
- for (const line of lines) {
- const trim = line.trim()
- if (line.startsWith("version:")) {
- version = line.slice("version:".length).trim()
- continue
- }
- if (trim.startsWith("- url:")) {
- flush()
- url = trim.slice("- url:".length).trim()
- continue
- }
- const indented = line.startsWith(" ") || line.startsWith("\t")
- if (!indented) flush()
- }
- flush()
-
- return { version, files }
-}
-
-async function read(sub: string, file: string) {
- const item = Bun.file(path.join(root, sub, file))
- if (!(await item.exists())) return undefined
- return parse(await item.text())
-}
-
-function pick(list: Item[], exts: string[]) {
- for (const ext of exts) {
- const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
- if (found) return found.url
- }
-}
-
-function link(raw: string) {
- if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
- return `https://github.com/${repo}/releases/download/v${version}/${raw}`
-}
-
-async function sign(url: string, key: string) {
- const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
- const asset = amap.get(name)
- const res = await fetch(asset?.url ?? url, {
- headers: {
- Authorization: `token ${token}`,
- ...(asset ? { Accept: "application/octet-stream" } : {}),
- },
- })
- if (!res.ok) {
- throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
- }
-
- const tmp = process.env.RUNNER_TEMP ?? "/tmp"
- const file = path.join(tmp, name)
- await Bun.write(file, await res.arrayBuffer())
- await $`bunx @tauri-apps/cli signer sign ${file}`
- const sigFile = Bun.file(`${file}.sig`)
- if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
- return (await sigFile.text()).trim()
-}
-
-const add = async (data: Record, key: string, raw: string | undefined) => {
- if (!raw) return
- if (data[key]) return
- const url = link(raw)
- data[key] = { url, signature: await sign(url, key) }
-}
-
-const alias = (data: Record, key: string, src: string) => {
- if (data[key]) return
- if (!data[src]) return
- data[key] = data[src]
-}
-
-const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
-const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
-const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
-const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
-const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
-const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
-
-const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
-if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
-
-const out: Record = {}
-
-const winxexe = pick(winx?.files ?? [], [".exe"])
-const winaexe = pick(wina?.files ?? [], [".exe"])
-
-const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
-const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
-
-const linxDeb = pick(linx?.files ?? [], [".deb"])
-const linxRpm = pick(linx?.files ?? [], [".rpm"])
-const linxAppImage = pick(linx?.files ?? [], [".appimage"])
-const linaDeb = pick(lina?.files ?? [], [".deb"])
-const linaRpm = pick(lina?.files ?? [], [".rpm"])
-const linaAppImage = pick(lina?.files ?? [], [".appimage"])
-
-await add(out, "windows-x86_64-nsis", winxexe)
-await add(out, "windows-aarch64-nsis", winaexe)
-await add(out, "darwin-x86_64-app", macxTarGz)
-await add(out, "darwin-aarch64-app", macaTarGz)
-
-await add(out, "linux-x86_64-deb", linxDeb)
-await add(out, "linux-x86_64-rpm", linxRpm)
-await add(out, "linux-x86_64-appimage", linxAppImage)
-await add(out, "linux-aarch64-deb", linaDeb)
-await add(out, "linux-aarch64-rpm", linaRpm)
-await add(out, "linux-aarch64-appimage", linaAppImage)
-
-alias(out, "windows-x86_64", "windows-x86_64-nsis")
-alias(out, "windows-aarch64", "windows-aarch64-nsis")
-alias(out, "darwin-x86_64", "darwin-x86_64-app")
-alias(out, "darwin-aarch64", "darwin-aarch64-app")
-alias(out, "linux-x86_64", "linux-x86_64-deb")
-alias(out, "linux-aarch64", "linux-aarch64-deb")
-
-const platforms = Object.fromEntries(
- Object.keys(out)
- .sort()
- .map((key) => [key, out[key]]),
-)
-
-if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
-
-const data = {
- version,
- notes: "",
- pub_date: new Date().toISOString(),
- platforms,
-}
-
-const tmp = process.env.RUNNER_TEMP ?? "/tmp"
-const file = path.join(tmp, "latest.json")
-await Bun.write(file, JSON.stringify(data, null, 2))
-
-const tag = `v${version}`
-
-if (dryRun) {
- console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)
- process.exit(0)
-}
-await $`gh release upload ${tag} ${file} --clobber --repo ${repo}`
-
-console.log(`finalized latest.json for ${tag}`)
diff --git a/packages/desktop/scripts/finalize-latest-yml.ts b/packages/desktop/scripts/finalize-latest-yml.ts
deleted file mode 100644
index 8f13bf29e4..0000000000
--- a/packages/desktop/scripts/finalize-latest-yml.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env bun
-
-import { $ } from "bun"
-import path from "path"
-
-const dir = process.env.LATEST_YML_DIR!
-if (!dir) throw new Error("LATEST_YML_DIR is required")
-
-const repo = process.env.GH_REPO
-if (!repo) throw new Error("GH_REPO is required")
-
-const version = process.env.CODEV_VERSION
-if (!version) throw new Error("CODEV_VERSION is required")
-
-type FileEntry = {
- url: string
- sha512: string
- size: number
- blockMapSize?: number
-}
-
-type LatestYml = {
- version: string
- files: FileEntry[]
- releaseDate: string
-}
-
-function parse(content: string): LatestYml {
- const lines = content.split("\n")
- let version = ""
- let releaseDate = ""
- const files: FileEntry[] = []
- let current: Partial | undefined
-
- const flush = () => {
- if (current?.url && current.sha512 && current.size) files.push(current as FileEntry)
- current = undefined
- }
-
- for (const line of lines) {
- const indented = line.startsWith(" ") || line.startsWith(" -")
- if (line.startsWith("version:")) version = line.slice("version:".length).trim()
- else if (line.startsWith("releaseDate:"))
- releaseDate = line.slice("releaseDate:".length).trim().replace(/^'|'$/g, "")
- else if (line.trim().startsWith("- url:")) {
- flush()
- current = { url: line.trim().slice("- url:".length).trim() }
- } else if (indented && current && line.trim().startsWith("sha512:"))
- current.sha512 = line.trim().slice("sha512:".length).trim()
- else if (indented && current && line.trim().startsWith("size:"))
- current.size = Number(line.trim().slice("size:".length).trim())
- else if (indented && current && line.trim().startsWith("blockMapSize:"))
- current.blockMapSize = Number(line.trim().slice("blockMapSize:".length).trim())
- else if (!indented && current) flush()
- }
- flush()
-
- return { version, files, releaseDate }
-}
-
-function serialize(data: LatestYml) {
- const lines = [`version: ${data.version}`, "files:"]
- for (const file of data.files) {
- lines.push(` - url: ${file.url}`)
- lines.push(` sha512: ${file.sha512}`)
- lines.push(` size: ${file.size}`)
- if (file.blockMapSize) lines.push(` blockMapSize: ${file.blockMapSize}`)
- }
- lines.push(`releaseDate: '${data.releaseDate}'`)
- return lines.join("\n") + "\n"
-}
-
-async function read(subdir: string, filename: string): Promise {
- const file = Bun.file(path.join(dir, subdir, filename))
- if (!(await file.exists())) return undefined
- return parse(await file.text())
-}
-
-const output: Record = {}
-
-// Windows: merge arm64 + x64 into single file
-const winX64 = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
-const winArm64 = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
-if (winX64 || winArm64) {
- const base = winArm64 ?? winX64!
- output["latest.yml"] = serialize({
- version: base.version,
- files: [...(winArm64?.files ?? []), ...(winX64?.files ?? [])],
- releaseDate: base.releaseDate,
- })
-}
-
-// Linux x64: pass through
-const linuxX64 = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
-if (linuxX64) output["latest-linux.yml"] = serialize(linuxX64)
-
-// Linux arm64: pass through
-const linuxArm64 = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
-if (linuxArm64) output["latest-linux-arm64.yml"] = serialize(linuxArm64)
-
-// macOS: merge arm64 + x64 into single file
-const macX64 = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
-const macArm64 = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
-if (macX64 || macArm64) {
- const base = macArm64 ?? macX64!
- output["latest-mac.yml"] = serialize({
- version: base.version,
- files: [...(macArm64?.files ?? []), ...(macX64?.files ?? [])],
- releaseDate: base.releaseDate,
- })
-}
-
-// Upload to release
-const tag = `v${version}`
-const tmp = process.env.RUNNER_TEMP ?? "/tmp"
-
-for (const [filename, content] of Object.entries(output)) {
- const filepath = path.join(tmp, filename)
- await Bun.write(filepath, content)
- await $`gh release upload ${tag} ${filepath} --clobber --repo ${repo}`
- console.log(`uploaded ${filename}`)
-}
-
-console.log("finalized latest yml files")
diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts
index cd483ed72f..2d87f13348 100644
--- a/packages/desktop/src/main/updater.ts
+++ b/packages/desktop/src/main/updater.ts
@@ -14,7 +14,9 @@ export function setupAutoUpdater(stop: () => Promise) {
autoUpdater.logger = logger
autoUpdater.channel = "latest"
autoUpdater.allowPrerelease = false
- autoUpdater.allowDowngrade = true
+ // The feed is a mutable bucket rather than an immutable GitHub release, so a
+ // stale or re-uploaded latest.yml would otherwise downgrade every client.
+ autoUpdater.allowDowngrade = false
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
logger.log("auto updater configured", {
diff --git a/script/publish.ts b/script/publish.ts
index 3dc0dee94e..5c453c36ca 100755
--- a/script/publish.ts
+++ b/script/publish.ts
@@ -50,10 +50,9 @@ await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
-if (Script.release) {
- await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
- await $`bun ./packages/desktop/scripts/finalize-latest-yml.ts`
-}
+// The desktop app updates from a MinIO bucket, not GitHub releases: electron-builder
+// writes latest*.yml next to each installer and those are uploaded to the bucket by
+// hand, so there is no release-time finalize step here.
if (Script.release && !Script.preview) {
await $`git commit -am "release: ${tag}"`