diff --git a/ui/src/model.test.ts b/ui/src/model.test.ts
--- a/ui/src/model.test.ts
+++ b/ui/src/model.test.ts
@@ -40,12 +40,29 @@ test('sorting leaves its input alone', () => {
 
 test('versions sort semantically, prereleases below their release', () => {
   assert.equal(names(sortEntries(rows, 'latest_build', 'desc')), 'bdac');
-  assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 2]);
-  assert.deepEqual(versionKey('V2'), [2, 0, 0, 0, 0]);
-  assert.deepEqual(versionKey('1.2.3-beta'), [1, 2, 3, -1, 0]);
+  assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 'rc', '2']);
+  assert.deepEqual(versionKey('V2'), [2, 0, 0, 0]);
+  assert.deepEqual(versionKey('1.2.3-beta'), [1, 2, 3, -1, 'beta']);
   assert.deepEqual(versionKey('nightly'), [-1]);
 });
 
+test('build versions compare every prerelease identifier before their release', () => {
+  const tags = ['v1.0.0-1', 'v1.0.0-9007199254740992', 'v1.0.0-9007199254740993',
+    'v1.0.0-alpha', 'v1.0.0-alpha.1', 'v1.0.0-alpha.beta',
+    'v1.0.0-beta', 'v1.0.0-beta.2', 'v1.0.0-beta.11', 'v1.0.0-rc.1', 'v1.0.0'];
+  const builds = tags.map((latest_build, i) => entry(String(tags.length - i), { latest_build }));
+  /** Read build tags back in the requested table order. */
+  const sortedTags = (dir: 'asc' | 'desc') => sortEntries(builds, 'latest_build', dir).map((r) => r.stats!.latest_build);
+  assert.deepEqual(sortedTags('asc'), tags);
+  assert.deepEqual(sortedTags('desc'), [...tags].reverse());
+});
+
+test('build metadata does not change version precedence, and short tags still work', () => {
+  const builds = [entry('b', { latest_build: 'V2+build.1' }), entry('a', { latest_build: '2.0.0+build.9' }),
+    entry('c', { latest_build: 'v2.0-rc.1+build.99' }), entry('d', { latest_build: 'nightly' }), entry('e', null)];
+  assert.equal(names(sortEntries(builds, 'latest_build', 'desc')), 'abcde');
+});
+
 test('commits since the build read through to since_build', () => {
   const lagged = [
     entry('a', { since_build: { commits: 3, basis: 'exact', from_sha: 'x' } }),
diff --git a/ui/src/model.ts b/ui/src/model.ts
--- a/ui/src/model.ts
+++ b/ui/src/model.ts
@@ -203,21 +203,31 @@ export type Dir = 'asc' | 'desc';
 /** The table's order: by a column, or `MANUAL` (the rows as last dragged). */
 export type Sort = { key: string; dir?: Dir };
 
-/** `v0.10.2-rc.1` → [0, 10, 2, -1, 1] so pre-releases sort below the release. */
-export function versionKey(tag: string): number[] {
-  const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([A-Za-z]*)\.?(\d+)?)?/);
+/** Core version, release flag, then prerelease identifiers; build metadata is ignored. */
+export function versionKey(tag: string): (number | string)[] {
+  const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?/);
   if (!m) return [-1];
-  return [+m[1]!, +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, +(m[5] ?? 0)];
+  return [+m[1]!, +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, ...(m[4]?.split('.') ?? [])];
 }
 
-function cmpArrays(a: number[], b: number[]): number {
-  for (let i = 0; i < Math.max(a.length, b.length); i++) {
-    const d = (a[i] ?? 0) - (b[i] ?? 0);
-    if (d) return d;
+/** Compare version keys by core numbers, release flag, then SemVer prerelease identifiers. */
+function cmpArrays(a: (number | string)[], b: (number | string)[]): number {
+  for (let i = 0; i < Math.min(a.length, b.length); i++) {
+    const av = a[i], bv = b[i];
+    if (av === bv) continue;
+    if (typeof av === 'number' && typeof bv === 'number') return av - bv;
+    const as = String(av), bs = String(bv);
+    const an = /^\d+$/.test(as), bn = /^\d+$/.test(bs);
+    if (an !== bn) return Number(bn) - Number(an);
+    // Compare numeric identifiers without losing precision to Number.
+    if (an && as.length !== bs.length) return as.length - bs.length;
+    if (as < bs) return -1;
+    if (as > bs) return 1;
   }
-  return 0;
+  return a.length - b.length;
 }
 
+/** Compare column values using the column's text, date, age, numeric or version order. */
 function cmpValues(kind: Kind, a: string | number, b: string | number): number {
   switch (kind) {
     case 'text': return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
