Конвенції ProjectK
Цей файл описує один патрон, до якого зведено код у релізі 0.19.0. Він існує тому, що розбіжності розповзаються тихо: до 0.19.0 у бекенді співіснували чотири розкладки модулів, а на фронті — чотирнадцять класів розбіжностей іменування. Якщо нова конвенція потрібна — спершу зміни цей файл, потім код.
Якщо ти прийшов ззовні: спершу issue з ідеєю
чи помилкою — обговоримо, чи й як це робити. Форк — лише щоб запропонувати зміни сюди через pull
request; внесок стає частиною Лілейки на умовах LICENSE, розділ 4.
Бекенд
Section titled “Бекенд”ProjectK.Common → (нічого)ProjectK.Infrastructure → CommonProjectK.BusinessLogic → CommonProjectK.API → Common, BusinessLogic, InfrastructureInfrastructure і BusinessLogic не знають одне про одного. Коли бізнес-логіці треба щось із
зовнішнього світу — інтерфейс оголошується в Common, а реалізується в Infrastructure
(наприклад IKurinReportSource, ISpreadsheetWriter, IAppUserRepository).
Що де лежить:
| Шар | Що | Чого там не буває |
|---|---|---|
Common |
сутності, інтерфейси, DTO, налаштування, чисті правила (RolePermissionMap, AgendaVisibility) |
ASP.NET-типи (IFormFile, ControllerBase), EF-типи |
Infrastructure |
EF, репозиторії, блоби, пошта, логери, фонові служби, сідинг, рендеринг PDF | доменні рішення |
BusinessLogic |
хендлери, доменні служби, мапінг-профілі | Microsoft.EntityFrameworkCore, HTTP |
API |
контролери, middleware, серіалізація, DI-хост | доменна логіка, прямі звернення до AppDbContext |
Папки Services/ в API немає і не має з’являтися. Якщо новий клас тягне на «сервіс» — його
місце в BusinessLogic (рішення) або Infrastructure (зовнішня система).
Кожен шар реєструє своє: AddInfrastructure(), AddBusinessLogic(); в API лишається тільки
хостове (HttpContextAccessor, прив’язка конфігурації, TimeProvider, ICurrentUserContext).
Вертикальні зрізи
Section titled “Вертикальні зрізи”Уся бізнес-логіка живе за одним шаблоном:
Modules/<Module>/Features/<Entity>/<Verb>/ <Verb><Entity>Command.cs // або Query <Verb><Entity>CommandValidator.cs <Verb><Entity>CommandHandler.csЗапит, валідатор і хендлер лежать поруч. Окремих папок Commands/, Queries/ чи Handlers/
не буває — саме їхнє співіснування й давало чотири різні розкладки.
Імена — за суфіксом, і він обовʼязковий: запит, що змінює стан, зветься …Command, запит, що лише
читає, — …Query (GetKurinsQuery, PreviewRosterImportQuery, ExportRegistryQuery);
хендлер — імʼя запиту плюс Handler, валідатор — плюс Validator. Короткий стиль без суфікса
(UpsertMember + UpsertMemberHandler), який жив у KurinModule, знято 2026-09-11 (DEBT-06).
Запит і його хендлер можуть лежати в одному файлі, названому за запитом
(DeleteGroupCommand.cs), — розкладка по файлах не є частиною правила.
Обробники доменних подій (INotificationHandler<DomainEventNotification<…>>) звуться
…EventHandler, щоб не плутатися з хендлерами запитів. Усе це перевіряє
ProjectK.Architecture.Tests → NamingRules.
Поза Features/ у модулі допускаються лише Models/ (типи відповідей), Services/ (доменні
служби, спільні для кількох зрізів — сюди ж ідуть збирачі звітів і розвʼязувачі) і Events/
(обробники чужих доменних подій). Окремих тек на кшталт Reports/ чи Solvers/ не буває: вони
зʼявились як мовчазні винятки й знято 2026-10-09 (DEBT-04).
Межі модулів
Section titled “Межі модулів”Шари кажуть, звідки код бере залежності; межі модулів — у кого він їх бере. Модуль не читає
таблиць сусіда. Три правила, і кожне перевіряється в ProjectK.Architecture.Tests, а не тримається
на пам’яті:
Контракт замість чужого репозиторію. Питання до іншого модуля ставиться через інтерфейс у
Common/Interfaces/Modules/<Module>/ — IMemberDirectory, IMembershipDirectory,
IOfficeDirectory, IMemberProgressDirectory. За межами модуля, якому належить сутність, її імені
не буває: поза мембером ніхто не називає ні Member, ні IMemberRepository. Пиши контракт так,
ніби виклик піде по мережі: читання, що повертає багатьох, робиться одним методом, бо «один за
ключем» перетворює список на N звернень.
Фізично мембер поки лежить у неймспейсі KurinModule — окремої збірки немає й у 0.20.0 не
планувалося. Межа проведена в тестах раніше за переїзд саме тому, що переїзд без неї неможливий.
Подія замість синхронного виклику. Коли після дії має статися щось у чужому модулі —
оголошуй подію (Common/Models/Events/) і публікуй через IDomainEventPublisher. Хендлер, який
сам пише в чужий інбокс, валить Modules_ShouldAnnounceWhatHappened_RatherThanWriteToTheInbox.
Той, хто зробив дію, не має знати, кого вона обходить.
Ключ замість навігації через межу. Сутність чужого модуля не буває навігаційною властивістю:
тримай MemberKey, а не Member. EF-навігація через межу — це прихований JOIN, який не переживе
поділу на сервіси; вона ж робить неможливим видалити щось одне, не потягнувши інше.
Борг, який ще не виправлений, заноситься поіменно в baseline відповідного правила. Baseline тільки коротшає: додати туди новий рядок — це визнати, що правило порушено свідомо, і такий рядок має мати задачу в трекері.
Належність питається в членства
Section titled “Належність питається в членства”Де стоїть людина — відповідає Membership, і ніщо інше. AppUser.KurinKey лишився як носій
одного випадку: засновник нового куреня, у якого членства ще фізично немає, бо курінь створено
хвилину тому. Поле пишеться раз, при відкритті акаунта, і більше ніколи не оновлюється — людина
перейшла в інший курінь або стала в другий, а поле про це не знає.
Тому будь-яке рішення — «чий це юнак», «скільки місць зайнято», «який курінь показати» — береться з
IMembershipDirectory, а не з поля акаунта. Знімок, що виглядає свіжим, небезпечніший за порожнє
поле: він і відмовляє тим, кому має дозволити, і дозволяє тим, кому вже ні.
DTO проти Records
Section titled “DTO проти Records”Common/Models/Dtos/<Module>/— усе, що перетинає межу API: те, що клієнт отримує. Запити — у<Module>/Requests/.Common/Models/Records/— внутрішні типи-значення, які ніколи не серіалізуються клієнту:ServiceResult,ResourceAccessDecision,AgendaViewerScope, результати завантаження блобів.
Перевірка проста: якщо тип згадується у відповіді або запиті контролера — це DTO.
(DateRangeDto довго лежав у Records/, хоч і був у відповіді планування.)
Репозиторії
Section titled “Репозиторії”Успадковують BaseEntityRepository<T>, який дає CRUD; ключ береться з моделі EF, тож сутність не
мусить називати його однаково. Перевизначай лише те, що справді відрізняється — eager loading,
звужений запит, свідома відмова від GetAllAsync.
Репозиторії групуються за модулем (Repositories/<Module>/) і доступні тільки через
IUnitOfWork — окремо вони не реєструються, бо це створювало б другий екземпляр на той самий
DbContext.
Запити для видалення — окремі від читальних. Читальний метод часто тягне граф і AsNoTracking;
для видалення це отрута: кожна сутність несе власну відчеплену копію батька, і при Remove EF
відмовляє, бо той батько вже відстежується. Пиши голий трекований запит
(MemberRepository.GetAllAsync(groupKey)), а не перевикористовуй читальний.
Що не прибирає база — прибирай перед видаленням. Іменуй такі методи від дії й документуй, чому
вони існують: ClearCategoryAsync, DeleteForGroupAsync, RemoveAssignmentsForTargetsAsync.
Два випадки, коли база не допоможе: зв’язок Restrict (провід гуртка) і посилання без зовнішнього
ключа (AgendaAssignment.TargetKey вказує на чотири різні таблиці). Роби це через трекер, а не
ExecuteDelete, — інакше воно закомітиться окремо, поперед SaveChanges.
Міграції
Section titled “Міграції”Міграція має бути ідемпотентною: WHERE NOT EXISTS, IF NOT EXISTS, guard перед drop. Її
застосовують на порожній базі та на копії з даними, і вона не має нашкодити, якщо виконається двічі.
Міграція, що знімає колонку, з якої дані кудись переїхали, спершу перевіряє переїзд і падає з
власним повідомленням, якщо щось не перенесено. Мовчазна втрата даних гірша за зупинений деплой —
див. DropMemberPlacementColumns, який THROW-ить, поки хоч одне розміщення не віддзеркалене в
Memberships.
Числа enum, що зберігаються, — заморожені
Section titled “Числа enum, що зберігаються, — заморожені”Enum із HasConversion<int>() лежить у базі числами, тож порядок його членів — це формат даних.
Вставка значення посередині зсуває всі наступні на одиницю, і кожен наявний рядок мовчки міняє
значення: Uchasnyk стає Rozviduvach, Skob — гетьманським.
Тому: наявні члени мають явні номери й не переставляються, а новий дописується в кінець із
наступним вільним. Якщо порядок щось означає, він живе окремо — див. PlastLadder, де записана
справжня послідовність ступенів, і Prykhylnyk = 10 стоїть у ній другим.
Ніщо не має порівнювати такі значення через < або >.
Одне значення — один підпис
Section titled “Одне значення — один підпис”Те, що людина читає з екрана й зі звіту, має бути тим самим словом. Підписи ступенів жили в трьох
таблицях — у реєстрі, у вивантаженні й у PDF-звіті, — і встигли розійтися: той самий Uchasnyk був
і «Заприсяження», і «Учасник», і «пл. уч.». Провід, що читає реєстр поруч зі звітом, мусив
здогадуватись, що це один рядок драбини.
Тепер підпис живе в одному місці на весь бекенд (PlastLevelNames), а фронт його дзеркалить.
Заводити локальну табличку підписів для нового екрана не можна — беріть наявну.
Виняток, який лишається навмисно: PLAST_LEVEL_DISPLAY_MAP на фронті — це префікс перед іменем
(«пл. скоб Іван Петренко»), а не назва рядка таблиці, тож «пл. скоб / вірл.» там був би неправильний.
Так само з правилами, а не лише з підписами: де проходить межа між юнацтвом і кадрою, сказано в
KurinRoster, бо реєстр рахує це в SQL, а звіт — у пам’яті, і два місця вже давали дві різні
відповіді. Тест GetListItemsByKurinKeyAsync_ShouldAgreeWithTheSharedStaffRule тримає їх разом.
Час читача приносить браузер
Section titled “Час читача приносить браузер”У базі й у логіці час — UTC, і це не міняється. Але документ, який людина відкриває й читає
(PDF-звіт куреня), має показувати години, які показував її годинник. Сервер поясу читача не
знає й знати не може — тож пояс їде параметром запиту (?timeZone=Europe/Kyiv), а бере його фронт із
Intl.DateTimeFormat().resolvedOptions().timeZone.
Пояс приходить ззовні, тож нерозпізнаний або порожній — це UTC, а не помилка: краще незручні години,
ніж звіт, який не зібрався. Перетворення живе в ReportClock; там же примусово ставиться
DateTimeKind.Utc, бо EF віддає datetime2 як Unspecified, і без цього результат залежав би від
поясу самого сервера.
Відповіді та помилки
Section titled “Відповіді та помилки”Кожна невдача повертається як { "error": "<StableCode>", "message": "<English text>" }.
У хендлері це ServiceResult<T>.Failure(type, code, message), у контролері — this.Failure(...).
Текст помилки не кладеться в Data.
error — стабільний код, за яким UI обирає формулювання. message англійською і призначений
розробнику; користувацький текст пише фронт (див. LOGIN_ERROR_TEXT).
Усі DateTime серіалізуються як UTC із Z — це робить UtcDateTimeConverter, вручну позначати
DateTimeKind не треба.
TimeProvider впорскується там, де час вирішує: терміни дії токенів і запрошень, вікна
перестороги, діапазон агенди за замовчуванням. Для простих позначок часу (CreatedAtUtc,
логування, сідери) далі використовується DateTime.UtcNow — інʼєкція там нічого не дає.
Мапінг
Section titled “Мапінг”Один спосіб — AutoMapper, профілі в BusinessLogic/MappingProfiles/. Мапінг у DTO має бути
повним; часткові мапінги в сутність позначаються MemberList.None із поясненням, бо решту полів
заповнює хендлер. MappingConfigurationTests не дасть додати мапу з незаповленим полем.
Складні відповіді, які збираються з кількох джерел із довідниками (логін, агенда, звіт), лишаються явними фабриками — профіль їх чесно не виражає.
Кодування і формат
Section titled “Кодування і формат”Усі файли — UTF-8 без BOM. (Один файл довго лежав у cp1251 і мовчки випадав з усіх автоматичних
правок; 329 інших несли BOM, якого .editorconfig не просить.)
Формат бекенду задає .editorconfig, а виконує dotnet format: file-scoped namespaces, жодних
зайвих using, відступи й пробіли за правилами. З 2026-09-11 увесь бекенд приведено до цього одним
проходом (FMT-01), і CI перевіряє його advisory-кроком «Check formatting». Перед PR:
dotnet format Backend/ProjectK.Backend/ProjectK.Backend.sln --exclude "**/Migrations/**"Міграції EF-генеровані й не форматуються. Нова конвенція формату спершу записується в
.editorconfig, потім застосовується окремим комітом без семантичних змін — інакше вона ховає
справжню зміну в diff-і.
Фронтенд
Section titled “Фронтенд”Іменування
Section titled “Іменування”| Що | Як | Не так |
|---|---|---|
| Файл компонента | member-card.ts |
member-card.component.ts, login-component.ts |
| Шаблон і стилі | member-card.html, member-card.css |
member-card.component.html |
| Клас компонента | MemberCardComponent |
MemberCard |
| Файл сервіса | agenda.service.ts у папці agenda-service/ (або в теці групи: probes-and-badges/) |
agenda-service.ts, голий файл у services/ |
| Файл моделі | member.dto.ts, kurin-scope-option.model.ts, member-probe-row.view.ts, plast-level.enum.ts |
memberDto.ts, kurinScopeOption.ts |
| Файл функції | base64-to-blob.function.ts |
base64ToBlob.function.ts |
| Папка фічі | notificationsModule/ |
notifications/ |
| Стилі в декораторі | styleUrl: './x.css' |
styleUrls: ['./x.css'] |
Імена файлів — лише kebab-case; суфікс каже, що всередині: .dto.ts для того, що приходить з
API, .view.ts для моделей подання, .enum.ts, .model.ts для решти, .function.ts для чистих
функцій. Приведено одним проходом 2026-09-11 (DEBT-07).
Єдиний свідомий виняток — кореневий клас App у app.ts: так його генерує сам Angular і так на
нього посилається бутстрап.
Розкладка модуля
Section titled “Розкладка модуля”Кожен features/<x>Module/ має одну й ту саму розкладку, і теки common/ у ньому немає:
features/<x>Module/ pages/ // те, на що веде маршрут: registry/, member-card/, login/ components/ // те, що вбудовується в сторінки: toolbar-header/, kurin-switcher/ services/ // <name>-service/<name>.service.ts models/ // dto, view, enum, model functions/ // чисті функції guards/, directives/ // за потребиСпільне для кількох модулів лежить у shared/. Що є сторінкою, а що компонентом, вирішує
app.routes.ts: якщо на нього є loadComponent, це pages/.
Кольори в CSS — лише токени (var(--p-…), var(--lil-…)) без hex-фолбеків: тема підключена
завжди, а фолбек лише маскує, що токен названо неправильно. Єдиний hex-список у коді — палітра
категорій агенди (BRANDBOOK.md §2), і це дані, які зберігаються в базі, а не стиль.
Те саме з типографікою й геометрією сторінки. Розміри та ваги заголовків задає тема через голі
h1/h2/h3 і ролеві класи .lil-page, .lil-page-head, .lil-tile-head (BRANDBOOK.md §3, §5);
CSS сторінки їх не повторює і не перевизначає, а Tailwind-класи розміру й ваги (text-xl,
font-bold, text-2xl…) на заголовках не вішають. Ширину колонки сторінки дає .lil-page, а не
власний width: min(100% - 2rem, …). Шапка таблиці всередині плитки — завжди .lil-tile-head у
<ng-template #caption>; локальних копій на кшталт .table-caption не заводять. Причина та сама, що
з кольорами: коли число лежить у пʼяти файлах, воно за пів року лежить у пʼяти різних версіях.
Суфікс Component лишається в класі, але не у файлі: ім’я файла й так лежить у папці, що
його називає, а в шаблоні та імпортах суфікс відрізняє компонент від сервіса чи моделі.
Помилки з API
Section titled “Помилки з API”Бекенд віддає { error, message }. UI мапить код на свій текст і ніколи не показує
HttpErrorResponse.message — Angular завжди заповнює його рядком на кшталт
"Http failure response for ...", тож фолбек на нього гарантовано показує користувачу службовий
текст замість людського.
Відгук на дії
Section titled “Відгук на дії”Кожна дія людини отримує відповідь, і для цього в компоненті нічого писати не треба:
- спінер і захист від повторного натискання дає
UserActionService: він памʼятає, на яку.p-buttonнатиснули, і крутить на ній спінер, доки не прийдуть усі запити, що з цього натискання виросли (зокрема ті, що обробник відповіді запустив слідом). Повторні натискання на кнопку, що крутиться, гасяться. Власний[loading]лишається дозволеним — тоді видно лише його; - тост дає
RequestFeedbackInterceptor: мутація, яку запустила людина, — «Збережено» (або «Видалено»), помилка — тост за статусом (400 — перевір поля, 403 — немає доступу, 404, 409, 5xx — помилка на сервері, 0 — немає звʼязку). 401 — справаAuthInterceptor. Помилка фонового читання тосту не дає: її показує сама сторінка.
Власний тост у компоненті — для конкретики («Учасника додано»). Якщо компонент показав тост у
своєму обробнику, загальний мовчить, тож двох тостів про одне не буває. MessageService один на
застосунок: providers: [MessageService] і власний <p-toast /> у сторінці не заводять — такий тост
не бачить дедуплікація і він зникає разом зі сторінкою, з якої пішла навігація.
Запит, про який говорити не треба, позначається в сервісі:
{ context: requestFeedback('silent' | 'errors', [handledStatuses]) } з
shared/functions/request-feedback.function.ts. silent — службові й навмисно мовчазні запити
(оновлення сесії, перевірка доступу, відновлення пароля, яке не каже, чи є акаунт); errors — POST,
що нічого не змінює (вхід, перегляд імпорту, експорт); handledStatuses — статуси, які сторінка
пояснює по-своєму (409 на підписі пункту проби — «уже підписано», а не помилка).
Телефон
Section titled “Телефон”Підказки (pTooltip) на дотиковому екрані не показуються: iOS вважає перший дотик наведенням, якщо
на ньому щось зʼявляється, і клік губиться. Тому все, що несе підказка на кнопці-іконці, має бути і в
ariaLabel. Так само нічого важливого не ховають за :hover (кнопка, що зʼявляється при наведенні,
на телефоні недосяжна з першого дотику) і не зсувають елемент при наведенні.
Рядок таблиці, що веде на картку, — звичайний (click) плюс справжнє посилання на імʼя, а не
selectionMode: повторний дотик до виділеного рядка знімав виділення й нікуди не вів. Телефон і
пошта — посилання (phoneHref, emailHref з shared/functions/contact-href.function.ts).
Випадайки відкриваються під полем (overlayAppendTo: 'body'), і overlayOptions.responsive не
вмикати: модальний режим Optimus ставить обгортці інлайн position: absolute; top: 0, тож у
діалозі чи на прокрученій сторінці список зʼявляється поза екраном, а після закриття лишає маску й
заблокований скрол. e2e/dropdown-overlays.spec.ts ловить обидва випадки.
Фото з телефона важить 5–15 МБ, а API приймає до 6 МБ на запит і обриває більший, не дочитавши, —
браузер бачить це як обрив мережі (статус 0). Тому image-cropper завжди має [resizeToWidth] і
[onlyScaleDown] (фото людини — ще й format="jpeg"), а файл, що йде без кропера, перевіряється
isImageTooLarge ще до відправки.
Вимоги до нового пароля показує <app-password-rules> під полем, а не оверлей
p-password [feedback], який на телефоні закриває клавіатура. Правила — ті самі, що в Program.cs
(PASSWORD_RULES), і форма не пускає пароль, який сервер відхилить.
Сайт і довідка
Section titled “Сайт і довідка”site/ — Astro + Starlight (npm run dev → http://localhost:4321, npm run build → site/dist/).
Дві гілки: «Для користувача» з docs/user/ (у тому числі покрокові how-to в docs/user/howto/) і
«Для розробника» з docs/dev/ (core/ — основи поруч із кореневими документами, guides/ — лише
пояснення розробнику, reference/ — переліки й каталоги, history/ — витоки, devlog/) плюс
кореневі документи (ARCHITECTURE.md, CONTRIBUTING.md, BRANDBOOK.md, SECURITY.md, docs/self-host/*,
docs/observability.md, docs/data-retention.md, docs/quality-baseline.md), які sync-docs.mjs
підтягує з frontmatter із їхнього # H1 і позначкою «Джерело» — тож їх не дублюють, а правлять там,
де вони лежать. Сторінки в docs/ пишуться у форматі Starlight: кожна починається з frontmatter
(title, description, sidebar.order), без власного # H1 — заголовок ставить Starlight;
картинки лежать поруч у docs/user/images/ і згадуються відносно (./images/…), щоб працювали й на
GitHub. Скрипт site/scripts/sync-docs.mjs копіює обидві теки в site/src/content/docs/{user,dev}/
(генеровані, в .gitignore). Діаграми — блок ```mermaid у markdown: astro-mermaid рендерить
його в браузері, brand.css фарбує в токени обох тем, а public/scripts/diagram-viewer.js додає
кнопку «На весь екран» із зумом і перетягуванням. Стиль сайту — site/src/styles/brand.css; нових кольорів і шрифтів там не вводити,
токени ті самі, що в lileyka-theme.css.
Демо на сайті (/demo/) — статична збірка застосунку ng build --configuration demo
(environment.demo.ts, isStaticDemo: true, base /demo/), у якій DemoApiInterceptor відповідає
на всі /api/ і /health із фікстур public/assets/demo/*.json. Фікстури не пишуться руками:
scripts/record-demo-fixtures.mjs обходить docker-стек demo і зберігає справжні відповіді,
по файлу на місце (Звʼязковий, Впорядник, Юнак) плюс public для сторінок без входу.
Перезаписувати їх треба після змін в API або в демо-сіді. Збірка сайту з демо —
scripts/build-site.sh; на Cloudflare Pages це build command, вихід site/dist.
Перевірка
Section titled “Перевірка”Бейслайни, які має тримати кожна зміна:
dotnet test Backend/ProjectK.Backend/ProjectK.Backend.sln- бекенд — 1166 тестів (BusinessLogic 572 · API 489 · Infrastructure 92 · Architecture 13)
- фронт — 754 тести, лінт 0 помилок (18 попереджень — поточний бейслайн, див.
docs/quality-baseline.md) - e2e — 101 тест
cd Frontend/projectk-frontend && npx ng lint && npx ng test --watch=false --browsers=ChromeHeadless && npx ng buildE2E піднімаються через докер:
./scripts/dev.sh tools up && ./scripts/dev.sh up e2e --buildВідомо: набір e2e нестабільний — приблизно один тест на прогін, щоразу інший. Причина не в бекенді, а в анімації діалогів (маска
p-dialog-maskперехоплює клік). Перш ніж вважати провал регресією, прожени спек ізольовано двічі.І навпаки: зелений прогін ще не означає, що перевірено те, що написано в назві тесту.
expect(…).toBeHidden()на селекторі, який після переробки шаблону більше не існує, проходить вхолосту; так само вхолосту проходить очікування на тексті, якого в інтерфейсі вже немає. Тому після зміни класів, заголовків чи формулювань —grepпоe2e/за старим рядком, а кнопки й заголовки адресуй доступною назвою (getByRole), не класом і не порядковим номером.Раз на реліз ганяй набір на щойно створеній базі (знести
projectK_e2e, підняти стек наново). Накопичена база може тримати неповні фікстури — і тоді рольові перевірки мовчки перевіряють не ту роль.
Релізи
Section titled “Релізи”Тег — vX.Y, а патч зʼявляється лише коли є патч: v1.0, далі v1.0.1. Суфікс на кшталт
-beta дозволений (v0.20.0-beta). Інших форм CI не приймає — крок «Extract Release Info» падає
з поясненням, щоб випадковий тег не поїхав у прод під виглядом версії.
Кодових назв більше немає. Релізи від v0.10.0-beta до v0.20.0-beta звалися мурашиними
іменами в лапках, і CI брав назву саме з лапок у назві релізу на GitHub. Тепер назва релізу — це
просто v1.0. Якщо в назві все-таки є щось у лапках, воно й далі підхопиться; якщо немає —
кодової назви немає ніде: ні в сайдбарі, ні в /health, ні у звіті куреня, ні в банері старту.
Місце, яке вирішує це одного разу: ReleaseDisplay на бекенді й displayCodeName на фронті.
Версію в коді руками не піднімати. v0.0.0-dev і LocalDevelopment у appsettings*.json та
environment*.ts — це плейсхолдери, які CI підміняє на події release. Вписати туди справжню
версію означає прибрати взірець, який шукає sed, і застрягти на ній назавжди.
Одна форма версії скрізь — тег без v: /health віддає 1.0, образи тегуються 1.0,
сайдбар і сторінка «Про Лілейку» показують 1.0. Раніше фронтенд лишав v, а бекенд ні, і на
одній сторінці той самий реліз звався двома іменами.
Текст нотаток релізу пишеться в todo/RELEASE-<версія>.md (детально, для себе) і
todo/RELEASE-<версія>-public.md (те, що йде в тіло релізу на GitHub).