detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
17 files changed
+529
-144
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user