Перейти до вмісту

Конвенції ProjectK

Цей файл описує один патрон, до якого зведено код у релізі 0.19.0. Він існує тому, що розбіжності розповзаються тихо: до 0.19.0 у бекенді співіснували чотири розкладки модулів, а на фронті — чотирнадцять класів розбіжностей іменування. Якщо нова конвенція потрібна — спершу зміни цей файл, потім код.

Якщо ти прийшов ззовні: спершу issue з ідеєю чи помилкою — обговоримо, чи й як це робити. Форк — лише щоб запропонувати зміни сюди через pull request; внесок стає частиною Лілейки на умовах LICENSE, розділ 4.


ProjectK.Common → (нічого)
ProjectK.Infrastructure → Common
ProjectK.BusinessLogic → Common
ProjectK.API → Common, BusinessLogic, Infrastructure

Infrastructure і 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).

Уся бізнес-логіка живе за одним шаблоном:

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).

Шари кажуть, звідки код бере залежності; межі модулів — у кого він їх бере. Модуль не читає таблиць сусіда. Три правила, і кожне перевіряється в 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, а не з поля акаунта. Знімок, що виглядає свіжим, небезпечніший за порожнє поле: він і відмовляє тим, кому має дозволити, і дозволяє тим, кому вже ні.

  • Common/Models/Dtos/<Module>/ — усе, що перетинає межу API: те, що клієнт отримує. Запити — у <Module>/Requests/.
  • Common/Models/Records/ — внутрішні типи-значення, які ніколи не серіалізуються клієнту: ServiceResult, ResourceAccessDecision, AgendaViewerScope, результати завантаження блобів.

Перевірка проста: якщо тип згадується у відповіді або запиті контролера — це DTO. (DateRangeDto довго лежав у Records/, хоч і був у відповіді планування.)

Успадковують 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.

Міграція має бути ідемпотентною: 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, і без цього результат залежав би від поясу самого сервера.

Кожна невдача повертається як { "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 — інʼєкція там нічого не дає.

Один спосіб — AutoMapper, профілі в BusinessLogic/MappingProfiles/. Мапінг у DTO має бути повним; часткові мапінги в сутність позначаються MemberList.None із поясненням, бо решту полів заповнює хендлер. MappingConfigurationTests не дасть додати мапу з незаповленим полем.

Складні відповіді, які збираються з кількох джерел із довідниками (логін, агенда, звіт), лишаються явними фабриками — профіль їх чесно не виражає.

Усі файли — UTF-8 без BOM. (Один файл довго лежав у cp1251 і мовчки випадав з усіх автоматичних правок; 329 інших несли BOM, якого .editorconfig не просить.)

Формат бекенду задає .editorconfig, а виконує dotnet format: file-scoped namespaces, жодних зайвих using, відступи й пробіли за правилами. З 2026-09-11 увесь бекенд приведено до цього одним проходом (FMT-01), і CI перевіряє його advisory-кроком «Check formatting». Перед PR:

Terminal window
dotnet format Backend/ProjectK.Backend/ProjectK.Backend.sln --exclude "**/Migrations/**"

Міграції EF-генеровані й не форматуються. Нова конвенція формату спершу записується в .editorconfig, потім застосовується окремим комітом без семантичних змін — інакше вона ховає справжню зміну в diff-і.


Що Як Не так
Файл компонента 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 і так на нього посилається бутстрап.

Кожен 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 лишається в класі, але не у файлі: ім’я файла й так лежить у папці, що його називає, а в шаблоні та імпортах суфікс відрізняє компонент від сервіса чи моделі.

Бекенд віддає { error, message }. UI мапить код на свій текст і ніколи не показує HttpErrorResponse.message — Angular завжди заповнює його рядком на кшталт "Http failure response for ...", тож фолбек на нього гарантовано показує користувачу службовий текст замість людського.

Кожна дія людини отримує відповідь, і для цього в компоненті нічого писати не треба:

  • спінер і захист від повторного натискання дає 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 на підписі пункту проби — «уже підписано», а не помилка).

Підказки (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), і форма не пускає пароль, який сервер відхилить.


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.

Бейслайни, які має тримати кожна зміна:

Terminal window
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 тест
Terminal window
cd Frontend/projectk-frontend && npx ng lint && npx ng test --watch=false --browsers=ChromeHeadless && npx ng build

E2E піднімаються через докер:

Terminal window
./scripts/dev.sh tools up && ./scripts/dev.sh up e2e --build

Відомо: набір e2e нестабільний — приблизно один тест на прогін, щоразу інший. Причина не в бекенді, а в анімації діалогів (маска p-dialog-mask перехоплює клік). Перш ніж вважати провал регресією, прожени спек ізольовано двічі.

І навпаки: зелений прогін ще не означає, що перевірено те, що написано в назві тесту. expect(…).toBeHidden() на селекторі, який після переробки шаблону більше не існує, проходить вхолосту; так само вхолосту проходить очікування на тексті, якого в інтерфейсі вже немає. Тому після зміни класів, заголовків чи формулювань — grep по e2e/ за старим рядком, а кнопки й заголовки адресуй доступною назвою (getByRole), не класом і не порядковим номером.

Раз на реліз ганяй набір на щойно створеній базі (знести projectK_e2e, підняти стек наново). Накопичена база може тримати неповні фікстури — і тоді рольові перевірки мовчки перевіряють не ту роль.

Тег — 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).