feat(settings): the instance reports which build it is (318 step 6)
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 2m50s
Build images / build-web (push) Successful in 2m49s
CI / integration (push) Successful in 3m52s

A dim line at the foot of Settings: `FabledCurator 2026.08.28.1249 · dev`.

This is no longer a convenience. Milestone 318 stopped publishing version
image tags, so an instance's own report is the ONLY answer to "which build is
this?" — there is no registry name left to check it against. Note #3127 §5
says it directly: a wrong answer here has no second source to contradict it.

Three states, kept distinct because collapsing any two of them lies:

  not asked yet         render nothing
  asked, no version     render "unknown"
  asked, has a version  render it

A blank footer reads as "no version", which is a different claim from "I
cannot say". And a failed health call deliberately does NOT mark the build
loaded — a network blip says nothing about the image, and presenting it as
"unknown" would look like a defective build.

Carried on /api/health rather than a new route: it answers at the same cost
(two module constants, no I/O) and TopNav already fetches it app-wide, so a
separate endpoint would mean a second request for two strings.

Both fields are OMITTED when unset rather than sent empty. Absence already
means "cannot say" — an image predating the field says exactly that by not
having the key — so a second spelling would make every reader special-case
it. The pre-existing test asserting the body is EXACTLY {"status": "ok"} is
what keeps a well-meaning `or ""` default from creeping in.

FC_CHANNEL now has one definition. It was read from the environment in
extension.py and would have been read again here; the new build_info module
holds both, and extension.py binds it as a module-level name so existing
tests monkeypatch it exactly as before. Separate from config.py on purpose:
those are operator settings meant to be changed, these describe the artifact.

Channel sits beside the version, never inside it (rule 149), asserted from
both ends. A `-dev` suffix would read as a 0 segment to the extension's
parseInt comparator and make every dev build compare equal — #2993 exactly.

Not hidden, per the operator and §7: the JS bundle and asset hashes
fingerprint the build anyway, and "I'm on 2026.08.28.1249" is the single most
useful line in a bug report.
This commit is contained in:
2026-08-28 18:08:31 -04:00
parent 5771fd5770
commit bce894ba24
9 changed files with 268 additions and 14 deletions
+8 -3
View File
@@ -482,6 +482,10 @@ jobs:
set -eu set -eu
DERIVED=$(sh scripts/artifacts.sh revision web) DERIVED=$(sh scripts/artifacts.sh revision web)
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT" echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
# Baked into the web image as FC_VERSION and reported by /api/health.
# A pure function of the revision — same commit, same string — so it
# adds no variability the reuse check would have to account for.
echo "version=$(sh scripts/artifacts.sh version web)" >> "$GITHUB_OUTPUT"
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT" echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
# The moving tag for this channel. Which tag we ask IS the channel — # The moving tag for this channel. Which tag we ask IS the channel —
@@ -611,11 +615,12 @@ jobs:
# decoration — an unstamped image is one that will always rebuild. # decoration — an unstamped image is one that will always rebuild.
labels: | labels: |
fc.revision=${{ steps.reuse.outputs.revision }} fc.revision=${{ steps.reuse.outputs.revision }}
# Only the web image carries a channel: it is the one that serves # Only the web image carries these: it is the one with a UI and an
# /api/extension/manifest. The ml and agent images have nothing to # HTTP surface to report them on. The ml and agent images have
# report it to. # nothing to tell.
build-args: | build-args: |
FC_CHANNEL=${{ steps.tag.outputs.channel }} FC_CHANNEL=${{ steps.tag.outputs.channel }}
FC_VERSION=${{ steps.reuse.outputs.version }}
# Registry-side manifest copy: no layer transfer, no local daemon, no # Registry-side manifest copy: no layer transfer, no local daemon, no
# rebuild. Each -t becomes another reference to the SAME manifest the # rebuild. Each -t becomes another reference to the SAME manifest the
+9 -3
View File
@@ -58,11 +58,17 @@ COPY --from=frontend-builder /build/dist ./frontend/dist
# exactly the shape every reader already has to handle. # exactly the shape every reader already has to handle.
# #
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and # Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
# this is the one value that differs between the dev and main builds of # these are the values that differ between builds of otherwise identical
# identical source — put it any earlier and the two channels could never share # source — put them any earlier and the two channels could never share a
# a cached pip install. # cached pip install.
#
# FC_VERSION is what the instance reports about itself in the UI. Since
# milestone 318 stopped publishing version image tags, that self-report is
# the only answer to "which build is this?" — nothing else names it.
ARG FC_CHANNEL="" ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL} ENV FC_CHANNEL=${FC_CHANNEL}
ARG FC_VERSION=""
ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080 EXPOSE 8080
+8 -5
View File
@@ -7,13 +7,13 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import hmac import hmac
import os
import re import re
from pathlib import Path from pathlib import Path
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import select from sqlalchemy import select
from ..build_info import FC_CHANNEL as _FC_CHANNEL
from ..extensions import get_session from ..extensions import get_session
from ..models import AppSetting from ..models import AppSetting
from ..services.extension_service import ( from ..services.extension_service import (
@@ -33,10 +33,13 @@ XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$") _XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
# Which channel this image belongs to — "dev" or "main" — baked in at build # Which channel this image belongs to — "dev" or "main" — baked in at build
# time from the FC_CHANNEL build arg (milestone 271 step 7). Empty for a local # time (milestone 271 step 7). Read from build_info rather than the environment
# build, or for any image predating the field. Tests override by monkeypatching # a second time: /api/health reports the same value, and two independent
# this constant, same as XPI_DIR above. # `os.environ.get` calls are two things that can drift.
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip() #
# Still bound as a module-level name here, so tests monkeypatch
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
FC_CHANNEL = _FC_CHANNEL
async def _ext_key_required(session) -> bool: async def _ext_key_required(session) -> bool:
+17 -2
View File
@@ -1,5 +1,20 @@
"""Health endpoint — no DB or Redis touch; just liveness.""" """Health endpoint — no DB or Redis touch; liveness, plus the build's identity.
The identity rides here rather than on a route of its own because it answers
at the same cost: two module constants, no I/O, nothing that can be slow or
fail. It is also already fetched app-wide — TopNav calls `refreshHealth` on
mount — so a separate endpoint would mean a second request for two strings.
Both fields are OMITTED when unset rather than sent empty. See build_info.
"""
from ..build_info import FC_CHANNEL, FC_VERSION
async def get_health(): async def get_health():
return {"status": "ok"}, 200 body = {"status": "ok"}
if FC_VERSION:
body["version"] = FC_VERSION
if FC_CHANNEL:
body["channel"] = FC_CHANNEL
return body, 200
+30
View File
@@ -0,0 +1,30 @@
"""What this build IS — stamped at image build time, not configurable.
Deliberately separate from `config.py`. Those are operator settings, read from
the environment and meant to be changed. These describe the artifact itself and
are baked in by CI (the `FC_VERSION` / `FC_CHANNEL` build args); an operator
setting them by hand is not a supported thing to do, it is just how a value
gets from the build into the running process.
**Absent rather than empty when unknown.** A locally-built image has no version,
and neither did any image predating the field — one spelling of "cannot say",
which every reader already has to handle, instead of a second one to
special-case (note #3127 §7).
**Why this matters more than it used to.** Milestone 318 stopped publishing
version image tags, so a running instance's self-report is now the *only*
answer to "which build is this?" — there is no registry name left to check it
against. A wrong value here has nothing to contradict it. That is why the UI
renders `unknown` rather than a blank or a plausible default: an empty footer
reads as "no version", which is a different and false claim.
The channel lives BESIDE the version and is never folded into it (rule 149).
A `-dev` suffix would be parsed by the extension's comparator as a segment
worth 0, making every dev build compare equal to every other — issue #2993's
exact failure.
"""
import os
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
+25 -1
View File
@@ -5,6 +5,18 @@ import { useApi } from '../composables/useApi.js'
export const useSystemStore = defineStore('system', () => { export const useSystemStore = defineStore('system', () => {
const api = useApi() const api = useApi()
const healthy = ref(null) // null=unknown, true=ok, false=down const healthy = ref(null) // null=unknown, true=ok, false=down
// What the instance says it is. Since milestone 318 stopped publishing
// version image tags, this is the only answer to "which build is this?" —
// there is no registry name left to check it against.
//
// Three states, and collapsing any two of them would lie:
// buildLoaded=false we have not asked yet -> render nothing
// buildLoaded=true, version='' the build cannot say -> render "unknown"
// buildLoaded=true, version=x this build is x
// A blank footer would read as "no version", which is a different claim.
const buildVersion = ref('')
const buildChannel = ref('')
const buildLoaded = ref(false)
const stats = ref(null) const stats = ref(null)
const statsLoading = ref(false) const statsLoading = ref(false)
@@ -12,8 +24,17 @@ export const useSystemStore = defineStore('system', () => {
try { try {
const body = await api.get('/api/health') const body = await api.get('/api/health')
healthy.value = body.status === 'ok' healthy.value = body.status === 'ok'
// Absent means "cannot say" — the server omits these rather than
// sending empty strings, so `?? ''` preserves that rather than
// inventing a value for it.
buildVersion.value = body.version ?? ''
buildChannel.value = body.channel ?? ''
buildLoaded.value = true
} catch { } catch {
healthy.value = false healthy.value = false
// Deliberately NOT setting buildLoaded: a failed health call tells us
// nothing about the build, and claiming "unknown" would present a
// network blip as a defective image.
} }
} }
@@ -26,5 +47,8 @@ export const useSystemStore = defineStore('system', () => {
} }
} }
return { healthy, stats, statsLoading, refreshHealth, refreshStats } return {
healthy, stats, statsLoading, refreshHealth, refreshStats,
buildVersion, buildChannel, buildLoaded,
}
}) })
+15
View File
@@ -54,6 +54,21 @@
<MaintenancePanel /> <MaintenancePanel />
</v-window-item> </v-window-item>
</v-window> </v-window>
<!-- Which build is this? With no version image tags (milestone 318) the
instance's own report is the only answer, so it is shown rather than
hidden. The instinct to treat it as information disclosure does not
survive contact: the JS bundle and asset hashes fingerprint the build
anyway, and "I'm on 2026.08.28.1249" is the single most useful line in
a bug report.
Channel sits BESIDE the version, never inside it (rule 149) — a
`-dev` suffix would read as a 0 segment to the extension's comparator
and make every dev build compare equal (#2993). -->
<div v-if="system.buildLoaded" class="text-caption text-medium-emphasis text-center mt-8">
FabledCurator {{ system.buildVersion || 'unknown' }}
<span v-if="system.buildChannel"> · {{ system.buildChannel }}</span>
</div>
</v-container> </v-container>
</template> </template>
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSystemStore } from '../src/stores/system.js'
// Which build am I running? Milestone 318 stopped publishing version image
// tags, so the instance's own report is the ONLY answer — there is no registry
// name left to check it against. That promotes this from a convenience to the
// mechanism, and it means the three states below have to stay distinct: a
// wrong answer here has nothing to contradict it.
//
// not asked yet -> render nothing
// asked, no version -> render "unknown"
// asked, has a version -> render it
//
// Collapsing the first two would show "unknown" during every page load, and
// collapsing either into a blank would read as "no version", which is a
// different and false claim.
function stubHealth(body, { fail = false } = {}) {
globalThis.fetch = vi.fn(async () => {
if (fail) throw new Error('network down')
return {
ok: true, status: 200, statusText: '200',
text: async () => JSON.stringify(body),
}
})
}
describe('system store — build identity', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => { vi.restoreAllMocks(); delete globalThis.fetch })
it('starts having asked nothing, so the footer renders nothing', () => {
const s = useSystemStore()
expect(s.buildLoaded).toBe(false)
})
it('reports the version and channel the instance claims', async () => {
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildLoaded).toBe(true)
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildChannel).toBe('dev')
})
it('keeps the channel OUT of the version string', async () => {
// The tempting shortcut is a `-dev` suffix. The extension's comparator
// parses each dotted segment with parseInt, so a suffixed segment reads as
// 0 and every dev build compares equal to every other — #2993 exactly
// (rule 149). If anyone ever "simplifies" by folding them together, the
// version stops being the bare derived number and this fails.
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildVersion).not.toContain('dev')
})
it('treats an absent version as "cannot say", not as a value', async () => {
// A locally-built image, or one predating the field. The server omits the
// key rather than sending an empty string; `?? ''` must preserve that
// rather than inventing something. The view renders "unknown" from it.
stubHealth({ status: 'ok' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildLoaded).toBe(true)
expect(s.buildVersion).toBe('')
expect(s.buildChannel).toBe('')
})
it('reports a version with no channel without inventing one', async () => {
stubHealth({ status: 'ok', version: '2026.08.28.1249' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildChannel).toBe('')
})
it('does not claim "unknown" when the health call itself failed', async () => {
// A network blip says nothing about the build. Marking it loaded here
// would present a transient failure as a defective image — and since
// nothing else names the build, there would be no second source to
// correct the impression.
stubHealth(null, { fail: true })
const s = useSystemStore()
await s.refreshHealth()
expect(s.healthy).toBe(false)
expect(s.buildLoaded).toBe(false)
})
})
+60
View File
@@ -9,3 +9,63 @@ async def test_health_returns_ok(client):
assert response.status_code == 200 assert response.status_code == 200
body = await response.get_json() body = await response.get_json()
assert body == {"status": "ok"} assert body == {"status": "ok"}
# --- build identity (milestone 318 step 6) --------------------------------
#
# With no version image tags left, /api/health is the only place an instance
# says which build it is. Both fields are OMITTED when unset rather than sent
# empty: absence already means "cannot say" — an image predating the field
# says exactly that by not having the key — and a second spelling would make
# every reader special-case it (note #3127 §7).
#
# The test above is load-bearing for that: it asserts the body is EXACTLY
# {"status": "ok"} when nothing is stamped, so a well-meaning `or ""` default
# fails it.
@pytest.mark.asyncio
async def test_health_reports_the_build_it_is(client, monkeypatch):
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
body = await (await client.get("/api/health")).get_json()
assert body == {
"status": "ok",
"version": "2026.08.28.1249",
"channel": "dev",
}
@pytest.mark.asyncio
async def test_health_keeps_the_channel_out_of_the_version(client, monkeypatch):
"""Rule 149, asserted rather than assumed.
The tempting shortcut is a `-dev` suffix on the version. The extension's
comparator parses each dotted segment with `parseInt`, so a suffixed
segment reads as 0 and every dev build compares equal to every other —
#2993 exactly. Two separate keys cannot express that mistake.
"""
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
body = await (await client.get("/api/health")).get_json()
assert body["version"] == "2026.08.28.1249"
assert "dev" not in body["version"]
@pytest.mark.asyncio
async def test_health_omits_a_channel_it_cannot_name(client, monkeypatch):
"""A locally-built image has a version but no channel. It must not gain an
empty one — the key's absence is the answer."""
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "")
body = await (await client.get("/api/health")).get_json()
assert body == {"status": "ok", "version": "2026.08.28.1249"}