screentinker/server/lib/version-precedence.js
screentinker 26c059c1b8
Every build from alpha10 onward sorted below alpha8 (#270)
A plain string compare on the prerelease tag put "alpha11" below "alpha8", because
'1' < '8'. The OTA check therefore answered client-newer and refused to offer the
update — while reporting the newer build as `latest` in the same response:

  latest_version: 1.9.34-alpha11   current_version: 1.9.34-alpha8
  update_available: false          reason: "client-newer"

So a fleet on alpha8 or alpha9 could not be moved forward at all, silently, and
nothing about the symptom pointed at version ordering. alpha10 was never really on
offer either; the last update that genuinely worked was alpha6 -> alpha8, where the
lexical order happens to be right by luck.

This is what semver specifies for a single alphanumeric identifier, and it is
simply not what the naming means. lib/version-precedence.js compares digit runs
NUMERICALLY, so alpha8 < alpha9 < alpha10 < alpha11, while leaving everything else
alphabetical — beta still outranks alpha, rc still outranks beta, and a release
still outranks any prerelease of the same core.

Dot-separated identifiers are compared per semver and a shorter run loses, so
moving the naming to the semver-correct `-alpha.11` form later needs no further
change here.

TWO comparators carried the assumption, each with a comment asserting lexical was
fine "for our naming" — true only while the counter stayed below 10. Both now use
the shared helper rather than a third copy drifting into the same trap:
  - lib/ota-breaker.js      the Android OTA path
  - lib/brightsign-update.js  the BrightSign host package, where a wrong-way
    comparison replaces the script that boots the player

lib/ghcr-check.js was checked and is unaffected: it rejects prerelease strings
outright rather than ordering them.

Tests pin the exact stranding case end to end — decide('1.9.34-alpha8',
'1.9.34-alpha11') must be an offer, not client-newer — plus the reverse direction,
so a future change cannot merely invert it.

1668/1668 pass.
2026-08-13 20:20:17 -05:00

63 lines
2.9 KiB
JavaScript

'use strict';
/*
* Precedence for PRERELEASE identifiers — the `-alpha11` half of `1.9.34-alpha11`.
*
* ⚠️ WHY THIS EXISTS: a plain string compare is what semver specifies for a single alphanumeric
* identifier, and it is wrong for how this project actually names builds. `"alpha11" < "alpha8"`
* because `'1' < '8'`, so EVERY build from alpha10 onward sorted below alpha8 and alpha9. The OTA
* check then answered `client-newer` and refused to offer the update at all — a fleet on alpha8
* could not be moved forward, silently, with the server reporting the newer build as `latest` in
* the same breath. Two comparators carried the same assumption, both with a comment saying lexical
* was "fine for our naming"; it was fine only while the counter stayed below 10.
*
* The rule here is natural ordering: split each identifier into digit and non-digit runs and
* compare digit runs NUMERICALLY. That gives what a human means by the name — alpha8 < alpha9 <
* alpha10 < alpha11 — while leaving everything else alphabetical, so beta still outranks alpha and
* rc still outranks beta.
*
* Dot-separated identifiers are compared one at a time per semver, and a shorter run of identifiers
* loses when all preceding ones are equal (`alpha` < `alpha.1`), so a future move to the semver-
* correct `-alpha.11` form keeps working without another change here.
*
* Deliberately NOT handled: whether a prerelease outranks a release. That is the caller's rule —
* both callers already implement it, and each has its own exceptions (ota-breaker treats the legacy
* `-patchN` scheme as released).
*/
// Compare one identifier, digit runs numerically. "alpha10" -> ["alpha", "10"].
function naturalCmp(x, y) {
const rx = String(x).match(/\d+|\D+/g) || [];
const ry = String(y).match(/\d+|\D+/g) || [];
for (let i = 0; i < Math.max(rx.length, ry.length); i++) {
const a = rx[i], b = ry[i];
if (a === undefined) return -1; // "alpha" < "alpha1"
if (b === undefined) return 1;
const aNum = /^\d+$/.test(a), bNum = /^\d+$/.test(b);
if (aNum && bNum) {
// Numeric, so 10 beats 8 — the whole point of this file.
if (Number(a) !== Number(b)) return Number(a) < Number(b) ? -1 : 1;
} else if (a !== b) {
// A digit run sorts below a word run, matching semver's numeric-identifiers-first rule.
if (aNum !== bNum) return aNum ? -1 : 1;
return a < b ? -1 : 1;
}
}
return 0;
}
/* Full prerelease precedence: dot-separated identifiers, each compared naturally. */
function preCmp(a, b) {
if (a === b) return 0;
const as = String(a).split('.'), bs = String(b).split('.');
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
if (as[i] === undefined) return -1; // "alpha" < "alpha.1"
if (bs[i] === undefined) return 1;
const c = naturalCmp(as[i], bs[i]);
if (c !== 0) return c;
}
return 0;
}
module.exports = { preCmp, naturalCmp };