detect newer version tags for pinned images and show them as a badge with release notes

This commit is contained in:
jarek committed 2026-08-20 08:28:26 +02:00
1 parent 8b67fc0662
commit a485c8580b
12 files changed
+456 -45

No files matched your search

+34 -4
View File
@@ -6,18 +6,33 @@
import { parseImageReference } from '../registry/image-ref';
import { parseTag } from './tag-parser';
import { listVersionTags } from './tag-source';
import { findNewerVersionTag, type FindNewerOptions, type NewerVersion } from './find-newer';
import { findNewerVersionTag, findNewerImageTag, type FindNewerOptions, type NewerVersion } from './find-newer';
import type { ArtifactKind } from './manifest-artifact';
/** Probe a single tag's artifact kind + manifest digest. Injected so check.ts stays unit-testable. */
export type TagKindProbe = (
registry: string,
repo: string,
tag: string
) => Promise<{ kind: ArtifactKind; digest: string | null }>;
/**
* Returns the newer-version suggestion for `imageRef`, or null when the current
* tag isn't a version (floating) or nothing newer is published. Never throws —
* a registry failure yields null, so the update check degrades gracefully.
*
* When `probeTagKind` is supplied, the chosen candidate is verified to be a real
* container image, not a Helm chart / other OCI artifact published into the same
* repo (monorepos do this). A non-image target is dropped and the next-highest
* version is tried. Only the candidate is probed. A probe failure is treated as
* "is an image" (fail-open) so a registry hiccup never hides a real update.
*/
export async function checkNewerVersion(
imageRef: string,
options: FindNewerOptions = {}
options: FindNewerOptions = {},
probeTagKind?: TagKindProbe
): Promise<NewerVersion | null> {
const { tag } = parseImageReference(imageRef);
const { registry, repo, tag } = parseImageReference(imageRef);
// Floating tag -> nothing to compare, and we skip the registry call entirely.
// Parse with the same override find-newer will use, so a custom-scheme tag
@@ -25,5 +40,20 @@ export async function checkNewerVersion(
if (!parseTag(tag, options.versionPattern ?? null)) return null;
const tags = await listVersionTags(imageRef);
return findNewerVersionTag(tag, tags, options);
if (!probeTagKind) return findNewerVersionTag(tag, tags, options);
return findNewerImageTag(
tag,
tags,
async (candidate) => {
try {
const { kind, digest } = await probeTagKind(registry, repo, candidate);
return { ok: kind === 'image', digest };
} catch {
return { ok: true }; // fail-open: never hide a real update on a probe error.
}
},
options
);
}
+34
View File
@@ -40,6 +40,8 @@ export interface NewerVersion {
bump: VersionBump;
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
skipped: string[];
/** The target tag's manifest digest (`sha256:...`), when probed. Lets the UI show/copy the new tag digest-pinned. */
digest?: string;
}
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
@@ -122,3 +124,35 @@ export function findNewerVersionTag(
skipped: deduped.map((c) => c.tag)
};
}
/**
* Like findNewerVersionTag, but verifies the chosen target is a real container
* image before offering it - dropping any tag that turns out to be a Helm chart or
* other non-image OCI artifact published into the same repo, and re-picking the
* next-highest version. `probe` returns `{ ok, digest }` where ok=true means a
* runnable image; the digest (when present) is attached to the result so the UI can
* offer the new tag digest-pinned. Async + injected so this stays pure/testable.
*
* Only the chosen candidate is probed, not every tag. `maxSkips` bounds the extra
* probes so a repo full of artifacts can't fan out unbounded. `probe` failures
* should resolve to `{ ok: true }` (fail-open) at the call site so a probe error
* never hides a real update.
*/
export async function findNewerImageTag(
currentTag: string,
allTags: string[],
probe: (tag: string) => Promise<{ ok: boolean; digest?: string | null }>,
options: FindNewerOptions = {},
maxSkips = 5
): Promise<NewerVersion | null> {
const excluded = new Set<string>();
for (let i = 0; i <= maxSkips; i++) {
const pool = excluded.size ? allTags.filter((t) => !excluded.has(t)) : allTags;
const newer = findNewerVersionTag(currentTag, pool, options);
if (!newer) return null;
const { ok, digest } = await probe(newer.tag);
if (ok) return digest ? { ...newer, digest } : newer;
excluded.add(newer.tag);
}
return null;
}
@@ -0,0 +1,75 @@
/**
* Classify a registry manifest as a runnable container image vs a non-image OCI
* artifact (Helm chart, SBOM, signature, ...). Monorepos increasingly publish
* Helm charts into the SAME repo as their images (e.g. `maximhq/bifrost` ships
* chart tag `2.1.29` alongside image tag `v1.6.11`), so the semver check must not
* offer a chart tag as a "newer version" of an image.
*
* Pure: takes the already-parsed manifest JSON, no I/O. The fetch lives in
* docker.ts; this just reads the media types.
*/
export type ArtifactKind = 'image' | 'chart' | 'other';
/** config.mediaType values that mean "this is a real container image config". */
const IMAGE_CONFIG_TYPES = new Set([
'application/vnd.oci.image.config.v1+json',
'application/vnd.docker.container.image.v1+json'
]);
/** Helm's OCI chart config media type. */
const HELM_CONFIG_TYPE = 'application/vnd.cncf.helm.config.v1+json';
/** Manifest LIST / image INDEX media types - a multi-arch image points here. */
const INDEX_TYPES = new Set([
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json'
]);
/** Single-image manifest media types. */
const IMAGE_MANIFEST_TYPES = new Set([
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json'
]);
/**
* Classify a manifest body (parsed JSON). `topMediaType` is the manifest's own
* media type when known (from the response Content-Type or a `mediaType` field);
* an image index has no `config`, so it is recognized by that type alone.
*
* - A manifest INDEX / manifest LIST is always a multi-arch IMAGE.
* - Otherwise the `config.mediaType` decides: Helm config -> chart, image config
* -> image, anything else -> other (SBOM, signature, unknown artifact).
* - Unknown / missing info defaults to 'image' (fail-open: never hide a real
* update just because a registry omitted a field).
*/
export function classifyManifest(
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
topMediaType?: string | null
): ArtifactKind {
if (!manifest) return 'image';
const top = manifest.mediaType || topMediaType || '';
if (INDEX_TYPES.has(top) || Array.isArray(manifest.manifests)) return 'image';
const cfg = manifest.config?.mediaType;
if (cfg === HELM_CONFIG_TYPE) return 'chart';
if (cfg && IMAGE_CONFIG_TYPES.has(cfg)) return 'image';
// A recognized single-image manifest type with no/other config is still an image.
if (IMAGE_MANIFEST_TYPES.has(top)) return 'image';
// A config media type we don't recognize, on a non-index manifest, is a
// non-image artifact (chart variants, SBOM, cosign signature, ...).
if (cfg) return 'other';
return 'image';
}
/** True only for a runnable container image (multi-arch index or single image). */
export function isRunnableImage(
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
topMediaType?: string | null
): boolean {
return classifyManifest(manifest, topMediaType) === 'image';
}