feat(admin): surface the registry move through the update check (#993)
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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,37 @@ async function fetchAvailableVersions() {
|
|||||||
/**
|
/**
|
||||||
* Check for available updates
|
* 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) {
|
async function checkForUpdates(forceRefresh = false) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -197,6 +228,7 @@ async function checkForUpdates(forceRefresh = false) {
|
|||||||
},
|
},
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
newerBetaAvailable,
|
newerBetaAvailable,
|
||||||
|
registryMigrationRequired: isPreRenameStable(currentVersion, currentChannel),
|
||||||
lastChecked: new Date().toISOString()
|
lastChecked: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,5 +272,7 @@ module.exports = {
|
|||||||
getReleasesSince,
|
getReleasesSince,
|
||||||
compareVersions,
|
compareVersions,
|
||||||
parseVersion,
|
parseVersion,
|
||||||
|
isPreRenameStable,
|
||||||
|
REGISTRY_RENAME_STABLE_FLOOR,
|
||||||
clearCache
|
clearCache
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ interface UpdateInfo {
|
|||||||
};
|
};
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
newerBetaAvailable?: 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;
|
lastChecked: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -83,6 +87,31 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
channel: channelLabel
|
channel: channelLabel
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="mt-2 rounded-md bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 p-2">
|
||||||
|
<p className="text-xs font-semibold text-amber-800 dark:text-amber-200">
|
||||||
|
{t('admin.updates.registryMoved.title', 'Pulling from the retired image registry')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300 mt-0.5">
|
||||||
|
{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}',
|
||||||
|
})}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/PicPeak/picpeak/blob/main/docs/migration-to-org.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t('admin.updates.registryMoved.link', 'See migration notes')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && (
|
{Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
|
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||||
|
|||||||
Reference in New Issue
Block a user