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