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); +});