chore: gate self-update prompts on published registry images (#1519)

GitHub Releases appear before docker-publish.yml finishes pushing images.

Probe Docker Hub and GHCR manifests before advertising a version as available.

Sanitize registry probe debug logs for CodeQL log-injection.
This commit is contained in:
Anso
2026-06-29 16:29:56 -04:00
committed by GitHub
parent 89b9341568
commit 624b586887
6 changed files with 312 additions and 42 deletions
@@ -256,7 +256,7 @@ describe('forced-recheck throttle', () => {
.set('Authorization', adminAuth);
expect(first.status).toBe(200);
expect(first.body.rechecked).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith('latest-version');
expect(invalidateSpy).toHaveBeenCalledWith('latest-version-info');
invalidateSpy.mockClear();
+53 -17
View File
@@ -16,6 +16,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockExecAsync,
mockFetchLatestSenchoVersion,
mockGetLatestVersion,
mockGetLatestVersionInfo,
mockGetSenchoVersion,
} = vi.hoisted(() => ({
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
@@ -45,6 +46,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockExecAsync: vi.fn().mockResolvedValue({ stdout: '' }),
mockFetchLatestSenchoVersion: vi.fn().mockRejectedValue(new Error('not configured')),
mockGetLatestVersion: vi.fn().mockResolvedValue(null),
mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null),
mockGetSenchoVersion: vi.fn().mockReturnValue(null),
}));
@@ -89,6 +91,7 @@ vi.mock('../services/FileSystemService', () => ({
vi.mock('../utils/version-check', () => ({
fetchLatestSenchoVersion: (...args: unknown[]) => mockFetchLatestSenchoVersion(...args),
getLatestVersion: (...args: unknown[]) => mockGetLatestVersion(...args),
getLatestVersionInfo: (...args: unknown[]) => mockGetLatestVersionInfo(...args),
}));
vi.mock('../services/CapabilityRegistry', async () => {
@@ -394,6 +397,7 @@ describe('MonitorService - host_alerts_enabled toggle', () => {
// Restore default mock implementations so version-check state does not
// leak into sibling describe blocks (F-11 suppression, version check).
mockGetLatestVersion.mockResolvedValue(null);
mockGetLatestVersionInfo.mockResolvedValue(null);
mockGetSenchoVersion.mockReturnValue(null);
});
@@ -471,13 +475,13 @@ describe('MonitorService - host_alerts_enabled toggle', () => {
it('still runs version check when disabled', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' });
expect(mockGetLatestVersion).toHaveBeenCalled();
expect(mockGetLatestVersionInfo).toHaveBeenCalled();
});
});
@@ -1082,7 +1086,7 @@ describe('MonitorService - Sencho version check', () => {
it('dispatches notification when newer version available', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null); // No previous notification
const svc = MonitorService.getInstance();
@@ -1098,7 +1102,7 @@ describe('MonitorService - Sencho version check', () => {
it('does not re-notify for the same version', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
// Running version < last notified, so self-heal does NOT clear the key.
mockGetSystemState.mockReturnValue('0.46.0');
@@ -1111,7 +1115,7 @@ describe('MonitorService - Sencho version check', () => {
it('handles version check failure gracefully', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue(null); // CacheService failed + no stale
mockGetLatestVersionInfo.mockResolvedValue(null); // CacheService failed + no stale
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
@@ -1123,7 +1127,7 @@ describe('MonitorService - Sencho version check', () => {
it('respects the 6-hour cooldown interval', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1131,14 +1135,14 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = Date.now() - 1 * 60 * 60 * 1000;
await (svc as any).evaluate();
// getLatestVersion should not have been called since we're within cooldown
expect(mockGetLatestVersion).not.toHaveBeenCalled();
// getLatestVersionInfo should not have been called since we're within cooldown
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
});
it('skips version check when getSenchoVersion returns null', async () => {
// Simulates the production-Docker scenario that previously leaked "0.0.0"
mockGetSenchoVersion.mockReturnValue(null);
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1148,14 +1152,14 @@ describe('MonitorService - Sencho version check', () => {
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything());
// Should not have even attempted the lookup.
expect(mockGetLatestVersion).not.toHaveBeenCalled();
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
});
// ── Regression coverage for PR: cooldown leak + dedup self-heal ───────
it('does NOT advance cooldown when getLatestVersion returns null (retries next cycle)', async () => {
it('does NOT advance cooldown when getLatestVersionInfo returns null (retries next cycle)', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue(null);
mockGetLatestVersionInfo.mockResolvedValue(null);
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1165,13 +1169,29 @@ describe('MonitorService - Sencho version check', () => {
await (svc as any).evaluate();
// Both evals should attempt the lookup since failures do not lock cooldown.
expect(mockGetLatestVersion).toHaveBeenCalledTimes(2);
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
expect((svc as any).lastVersionCheckAt).toBe(0);
});
it('does NOT advance cooldown while a GitHub release is pending registry publish', async () => {
mockGetSenchoVersion.mockReturnValue('0.93.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.93.0', publishPending: true });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
await (svc as any).evaluate();
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
expect((svc as any).lastVersionCheckAt).toBe(0);
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available'));
});
it('DOES advance cooldown on a successful lookup (prevents re-fetch inside window)', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1182,10 +1202,10 @@ describe('MonitorService - Sencho version check', () => {
expect(firstCooldown).toBeGreaterThan(0);
// Second eval immediately after: cooldown gate should block it.
mockGetLatestVersion.mockClear();
mockGetLatestVersionInfo.mockClear();
await (svc as any).evaluate();
expect(mockGetLatestVersion).not.toHaveBeenCalled();
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
// Exactly one dispatch across both evals.
const availabilityDispatches = mockDispatchAlert.mock.calls.filter(
(args: unknown[]) => typeof args[2] === 'string' && args[2].includes('available'),
@@ -1198,7 +1218,7 @@ describe('MonitorService - Sencho version check', () => {
// User has now upgraded to 0.46.0; a new release (0.47.0) just dropped.
const store = wireStatefulSystemState({ last_sencho_update_notified_version: '0.46.0' });
mockGetSenchoVersion.mockReturnValue('0.46.0');
mockGetLatestVersion.mockResolvedValue('0.47.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.47.0', publishPending: false });
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
@@ -1207,6 +1227,22 @@ describe('MonitorService - Sencho version check', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0'));
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
});
it('notifies once the registry publish completes after a pending GitHub release', async () => {
mockGetSenchoVersion.mockReturnValue('0.93.0');
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.93.0', publishPending: true });
await (svc as any).evaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.94.0', publishPending: false });
await (svc as any).evaluate();
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
});
});
// ── Per-container parallel fan-out ────────────────────────────────────
+149
View File
@@ -0,0 +1,149 @@
/**
* Unit tests for Sencho upstream version lookup and registry publish gating.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockGetRemoteDigestResult = vi.fn();
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
vi.mock('../services/registry-api', () => ({
getRemoteDigestResult: (...args: unknown[]) => mockGetRemoteDigestResult(...args),
}));
import { CacheService } from '../services/CacheService';
import {
fetchLatestSenchoVersion,
fetchLatestSenchoVersionInfo,
getLatestVersion,
isSenchoVersionPublished,
} from '../utils/version-check';
function ghRelease(version: string) {
return {
ok: true,
json: async () => ({ tag_name: `v${version}` }),
};
}
function dockerHubTags(...versions: string[]) {
return {
ok: true,
json: async () => ({
results: versions.map(name => ({ name })),
}),
};
}
describe('isSenchoVersionPublished', () => {
beforeEach(() => {
mockGetRemoteDigestResult.mockReset();
});
it('returns true when either mirror has a pullable manifest', async () => {
mockGetRemoteDigestResult
.mockResolvedValueOnce({ ok: false, reason: 'not found' })
.mockResolvedValueOnce({ ok: true, digest: 'sha256:abc' });
await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(true);
expect(mockGetRemoteDigestResult).toHaveBeenCalledTimes(2);
});
it('returns false when every mirror probe fails', async () => {
mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' });
await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(false);
});
it('returns false for invalid semver input', async () => {
await expect(isSenchoVersionPublished('not-a-version')).resolves.toBe(false);
expect(mockGetRemoteDigestResult).not.toHaveBeenCalled();
});
});
describe('fetchLatestSenchoVersionInfo', () => {
beforeEach(() => {
mockFetch.mockReset();
mockGetRemoteDigestResult.mockReset();
mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' });
});
it('returns GitHub version when the registry manifest is published', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0', '0.93.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.94.0',
publishPending: false,
});
});
it('falls back to Docker Hub and marks publishPending when GitHub is ahead of the registry', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.93.0'));
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.93.0',
publishPending: true,
});
expect(mockGetRemoteDigestResult).toHaveBeenCalled();
});
it('uses Docker Hub when GitHub is unavailable', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(dockerHubTags('0.93.0'));
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.93.0',
publishPending: false,
});
expect(mockGetRemoteDigestResult).not.toHaveBeenCalled();
});
it('throws when both upstream lookups fail', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false });
await expect(fetchLatestSenchoVersionInfo()).rejects.toThrow(
'Both GitHub and Docker Hub version lookups failed',
);
});
});
describe('getLatestVersion cache', () => {
beforeEach(() => {
mockFetch.mockReset();
mockGetRemoteDigestResult.mockReset();
CacheService.getInstance().flush();
});
afterEach(() => {
CacheService.getInstance().flush();
});
it('returns the published semver from the cached lookup', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(getLatestVersion()).resolves.toBe('0.94.0');
await expect(getLatestVersion()).resolves.toBe('0.94.0');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('fetchLatestSenchoVersion returns the version string only', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(fetchLatestSenchoVersion()).resolves.toBe('0.94.0');
});
});
+15 -7
View File
@@ -8,7 +8,7 @@ import { normalizeImageRef } from './DriftDetectionService';
import { NotificationService } from './NotificationService';
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersion } from '../utils/version-check';
import { getLatestVersionInfo } from '../utils/version-check';
import { isDebugEnabled } from '../utils/debug';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
@@ -537,10 +537,10 @@ export class MonitorService {
/**
* Check GitHub/Docker Hub for a newer Sencho release and dispatch a
* one-shot notification. Uses getLatestVersion() which wraps CacheService
* (30 min TTL + inflight dedup + stale-on-error) so transient network
* blips do not cause gaps, and the check stays consistent with the Fleet
* update banner.
* one-shot notification. Uses getLatestVersionInfo() which wraps CacheService
* (30 min TTL when published, shorter while a GitHub release awaits registry
* publish + inflight dedup) so transient network blips do not cause gaps,
* and the check stays consistent with the Fleet update banner.
*
* The 6-hour cooldown gate prevents bell spam: it is only advanced on a
* SUCCESSFUL lookup. A failed lookup retries on the next eval cycle
@@ -564,15 +564,23 @@ export class MonitorService {
return;
}
const latest = await getLatestVersion();
if (!isValidVersion(latest)) {
const versionInfo = await getLatestVersionInfo();
if (!versionInfo || !isValidVersion(versionInfo.version)) {
// Network failure (GitHub + Docker Hub both down, no stale cache).
// Do NOT advance the cooldown so the next eval retries.
if (isDebugEnabled()) console.debug('[Monitor:diag] Latest Sencho version unresolvable; will retry next cycle');
return;
}
if (versionInfo.publishPending) {
// GitHub announced a release before the image is pullable. Retry on
// the next eval cycle without advancing the 6-hour cooldown.
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho release pending registry publish; will retry next cycle');
return;
}
this.lastVersionCheckAt = Date.now();
const latest = versionInfo.version;
const db = DatabaseService.getInstance();
const stateKey = MonitorService.SENCHO_UPDATE_NOTIFIED_KEY;
+93 -16
View File
@@ -1,12 +1,24 @@
import semver from 'semver';
import { CacheService } from '../services/CacheService';
import { getRemoteDigestResult } from '../services/registry-api';
import { isDebugEnabled } from './debug';
import { sanitizeForLog } from './safeLog';
/**
* Fetches the latest Sencho release version from GitHub or Docker Hub.
* Extracted from index.ts so both the fleet endpoint and MonitorService
* can share the same lookup logic.
*
* GitHub releases/latest is the canonical semver source, but availability
* is gated on a pullable registry manifest so operators are not prompted
* before docker-publish.yml finishes pushing the image.
*/
const SENCHO_PUBLISH_MIRRORS = [
{ registry: 'registry-1.docker.io', repo: 'saelix/sencho' },
{ registry: 'ghcr.io', repo: 'studio-saelix/sencho' },
] as const;
async function fetchFromGitHub(): Promise<string | null> {
const res = await fetch('https://api.github.com/repos/studio-saelix/sencho/releases/latest', {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Sencho' },
@@ -33,48 +45,113 @@ async function fetchFromDockerHub(): Promise<string | null> {
return tags[0];
}
export async function fetchLatestSenchoVersion(): Promise<string> {
/** True when at least one public mirror has a pullable manifest for the semver tag. */
export async function isSenchoVersionPublished(version: string): Promise<boolean> {
if (!semver.valid(version)) return false;
const results = await Promise.all(
SENCHO_PUBLISH_MIRRORS.map(async ({ registry, repo }) => {
const result = await getRemoteDigestResult(registry, repo, version, null);
if (isDebugEnabled() && !result.ok) {
console.debug(
`[VersionCheck] Manifest probe failed for ${sanitizeForLog(registry)}/${sanitizeForLog(repo)}:${sanitizeForLog(version)}: ${sanitizeForLog(result.reason)}`,
);
}
return result.ok;
}),
);
return results.some(Boolean);
}
export interface LatestVersionInfo {
version: string;
/** GitHub announced a newer release than the highest published registry tag. */
publishPending: boolean;
}
export async function fetchLatestSenchoVersionInfo(): Promise<LatestVersionInfo> {
let gh: string | null = null;
let hub: string | null = null;
try {
const gh = await fetchFromGitHub();
if (gh) return gh;
gh = await fetchFromGitHub();
} catch (err) {
// GitHub API fails for private repos or rate limits; try Docker Hub
console.warn('[VersionCheck] GitHub fetch failed:', (err as Error).message);
}
try {
const hub = await fetchFromDockerHub();
if (hub) return hub;
hub = await fetchFromDockerHub();
} catch (err) {
console.warn('[VersionCheck] Docker Hub fetch failed:', (err as Error).message);
}
// Throw so CacheService falls back to a stale value if one exists,
// and so we do not poison the cache with null.
if (gh && semver.valid(gh)) {
if (await isSenchoVersionPublished(gh)) {
return { version: gh, publishPending: false };
}
if (hub && semver.valid(hub)) {
return { version: hub, publishPending: true };
}
throw new Error(`GitHub release ${gh} is not yet published on any registry mirror`);
}
if (hub && semver.valid(hub)) {
return { version: hub, publishPending: false };
}
throw new Error('Both GitHub and Docker Hub version lookups failed');
}
export async function fetchLatestSenchoVersion(): Promise<string> {
const info = await fetchLatestSenchoVersionInfo();
return info.version;
}
/**
* Cached wrapper shared by the Fleet endpoint and MonitorService.
* CacheService provides TTL, inflight deduplication, and stale-on-error
* fallback so transient network blips do not cause user-visible gaps.
*/
const LATEST_VERSION_CACHE_KEY = 'latest-version';
const LATEST_VERSION_INFO_CACHE_KEY = 'latest-version-info';
const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
const PENDING_PUBLISH_CACHE_TTL = 3 * 60 * 1000; // 3 minutes
export async function getLatestVersion(forceRefresh = false): Promise<string | null> {
let inflightLatestVersionInfo: Promise<LatestVersionInfo> | null = null;
export async function getLatestVersionInfo(forceRefresh = false): Promise<LatestVersionInfo | null> {
const cache = CacheService.getInstance();
if (forceRefresh) {
CacheService.getInstance().invalidate(LATEST_VERSION_CACHE_KEY);
cache.invalidate(LATEST_VERSION_INFO_CACHE_KEY);
}
const cached = cache.get<LatestVersionInfo>(LATEST_VERSION_INFO_CACHE_KEY);
if (cached) return cached;
if (!inflightLatestVersionInfo) {
inflightLatestVersionInfo = (async () => {
try {
const info = await fetchLatestSenchoVersionInfo();
const ttl = info.publishPending ? PENDING_PUBLISH_CACHE_TTL : LATEST_VERSION_CACHE_TTL;
cache.set(LATEST_VERSION_INFO_CACHE_KEY, info, ttl);
return info;
} finally {
inflightLatestVersionInfo = null;
}
})();
}
try {
return await CacheService.getInstance().getOrFetch<string>(
LATEST_VERSION_CACHE_KEY,
LATEST_VERSION_CACHE_TTL,
fetchLatestSenchoVersion,
);
return await inflightLatestVersionInfo;
} catch {
return null;
}
}
export async function getLatestVersion(forceRefresh = false): Promise<string | null> {
const info = await getLatestVersionInfo(forceRefresh);
return info?.version ?? null;
}
// --- Release details (includes body/notes for the changelog tab) ---
export interface SenchoRelease {