detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
36 files changed
+8820
-98
No files matched your search
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Decide whether a running image has a newer VERSION tag available. Glues the
|
||||
* registry tag list (I/O) to the pure version comparison, and short-circuits for
|
||||
* floating tags so `latest`/`stable`/sha never trigger a registry fetch.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function checkNewerVersion(
|
||||
imageRef: string,
|
||||
options: FindNewerOptions = {}
|
||||
): Promise<NewerVersion | null> {
|
||||
const { tag } = parseImageReference(imageRef);
|
||||
|
||||
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
||||
if (!parseTag(tag)) return null;
|
||||
|
||||
const tags = await listVersionTags(imageRef);
|
||||
return findNewerVersionTag(tag, tags, options);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Given the tag a container currently runs and every tag the registry offers,
|
||||
* decide whether a newer VERSION tag exists — and which versions were skipped
|
||||
* along the way. Pure: no network, no registry knowledge, fully unit-testable.
|
||||
*
|
||||
* This is advisory only. We never auto-apply it: the tag lives in the user's
|
||||
* compose, which we don't own. We just tell them "a newer version is out".
|
||||
*/
|
||||
|
||||
import {
|
||||
parseTag,
|
||||
prefixMatches,
|
||||
flavorMatches,
|
||||
compareParts,
|
||||
isPrerelease,
|
||||
type ParsedTag
|
||||
} from './tag-parser';
|
||||
|
||||
export type VersionBump = 'major' | 'minor' | 'patch';
|
||||
|
||||
export interface FindNewerOptions {
|
||||
/** Cap the offered bump: `patch` stays quiet, `major` offers everything. Default `major`. */
|
||||
maxBump?: VersionBump;
|
||||
/** Consider `-rc`/`-beta`/... tags even when the current tag is stable. Default false. */
|
||||
includePrerelease?: boolean;
|
||||
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
|
||||
matchFlavor?: boolean;
|
||||
}
|
||||
|
||||
export interface NewerVersion {
|
||||
/** The recommended tag to move to, e.g. `16.4-alpine`. */
|
||||
tag: string;
|
||||
/** How big the jump is, relative to the current version. */
|
||||
bump: VersionBump;
|
||||
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
|
||||
export function classifyBump(current: ParsedTag, candidate: ParsedTag): VersionBump {
|
||||
if ((candidate.parts[0] ?? 0) !== (current.parts[0] ?? 0)) return 'major';
|
||||
if ((candidate.parts[1] ?? 0) !== (current.parts[1] ?? 0)) return 'minor';
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
const BUMP_RANK: Record<VersionBump, number> = { patch: 1, minor: 2, major: 3 };
|
||||
|
||||
/** Canonical key for a numeric version: trailing zero segments trimmed, so `1.2`
|
||||
* and `1.2.0` (equal under compareParts) map to the same key. Keeps at least one
|
||||
* segment (`0.0.0` -> `0`). */
|
||||
function versionKey(parts: number[]): string {
|
||||
let end = parts.length;
|
||||
while (end > 1 && parts[end - 1] === 0) end--;
|
||||
return parts.slice(0, end).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the newest version tag strictly greater than `currentTag`, or null.
|
||||
*
|
||||
* Returns null when the current tag isn't a version at all (`latest`, `stable`,
|
||||
* a sha) — there is nothing to compare, so no suggestion is ever made.
|
||||
*/
|
||||
export function findNewerVersionTag(
|
||||
currentTag: string,
|
||||
allTags: string[],
|
||||
options: FindNewerOptions = {}
|
||||
): NewerVersion | null {
|
||||
const current = parseTag(currentTag);
|
||||
if (!current) return null; // floating tag — not a version, never suggest.
|
||||
|
||||
const { maxBump = 'major', includePrerelease = false, matchFlavor = true } = options;
|
||||
const maxRank = BUMP_RANK[maxBump];
|
||||
const currentIsPrerelease = isPrerelease(current);
|
||||
|
||||
const candidates = allTags
|
||||
.map((tag) => ({ tag, parsed: parseTag(tag) }))
|
||||
.filter((c): c is { tag: string; parsed: ParsedTag } => c.parsed !== null)
|
||||
.filter((c) => prefixMatches(c.parsed.prefix, current.prefix))
|
||||
.filter((c) => !matchFlavor || flavorMatches(c.parsed, current))
|
||||
.filter((c) => includePrerelease || currentIsPrerelease || !isPrerelease(c.parsed))
|
||||
.filter((c) => compareParts(c.parsed, current) > 0)
|
||||
.filter((c) => BUMP_RANK[classifyBump(current, c.parsed)] <= maxRank);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// A registry commonly publishes the same version both with and without the
|
||||
// `v` prefix (`v3.6` and `3.6`). They are the SAME version, so collapse them
|
||||
// to one entry, keeping the variant whose prefix matches the running tag (so
|
||||
// `v3.0` suggests `v3.6`, not `3.6`). Keyed by the numeric tuple.
|
||||
const byVersion = new Map<string, { tag: string; parsed: ParsedTag }>();
|
||||
for (const c of candidates) {
|
||||
// Key on the numeric value with trailing zero segments trimmed, so `1.2` and
|
||||
// `1.2.0` (equal under compareParts) collapse to one entry instead of both
|
||||
// landing in `skipped` and racing for `target`.
|
||||
const key = versionKey(c.parsed.parts);
|
||||
const existing = byVersion.get(key);
|
||||
if (!existing) {
|
||||
byVersion.set(key, c);
|
||||
continue;
|
||||
}
|
||||
// Prefer the variant that shares the current tag's exact prefix.
|
||||
const cMatches = c.parsed.prefix === current.prefix;
|
||||
const existingMatches = existing.parsed.prefix === current.prefix;
|
||||
if (cMatches && !existingMatches) byVersion.set(key, c);
|
||||
}
|
||||
|
||||
const deduped = Array.from(byVersion.values());
|
||||
// Highest version wins; sort ascending so `skipped` reads oldest -> newest.
|
||||
deduped.sort((a, b) => compareParts(a.parsed, b.parsed));
|
||||
const target = deduped[deduped.length - 1];
|
||||
|
||||
return {
|
||||
tag: target.tag,
|
||||
bump: classifyBump(current, target.parsed),
|
||||
skipped: deduped.map((c) => c.tag)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Fetch per-version release notes from GitHub for the versions a semver check
|
||||
* surfaced. The match core (release-tag <-> wanted-version) is pure and unit-
|
||||
* tested; only the fetch touches the network. Degrades to an empty list on any
|
||||
* failure, so the modal always renders (it falls back to a plain link).
|
||||
*/
|
||||
|
||||
import { parseTag, compareParts } from './tag-parser';
|
||||
import type { ReleaseSource } from './release-source';
|
||||
|
||||
export interface ReleaseNote {
|
||||
/** The requested version tag this note is for, e.g. `16.4-alpine`. */
|
||||
version: string;
|
||||
/** The GitHub release name/title (may be empty). */
|
||||
name: string;
|
||||
/** The GitHub release tag, e.g. `v16.4`. */
|
||||
githubTag: string;
|
||||
/** Markdown body of the release. */
|
||||
body: string;
|
||||
/** ISO date the release was published. */
|
||||
publishedAt: string | null;
|
||||
/** Direct link to the release page. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
name: string | null;
|
||||
body: string | null;
|
||||
published_at: string | null;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick, for each wanted version tag, the GitHub release whose tag parses to the
|
||||
* SAME numeric version. Pure: `releases` is whatever the API returned, `wanted`
|
||||
* is the list of image tags (target + skipped). Flavor/prefix noise is ignored —
|
||||
* only the numeric tuple has to match, so `v16.4` matches `16.4-alpine`.
|
||||
*/
|
||||
export function matchReleasesToVersions(
|
||||
releases: Pick<GithubRelease, 'tag_name' | 'name' | 'body' | 'published_at' | 'html_url'>[],
|
||||
wanted: string[]
|
||||
): ReleaseNote[] {
|
||||
const notes: ReleaseNote[] = [];
|
||||
for (const version of wanted) {
|
||||
const parsedWanted = parseTag(version);
|
||||
if (!parsedWanted) continue;
|
||||
const match = releases.find((r) => {
|
||||
const parsedRelease = parseTag(r.tag_name);
|
||||
return parsedRelease && compareParts(parsedRelease, parsedWanted) === 0;
|
||||
});
|
||||
if (match) {
|
||||
notes.push({
|
||||
version,
|
||||
name: match.name ?? '',
|
||||
githubTag: match.tag_name,
|
||||
body: match.body ?? '',
|
||||
publishedAt: match.published_at,
|
||||
url: match.html_url
|
||||
});
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
const MAX_PAGES = 3; // 300 recent releases is plenty to cover a handful of skipped versions.
|
||||
|
||||
/**
|
||||
* Fetch release notes for `wanted` versions from a GitHub or Gitea/Forgejo forge.
|
||||
* Both APIs return the same fields and page newest-first; only the page-size
|
||||
* query param differs (`per_page` vs `limit`). Walks pages until every wanted
|
||||
* version is matched or the page budget is spent. Never throws.
|
||||
*/
|
||||
export async function fetchReleaseNotes(
|
||||
src: ReleaseSource,
|
||||
wanted: string[],
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ReleaseNote[]> {
|
||||
if (wanted.length === 0) return [];
|
||||
|
||||
const pageSizeParam = src.kind === 'github' ? 'per_page' : 'limit';
|
||||
const collected: GithubRelease[] = [];
|
||||
try {
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const url = `${src.apiBase}?${pageSizeParam}=${PER_PAGE}&page=${page}`;
|
||||
const res = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'dockhand' }
|
||||
});
|
||||
if (!res.ok) break;
|
||||
const batch = (await res.json()) as GithubRelease[];
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
collected.push(...batch.filter((r) => !r.draft));
|
||||
|
||||
// Stop early once we can match everything we're after.
|
||||
const matched = matchReleasesToVersions(collected, wanted);
|
||||
if (matched.length >= wanted.length) break;
|
||||
if (batch.length < PER_PAGE) break;
|
||||
}
|
||||
} catch {
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
}
|
||||
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Resolve WHERE to fetch a container image's release notes from. Two forge
|
||||
* families share one release-notes shape and are handled here:
|
||||
*
|
||||
* - GitHub -> https://api.github.com/repos/<slug>/releases
|
||||
* - Gitea / Forgejo -> https://<host>/api/v1/repos/<slug>/releases
|
||||
* (Codeberg is a Forgejo instance; self-hosted Gitea/Forgejo work too.)
|
||||
*
|
||||
* Both return the same fields (`tag_name`, `name`, `body`, `published_at`,
|
||||
* `html_url`), so the fetch + version-match logic is identical - only the base
|
||||
* URL differs. Priority mirrors resolveChangelogUrl: the
|
||||
* `org.opencontainers.image.source` label wins, then a `ghcr.io/<owner>/<repo>`
|
||||
* image name (always GitHub).
|
||||
*
|
||||
* Pure, no I/O, unit-tested. Images with no recognizable source resolve to null
|
||||
* (we never guess a repo - a wrong repo is worse than no notes).
|
||||
*/
|
||||
|
||||
const GHCR_PREFIX = 'ghcr.io/';
|
||||
|
||||
export type ForgeKind = 'github' | 'gitea';
|
||||
|
||||
export interface ReleaseSource {
|
||||
kind: ForgeKind;
|
||||
/** `owner/repo`. */
|
||||
slug: string;
|
||||
/** Fully-formed releases API base, ready for `?per_page`/`?limit` + `&page`. */
|
||||
apiBase: string;
|
||||
/** Human-facing releases page (for the "View releases" fallback link). */
|
||||
releasesUrl: string;
|
||||
}
|
||||
|
||||
/** Pull `owner/repo` out of a forge URL, tolerating trailing slash / `.git`. */
|
||||
function slugFromUrl(url: string, host: string): string | null {
|
||||
const idx = url.indexOf(host);
|
||||
if (idx === -1) return null;
|
||||
const after = url
|
||||
.slice(idx + host.length)
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\.git$/, '');
|
||||
const parts = after.split('/');
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripImageTag(image: string): string {
|
||||
const atIdx = image.indexOf('@');
|
||||
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
|
||||
const colonIdx = withoutDigest.lastIndexOf(':');
|
||||
if (colonIdx > withoutDigest.lastIndexOf('/')) {
|
||||
return withoutDigest.slice(0, colonIdx);
|
||||
}
|
||||
return withoutDigest;
|
||||
}
|
||||
|
||||
export function resolveReleaseSource(
|
||||
imageName: string | null | undefined,
|
||||
labels?: Record<string, string> | null
|
||||
): ReleaseSource | null {
|
||||
const source = labels?.['org.opencontainers.image.source'];
|
||||
if (source) {
|
||||
const host = hostOf(source);
|
||||
if (host === 'github.com') {
|
||||
const slug = slugFromUrl(source, 'github.com');
|
||||
if (slug) return githubSource(slug);
|
||||
} else if (host) {
|
||||
// Any other host is treated as a Gitea/Forgejo instance. Its releases
|
||||
// API is host-relative, so the notes come from the SAME host the image
|
||||
// declares (codeberg.org, gitea.com, a self-hosted forge, ...).
|
||||
const slug = slugFromUrl(source, host);
|
||||
if (slug) {
|
||||
return {
|
||||
kind: 'gitea',
|
||||
slug,
|
||||
apiBase: `https://${host}/api/v1/repos/${slug}/releases`,
|
||||
releasesUrl: `https://${host}/${slug}/releases`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageName && imageName.startsWith(GHCR_PREFIX)) {
|
||||
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
|
||||
const parts = repo.split('/');
|
||||
if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
return githubSource(`${parts[0]}/${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function githubSource(slug: string): ReleaseSource {
|
||||
return {
|
||||
kind: 'github',
|
||||
slug,
|
||||
apiBase: `https://api.github.com/repos/${slug}/releases`,
|
||||
releasesUrl: `https://github.com/${slug}/releases`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* List tags from Docker Hub via its own tags API (not the Registry v2
|
||||
* `/tags/list`), ordered newest-first, so recent version tags land on the first
|
||||
* page or two.
|
||||
*
|
||||
* No server-side `name=` filter: Hub's `name` param is a SUBSTRING match, so
|
||||
* narrowing by the current major digit (e.g. `1` for `1.25`) would exclude a
|
||||
* clean newer major like `2.0` (no `1` in the name) and silently hide the single
|
||||
* most important upgrade. We page newest-first instead and let the pure
|
||||
* comparator pick the target.
|
||||
*/
|
||||
|
||||
/** Newest-first pages; a few pages cover recent version tags across major lines. */
|
||||
const MAX_PAGES = 3;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface HubTag {
|
||||
name: string;
|
||||
}
|
||||
interface HubTagsResponse {
|
||||
results?: HubTag[];
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param repo Full Hub repo path, e.g. `library/nginx` or `grafana/grafana`.
|
||||
*/
|
||||
export async function fetchDockerHubTags(repo: string): Promise<string[]> {
|
||||
const tags: string[] = [];
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const url = `https://hub.docker.com/v2/repositories/${repo}/tags?page_size=${PAGE_SIZE}&page=${page}&ordering=last_updated`;
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) break;
|
||||
|
||||
const data = (await response.json()) as HubTagsResponse;
|
||||
tags.push(...(data.results ?? []).map((t) => t.name));
|
||||
if (!data.next) break;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* List tags from any Registry HTTP API v2 host (Codeberg, GitLab, Harbor,
|
||||
* self-hosted, ...) via `/v2/<repo>/tags/list`, following the `Link: rel="next"`
|
||||
* header across pages so version tags aren't lost behind a first page full of
|
||||
* git-hash tags. Auth uses our shared WWW-Authenticate token flow, which also
|
||||
* works anonymously for public repos.
|
||||
*/
|
||||
import { getRegistryAuthHeader } from '../../docker';
|
||||
|
||||
/** Stop after this many pages — a guard against pathological repos, not a real limit. */
|
||||
const MAX_PAGES = 50;
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
interface TagsListResponse {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Parse `</v2/repo/tags/list?last=x&n=1000>; rel="next"` into the next path. */
|
||||
function nextPagePath(linkHeader: string | null): string | null {
|
||||
const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function fetchGenericV2Tags(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: { username: string; password: string } | null
|
||||
): Promise<string[]> {
|
||||
const base = `https://${registry}`;
|
||||
const authHeader = await getRegistryAuthHeader(base, `repository:${repo}:pull`, credentials ?? undefined);
|
||||
const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': 'Dockhand/1.0' };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
const tags: string[] = [];
|
||||
let path: string | null = `/v2/${repo}/tags/list?n=${PAGE_SIZE}`;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES && path; page++) {
|
||||
const response = await fetch(`${base}${path}`, { headers });
|
||||
if (!response.ok) break; // 4xx/5xx: stop with whatever we have.
|
||||
|
||||
const data = (await response.json()) as TagsListResponse;
|
||||
tags.push(...(data.tags ?? []));
|
||||
path = nextPagePath(response.headers.get('link'));
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Parse a Docker image tag into its numeric version and the text around it, so
|
||||
* two tags of the SAME image can be compared as versions.
|
||||
*
|
||||
* A tag is `<prefix><version><suffix>` where version is the first dotted-number
|
||||
* run: `v1.25-alpine` -> prefix `v`, version `1.25`, suffix `-alpine`. The
|
||||
* version becomes a plain numeric tuple (`[1, 25]`), which compares CalVer
|
||||
* (`2024.1.3`) and SemVer (`1.2.3`) alike with no semver library and no special
|
||||
* cases. Pure functions, no I/O.
|
||||
*/
|
||||
|
||||
/** The first dotted-number run in a tag: 2 to 4 numeric segments. */
|
||||
const VERSION_RE = /\d+\.\d+(?:\.\d+)?(?:\.\d+)?/;
|
||||
|
||||
export interface ParsedTag {
|
||||
/** The matched version text, e.g. `1.25` or `2024.1.3`. */
|
||||
version: string;
|
||||
/** Text before the version, e.g. `v` in `v1.25`. */
|
||||
prefix: string;
|
||||
/** Text after the version, e.g. `-alpine` in `1.25-alpine`. This is the "flavor". */
|
||||
suffix: string;
|
||||
/** The version as a numeric tuple, e.g. `[1, 25]`. */
|
||||
parts: number[];
|
||||
}
|
||||
|
||||
/** Parse a tag into its version parts, or null when it has no version (`latest`, `stable`, a sha). */
|
||||
export function parseTag(tag: string): ParsedTag | null {
|
||||
const match = VERSION_RE.exec(tag);
|
||||
if (!match) return null;
|
||||
|
||||
const version = match[0];
|
||||
return {
|
||||
version,
|
||||
prefix: tag.slice(0, match.index),
|
||||
suffix: tag.slice(match.index + version.length),
|
||||
parts: version.split('.').map(Number)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two prefixes are equivalent, treating `""` and `"v"` as the same — projects
|
||||
* routinely add or drop the leading `v` between releases (`1.2.3` vs `v1.2.3`).
|
||||
*/
|
||||
export function prefixMatches(a: string, b: string): boolean {
|
||||
const normalize = (p: string) => (p === 'v' ? '' : p);
|
||||
return normalize(a) === normalize(b);
|
||||
}
|
||||
|
||||
// A channel marker delimited by start/`-`/`.`, optionally followed by a number
|
||||
// (`rc1`, `beta.2`) or another delimiter. `\b` won't do: `rc1` has no boundary
|
||||
// between `c` and `1`.
|
||||
const PRERELEASE_RE = /(^|[-.])(rc|beta|alpha|nightly|dev|pre|snapshot)(\d|[-.]|$)/i;
|
||||
|
||||
// A trailing commit hash, e.g. `-b2da6b90f` in searxng's `2026.8.16-b2da6b90f`.
|
||||
// Requiring 7+ pure-hex chars keeps real build variants (`-alpine`, `-ls123`,
|
||||
// `-ubuntu22.04`) intact - they aren't long hex runs - while collapsing the
|
||||
// per-release hash so every hashed tag of one image shares a flavor and can be
|
||||
// compared. Guarded by at least one letter so a plain numeric build (`-12345678`)
|
||||
// isn't mistaken for a hash.
|
||||
const COMMIT_HASH_RE = /[-.](?=[0-9a-f]*[a-f])[0-9a-f]{7,}$/i;
|
||||
|
||||
export function isPrerelease(tag: ParsedTag): boolean {
|
||||
return PRERELEASE_RE.test(tag.suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "flavor" is the stable part of the suffix — the build variant like
|
||||
* `-alpine` or `-ls123` — with any prerelease channel and any trailing commit
|
||||
* hash stripped off. So `1.0-alpine` and `1.1-alpine` share the flavor `-alpine`,
|
||||
* `1.0-rc1` / `1.0-rc2` share the flavor `` (they only differ in the prerelease),
|
||||
* and `2026.8.15-a1b2c3d` / `2026.8.16-b2da6b9` share the flavor `` (they only
|
||||
* differ in the per-release hash). This lets one hashed tag suggest the next
|
||||
* while still never mixing `-alpine` with bare.
|
||||
*/
|
||||
export function flavorOf(tag: ParsedTag): string {
|
||||
return tag.suffix
|
||||
.replace(COMMIT_HASH_RE, '')
|
||||
.replace(PRERELEASE_RE, (_m, delim: string) => (delim === '.' ? '' : delim))
|
||||
.replace(/[-.]+$/, ''); // drop the delimiter the prerelease left dangling
|
||||
}
|
||||
|
||||
/** A newer version is only offered within the SAME flavor. Exact match, prerelease-agnostic. */
|
||||
export function flavorMatches(a: ParsedTag, b: ParsedTag): boolean {
|
||||
return flavorOf(a) === flavorOf(b);
|
||||
}
|
||||
|
||||
/** Compare two version tuples pairwise (missing segments count as 0). <0, 0, or >0. */
|
||||
export function compareParts(a: ParsedTag, b: ParsedTag): number {
|
||||
const length = Math.max(a.parts.length, b.parts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.parts[i] ?? 0) - (b.parts[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* List every version-looking tag an image's registry publishes, so the pure
|
||||
* `findNewerVersionTag` can pick the newest. Routes to the right fetch strategy
|
||||
* by host (Docker Hub API vs generic Registry v2), and caches per repo for 6h —
|
||||
* the set of published tags changes slowly and this keeps registries un-hammered.
|
||||
*
|
||||
* GHCR note: ghcr.io works through the generic v2 path here. GitHub's Packages
|
||||
* API returns versions newest-first (nicer for repos buried under git-hash tags),
|
||||
* but it needs a token we don't yet store — a phase-2 optimization, not a gap.
|
||||
*/
|
||||
import { parseImageReference, isDockerHub } from '../registry/image-ref';
|
||||
import { findRegistryCredentials } from '../docker';
|
||||
import { fetchDockerHubTags } from './strategies/docker-hub';
|
||||
import { fetchGenericV2Tags } from './strategies/generic-v2';
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
interface CacheEntry {
|
||||
tags: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
// Keyed by `registry/repo`. Registry credentials are stored per-HOST (not per
|
||||
// environment) in Dockhand's registries table, so the tag list for a given
|
||||
// registry/repo is the same regardless of which env asked - the key needs no env
|
||||
// dimension. A generic V2 registry that needs auth uses the stored credentials
|
||||
// for that host (same lookup the digest check uses); an unauthenticated repo just
|
||||
// works anonymously.
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
// In-flight fetches keyed the same way, so a burst of same-image containers in
|
||||
// one check pass (CONCURRENCY=20) collapses to ONE registry request per repo
|
||||
// instead of one per container.
|
||||
const inflight = new Map<string, Promise<string[]>>();
|
||||
|
||||
/**
|
||||
* All tags published for the image behind `imageRef`, cached per repo. Returns
|
||||
* `[]` on any registry failure — the caller treats "no tags" as "nothing newer".
|
||||
*/
|
||||
export async function listVersionTags(imageRef: string): Promise<string[]> {
|
||||
const { registry, repo } = parseImageReference(imageRef);
|
||||
const cacheKey = `${registry}/${repo}`;
|
||||
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.tags;
|
||||
|
||||
const pending = inflight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
if (isDockerHub(registry)) {
|
||||
tags = await fetchDockerHubTags(repo);
|
||||
} else {
|
||||
const credentials = await findRegistryCredentials(registry).catch(() => null);
|
||||
tags = await fetchGenericV2Tags(registry, repo, credentials);
|
||||
}
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
cache.set(cacheKey, { tags, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return tags;
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the per-repo tag cache. */
|
||||
export function __resetTagSourceCacheForTests(): void {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
Reference in new issue
Block a user