Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
29 changes: 23 additions & 6 deletions client/src/components/UpdateCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -225,7 +242,7 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
<div className="version-row">
<span className="version-label">Available</span>
<span className="version-value is-available" title={availableDigest || ''}>
{availableVersion || 'newer image'}
{isMeaningfulVersion(availableVersion) ? availableVersion : 'newer image'}
</span>
</div>
)}
Expand Down
71 changes: 66 additions & 5 deletions server/src/changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|null>}
*/
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.
*
Expand Down Expand Up @@ -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,
};
39 changes: 37 additions & 2 deletions server/src/checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|null>}
*/
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).
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions server/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions server/src/version.js
Original file line number Diff line number Diff line change
@@ -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 };
24 changes: 23 additions & 1 deletion server/test/changelog.test.js
Original file line number Diff line number Diff line change
@@ -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'), {
Expand Down Expand Up @@ -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',
Expand Down
29 changes: 29 additions & 0 deletions server/test/version.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading