mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 22:25:30 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user