From 137a42f259692999fe88b75bbe6652d34893ef11 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:36:50 +0200
Subject: [PATCH] feat(admin): surface the registry move through the update
check (#993)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Relates to #985 — does NOT close it.
Adds registryMigrationRequired to the update-check payload (stable channel below
3.45.0) and an amber block in UpdateNotification explaining that the retired
registry path still responds, so `docker compose pull` appears to succeed while
serving the same frozen build.
Known limitation, established in review and merged deliberately: this cannot
reach the operators #985 describes. PicPeak is self-hosted, so the update-check
code runs inside the operator's own image — a v3.44.0 install runs v3.44.0's
backend forever, and the only external call returns release metadata, not logic.
Every build containing this predicate is >= 3.45.0, where it is false by
definition. The release-notes fallback fails too: the changelog modal shipped
2026-05-29, two days after the freeze.
Correct for any future rename, no runtime cost, but #985 stays open — the
population it describes still has no in-app channel. Viable routes are external
(retired GHCR package description, repo README, docs).
'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
---
.../updateCheckRegistryMigration.test.js | 52 +++++++++++++++++++
backend/src/services/updateCheckService.js | 34 ++++++++++++
.../components/admin/UpdateNotification.tsx | 29 +++++++++++
3 files changed, 115 insertions(+)
create mode 100644 backend/__tests__/services/updateCheckRegistryMigration.test.js
diff --git a/backend/__tests__/services/updateCheckRegistryMigration.test.js b/backend/__tests__/services/updateCheckRegistryMigration.test.js
new file mode 100644
index 00000000..6c55f961
--- /dev/null
+++ b/backend/__tests__/services/updateCheckRegistryMigration.test.js
@@ -0,0 +1,52 @@
+/**
+ * Pre-rename detection for the registry-move notice (#985).
+ *
+ * The in-app MigrationBanner shipped 2026-06-29, a month AFTER
+ * ghcr.io/the-luap/picpeak/* stopped receiving images on 2026-05-27. Anyone
+ * still pulling the retired path is therefore running a build that predates the
+ * banner and can never render it — the structural gap that keeps producing
+ * reports like #982. The update check is the one channel that still reaches
+ * them, so it carries the notice instead.
+ *
+ * The boundary is exact rather than heuristic: v3.44.0 shipped on the freeze
+ * date and v3.45.0 followed on 2026-07-09 to the org registry only, with
+ * nothing published in between.
+ */
+
+const { isPreRenameStable, REGISTRY_RENAME_STABLE_FLOOR } = require('../../src/services/updateCheckService');
+
+describe('isPreRenameStable (#985)', () => {
+ it('pins the floor to the first org-registry-only stable release', () => {
+ expect(REGISTRY_RENAME_STABLE_FLOOR).toBe('3.45.0');
+ });
+
+ it('flags stable installs below the floor', () => {
+ // v3.44.0 is the last stable that reached the retired path.
+ expect(isPreRenameStable('3.44.0', 'stable')).toBe(true);
+ expect(isPreRenameStable('3.43.1', 'stable')).toBe(true);
+ expect(isPreRenameStable('2.6.5', 'stable')).toBe(true);
+ });
+
+ it('leaves stable installs at or above the floor alone', () => {
+ expect(isPreRenameStable('3.45.0', 'stable')).toBe(false);
+ expect(isPreRenameStable('3.45.13', 'stable')).toBe(false);
+ expect(isPreRenameStable('3.99.0', 'stable')).toBe(false);
+ });
+
+ it('never fires on the beta channel', () => {
+ // The beta boundary is inferred, not clean — 3.59.0-beta.0 landed two days
+ // after the freeze. A false positive would tell a correctly-configured
+ // operator their registry is retired, so beta is deliberately excluded even
+ // where the number looks old.
+ expect(isPreRenameStable('3.44.0-beta.0', 'beta')).toBe(false);
+ expect(isPreRenameStable('3.58.0-beta.0', 'beta')).toBe(false);
+ expect(isPreRenameStable('3.99.0-beta.0', 'beta')).toBe(false);
+ });
+
+ it('does not fire on an unresolvable version', () => {
+ // getCurrentVersion() falls back to '0.0.0' when package.json is
+ // unreadable. That is a broken install, not a pre-rename one — claiming its
+ // registry is retired would send the operator down the wrong path.
+ expect(isPreRenameStable('0.0.0', 'stable')).toBe(false);
+ });
+});
diff --git a/backend/src/services/updateCheckService.js b/backend/src/services/updateCheckService.js
index 4fc7936f..240a06a6 100644
--- a/backend/src/services/updateCheckService.js
+++ b/backend/src/services/updateCheckService.js
@@ -151,6 +151,37 @@ async function fetchAvailableVersions() {
/**
* Check for available updates
*/
+// First stable release published to the org registry only. v3.44.0 shipped
+// 2026-05-27, the same day ghcr.io/the-luap/picpeak/* stopped receiving images;
+// v3.45.0 followed on 2026-07-09 on ghcr.io/picpeak/picpeak/* alone. Nothing
+// was published in between, so "stable below this" is an exact detector for an
+// install still pulling the retired path — not a heuristic.
+const REGISTRY_RENAME_STABLE_FLOOR = '3.45.0';
+
+/**
+ * Is this install running a pre-rename image, i.e. still pulling from the
+ * retired `ghcr.io/the-luap/picpeak/*` path? (#985)
+ *
+ * The in-app MigrationBanner cannot reach these operators: it shipped
+ * 2026-06-29, a month after the old registry froze, so their build predates the
+ * banner itself. The update check is the one channel that does reach them —
+ * their instance is demonstrably still talking to GitHub, which is how they see
+ * "update available" at all.
+ *
+ * Stable channel only, deliberately. The beta boundary is inferred rather than
+ * clean (3.59.0-beta.0 landed two days after the freeze), and a false positive
+ * here tells a correctly-configured operator their registry is retired.
+ */
+function isPreRenameStable(currentVersion, channel) {
+ if (channel !== 'stable') return false;
+ // getCurrentVersion() falls back to '0.0.0' when package.json is unreadable.
+ // That is a broken install, not a pre-rename one — it sorts below the floor,
+ // so guard it explicitly rather than sending that operator to change their
+ // image path.
+ if (!currentVersion || currentVersion === '0.0.0') return false;
+ return compareVersions(currentVersion, REGISTRY_RENAME_STABLE_FLOOR) < 0;
+}
+
async function checkForUpdates(forceRefresh = false) {
const now = Date.now();
@@ -197,6 +228,7 @@ async function checkForUpdates(forceRefresh = false) {
},
updateAvailable,
newerBetaAvailable,
+ registryMigrationRequired: isPreRenameStable(currentVersion, currentChannel),
lastChecked: new Date().toISOString()
};
@@ -240,5 +272,7 @@ module.exports = {
getReleasesSince,
compareVersions,
parseVersion,
+ isPreRenameStable,
+ REGISTRY_RENAME_STABLE_FLOOR,
clearCache
};
diff --git a/frontend/src/components/admin/UpdateNotification.tsx b/frontend/src/components/admin/UpdateNotification.tsx
index 248c3eb5..eef38d28 100644
--- a/frontend/src/components/admin/UpdateNotification.tsx
+++ b/frontend/src/components/admin/UpdateNotification.tsx
@@ -16,6 +16,10 @@ interface UpdateInfo {
};
updateAvailable: boolean;
newerBetaAvailable?: boolean;
+ /** Running a pre-rename stable build, i.e. still pulling the retired
+ * ghcr.io/the-luap/picpeak/* path (#985). Such installs predate
+ * MigrationBanner and can never render it, so the notice rides here. */
+ registryMigrationRequired?: boolean;
lastChecked: string;
error?: string;
message?: string;
@@ -83,6 +87,31 @@ export const UpdateNotification: React.FC
+ {t('admin.updates.registryMoved.title', 'Pulling from the retired image registry')} +
++ {t('admin.updates.registryMoved.body', { + defaultValue: 'This update will not arrive until you change the image path in docker-compose.yml to {{newPath}}. The old path still responds, so `docker compose pull` appears to succeed while serving the same frozen build.', + newPath: 'ghcr.io/picpeak/picpeak/{backend,frontend}', + })}{' '} + + {t('admin.updates.registryMoved.link', 'See migration notes')} + +
+