diff --git a/.env.example b/.env.example
index 51f384a..71dd3b3 100644
--- a/.env.example
+++ b/.env.example
@@ -44,6 +44,11 @@ BASE_URL=http://localhost:5000
# Leave unset to disable notifications.
# DISCORD_WEBHOOK_URL=
+# Optional GitHub token (read-only, no scopes needed) used for changelog and
+# version lookups. Raises GitHub's anonymous 60/hr API limit to 5000/hr — handy
+# if you watch many images. Leave unset to use the anonymous limit.
+# GITHUB_TOKEN=
+
# Name of this app's OWN container. It is excluded from the dashboard so it
# can't be told to update (and thereby restart) itself mid-update. Defaults to
# "dockpull" (the container_name in the shipped docker-compose.yml); change
diff --git a/API_CONTRACT.md b/API_CONTRACT.md
index 8fc3138..33fc9a3 100644
--- a/API_CONTRACT.md
+++ b/API_CONTRACT.md
@@ -235,10 +235,11 @@ Field notes:
different from `currentDigest`.
- `availableDigest` — the digest from that unresolved event, if any (else
`null`).
-- `availableVersion` — the `org.opencontainers.image.version` label read from
- the AVAILABLE (remote) image when the update was found, best-effort (else
- `null` — e.g. the image sets no version label, or `updateAvailable` is
- `false`).
+- `availableVersion` — a human version for the AVAILABLE (remote) image,
+ resolved when the update was found (best-effort, else `null`). Prefers the
+ image's `org.opencontainers.image.version` label; when that isn't a usable
+ version (e.g. `main`/`latest`/a sha) but the image declares a GitHub source,
+ falls back to that repo's latest release tag.
- `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version":
update indicator is suppressed and the container is grouped separately, but
a manual update is still allowed).
diff --git a/README.md b/README.md
index 056c8d4..475dacb 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ All config is via environment variables (see [`.env.example`](./.env.example)).
| `BASE_URL` | `http://localhost:5000` | | Public URL; if `https`, the cookie is set `Secure`. |
| `TRUST_PROXY` | _off_ | | Set (e.g. `1`) when behind a reverse proxy so rate-limiting sees real client IPs. |
| `DISCORD_WEBHOOK_URL` | — | | Discord webhook for notifications (also editable in Settings). |
+| `GITHUB_TOKEN` | — | | Optional read-only token; raises GitHub's 60/hr changelog/version API limit to 5000/hr. |
| `SCHEDULED_CHECK_TIME` | `09:00` | | Daily local time (HH:MM) for the background scan. |
| `BACKGROUND_CHECK_ENABLED` | `true` | | Whether the scheduled scan runs. |
| `SELF_CONTAINER_NAME` | `dockpull` | | This app's container, excluded so it can't update itself. |
diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx
index b95ac3d..7874180 100644
--- a/client/src/components/UpdateCard.jsx
+++ b/client/src/components/UpdateCard.jsx
@@ -10,12 +10,29 @@ function shortDigest(digest) {
return clean.slice(0, 12);
}
-// Prefer a human version (OCI version label), then the tag, then a short
-// digest as a last resort, so the card shows "1.27.3" / "latest" rather than a
-// meaningless hash.
+// Channel/branch words and shas aren't real versions — showing them produces
+// misleading "main → main" cards. Mirrors server/src/version.js.
+const VERSION_STOPWORDS = new Set([
+ 'latest', 'edge', 'stable', 'nightly', 'rolling', 'dev', 'devel', 'develop',
+ 'development', 'main', 'master', 'head', 'release', 'releases', 'snapshot',
+ 'canary', 'prod', 'production', 'current', 'beta', 'alpha', 'rc',
+]);
+function isMeaningfulVersion(v) {
+ if (typeof v !== 'string') return false;
+ const s = v.trim();
+ if (!s) return false;
+ if (VERSION_STOPWORDS.has(s.toLowerCase())) return false;
+ if (/^sha-?256[:-]/i.test(s)) return false;
+ if (/^[0-9a-f]{7,64}$/i.test(s)) return false;
+ return true;
+}
+
+// Prefer a real version (OCI version label), then a meaningful tag, then a
+// short digest as a last resort, so the card shows "1.27.3" rather than a
+// junk channel name like "main" or a meaningless hash.
function displayVersion({ currentVersion, tag, currentDigest }) {
- if (currentVersion) return currentVersion;
- if (tag) return tag;
+ if (isMeaningfulVersion(currentVersion)) return currentVersion;
+ if (isMeaningfulVersion(tag)) return tag;
return shortDigest(currentDigest);
}
@@ -225,7 +242,7 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
Available
- {availableVersion || 'newer image'}
+ {isMeaningfulVersion(availableVersion) ? availableVersion : 'newer image'}
)}
diff --git a/server/src/changelog.js b/server/src/changelog.js
index e149c3f..8ef9501 100644
--- a/server/src/changelog.js
+++ b/server/src/changelog.js
@@ -81,19 +81,73 @@ export function buildRegistryLink(image) {
return null;
}
+// Optional token raises GitHub's unauthenticated 60/hr limit to 5000/hr.
+function githubHeaders() {
+ const headers = {
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'dockpull',
+ };
+ const token = process.env.GITHUB_TOKEN;
+ if (token) headers.Authorization = `Bearer ${token}`;
+ return headers;
+}
+
async function fetchGitHubReleases(owner, repo, timeoutMs = 10000) {
const url = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=30`;
const res = await fetch(url, {
- headers: {
- Accept: 'application/vnd.github+json',
- 'User-Agent': 'dockpull',
- },
+ headers: githubHeaders(),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
return res.json();
}
+/**
+ * From a newest-first release list, pick the tag of the latest real release —
+ * skipping drafts and prereleases. Pure + testable.
+ *
+ * @param {Array<{tag_name?: string, name?: string, draft?: boolean, prerelease?: boolean}>} releases
+ * @returns {string|null}
+ */
+export function pickLatestReleaseTag(releases) {
+ if (!Array.isArray(releases)) return null;
+ for (const r of releases) {
+ if (r && !r.draft && !r.prerelease) {
+ const tag = (r.tag_name || r.name || '').trim();
+ if (tag) return tag;
+ }
+ }
+ return null;
+}
+
+// Cache release-tag lookups so repeated checks don't re-hit the GitHub API.
+const releaseTagCache = new Map(); // "owner/repo" -> { at, tag }
+const RELEASE_TAG_TTL_MS = 30 * 60 * 1000;
+
+/**
+ * Best-effort latest-release tag for a GitHub repo (cached). Returns null on
+ * any failure — callers treat it as "no better version available".
+ *
+ * @param {string} owner
+ * @param {string} repo
+ * @returns {Promise}
+ */
+export async function getLatestReleaseTag(owner, repo) {
+ const key = `${owner}/${repo}`;
+ const cached = releaseTagCache.get(key);
+ if (cached && Date.now() - cached.at < RELEASE_TAG_TTL_MS) {
+ return cached.tag;
+ }
+ try {
+ const releases = await fetchGitHubReleases(owner, repo);
+ const tag = pickLatestReleaseTag(releases);
+ releaseTagCache.set(key, { at: Date.now(), tag });
+ return tag;
+ } catch {
+ return null;
+ }
+}
+
/**
* Resolve a changelog payload for a container's image.
*
@@ -135,4 +189,11 @@ export async function getChangelog({ image, sourceUrl, currentVersion }) {
return { type: 'none' };
}
-export default { parseGitHubRepo, selectNewerReleases, buildRegistryLink, getChangelog };
+export default {
+ parseGitHubRepo,
+ selectNewerReleases,
+ buildRegistryLink,
+ getChangelog,
+ pickLatestReleaseTag,
+ getLatestReleaseTag,
+};
diff --git a/server/src/checker.js b/server/src/checker.js
index ef320fd..b90a148 100644
--- a/server/src/checker.js
+++ b/server/src/checker.js
@@ -10,10 +10,34 @@
import { listContainers } from './docker.js';
import { getRemoteDigest, getRemoteVersion } from './registry.js';
import { digestsEqual } from './reconcile.js';
+import { isMeaningfulVersion } from './version.js';
+import { parseGitHubRepo, getLatestReleaseTag } from './changelog.js';
import * as db from './db.js';
const CONCURRENCY = 4;
+/**
+ * Best-effort human version for the AVAILABLE image. Prefer the image's own
+ * `org.opencontainers.image.version` label; if that isn't a usable version
+ * (e.g. `main`, `latest`, a sha) but the image declares a GitHub source, fall
+ * back to that repo's latest release tag (cached). Returns null if nothing
+ * meaningful is found.
+ *
+ * @param {{ image: string, sourceUrl?: string|null }} c
+ * @returns {Promise}
+ */
+async function resolveAvailableVersion(c) {
+ const labelVersion = await getRemoteVersion(c.image);
+ if (isMeaningfulVersion(labelVersion)) return labelVersion;
+
+ const gh = parseGitHubRepo(c.sourceUrl);
+ if (gh) {
+ const tag = await getLatestReleaseTag(gh.owner, gh.repo);
+ if (isMeaningfulVersion(tag)) return tag;
+ }
+ return labelVersion || null;
+}
+
/**
* @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>}
* @throws if the Docker daemon can't be reached (caller maps to 503).
@@ -53,10 +77,21 @@ export async function runCheck() {
// unresolved event for this exact digest (avoid duplicate rows on
// repeated checks).
const existing = db.latestUnresolvedEventForRef(c.normalizedRef);
- if (existing && digestsEqual(existing.digest, remote)) continue;
+ if (existing && digestsEqual(existing.digest, remote)) {
+ // Already flagged. If we previously stored a junk version label
+ // (e.g. "main"), try to backfill a real one now without waiting for
+ // a new image to appear.
+ if (!isMeaningfulVersion(existing.available_version)) {
+ const better = await resolveAvailableVersion(c);
+ if (isMeaningfulVersion(better)) {
+ db.updateEventAvailableVersion(c.normalizedRef, remote, better);
+ }
+ }
+ continue;
+ }
// Best-effort: only paid for images that actually have an update.
- const availableVersion = await getRemoteVersion(c.image);
+ const availableVersion = await resolveAvailableVersion(c);
db.recordEvent({
image: c.image,
diff --git a/server/src/db.js b/server/src/db.js
index b57f53e..dd271c3 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -71,6 +71,10 @@ const stmts = {
UPDATE update_events SET resolved = 1
WHERE normalized_ref = ? AND resolved = 0
`),
+ updateEventAvailableVersion: db.prepare(`
+ UPDATE update_events SET available_version = ?
+ WHERE normalized_ref = ? AND digest = ? AND resolved = 0
+ `),
recordUpdate: db.prepare(`
INSERT INTO update_history (container_name, image, old_digest, new_digest, status, message)
VALUES (@container_name, @image, @old_digest, @new_digest, @status, @message)
@@ -139,6 +143,10 @@ export function resolveEventsForRef(normalized_ref) {
return stmts.resolveEventsForRef.run(normalized_ref);
}
+export function updateEventAvailableVersion(normalized_ref, digest, available_version) {
+ return stmts.updateEventAvailableVersion.run(available_version ?? null, normalized_ref, digest);
+}
+
export function recordUpdate({ container_name, image, old_digest, new_digest, status, message }) {
return stmts.recordUpdate.run({
container_name,
diff --git a/server/src/version.js b/server/src/version.js
new file mode 100644
index 0000000..55e5647
--- /dev/null
+++ b/server/src/version.js
@@ -0,0 +1,51 @@
+/**
+ * Decide whether a version string is actually useful to show a user.
+ *
+ * Some images set `org.opencontainers.image.version` to a branch name, a
+ * channel, or a git sha (e.g. homarr labels every build `main` and ships
+ * `:latest`). Those are not versions — surfacing them produces misleading
+ * "main → main" cards. This predicate lets callers fall back to something
+ * better (a GitHub release tag, the image tag, or the digest).
+ */
+
+// Channel / branch words that are never a meaningful version.
+const STOPWORDS = new Set([
+ 'latest',
+ 'edge',
+ 'stable',
+ 'nightly',
+ 'rolling',
+ 'dev',
+ 'devel',
+ 'develop',
+ 'development',
+ 'main',
+ 'master',
+ 'head',
+ 'release',
+ 'releases',
+ 'snapshot',
+ 'canary',
+ 'prod',
+ 'production',
+ 'current',
+ 'beta',
+ 'alpha',
+ 'rc',
+]);
+
+/**
+ * @param {unknown} v
+ * @returns {boolean} true if `v` looks like a real version worth displaying.
+ */
+export function isMeaningfulVersion(v) {
+ if (typeof v !== 'string') return false;
+ const s = v.trim();
+ if (!s) return false;
+ if (STOPWORDS.has(s.toLowerCase())) return false;
+ if (/^sha-?256[:-]/i.test(s)) return false; // a digest, not a version
+ if (/^[0-9a-f]{7,64}$/i.test(s)) return false; // bare git/image sha
+ return true;
+}
+
+export default { isMeaningfulVersion };
diff --git a/server/test/changelog.test.js b/server/test/changelog.test.js
index 9d5f15e..2631186 100644
--- a/server/test/changelog.test.js
+++ b/server/test/changelog.test.js
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { parseGitHubRepo, selectNewerReleases, buildRegistryLink } from '../src/changelog.js';
+import {
+ parseGitHubRepo,
+ selectNewerReleases,
+ buildRegistryLink,
+ pickLatestReleaseTag,
+} from '../src/changelog.js';
test('parseGitHubRepo: extracts owner/repo, strips .git', () => {
assert.deepEqual(parseGitHubRepo('https://github.com/jellyfin/jellyfin'), {
@@ -37,6 +42,23 @@ test('selectNewerReleases: unknown current version -> recent few', () => {
assert.equal(selectNewerReleases(releases, null).length, 3);
});
+test('pickLatestReleaseTag: newest non-draft, non-prerelease tag', () => {
+ const releases = [
+ { tag_name: 'v2.0.0-rc.1', prerelease: true },
+ { tag_name: 'v1.9.0-draft', draft: true },
+ { tag_name: 'v1.68.1' },
+ { tag_name: 'v1.68.0' },
+ ];
+ assert.equal(pickLatestReleaseTag(releases), 'v1.68.1');
+});
+
+test('pickLatestReleaseTag: falls back to name; null when none', () => {
+ assert.equal(pickLatestReleaseTag([{ name: 'Release 5.0' }]), 'Release 5.0');
+ assert.equal(pickLatestReleaseTag([{ tag_name: 'v1', draft: true }]), null);
+ assert.equal(pickLatestReleaseTag([]), null);
+ assert.equal(pickLatestReleaseTag(null), null);
+});
+
test('buildRegistryLink: docker hub official + namespaced, ghcr', () => {
assert.deepEqual(buildRegistryLink('nginx:latest'), {
url: 'https://hub.docker.com/_/nginx',
diff --git a/server/test/version.test.js b/server/test/version.test.js
new file mode 100644
index 0000000..ba08e47
--- /dev/null
+++ b/server/test/version.test.js
@@ -0,0 +1,29 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { isMeaningfulVersion } from '../src/version.js';
+
+test('isMeaningfulVersion: accepts real versions', () => {
+ for (const v of ['1.68.1', 'v1.68.1', '1.68', '2024.1.1', '10.9.0', 'v2', '1.0.0-rc.1']) {
+ assert.equal(isMeaningfulVersion(v), true, `${v} should be meaningful`);
+ }
+});
+
+test('isMeaningfulVersion: rejects channel/branch stopwords', () => {
+ for (const v of ['main', 'master', 'latest', 'edge', 'stable', 'nightly', 'develop', 'HEAD', 'Latest', 'release']) {
+ assert.equal(isMeaningfulVersion(v), false, `${v} should be junk`);
+ }
+});
+
+test('isMeaningfulVersion: rejects shas and digests', () => {
+ assert.equal(isMeaningfulVersion('a1b2c3d'), false);
+ assert.equal(isMeaningfulVersion('57ef0af4a252ea39727caeba7e13587dabc6254e'), false);
+ assert.equal(isMeaningfulVersion('sha256:abc123'), false);
+});
+
+test('isMeaningfulVersion: rejects empty / non-strings', () => {
+ assert.equal(isMeaningfulVersion(''), false);
+ assert.equal(isMeaningfulVersion(' '), false);
+ assert.equal(isMeaningfulVersion(null), false);
+ assert.equal(isMeaningfulVersion(undefined), false);
+ assert.equal(isMeaningfulVersion(123), false);
+});