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 = ({ onDismis channel: channelLabel })}

+ {/* Pre-rename install (#985): pulling the retired registry path means + `docker compose pull` succeeds against a frozen tag and the update + never actually arrives. These builds predate MigrationBanner, so + this is the only place the instruction can reach them. */} + {updateInfo.registryMigrationRequired && ( +
+

+ {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')} + +

+
+ )} {Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && (