fix(updater): stop false-positive "update available" loop when commit is unknown

Dockerfile never received DASHCADDY_COMMIT at build, so /app/VERSION held
'unknown'. _isNewer then treated same-version-different-commit as newer,
making the auto-updater rebuild the container indefinitely (each rebuild
still produced commit='unknown').

- self-updater._isNewer: normalize commits; treat unknown/null/empty as no
  commit info and fall back to pure version comparison
- self-updater._autoCheckAndApply + routes/updates: refuse to apply when
  local version >= remote version (belt-and-suspenders)
- update-management.js: hide '(unknown)' from version label
- Dockerfile: COPY VERSION instead of writing from build arg
- VERSION: committed placeholder ('dev'); scripts/release.sh now writes
  the real short SHA into the tarball's VERSION before tar-ing, so every
  published release ships with an accurate commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sami
2026-05-12 18:20:59 -07:00
co-authored by Claude Opus 4.7
parent 88206ff215
commit f65af5d7fd
6 changed files with 46 additions and 10 deletions
+4 -2
View File
@@ -13,8 +13,10 @@ COPY src/ ./src/
COPY routes/ ./routes/ COPY routes/ ./routes/
COPY openapi.yaml ./ COPY openapi.yaml ./
ARG DASHCADDY_COMMIT=unknown # VERSION file holds the short git SHA the image was built from. Committed as
RUN echo "${DASHCADDY_COMMIT}" > VERSION # 'dev' for source builds; the release script (scripts/release.sh) overwrites it
# with the actual commit hash before tarballing each release.
COPY VERSION ./
# Note: Running as root because container needs Docker socket access # Note: Running as root because container needs Docker socket access
# (which is root-equivalent anyway). Socket access required for container management. # (which is root-equivalent anyway). Socket access required for container management.
+1
View File
@@ -0,0 +1 @@
dev
+11 -2
View File
@@ -95,6 +95,15 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
if (!check.available) { if (!check.available) {
return res.json({ success: true, message: 'Already up to date' }); return res.json({ success: true, message: 'Already up to date' });
} }
// Refuse same-version applies. The check.available flag can theoretically be
// true with equal versions (commit-mismatch path); applying anyway just
// rebuilds the container without changing anything user-visible and pollutes
// history with v1.4.0 → v1.4.0 entries.
const localV = check.local && check.local.version;
const remoteV = check.remote && check.remote.version;
if (localV && remoteV && localV === remoteV) {
return res.json({ success: true, message: 'Already up to date', version: localV });
}
// Start async — container may restart // Start async — container may restart
selfUpdater.applyUpdate(check.remote).catch(err => { selfUpdater.applyUpdate(check.remote).catch(err => {
logError('self-update', err); logError('self-update', err);
@@ -102,8 +111,8 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
res.json({ res.json({
success: true, success: true,
message: 'Update initiated', message: 'Update initiated',
fromVersion: check.local.version, fromVersion: localV,
toVersion: check.remote.version, toVersion: remoteV,
}); });
}, 'system-update-apply')); }, 'system-update-apply'));
+25 -5
View File
@@ -400,10 +400,17 @@ class SelfUpdater extends EventEmitter {
async _autoCheckAndApply() { async _autoCheckAndApply() {
try { try {
const result = await this.checkForUpdate(); const result = await this.checkForUpdate();
if (result.available && result.remote) { if (!result.available || !result.remote) return;
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version); // Belt-and-suspenders: never auto-apply a same-version update. A bug here
await this.applyUpdate(result.remote); // creates an infinite rebuild loop (each fresh container has commit='unknown'
// so it keeps comparing as different from remote forever).
if (this._compareVersions(result.local.version, result.remote.version) >= 0) {
console.log('[SelfUpdater] Skipping auto-apply: local %s is not older than remote %s',
result.local.version, result.remote.version);
return;
} }
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
await this.applyUpdate(result.remote);
} catch (e) { } catch (e) {
console.error('[SelfUpdater] Auto-update error:', e.message); console.error('[SelfUpdater] Auto-update error:', e.message);
} }
@@ -496,11 +503,24 @@ class SelfUpdater extends EventEmitter {
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true; if (versionCompare < 0) return true;
if (versionCompare > 0) return false; if (versionCompare > 0) return false;
// Same version — check commit hash // Same version — only flag as newer if BOTH commits are known and differ.
if (remote.commit && local.commit && remote.commit !== local.commit) return true; // If local.commit is missing or 'unknown' (Dockerfile default when no build arg
// is passed), we can't distinguish builds, so trust the version number and
// treat them as equivalent. Otherwise the updater loops forever applying
// the same version because every fresh container build has commit='unknown'.
const localCommit = this._normalizeCommit(local.commit);
const remoteCommit = this._normalizeCommit(remote.commit);
if (localCommit && remoteCommit && localCommit !== remoteCommit) return true;
return false; return false;
} }
_normalizeCommit(value) {
if (!value) return null;
const str = String(value).trim().toLowerCase();
if (!str || str === 'unknown' || str === 'null' || str === 'undefined') return null;
return str;
}
_compareVersions(a, b) { _compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
+3
View File
@@ -84,6 +84,9 @@ ssh "$RELEASE_HOST" "set -e
fi fi
rm -rf .git rm -rf .git
find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true
# Bake the actual commit SHA into the api VERSION file so containers built
# from this tarball report a real commit (not the 'dev' placeholder).
echo \"$COMMIT\" > dashcaddy-api/VERSION
cd /tmp/dashcaddy-build cd /tmp/dashcaddy-build
tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/ tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/
" "
+2 -1
View File
@@ -335,7 +335,8 @@
const res = await fetch('/api/v1/system/version'); const res = await fetch('/api/v1/system/version');
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
dcVersionInfo.textContent = 'v' + data.version + (data.commit ? ' (' + data.commit.substring(0, 7) + ')' : ''); const commit = data.commit && data.commit !== 'unknown' ? data.commit : null;
dcVersionInfo.textContent = 'v' + data.version + (commit ? ' (' + commit.substring(0, 7) + ')' : '');
} }
} catch (_) { } catch (_) {
dcVersionInfo.textContent = 'Unable to fetch version'; dcVersionInfo.textContent = 'Unable to fetch version';