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

+19 -2
View File
@@ -5,6 +5,10 @@
* 1. `dockhand.changelog.url` label — explicit override set by the image
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
* everything. Pattern matches the existing dockhand.* label convention.
* May contain `{{version}}` / `{{tag}}` placeholders (see interpolation
* below) to build a per-version link (e.g. a monorepo release stream like
* `.../releases/tag/transports/{{version}}`); a plain URL with no
* placeholders is returned verbatim, unchanged from before.
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
* points at github.com the canonical changelog page is `<source>/releases`.
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
@@ -20,6 +24,18 @@
const GITHUB_HOST = 'github.com';
const GHCR_PREFIX = 'ghcr.io/';
/**
* Substitute `{{version}}` / `{{tag}}` (case-insensitive, whitespace-tolerant)
* in a changelog-url override. Backward-compatible: a URL with NO placeholder is
* returned unchanged, and if `version` is not supplied (e.g. the generic
* "changelog" link on the container/stack list, which has no target version) the
* template is left as-is so callers can decide - the plain-URL case is unaffected.
*/
export function interpolateChangelogUrl(url: string, version?: string | null): string {
if (!version || !url.includes('{{')) return url;
return url.replace(/\{\{\s*(version|tag)\s*\}\}/gi, version);
}
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
@@ -38,12 +54,13 @@ function stripImageTag(image: string): string {
export function resolveChangelogUrl(
imageName: string | null | undefined,
labels?: Record<string, string> | null
labels?: Record<string, string> | null,
version?: string | null
): string | null {
if (!imageName) return null;
const override = labels?.['dockhand.changelog.url'];
if (override && override.trim()) return override.trim();
if (override && override.trim()) return interpolateChangelogUrl(override.trim(), version);
const source = labels?.['org.opencontainers.image.source'];
if (source && source.includes(GITHUB_HOST)) {
+68
View File
@@ -0,0 +1,68 @@
/**
* Build a digest-pinned image reference from a `repo:tag` and the image's
* RepoDigests, for the "copy pinned reference" action. A supply-chain-hardened
* reference keeps the human-readable tag AND pins the immutable digest:
*
* nginx:1.25 + ["nginx@sha256:abc..."] -> nginx:1.25@sha256:abc...
*
* Pure, no I/O - unit-tested. Returns null when no digest matches the repo (an
* image built locally and never pushed has no RepoDigests), so the caller can
* hide/disable the button instead of copying a broken ref.
*/
/** Split `repo:tag` (or a bare `repo`) into its repo and tag parts. A colon that
* is part of a registry `host:port` is not a tag separator. */
function splitRepoTag(fullRef: string): { repo: string; tag: string | null } {
const lastColon = fullRef.lastIndexOf(':');
if (lastColon === -1) return { repo: fullRef, tag: null };
// A tag never contains a slash; `host:port/repo` has the slash AFTER the colon.
if (fullRef.slice(lastColon + 1).includes('/')) return { repo: fullRef, tag: null };
return { repo: fullRef.slice(0, lastColon), tag: fullRef.slice(lastColon + 1) };
}
/** The `sha256:...` part of a `repo@sha256:...` digest, or null. */
function digestOf(repoDigest: string): string | null {
const at = repoDigest.indexOf('@');
return at > 0 ? repoDigest.slice(at + 1) : null;
}
/**
* Return `repo:tag@sha256:...` for `fullRef`, picking the digest whose repo
* matches. Falls back to the single available digest when there is exactly one
* (common case: one repo, one digest). Returns null if no usable digest exists.
*/
export function buildPinnedRef(fullRef: string, repoDigests: string[] | undefined | null): string | null {
if (!repoDigests || repoDigests.length === 0) return null;
const { repo, tag } = splitRepoTag(fullRef);
// Prefer the digest published for the SAME repo as this tag (an image can carry
// digests for several repo names when it was tagged/pushed under more than one).
const match =
repoDigests.find((d) => d.slice(0, d.indexOf('@')) === repo) ??
(repoDigests.length === 1 ? repoDigests[0] : null);
if (!match) return null;
const digest = digestOf(match);
if (!digest) return null;
return tag ? `${repo}:${tag}@${digest}` : `${repo}@${digest}`;
}
/**
* The manifest digest (`sha256:...`) for `fullRef`, shortened for display, or null.
* Same matching rules as buildPinnedRef. `chars` counts hex digits shown after the
* `sha256:` prefix.
*/
export function shortDigest(
fullRef: string,
repoDigests: string[] | undefined | null,
chars = 12
): string | null {
const pinned = buildPinnedRef(fullRef, repoDigests);
if (!pinned) return null;
const at = pinned.lastIndexOf('@');
const digest = pinned.slice(at + 1); // sha256:....
const colon = digest.indexOf(':');
if (colon === -1) return digest.slice(0, chars);
return `${digest.slice(0, colon + 1)}${digest.slice(colon + 1, colon + 1 + chars)}`;
}