The dev → main promotion could not merge: `main` sat 22 commits ahead of `dev`
with content dev had never seen, and git found genuine conflicts in CLAUDE.md
and README.md plus modify/delete on the eight workflow files #87 removed.
The cause is that recent promotions were merged with squash rather than "Create
merge commit". A squash replays dev's commits onto main under new SHAs, so the
two branches share content but not history — main appears to have independently
edited the same files, and every subsequent promotion inherits the divergence.
`promote` warns about exactly this.
Resolution: both conflicts take dev's side, not as a default but because main's
side describes workflows that no longer exist (`*-deploy-dev.yml`,
`backend-build-push` and friends). Keeping it would reintroduce the class of
staleness the root CLAUDE.md now forbids — statements naming files that are not
there. The eight deleted workflows stay deleted; the merge result carries the
nine that #87 left behind.
Verified after resolution: no conflict markers anywhere, no unmerged paths, no
document naming a deleted workflow, all nine workflow files parse, and
shellcheck is clean across the tree.
Promotions from here should use "Create merge commit" so this does not recur.
MonthPage and RecordsPage each made two backend calls sequentially
(CityMonth/CityRecords, then City) where the second never depended on the
first's result. fetchWithCity launches both concurrently via goroutines
and a WaitGroup.
Verified live against a stub with an injected 400ms delay on both
endpoints: city page (1 call) and month/records pages (2 calls each) all
cost ~0.404s now, not double for the two-call pages.
Error priority preserved exactly: primary's error wins even when City
also fails, matching the old sequential code. One trade-off: City() is
now always launched even on a request about to 404 from primary, costing
one extra cheap lookup on that rare path.
Tests include a deterministic concurrency proof via rendezvous channels
(the old sequential code would deadlock this test, not just run it
slower). Full suite green under -race -count=2.
docker compose config --images daemon does not filter to the named service
on this host's Compose v5.3.1 -- it prints every service's image, one per
line, in file order, so `| head -1` was silently grabbing db's image
(timescaledb) instead of daemon's. The probe then always found no
/usr/local/bin/thermograph-daemon in a Postgres image and dropped daemon
from every backend deploy, regardless of what the real backend image
contained.
Fixed by building the image reference directly from the same vars
docker-compose.yml's daemon.image: already interpolates, instead of going
through docker compose config at all.
Reproduced against beta directly: the old sequence selected the wrong
image; the new construction resolves correctly and the binary probe
passes.
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.
Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:
- Render-context keys were snake_case throughout while the templates read
PascalCase fields. A missing map key doesn't error, it silently renders
empty -- title, meta description, canonical URL, OpenGraph tags, and the
homepage's entire ranked list were blank on every page despite every
route returning 200. Fixed by renaming every key to match each
template's own documented field contract, and passing API structs
straight through wherever their fields already matched (removes a whole
layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
"lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
JAVASCRIPT context to html/template's escaper regardless of the
script's type attribute, so template.HTML gets re-escaped as a quoted
JS string. Needed template.JS. The glossary term page's JSON-LD was
never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
output (verified in isolation) -- both need a FuncMap function
returning template.HTML/template.JS to survive.
Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.
go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.