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
+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 {