App update installs a different version than the one it offers, when two app stores carry the same app ID
What happened
An app installed from a community app store was also present, at a higher version, in a second community app store I had added. The dashboard offered the higher version and rendered its release notes. Clicking Update installed a different, lower version — the one from the other store — and then immediately went back to offering the higher version again.
The update badge never clears. You can click Update as many times as you like; it re-installs a version you already have and re-offers one it will not fetch.
| Version offered in App Store → Updates | 0.1.20-rc1 (with 0.1.20-rc1's release notes) |
| Version actually installed by the click | 0.1.19 |
| Badge after the update | still offering 0.1.20-rc1 |
Confirmed on the box after the click:
$ docker inspect <app>_server_1 --format '{{.Config.Image}}'
ghcr.io/<owner>/<app>:0.1.19@sha256:4bc8620e...
$ grep '^version' ~/umbrel/app-data/<app>/umbrel-app.yml
version: "0.1.19"
$ grep -H '^version' ~/umbrel/app-stores/*/<app>/umbrel-app.yml
.../<store-A>/<app>/umbrel-app.yml:version: "0.1.19" # listed first in appRepositories
.../<store-B>/<app>/umbrel-app.yml:version: "0.1.20-rc1" # listed second in appRepositoriesNo error was shown at any point. The update reported success, and it did do a real update (0.1.19-rc1 → 0.1.19) — just not the one advertised.
Cause
The install side resolves an app to the first repository in appRepositories order that
contains the app ID, with no version comparison at all —
source/modules/apps/app-store.ts:
async getAppTemplateFilePath(appId: string) {
const registry = await this.registry()
// Find the app in the registry
for (const repo of registry) {
const app = repo.apps.find((app) => app.id === appId)
if (app) {
const repositories = await this.getRepositories()
const repoPath = repositories.find((repository) => repository.url === repo.url)!.path
if (!repoPath) throw new Error(`Repository path not found for ${repo.url}`)
return `${repoPath}/${appId}`
}
}
throw new Error(`App with ID ${appId} not found in any repository`)
}registry() preserves appRepositories order, so with store A ahead of store B in that list,
every install and update of that app ID comes from store A regardless of what store B holds.
The offer side evidently does not use the same resolution — it surfaced store B's version and store B's release notes. I read the install path in the shipped TypeScript source on the box and confirmed it there; I did not chase the offer-side selection through the minified UI bundle, so I can only report that half empirically: the version offered was the one this function does not return.
That mismatch is the bug, whichever way it is resolved.
Impact
- The update button never converges. A permanently stuck badge, and repeated no-op updates.
- The release notes belong to a version you do not receive. This is the part that worries me. An operator reads notes describing fixes — including, in the general case, security fixes — clicks Update, is told it succeeded, and is left on a build without them. Nothing anywhere says a different version was installed.
- It is silent. No warning, no error, no log line, and
apps.state.queryreturnsready. The only way to notice is to compare the installed manifest or the container image digest against what the dashboard offered.
Expected
Either behaviour would be defensible, but the two halves must agree:
- Offer what will be installed — compute the offered version with the same resolution
getAppTemplateFilePathuses, so an app resolving to store A only ever offers store A's version; or - Install what was offered — resolve to the store whose version is being advertised.
If the first-match rule is intended, it would also help to surface it: something in the UI saying which store an app resolves to, and a warning when a registered store carries an app ID that another store already claims. Two stores silently claiming the same ID, with order deciding the winner and nothing displaying that order, is difficult to diagnose from the dashboard alone.
Workaround (verified)
Remove the shadowing store so that only one registered store carries the app ID, update, then
add the other store back. Confirmed to work: with the second store removed, the update
button offered and installed 0.1.20-rc1, with the container image digests matching the
intended build exactly.
Removing an app store does not disturb anything already installed from it. I can state this
from the accidental case below: the app stayed running, its containers were untouched,
apps.state.query kept returning ready, and its app-data was unaffected — for a period
during which no registered store carried its app ID at all. Installs appear fully decoupled
from the store they came from. That makes the workaround cheap, but it is still a workaround:
it requires knowing about a resolution rule the UI never shows.
Related, met while applying the workaround
The UI gives no feedback nor confirmation when deleting a community app store. I accidentally deleted all added stores.
Re-adding does not require the dashboard, which helps when the dashboard is the thing misbehaving:
umbreld client appStore.addRepository.mutate --url "<store url>"Adding a repository also triggers an immediate clone — the store showed its version in ~10–15 s, against roughly 270 s for the periodic re-sync to notice a push. Useful to know independently of this bug.
Environment
- umbrelOS 1.7.4, Raspberry Pi 4 Model B,
aarch64 - Three app repositories registered: the official store plus two community stores
- The app was installed from one community store; the other carried a higher version of the same app ID
How I found it
Field-testing a release candidate of BrollyZapper, an app I publish myself, served from a second community store so the candidate never reaches the public one. The candidate was offered, and the public store's older release was installed instead.
The reason it was caught at all is that the release procedure verifies image digests rather
than version strings. A version number is not sufficient evidence here: the dashboard, the tile
and apps.list.query will all report a version that came from a store you were not deploying
from, and everything looks successful. An operator checking the version alone would conclude the
update had worked.
Source: getumbrel/umbrel