From e3e4cedc779137f2dea23fe91ccae612505abd31 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 11:52:00 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(ui):=20Erudit=20blank=20tiles=20carry?= =?UTF-8?q?=20the=20star=20(=E2=9C=BB)=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Erudit variant's blank is the "звёздочка", so render it with a star. An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank keeps its designated letter and carries ✻ where the (absent) point value sits — on the board and in the Stats best-move tiles. The Scrabble variants are unchanged. Gated by usesStarBlank() in lib/variants.ts. --- docs/UI_DESIGN.md | 5 ++++- ui/src/components/WordTiles.svelte | 22 ++++++++++++++++------ ui/src/game/Board.svelte | 11 ++++++++++- ui/src/game/Game.svelte | 4 ++-- ui/src/game/Rack.svelte | 17 +++++++++++++++-- ui/src/lib/variants.test.ts | 15 +++++++++++++++ ui/src/lib/variants.ts | 14 ++++++++++++++ ui/src/screens/Stats.svelte | 2 +- 8 files changed, 77 insertions(+), 13 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a488475..3c24066 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -109,7 +109,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib ## Tiles & board - **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the - value), the point value bottom-right; blanks show no value. + value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the + "звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack + tile, and a placed blank carries it in the value corner; the Scrabble variants leave the + blank unmarked (`usesStarBlank` in `lib/variants.ts`). - **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing the board's width** inside a fixed-size viewport (a real layout change → native scroll that works consistently across browsers; no `transform`, which broke scrolling diff --git a/ui/src/components/WordTiles.svelte b/ui/src/components/WordTiles.svelte index 1a70ff4..c170558 100644 --- a/ui/src/components/WordTiles.svelte +++ b/ui/src/components/WordTiles.svelte @@ -1,12 +1,14 @@ @@ -15,7 +17,11 @@ {#each word as tile, i (i)} {/each} @@ -50,4 +56,8 @@ font-size: 7px; font-weight: 600; } + /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits. */ + .blankmark { + font-size: 10px; + } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 11256b9..024c0e3 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -4,6 +4,7 @@ import type { Premium } from '../lib/premiums'; import { valueForLetter } from '../lib/alphabet'; import type { Variant } from '../lib/model'; + import { usesStarBlank, BLANK_STAR } from '../lib/variants'; import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels'; import type { Locale } from '../lib/i18n/catalog'; @@ -254,7 +255,11 @@ > {#if letter} {letter} - {#if !blank}{valueForLetter(variant, letter)}{/if} + {#if !blank} + {valueForLetter(variant, letter)} + {:else if usesStarBlank(variant)} + {BLANK_STAR} + {/if} {:else if r === centre.row && c === centre.col} {:else if bl?.kind === 'single'} @@ -410,6 +415,10 @@ font-size: 2.4cqw; font-weight: 600; } + /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits. */ + .blankmark { + font-size: 3.2cqw; + } .star { position: absolute; inset: 0; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index d05ffd9..cb967cc 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -17,7 +17,7 @@ import { badgeKind } from '../lib/unread'; import { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; - import { variantNameKey } from '../lib/variants'; + import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; import { shareOrDownloadGcg } from '../lib/share'; @@ -1293,7 +1293,7 @@ {#if drag}
- {drag.blank ? '' : drag.letter} + {drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}
{/if} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index efe7cee..7289a7e 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -3,6 +3,7 @@ import { BLANK } from '../lib/placement'; import { valueForLetter } from '../lib/alphabet'; import type { Variant } from '../lib/model'; + import { usesStarBlank, BLANK_STAR } from '../lib/variants'; let { slots, @@ -66,8 +67,12 @@ animate:hop={shuffling} onpointerdown={(e) => ondown(e, slot.index)} > - {slot.letter === BLANK ? '' : slot.letter} - {#if slot.letter !== BLANK}{valueForLetter(variant, slot.letter)}{/if} + {#if slot.letter === BLANK} + {#if usesStarBlank(variant)}{BLANK_STAR}{/if} + {:else} + {slot.letter} + {valueForLetter(variant, slot.letter)} + {/if} {/each} @@ -133,4 +138,12 @@ font-size: 0.7rem; font-weight: 600; } + /* Erudit's blank ("звёздочка") shows its star centred on the otherwise empty tile face. */ + .star { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 1.5rem; + } diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index e5e5712..e8a208d 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -5,6 +5,8 @@ import { availableVariants, supportsMultipleWordsToggle, multipleWordsForRequest, + usesStarBlank, + BLANK_STAR, } from './variants'; describe('ALL_VARIANTS', () => { @@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => { expect(multipleWordsForRequest('scrabble_en', true)).toBe(true); }); }); + +describe('usesStarBlank', () => { + it('marks the blank with a star for Erudit only', () => { + expect(usesStarBlank('erudit_ru')).toBe(true); + expect(usesStarBlank('scrabble_ru')).toBe(false); + expect(usesStarBlank('scrabble_en')).toBe(false); + }); + + it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => { + expect(BLANK_STAR).toBe('✻'); + expect(BLANK_STAR.codePointAt(0)).toBe(0x273b); + }); +}); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index f317a50..5981007 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record = { // ru -> Russian + Эрудит. export const VARIANT_LANGUAGE: Record = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' }; +// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the +// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY +// TEARDROP-SPOKED ASTERISK. +export const BLANK_STAR = '✻'; + +// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only +// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it +// in the value corner (the corner is free — a blank has no point value). The Scrabble +// variants leave the blank unmarked (an empty rack face; a placed blank shown by its +// designated letter alone). +export function usesStarBlank(v: Variant): boolean { + return v === 'erudit_ru'; +} + // availableVariants gates ALL_VARIANTS by the player's variant preferences (the set // they enabled in Settings). An empty or absent set is ungated (returns every variant) // — a safety fallback; a real profile always carries at least one preference. diff --git a/ui/src/screens/Stats.svelte b/ui/src/screens/Stats.svelte index 74c48f1..56f9672 100644 --- a/ui/src/screens/Stats.svelte +++ b/ui/src/screens/Stats.svelte @@ -73,7 +73,7 @@ {#each bestMoves as bm (bm.variant)} {t(variantNameKey(bm.variant))} {bm.score} - + {/each} -- 2.52.0 From a393561d790375a0d1045dbe9cd6de13bfd4564d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 12:11:35 +0200 Subject: [PATCH 2/6] fix(ui): align the Erudit blank star with neighbouring tiles Rack: the empty-blank star is now top-anchored level with the letters and a touch larger (was centred, sitting low). Board and Stats best-move tiles: the placed-blank star's ink is centred on the value digits' line (was slightly high). CSS-only nudges; pixel offsets measured against the rendered glyphs. --- ui/src/components/WordTiles.svelte | 6 ++++-- ui/src/game/Board.svelte | 6 ++++-- ui/src/game/Rack.svelte | 12 +++++++----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/src/components/WordTiles.svelte b/ui/src/components/WordTiles.svelte index c170558..6bfb652 100644 --- a/ui/src/components/WordTiles.svelte +++ b/ui/src/components/WordTiles.svelte @@ -56,8 +56,10 @@ font-size: 7px; font-weight: 600; } - /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits. */ + /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits, + its ink kept on the value digits' line (mirrors the board tile). */ .blankmark { - font-size: 10px; + font-size: 8px; + bottom: 0; } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 024c0e3..a229155 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -415,9 +415,11 @@ font-size: 2.4cqw; font-weight: 600; } - /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits. */ + /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits, + its ink centred on the same line as a neighbouring tile's value digit. */ .blankmark { - font-size: 3.2cqw; + font-size: 2.8cqw; + bottom: 0; } .star { position: absolute; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 7289a7e..448718b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -138,12 +138,14 @@ font-size: 0.7rem; font-weight: 600; } - /* Erudit's blank ("звёздочка") shows its star centred on the otherwise empty tile face. */ + /* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty + tile face, top-aligned with the neighbouring letters (slightly larger so it reads). */ .star { position: absolute; - inset: 0; - display: grid; - place-items: center; - font-size: 1.5rem; + top: 8%; + left: 0; + right: 0; + text-align: center; + font-size: 1.7rem; } -- 2.52.0 From 81680a1d5e60adeb48d3b977342c3e819d00c731 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 12:22:48 +0200 Subject: [PATCH 3/6] fix(ui): raise the rack blank star to centre on the letters Top-aligning it still read a touch low; move the empty-blank star up (top 8% -> 0.5%) so its ink centres against the rack letters' block, matching the board tile's centred mark. Size unchanged. --- ui/src/game/Rack.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 448718b..98ac3c4 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -139,10 +139,11 @@ font-weight: 600; } /* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty - tile face, top-aligned with the neighbouring letters (slightly larger so it reads). */ + tile face; the small top offset centres its ink against the neighbouring letters' block + (it is slightly larger than them so it reads). */ .star { position: absolute; - top: 8%; + top: 0.5%; left: 0; right: 0; text-align: center; -- 2.52.0 From 62f66735a35ed2d025dac24024128540090aeee0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 12:55:06 +0200 Subject: [PATCH 4/6] fix(ui): nudge the rack blank star up a pixel By eye the centred star still read a hair low; subtract 1px from its top offset (top: calc(0.5% - 1px)). --- ui/src/game/Rack.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 98ac3c4..ec81591 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -139,11 +139,11 @@ font-weight: 600; } /* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty - tile face; the small top offset centres its ink against the neighbouring letters' block - (it is slightly larger than them so it reads). */ + tile face; the top offset centres its ink against the neighbouring letters' block, nudged + up a pixel to sit right by eye (it is slightly larger than them so it reads). */ .star { position: absolute; - top: 0.5%; + top: calc(0.5% - 1px); left: 0; right: 0; text-align: center; -- 2.52.0 From bb0e3e17e5ff34ff6981753f0470568dd72785e9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 14:45:52 +0200 Subject: [PATCH 5/6] chore(deploy): pin dictionary seed to v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump DICT_VERSION v1.2.1 -> v1.3.0 across the seed surface: .env.example, the compose build-arg default, both backend Dockerfile stages, the loadtest Dockerfile, the CI dawg-download version, and the deploy/backend docs. v1.3.0 drops the abbreviation class from the Russian word list (scrabble-dictionary #6). This only seeds a FRESH volume; a live contour/prod volume is unaffected (the .seed_version marker wins — seed-drift guard) and moves to v1.3.0 through the admin console (ARCHITECTURE §5). Per-contour deploy still overrides via TEST_/PROD_DICT_VERSION. --- .gitea/workflows/ci.yaml | 2 +- backend/Dockerfile | 4 ++-- backend/README.md | 2 +- deploy/.env.example | 2 +- deploy/README.md | 2 +- deploy/docker-compose.yml | 2 +- loadtest/Dockerfile | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index fe5a438..d11622f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -31,7 +31,7 @@ on: # unit/integration jobs inherit it. The deploy job overrides it per contour with # vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. env: - DICT_VERSION: v1.2.1 + DICT_VERSION: v1.3.0 jobs: # changes detects which areas a PR/push touched, so the test jobs can skip when diff --git a/backend/Dockerfile b/backend/Dockerfile index a95c7e8..bc7bb97 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,7 @@ # --- dictionary artifact ----------------------------------------------------- FROM alpine:3.20 AS dawg -ARG DICT_VERSION=v1.2.1 +ARG DICT_VERSION=v1.3.0 RUN apk add --no-cache curl tar RUN mkdir -p /dawg \ && curl -fsSL -o /tmp/dawg.tar.gz \ @@ -42,7 +42,7 @@ FROM gcr.io/distroless/static-debian12:nonroot # Re-declare the build arg in this stage so it labels the seed dictionary. One # DICT_VERSION drives both the artifact the dawg stage downloads and the version # label the binary pins, so the resident version equals the release tag. -ARG DICT_VERSION=v1.2.1 +ARG DICT_VERSION=v1.3.0 COPY --from=build /out/backend /usr/local/bin/backend # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # mounted at /opt/dawg inherits this ownership on first use, so the admin console diff --git a/backend/README.md b/backend/README.md index a833500..5a6230b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p ```sh docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): -mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg +mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_DICT_DIR=/tmp/dawg \ GOPRIVATE='gitea.iliadenisov.ru/*' \ diff --git a/deploy/.env.example b/deploy/.env.example index 584b9af..027b307 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required # the active version lives in the DB. On a live volume a changed value is ignored (the # recorded .seed_version marker wins — the seed-drift guard); change a running # contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). -DICT_VERSION=v1.2.1 +DICT_VERSION=v1.3.0 # --- Logging ---------------------------------------------------------------- LOG_LEVEL=info diff --git a/deploy/README.md b/deploy/README.md index 7007af3..24cc2cf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | --- | --- | --- | --- | | `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_USER` | variable | `scrabble` | Database user. | -| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | +| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 12f7cb4..143eade 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -70,7 +70,7 @@ services: args: # Seed dictionary for a FRESH volume; the per-contour value comes from the # deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below. - DICT_VERSION: ${DICT_VERSION:-v1.2.1} + DICT_VERSION: ${DICT_VERSION:-v1.3.0} # Build version stamped into the binary (git tag; see pkg/version). VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/loadtest/Dockerfile b/loadtest/Dockerfile index 410bd34..1103051 100644 --- a/loadtest/Dockerfile +++ b/loadtest/Dockerfile @@ -12,7 +12,7 @@ # --- dictionary artifact ----------------------------------------------------- FROM alpine:3.20 AS dawg -ARG DICT_VERSION=v1.2.1 +ARG DICT_VERSION=v1.3.0 RUN apk add --no-cache curl tar RUN mkdir -p /dawg \ && curl -fsSL -o /tmp/dawg.tar.gz \ -- 2.52.0 From 1ba52dd0b4ba65774c10c8a080ff9a26602f5c33 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 15:03:07 +0200 Subject: [PATCH 6/6] refactor(deploy): make DICT_VERSION a required build arg (single-sourced) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the literal version default from the build files (backend Dockerfile both stages, loadtest Dockerfile, the compose build-arg) so the release tag is not duplicated as a stale-prone default a newcomer can't tell from the real source. DICT_VERSION is now required: compose uses ${DICT_VERSION:?…} and the Dockerfiles have no ARG default, so a missing value fails loudly instead of baking a stale tag. The tag lives only in its genuine sources — ci.yaml env (CI tests), the Gitea TEST_/PROD_DICT_VERSION variables (deploy seed) and deploy/.env.example (local). Adds a "Bumping the dictionary version" section to deploy/README and fixes the bare docker-build examples (CLAUDE.md, README.md, loadtest/README) to pass --build-arg. --- CLAUDE.md | 2 +- README.md | 2 +- backend/Dockerfile | 10 ++++++---- deploy/README.md | 22 ++++++++++++++++++++++ deploy/docker-compose.yml | 8 +++++--- loadtest/Dockerfile | 3 ++- loadtest/README.md | 4 ++-- 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11f5b73..75321f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080 cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI pnpm start # UI mock mode: lobby -> game, no backend -docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA +docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing docker compose -f deploy/docker-compose.yml config # validate the full contour diff --git a/README.md b/README.md index 5bab9a1..354adce 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron services build from multi-stage distroless `*/Dockerfile`. ```sh -docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact +docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env) ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index bc7bb97..b593d33 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,12 +7,14 @@ # (GOPRIVATE), so the build stage needs git and network. # # Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all -# in the Docker context: -# docker build -f backend/Dockerfile -t scrabble-backend . +# in the Docker context. DICT_VERSION has no default — the caller supplies the +# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md +# "Bumping the dictionary version"): +# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # --- dictionary artifact ----------------------------------------------------- FROM alpine:3.20 AS dawg -ARG DICT_VERSION=v1.3.0 +ARG DICT_VERSION RUN apk add --no-cache curl tar RUN mkdir -p /dawg \ && curl -fsSL -o /tmp/dawg.tar.gz \ @@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot # Re-declare the build arg in this stage so it labels the seed dictionary. One # DICT_VERSION drives both the artifact the dawg stage downloads and the version # label the binary pins, so the resident version equals the release tag. -ARG DICT_VERSION=v1.3.0 +ARG DICT_VERSION COPY --from=build /out/backend /usr/local/bin/backend # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # mounted at /opt/dawg inherits this ownership on first use, so the admin console diff --git a/deploy/README.md b/deploy/README.md index 24cc2cf..89fa882 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -117,6 +117,28 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally **unset** — caddy owns `/_gm` in the contour. +## Bumping the dictionary version + +The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from +[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is +a build-time input with **no default** in the images, so it is set in exactly two places to +move the whole stack — change both to a new release: + +1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs + download that dawg). +2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag + the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as + `DICT_VERSION`). + +For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare +`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no +default — a missing value fails loudly instead of baking a stale tag. + +Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the +seed-drift guard). A running contour/prod moves to a new release **through the admin console** +`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games +keep their pinned version, new games use the new one (ARCHITECTURE.md §5). + ## Production rollout Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only, diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 143eade..369aa18 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -68,9 +68,11 @@ services: context: .. dockerfile: backend/Dockerfile args: - # Seed dictionary for a FRESH volume; the per-contour value comes from the - # deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below. - DICT_VERSION: ${DICT_VERSION:-v1.3.0} + # Seed dictionary for a FRESH volume; required (no default) so the release tag is + # set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION) + # or .env for local builds. See the volume note below + deploy/README.md "Bumping the + # dictionary version". + DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env} # Build version stamped into the binary (git tag; see pkg/version). VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/loadtest/Dockerfile b/loadtest/Dockerfile index 1103051..e9c21a8 100644 --- a/loadtest/Dockerfile +++ b/loadtest/Dockerfile @@ -12,7 +12,8 @@ # --- dictionary artifact ----------------------------------------------------- FROM alpine:3.20 AS dawg -ARG DICT_VERSION=v1.3.0 +# Required, no default: the build caller supplies the scrabble-dictionary release tag. +ARG DICT_VERSION RUN apk add --no-cache curl tar RUN mkdir -p /dawg \ && curl -fsSL -o /tmp/dawg.tar.gz \ diff --git a/loadtest/README.md b/loadtest/README.md index 1af9c04..4a34223 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot container on the contour's docker network (this bypasses the host→gateway hairpin): ```sh -# from the repo root -docker build -f loadtest/Dockerfile -t scrabble-loadtest . +# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag) +docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest . docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ -e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \ -- 2.52.0