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-19 14:26:45 +02:00
1 parent 29795c074c
commit 99b1672be5
17 files changed
+529 -144

No files matched your search

+3 -1
View File
@@ -20,7 +20,9 @@ export async function checkNewerVersion(
const { tag } = parseImageReference(imageRef);
// Floating tag -> nothing to compare, and we skip the registry call entirely.
if (!parseTag(tag)) return null;
// Parse with the same override find-newer will use, so a custom-scheme tag
// isn't wrongly dismissed as floating before the registry fetch.
if (!parseTag(tag, options.versionPattern ?? null)) return null;
const tags = await listVersionTags(imageRef);
return findNewerVersionTag(tag, tags, options);
+10 -3
View File
@@ -25,6 +25,12 @@ export interface FindNewerOptions {
includePrerelease?: boolean;
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
matchFlavor?: boolean;
/**
* A compiled `dockhand.version.pattern` override (from compileVersionPattern).
* When set, both the current tag and every candidate are parsed with it, so an
* image with a non-standard tag scheme can still be compared. Absent = default.
*/
versionPattern?: RegExp | null;
}
export interface NewerVersion {
@@ -65,15 +71,16 @@ export function findNewerVersionTag(
allTags: string[],
options: FindNewerOptions = {}
): NewerVersion | null {
const current = parseTag(currentTag);
const { maxBump = 'major', includePrerelease = false, matchFlavor = true, versionPattern = null } = options;
const current = parseTag(currentTag, versionPattern);
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) }))
.map((tag) => ({ tag, parsed: parseTag(tag, versionPattern) }))
.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))
+76 -2
View File
@@ -23,8 +23,18 @@ export interface ParsedTag {
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 {
/**
* Parse a tag into its version parts, or null when it has no version (`latest`,
* `stable`, a sha). An optional `override` regex (from a `dockhand.version.pattern`
* label) replaces the default detection for images whose tags don't match the
* generic dotted-number shape (e.g. a CalVer-plus-hash like `2024.12.5-a1b2c3d`).
* The override must capture numeric named groups `major`/`minor`/`patch` (patch
* optional, plus any further `p4`/`p5`); those become `parts`. A tag the override
* doesn't match is treated as a non-version (null) - same as a floating tag.
*/
export function parseTag(tag: string, override?: RegExp | null): ParsedTag | null {
if (override) return parseWithOverride(tag, override);
const match = VERSION_RE.exec(tag);
if (!match) return null;
@@ -37,6 +47,36 @@ export function parseTag(tag: string): ParsedTag | null {
};
}
// Numeric named groups the override may capture, in version order. Only `major`
// is required; the rest fill in as present.
const OVERRIDE_PART_GROUPS = ['major', 'minor', 'patch', 'p4', 'p5'] as const;
function parseWithOverride(tag: string, override: RegExp): ParsedTag | null {
const match = override.exec(tag);
if (!match || !match.groups) return null;
const parts: number[] = [];
for (const name of OVERRIDE_PART_GROUPS) {
const raw = match.groups[name];
if (raw === undefined) break; // stop at the first absent segment
const n = Number(raw);
if (!Number.isFinite(n)) return null; // a captured non-number is not a version
parts.push(n);
}
if (parts.length === 0) return null; // no `major` captured -> not a version
// The whole match is the "version"; prefix/suffix are the text around it so
// flavor matching (e.g. `-alpine`) keeps working under an override too.
const version = match[0];
const index = match.index;
return {
version,
prefix: tag.slice(0, index),
suffix: tag.slice(index + version.length),
parts
};
}
/**
* 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`).
@@ -93,3 +133,37 @@ export function compareParts(a: ParsedTag, b: ParsedTag): number {
}
return 0;
}
// The `dockhand.version.pattern` label value must start with this scheme, leaving
// room for future non-regex strategies (e.g. `calver:`) under the same label.
const VERSION_PATTERN_SCHEME = 'regex:';
const MAX_PATTERN_LENGTH = 300;
// Nested quantifiers (`(a+)+`, `(a*)*`, `(a+)*`) are the classic catastrophic-
// backtracking shape. Refuse them rather than run an attacker-supplied ReDoS on
// every tag. A conservative reject: a group closed with a quantifier immediately
// followed by another quantifier.
const NESTED_QUANTIFIER_RE = /[+*}]\s*\)[+*?]|\([^)]*[+*][^)]*\)[+*]/;
/**
* Compile a `dockhand.version.pattern` label value into a RegExp for parseTag's
* override, or null when absent/invalid. Never throws - a bad pattern degrades to
* the default parser (null), so a typo can't break the update check. Requires the
* `regex:` scheme, a `major` named group, a sane length, and rejects obvious
* catastrophic-backtracking shapes (the value comes from an untrusted image label).
*/
export function compileVersionPattern(value: string | undefined | null): RegExp | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed.startsWith(VERSION_PATTERN_SCHEME)) return null;
const source = trimmed.slice(VERSION_PATTERN_SCHEME.length);
if (source.length === 0 || source.length > MAX_PATTERN_LENGTH) return null;
if (!source.includes('(?<major>')) return null; // must yield at least a major
if (NESTED_QUANTIFIER_RE.test(source)) return null; // ReDoS guard
try {
return new RegExp(source);
} catch {
return null; // invalid regex -> fall back to the default parser
}
}