mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-05-19 07:42:52 +00:00
fix(scripts): CHANGELOG.md at release time + Markdown fragments
Two changes that together eliminate changelog merge conflicts from concurrent PRs: 1. **CHANGELOG.md is regenerated only by release.py.** The previous cut still had every changelog.py invocation rewrite CHANGELOG.md with a different [Unreleased] block, so two PRs producing different unreleased content collided on the MD file. Checked-in CHANGELOG.md now contains released versions only. Unreleased is rendered on demand from changelog.d/ via scripts/preview-changelog.py — prints to stdout, writes nothing. 2. **Fragment format: Markdown instead of TOML.** Filenames now look like `<type>-<slug>-<hex4>.md` (e.g. `fixed-dev-version-mismatch-a1b2.md`). Type is readable at-a-glance in the directory listing; 4-char random hex prevents collision when two PRs pick the same slug. Body is plain Markdown with `## English` / `## Русский` sections — renders directly on GitHub, no YAML/TOML parser dependency. - scripts/changelog_lib.py: MD parser replaces tomllib. render_full_md drops the [Unreleased] block; write_md(data) signature simplified; render_unreleased_md(fragments) for on-demand preview. - scripts/changelog.py: writes <type>-<slug>-<hex4>.md, no MD regen. - scripts/release.py: updated to the new write_md signature. - scripts/preview-changelog.py: new. - changelog.d/*.md: 10 existing TOML fragments migrated to MD. One fragment (changelog-entries-now-live-as-per) updated to say Markdown instead of TOML since that's the final state by the time this ships. - CHANGELOG.md: regenerated — Unreleased block gone. - .gitattributes: merge=union moved from *.toml to *.md. - docs/changelog.md, docs/releasing.md, CONTRIBUTING.md, changelog.d/README.md, CLAUDE.md: describe the new format + flow.
This commit is contained in:
parent
58d22bb025
commit
85da8f85de
21 changed files with 301 additions and 190 deletions
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# Changelog fragments live one-per-file under changelog.d/ so concurrent
|
||||
# PRs don't collide on a shared changelog file. Filenames carry a
|
||||
# timestamp prefix so collisions on the actual filename are extremely
|
||||
# unlikely — `union` is belt-and-suspenders for the edge case where two
|
||||
# PRs somehow pick the exact same name.
|
||||
changelog.d/*.toml merge=union
|
||||
# PRs don't collide on a shared changelog file. Filenames end in a
|
||||
# random 4-char hex suffix, so probability of collision is ~1/65536
|
||||
# even per pair of identical slugs — `union` is belt-and-suspenders for
|
||||
# the astronomically rare filename clash.
|
||||
changelog.d/*.md merge=union
|
||||
|
|
|
|||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -5,24 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Dashboard now splits issues into Errors (red, block protection) and Warnings (amber, setup is suboptimal but working). Four new warnings: kernel supports kmod but only Zygisk is installed; kmod and Zygisk both active simultaneously (means you have to remember per-app Z-off for banking / payment apps that detect Zygisk); debug logging left on; SELinux in Permissive mode (exposes six detection vectors that VPN Hide relies on the kernel to block).
|
||||
- Debug logging toggle in Diagnostics: off by default — VPN Hide, LSPosed hooks (VpnHide-NC/NI/LP and the package-visibility filter), and zygisk keep logcat near-silent. Start recording and Collect debug log automatically enable verbose logging for the duration of the capture and restore it afterwards, so the toggle is only needed if you want logs emitted continuously outside a capture. Errors always pass through so hook-install failures remain visible.
|
||||
- Expand Russian-apps filter: Youla, Delivery Club, SDEK, Russian Post, Dom.ru, ConsultantPlus, etc. — local retailers, pharmacies, food chains and services.
|
||||
- Diagnose wrong-variant kmod installs: Dashboard surfaces when the installed kernel module is built for a different GKI variant than your kernel (e.g. android13-5.10 installed on android14-6.1), when your kernel is missing kretprobes, or has no compatible kmod variant at all — and points to the right zip. Diagnostics screen adds a Kmod load trace section with boot-time insmod status for bug reports.
|
||||
|
||||
### Changed
|
||||
- Help text on Protection screens (Tun / Apps / Ports) moved from a hard-to-discover ? icon in the top bar to always-visible collapsible cards at the top of each list. Users who read and understood the hints can collapse them — the state is remembered across app restarts.
|
||||
- Changelog entries now live as per-PR TOML fragments under changelog.d/ instead of a shared JSON section, so concurrent PRs no longer conflict on the changelog.
|
||||
|
||||
### Fixed
|
||||
- Dashboard now shows a consistent version string for all modules. Kernel-module, Zygisk and Ports module cards used to display the Magisk-style 'vX.Y.Z' from their module.prop, while the LSPosed hook module card showed the Android-style 'X.Y.Z' from the APK's versionName — on the same screen, for the same version number. The 'v' prefix is now stripped at parse time so every card reads 'X.Y.Z' (or 'X.Y.Z-N-gSHA' for dev builds).
|
||||
- App Hiding: marking the same app as both H (Hidden) and O (Observer) caused it to crash on startup — the app would query its own PackageInfo, our system_server hook matched it as an observer and stripped its own package from the result, and the framework bailed. Roles are now mutually exclusive: toggling one clears the other, and existing H+O configs are migrated to O-only on first launch.
|
||||
- Protection toolbar: the filter icon indicator for "filter is applied" no longer blends into the topbar background on Material You palettes. Active state is now a FilledIconButton (primary / onPrimary pair, guaranteed contrast) instead of a plain icon tinted with primary on top of primaryContainer.
|
||||
- Dev builds of the app no longer trigger a false 'module version mismatch' warning on the Dashboard. The check now strips the git-describe dev suffix (e.g. 0.6.2-14-g1f2205e vs module 0.6.2) before comparing.
|
||||
|
||||
## v0.6.2
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ Before opening a PR, add a changelog entry:
|
|||
# types: added | changed | fixed | removed | deprecated | security
|
||||
```
|
||||
|
||||
This writes a new TOML fragment to `changelog.d/` and regenerates `CHANGELOG.md`. Commit the new fragment and the regenerated `CHANGELOG.md` with your change. Two PRs adding entries simultaneously get separate files and don't conflict on merge.
|
||||
This writes a new Markdown fragment to `changelog.d/`. That's the only file changed — `CHANGELOG.md` is regenerated only at release time, which is what keeps concurrent PRs from conflicting. Commit just the fragment alongside your code change. Run `./scripts/preview-changelog.py` to see the pending entries together.
|
||||
|
||||
**Skip the entry** for internal refactors with no behaviour change, docs-only, CI-only, and test-only changes.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
type = "changed"
|
||||
en = """
|
||||
Changelog entries now live as per-PR TOML fragments under changelog.d/ instead of a shared JSON section, so concurrent PRs no longer conflict on the changelog.
|
||||
"""
|
||||
ru = """
|
||||
Записи changelog теперь лежат пофайлово в changelog.d/ (TOML-фрагменты) вместо общей секции в JSON — одновременные PR больше не конфликтуют на changelog.
|
||||
"""
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Unreleased changelog fragments
|
||||
|
||||
Each file here is a single entry that will land in the next release.
|
||||
`./scripts/release.py` rotates them all into `history[0]` of
|
||||
`lsposed/app/src/main/assets/changelog.json` and deletes the files.
|
||||
Each `.md` file here is a single entry that will land in the next
|
||||
release. `./scripts/release.py` rotates all fragments into `history[0]`
|
||||
of `lsposed/app/src/main/assets/changelog.json` and deletes the files.
|
||||
|
||||
## Adding an entry
|
||||
|
||||
|
|
@ -12,35 +12,45 @@ Each file here is a single entry that will land in the next release.
|
|||
|
||||
Types: `added`, `changed`, `fixed`, `removed`, `deprecated`, `security`.
|
||||
|
||||
This writes `changelog.d/YYYYMMDDHHMMSS-<slug>.toml` and regenerates
|
||||
`CHANGELOG.md` + `update-json/changelog.md`.
|
||||
This writes a single file `changelog.d/<type>-<slug>-<hex4>.md`. That's
|
||||
the only file modified — `CHANGELOG.md` is regenerated only at release
|
||||
time, so two concurrent PRs can't collide on it. The 4-char hex suffix
|
||||
is random; two teammates picking the same slug still produce different
|
||||
filenames.
|
||||
|
||||
Commit the new fragment alongside your code change.
|
||||
Commit the new fragment alongside your code change. To preview the
|
||||
pending (fragments-only) changelog, run `./scripts/preview-changelog.py`.
|
||||
|
||||
## Why a directory of fragments instead of a single file
|
||||
## Fragment file format
|
||||
|
||||
Every PR that edits the same JSON/MD section conflicts with every other
|
||||
PR doing the same. Fragments sidestep that: each PR adds its own file,
|
||||
so merges don't touch the same bytes.
|
||||
Filename: `<type>-<slug>-<hex4>.md` — the type is part of the name so
|
||||
you can tell what kind of change a fragment is at a glance in the
|
||||
directory listing.
|
||||
|
||||
The timestamp-prefixed filenames keep rendered order roughly
|
||||
chronological without relying on git diff order. `.gitattributes` marks
|
||||
`*.toml` here as `merge=union` as a belt-and-suspenders fallback if two
|
||||
PRs somehow pick the exact same filename (rare — filenames include a
|
||||
second-precision timestamp plus a slug).
|
||||
Body: a date line on top, then two language sections. Renders directly
|
||||
on GitHub — no TOML, no YAML frontmatter, no parser dependency.
|
||||
|
||||
## Fragment file format (TOML)
|
||||
```markdown
|
||||
_2026-04-19_
|
||||
|
||||
```toml
|
||||
type = "fixed"
|
||||
en = """
|
||||
App no longer crashes when …
|
||||
"""
|
||||
ru = """
|
||||
Приложение больше не падает когда …
|
||||
"""
|
||||
## English
|
||||
|
||||
App no longer crashes when ...
|
||||
|
||||
## Русский
|
||||
|
||||
Приложение больше не падает когда ...
|
||||
```
|
||||
|
||||
Leading/trailing whitespace inside the triple-quoted strings is stripped
|
||||
when the fragment is loaded. Do not commit fragments by hand unless
|
||||
you know what you're doing — prefer the `changelog.py` script.
|
||||
The date is in `YYYY-MM-DD` format, wrapped in `_…_` so GitHub renders
|
||||
it as a subtle italic line. `changelog.py` writes today's date
|
||||
automatically; when editing by hand, keep the format literal so the
|
||||
parser recognises it.
|
||||
|
||||
Leading/trailing whitespace inside each language section is stripped
|
||||
when loaded. Fragments are sorted chronologically by that date when
|
||||
the Unreleased preview is rendered; ties fall back to filename order.
|
||||
|
||||
Prefer the `changelog.py` script for creating fragments; editing by
|
||||
hand works but you need to match the filename pattern and section
|
||||
headings exactly.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
type = "added"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Dashboard now splits issues into Errors (red, block protection) and Warnings (amber, setup is suboptimal but working). Four new warnings: kernel supports kmod but only Zygisk is installed; kmod and Zygisk both active simultaneously (means you have to remember per-app Z-off for banking / payment apps that detect Zygisk); debug logging left on; SELinux in Permissive mode (exposes six detection vectors that VPN Hide relies on the kernel to block).
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Обзор теперь делит проблемы на Ошибки (красные, ломают защиту) и Предупреждения (жёлтые, защита работает, но конфигурация не оптимальная). Четыре новых предупреждения: ядро поддерживает kmod, но установлен только Zygisk; kmod и Zygisk активны одновременно (значит, для каждого банковского или платёжного приложения придётся помнить выключать Z вручную, иначе они его обнаруживают); оставлены включёнными отладочные логи; SELinux в режиме Permissive (открывает шесть векторов детекции, которые должен блокировать).
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "added"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Debug logging toggle in Diagnostics: off by default — VPN Hide, LSPosed hooks (VpnHide-NC/NI/LP and the package-visibility filter), and zygisk keep logcat near-silent. Start recording and Collect debug log automatically enable verbose logging for the duration of the capture and restore it afterwards, so the toggle is only needed if you want logs emitted continuously outside a capture. Errors always pass through so hook-install failures remain visible.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Переключатель отладочных логов в Диагностике: по умолчанию выключен — приложение, хуки LSPosed (VpnHide-NC/NI/LP, фильтр видимости пакетов) и zygisk почти ничего не пишут в logcat. Кнопки «Начать запись» и «Собрать отладочный лог» автоматически включают логирование на время захвата и возвращают состояние обратно, так что переключатель нужен только если вы хотите, чтобы логи писались постоянно, вне сессии захвата. Ошибки всегда видны, чтобы проблемы установки хуков не терялись.
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "added"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Diagnose wrong-variant kmod installs: Dashboard surfaces when the installed kernel module is built for a different GKI variant than your kernel (e.g. android13-5.10 installed on android14-6.1), when your kernel is missing kretprobes, or has no compatible kmod variant at all — and points to the right zip. Diagnostics screen adds a Kmod load trace section with boot-time insmod status for bug reports.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Диагностика неправильно установленного kmod: Обзор теперь сообщает, если установленный модуль ядра собран под другой GKI-вариант (например, android13-5.10 при ядре android14-6.1), если ядро без kretprobes или для него нет подходящего варианта kmod вовсе — и указывает правильный zip. На экране Диагностика добавлена секция «Трасса загрузки kmod» для багрепортов.
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "added"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Expand Russian-apps filter: Youla, Delivery Club, SDEK, Russian Post, Dom.ru, ConsultantPlus, etc. — local retailers, pharmacies, food chains and services.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Расширен фильтр российских приложений: Youla, Delivery Club, СДЭК, Почта России, Дом.ру, КонсультантПлюс и т. д. — локальный ритейл, аптеки, рестораны и сервисы.
|
||||
"""
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Changelog entries now live as per-PR Markdown fragments under changelog.d/ instead of a shared JSON section, and CHANGELOG.md is regenerated only at release time, so concurrent PRs no longer conflict on the changelog.
|
||||
|
||||
## Русский
|
||||
|
||||
Записи changelog теперь лежат пофайлово в changelog.d/ (Markdown-фрагменты) вместо общей секции в JSON, а CHANGELOG.md перегенерируется только при релизе — одновременные PR больше не конфликтуют на changelog.
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "changed"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Help text on Protection screens (Tun / Apps / Ports) moved from a hard-to-discover ? icon in the top bar to always-visible collapsible cards at the top of each list. Users who read and understood the hints can collapse them — the state is remembered across app restarts.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Подсказки на вкладке Защита (Туннели / Приложения / Порты) переехали из неприметной иконки ? в заголовке в постоянно видимые сворачиваемые карточки над списком. Прочитавшие и понявшие могут свернуть — состояние запоминается между запусками приложения.
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "fixed"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
App Hiding: marking the same app as both H (Hidden) and O (Observer) caused it to crash on startup — the app would query its own PackageInfo, our system_server hook matched it as an observer and stripped its own package from the result, and the framework bailed. Roles are now mutually exclusive: toggling one clears the other, and existing H+O configs are migrated to O-only on first launch.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Скрытие приложений: если приложение было отмечено одновременно как H (скрытое) и O (observer), оно падало при запуске — запрашивало свою собственную PackageInfo, наш хук в system_server вырезал его из ответа как наблюдателю, и фреймворк ломался. Роли теперь взаимоисключающие: включение одной сбрасывает другую, а уже сохранённая комбинация H+O автоматически превращается в O при первом запуске.
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "fixed"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Dashboard now shows a consistent version string for all modules. Kernel-module, Zygisk and Ports module cards used to display the Magisk-style 'vX.Y.Z' from their module.prop, while the LSPosed hook module card showed the Android-style 'X.Y.Z' from the APK's versionName — on the same screen, for the same version number. The 'v' prefix is now stripped at parse time so every card reads 'X.Y.Z' (or 'X.Y.Z-N-gSHA' for dev builds).
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
На панели теперь одинаковый формат версий для всех модулей. Раньше карточки модулей ядра, Zygisk и скрытия портов показывали Magisk-стиль 'vX.Y.Z' из module.prop, а карточка LSPosed-хука — Android-стиль 'X.Y.Z' из versionName APK — на одном экране, для одной и той же версии. Префикс 'v' теперь снимается при парсинге, так что везде показывается 'X.Y.Z' (или 'X.Y.Z-N-gSHA' для dev-сборок).
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "fixed"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Dev builds of the app no longer trigger a false 'module version mismatch' warning on the Dashboard. The check now strips the git-describe dev suffix (e.g. 0.6.2-14-g1f2205e vs module 0.6.2) before comparing.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Сборки приложения из ветки больше не вызывают ложное предупреждение «версия модуля не совпадает» в Обзоре. Теперь сравнение отбрасывает суффикс git describe (0.6.2-14-g1f2205e → 0.6.2).
|
||||
"""
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
type = "fixed"
|
||||
en = """
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
Protection toolbar: the filter icon indicator for "filter is applied" no longer blends into the topbar background on Material You palettes. Active state is now a FilledIconButton (primary / onPrimary pair, guaranteed contrast) instead of a plain icon tinted with primary on top of primaryContainer.
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Панель защиты: индикатор активного фильтра на иконке в тулбаре больше не сливается с фоном на Material You палитрах. В активном состоянии вместо подкрашенной иконки теперь заливной FilledIconButton (пара primary / onPrimary, контраст гарантирован).
|
||||
"""
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Two locations:
|
||||
|
||||
- **`changelog.d/*.toml`** — one TOML file per unreleased entry. Each PR with a user-visible change adds its own file, so concurrent PRs don't touch the same bytes and don't conflict on the changelog. `release.py` rotates these files into the JSON and deletes them.
|
||||
- **`changelog.d/*.md`** — one Markdown file per unreleased entry. Each PR with a user-visible change adds its own file, so concurrent PRs don't touch the same bytes and don't conflict on the changelog. `release.py` rotates these files into the JSON and deletes them.
|
||||
- **`lsposed/app/src/main/assets/changelog.json`** — bilingual (en/ru), released history only:
|
||||
|
||||
```json
|
||||
|
|
@ -18,24 +18,38 @@ Two locations:
|
|||
|
||||
### Fragment file format
|
||||
|
||||
```toml
|
||||
type = "fixed"
|
||||
en = """
|
||||
Filename: `<type>-<slug>-<hex4>.md` (e.g. `fixed-dev-version-mismatch-a1b2.md`). The type (`added` / `changed` / `fixed` / `removed` / `deprecated` / `security`) is part of the name, so you can tell what a fragment is at a glance. The 4-char random hex keeps filenames collision-proof across concurrent PRs.
|
||||
|
||||
Body is plain Markdown — renders straight on GitHub, no YAML/TOML parser dependency:
|
||||
|
||||
```markdown
|
||||
_2026-04-19_
|
||||
|
||||
## English
|
||||
|
||||
App no longer crashes when …
|
||||
"""
|
||||
ru = """
|
||||
|
||||
## Русский
|
||||
|
||||
Приложение больше не падает когда …
|
||||
"""
|
||||
```
|
||||
|
||||
Types: `added`, `changed`, `fixed`, `removed`, `deprecated`, `security`. Triple-quoted multiline strings are stripped of leading/trailing whitespace when loaded.
|
||||
Date in `YYYY-MM-DD` format wrapped in `_…_` for subtle italic rendering. `changelog.py` writes today's date automatically. Fragments are sorted chronologically by that date for the Unreleased preview (ties → filename order). Leading/trailing whitespace inside each language section is stripped when loaded.
|
||||
|
||||
## Generated files (do NOT edit by hand)
|
||||
|
||||
Two markdown files are regenerated from `changelog.d/` + JSON by `scripts/changelog_lib.py`:
|
||||
Both markdown files are **only regenerated at release time** by `release.py`. Between releases they contain released versions only — the unreleased entries live in `changelog.d/`. This split is what keeps PRs from conflicting: if every PR regenerated `CHANGELOG.md`, two concurrent PRs would each write a different Unreleased block and collide.
|
||||
|
||||
- `CHANGELOG.md` at repo root — full history, Keep a Changelog format. Renders `## [Unreleased]` on top when `changelog.d/` has fragments, then each history entry as `## vX.Y.Z`. CI extracts a single `## vX.Y.Z` block for the **GitHub release body**, so don't edit release notes by hand either.
|
||||
- `update-json/changelog.md` — last 5 **released** versions only (no Unreleased block). Shown by Magisk/KSU in the update popup inside the manager app.
|
||||
- `CHANGELOG.md` at repo root — released history, Keep a Changelog format. CI extracts a single `## vX.Y.Z` block for the **GitHub release body**. Contains no `## [Unreleased]` block between releases.
|
||||
- `update-json/changelog.md` — last 5 released versions only. Shown by Magisk/KSU in the update popup.
|
||||
|
||||
To see what's pending (not yet in `CHANGELOG.md`) run:
|
||||
|
||||
```sh
|
||||
./scripts/preview-changelog.py # prints to stdout, writes nothing
|
||||
```
|
||||
|
||||
…or just browse `changelog.d/*.md` directly — each fragment renders as readable Markdown on GitHub.
|
||||
|
||||
## Adding an entry
|
||||
|
||||
|
|
@ -45,9 +59,9 @@ From a PR branch:
|
|||
./scripts/changelog.py <type> "<EN text>" "<RU text>"
|
||||
```
|
||||
|
||||
Writes `changelog.d/<timestamp>-<slug>.toml` and regenerates both markdown files. Commit the new fragment file + `CHANGELOG.md` alongside your code change (the update-json markdown only changes on release).
|
||||
Writes a Markdown fragment to `changelog.d/<type>-<slug>-<hex4>.md`. **Nothing else is modified** — no `CHANGELOG.md` regeneration, no `changelog.json` update. Commit just the new fragment alongside your code change. Two PRs doing this simultaneously produce two separate files and never conflict on merge.
|
||||
|
||||
Pass `--slug <slug>` if the auto-derived slug collides with an existing fragment — filenames already carry a second-precision timestamp, so collisions are rare.
|
||||
Pass `--slug <slug>` to override the auto-derived slug. Filenames already carry a random 4-char hex suffix, so filename collisions are effectively impossible even when two PRs pick the same slug.
|
||||
|
||||
## When to add an entry
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Model
|
||||
|
||||
- `VERSION` file = **the last released version** on `main`. It is only modified by `release.py`.
|
||||
- `changelog.d/*.toml` = work in progress. One TOML file per unreleased entry, accumulated via `./scripts/changelog.py` during normal development. See [changelog.md](changelog.md).
|
||||
- `changelog.d/*.md` = work in progress. One Markdown file per unreleased entry, accumulated via `./scripts/changelog.py` during normal development. See [changelog.md](changelog.md).
|
||||
- Intermediate builds (main, feature branches, local) get a version string derived from `git describe` — propagated into `module.prop` / APK `versionName` at build time. See [build versions](#build-versions) below.
|
||||
|
||||
## Cutting a release
|
||||
|
|
|
|||
|
|
@ -13,34 +13,34 @@ Usage:
|
|||
|
||||
Types: added, changed, fixed, removed, deprecated, security
|
||||
|
||||
Writes a TOML file to `changelog.d/<timestamp>-<slug>.toml`. Each
|
||||
fragment is a single entry; `release.py` rotates them all into history
|
||||
and deletes them. Because each PR adds its own file, concurrent PRs
|
||||
don't conflict on the changelog.
|
||||
Writes a Markdown file to `changelog.d/<type>-<slug>-<hex4>.md`. Nothing
|
||||
else. `CHANGELOG.md` is only regenerated at release time — that's what
|
||||
keeps concurrent PRs from conflicting on it.
|
||||
|
||||
`release.py` rotates every fragment into `history[0]` and deletes them.
|
||||
|
||||
The slug defaults to the first few words of the English text; pass
|
||||
`--slug` to override (e.g. if the auto-slug collides with a teammate's
|
||||
fragment — rare because filenames carry a second-precision timestamp).
|
||||
`--slug` to override. A 4-char random hex suffix is appended so two
|
||||
PRs that happen to pick the same slug still produce different
|
||||
filenames and don't collide on merge.
|
||||
|
||||
Regenerates `CHANGELOG.md` (Unreleased block at the top, sourced from
|
||||
fragments) and `update-json/changelog.md` (release history only).
|
||||
To preview the pending (fragment-only) changelog locally without
|
||||
writing anything, run `./scripts/preview-changelog.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import date as _date
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from changelog_lib import ( # type: ignore[import-not-found]
|
||||
FRAGMENTS_DIR,
|
||||
VALID_TYPES,
|
||||
load_fragments,
|
||||
load_json,
|
||||
write_md,
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
|
|
@ -61,30 +61,23 @@ def auto_slug(text: str) -> str:
|
|||
return slug or "entry"
|
||||
|
||||
|
||||
def fragment_path(slug: str) -> Path:
|
||||
"""Disambiguate on the rare collision by appending -2, -3, ..."""
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
def fragment_path(type_: str, slug: str) -> Path:
|
||||
"""`<type>-<slug>-<hex4>.md`. The 4-char hex suffix is random so two
|
||||
teammates picking the same slug still produce different filenames —
|
||||
probability of collision is 1/65536 per pair of same-slug attempts,
|
||||
effectively zero for this project's volume.
|
||||
"""
|
||||
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = FRAGMENTS_DIR / f"{timestamp}-{slug}.toml"
|
||||
if not base.exists():
|
||||
return base
|
||||
n = 2
|
||||
while True:
|
||||
candidate = FRAGMENTS_DIR / f"{timestamp}-{slug}-{n}.toml"
|
||||
suffix = secrets.token_hex(2) # 4 hex chars
|
||||
candidate = FRAGMENTS_DIR / f"{type_}-{slug}-{suffix}.md"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
n += 1
|
||||
|
||||
|
||||
def write_fragment(path: Path, type_: str, en: str, ru: str) -> None:
|
||||
# Hand-rolled TOML writer (stdlib only). Triple-quoted multiline
|
||||
# strings keep long entries readable in diffs and avoid quote
|
||||
# escaping.
|
||||
body = (
|
||||
f'type = "{type_}"\n'
|
||||
f'en = """\n{en}\n"""\n'
|
||||
f'ru = """\n{ru}\n"""\n'
|
||||
)
|
||||
def write_fragment(path: Path, en: str, ru: str) -> None:
|
||||
today = _date.today().isoformat()
|
||||
body = f"_{today}_\n\n## English\n\n{en}\n\n## Русский\n\n{ru}\n"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
|
|
@ -98,17 +91,17 @@ def main() -> int:
|
|||
args = parser.parse_args()
|
||||
|
||||
slug = args.slug or auto_slug(args.en)
|
||||
path = fragment_path(slug)
|
||||
write_fragment(path, args.type, args.en.strip(), args.ru.strip())
|
||||
|
||||
data = load_json()
|
||||
fragments = load_fragments()
|
||||
write_md(data, fragments)
|
||||
path = fragment_path(args.type, slug)
|
||||
write_fragment(path, args.en.strip(), args.ru.strip())
|
||||
|
||||
console.print(f"[green]wrote[/green] {path.relative_to(Path.cwd())}")
|
||||
console.print(f" [cyan]type:[/cyan] {args.type}")
|
||||
console.print(f" [cyan]en:[/cyan] {args.en}")
|
||||
console.print(f" [cyan]ru:[/cyan] {args.ru}")
|
||||
console.print(
|
||||
"\n[dim]commit just this fragment — CHANGELOG.md is regenerated on "
|
||||
"release only, so per-PR changes no longer conflict.[/dim]",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,30 +11,41 @@ Storage layout:
|
|||
|
||||
{ "history": [ { "version": "0.6.1", "sections": [...] }, ... ] }
|
||||
|
||||
* `changelog.d/*.toml` — one TOML file per pending entry. Each file is
|
||||
a single unreleased item:
|
||||
* `changelog.d/*.md` — one Markdown file per pending entry. Filename
|
||||
encodes the type and carries a short hex suffix for collision-proof
|
||||
uniqueness: `<type>-<slug>-<hex4>.md` (e.g.
|
||||
`fixed-dev-version-mismatch-a1b2.md`).
|
||||
|
||||
type = "fixed"
|
||||
en = "..."
|
||||
ru = "..."
|
||||
Body is plain Markdown with two language sections — renders nicely
|
||||
straight on GitHub, no frontmatter, no YAML dependency:
|
||||
|
||||
## English
|
||||
|
||||
App no longer crashes when ...
|
||||
|
||||
## Русский
|
||||
|
||||
Приложение больше не падает когда ...
|
||||
|
||||
Fragments live on disk and are accumulated across PRs. Because each
|
||||
entry is its own file, two PRs concurrently adding entries don't
|
||||
touch the same bytes and don't conflict. `release.py` rotates all
|
||||
fragments into `history[0]` and deletes them.
|
||||
|
||||
Generated (overwritten every script run, never hand-edited):
|
||||
Generated (overwritten by `release.py` only — never by `changelog.py`,
|
||||
so PRs don't write to these files and don't conflict):
|
||||
|
||||
* `CHANGELOG.md` at the repo root — Keep a Changelog, full history with
|
||||
an optional `## [Unreleased]` block sourced from the fragments.
|
||||
* `CHANGELOG.md` at the repo root — Keep a Changelog, *released*
|
||||
history only. No `[Unreleased]` block — to preview pending fragments,
|
||||
run `./scripts/preview-changelog.py` (prints to stdout, never writes).
|
||||
* `update-json/changelog.md` — last MD_RECENT_VERSIONS released
|
||||
versions, no Unreleased. Served to Magisk/KSU update popups.
|
||||
versions. Served to Magisk/KSU update popups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
|
@ -67,28 +78,64 @@ def save_json(data: dict) -> None:
|
|||
)
|
||||
|
||||
|
||||
_FRAGMENT_FILENAME = re.compile(r"^(?P<type>[a-z]+)-(?P<slug>.+)-(?P<hex>[0-9a-f]{4,})\.md$")
|
||||
_DATE_LINE = re.compile(r"^_(\d{4}-\d{2}-\d{2})_\s*$", re.M)
|
||||
_EN_HEADING = re.compile(r"^##\s+English\s*$", re.M)
|
||||
_RU_HEADING = re.compile(r"^##\s+Русский\s*$", re.M)
|
||||
|
||||
|
||||
def parse_fragment(path: Path) -> dict:
|
||||
"""Parse a single fragment MD file. Raises ValueError on malformed
|
||||
input so callers can surface a meaningful error.
|
||||
"""
|
||||
m = _FRAGMENT_FILENAME.match(path.name)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
f"{path.name}: filename must be `<type>-<slug>-<hex>.md` "
|
||||
f"where <type> is one of {VALID_TYPES}",
|
||||
)
|
||||
type_ = m.group("type")
|
||||
if type_ not in VALID_TYPES:
|
||||
raise ValueError(f"{path.name}: type {type_!r} not in {VALID_TYPES}")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
date_match = _DATE_LINE.search(text)
|
||||
if date_match is None:
|
||||
raise ValueError(
|
||||
f"{path.name}: body must start with a date line like `_YYYY-MM-DD_`",
|
||||
)
|
||||
date = date_match.group(1)
|
||||
|
||||
en_match = _EN_HEADING.search(text)
|
||||
ru_match = _RU_HEADING.search(text)
|
||||
if en_match is None or ru_match is None:
|
||||
raise ValueError(
|
||||
f"{path.name}: body must contain both `## English` and `## Русский` sections",
|
||||
)
|
||||
if en_match.start() > ru_match.start():
|
||||
raise ValueError(f"{path.name}: `## English` must appear before `## Русский`")
|
||||
if date_match.start() > en_match.start():
|
||||
raise ValueError(f"{path.name}: date line must appear before the language sections")
|
||||
|
||||
en_body = text[en_match.end():ru_match.start()].strip()
|
||||
ru_body = text[ru_match.end():].strip()
|
||||
if not en_body:
|
||||
raise ValueError(f"{path.name}: empty English section")
|
||||
if not ru_body:
|
||||
raise ValueError(f"{path.name}: empty Русский section")
|
||||
|
||||
return {"path": path, "type": type_, "date": date, "en": en_body, "ru": ru_body}
|
||||
|
||||
|
||||
def load_fragments() -> list[dict]:
|
||||
"""Read every `*.toml` under `changelog.d/`, sorted by filename so
|
||||
the rendered order is deterministic (filenames carry a timestamp
|
||||
prefix, so order ≈ chronological).
|
||||
"""Read every `*.md` under `changelog.d/` and sort chronologically
|
||||
by the embedded date (oldest first). Ties (same date) fall back to
|
||||
filename order for determinism.
|
||||
"""
|
||||
if not FRAGMENTS_DIR.is_dir():
|
||||
return []
|
||||
fragments: list[dict] = []
|
||||
for path in sorted(FRAGMENTS_DIR.glob("*.toml")):
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
type_ = data.get("type")
|
||||
en_raw = data.get("en")
|
||||
ru_raw = data.get("ru")
|
||||
if type_ not in VALID_TYPES:
|
||||
raise ValueError(f"{path}: type must be one of {VALID_TYPES}, got {type_!r}")
|
||||
if not isinstance(en_raw, str) or not en_raw.strip():
|
||||
raise ValueError(f"{path}: missing or empty 'en'")
|
||||
if not isinstance(ru_raw, str) or not ru_raw.strip():
|
||||
raise ValueError(f"{path}: missing or empty 'ru'")
|
||||
# Triple-quoted TOML strings keep the trailing \n before the
|
||||
# closing """; strip so rendered markdown doesn't grow blank lines.
|
||||
fragments.append({"path": path, "type": type_, "en": en_raw.strip(), "ru": ru_raw.strip()})
|
||||
fragments = [parse_fragment(p) for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md"]
|
||||
fragments.sort(key=lambda f: (f["date"], f["path"].name))
|
||||
return fragments
|
||||
|
||||
|
||||
|
|
@ -133,19 +180,31 @@ def _render_entry(heading: str, entry: dict, out: list[str]) -> None:
|
|||
out.append("")
|
||||
|
||||
|
||||
def render_full_md(data: dict, fragments: list[dict]) -> str:
|
||||
"""Full history (with optional Unreleased block on top from
|
||||
fragments), Keep a Changelog header.
|
||||
def render_full_md(data: dict) -> str:
|
||||
"""Full *released* history, Keep a Changelog header. The Unreleased
|
||||
block is deliberately NOT included: it would be regenerated by every
|
||||
PR and re-introduce CHANGELOG.md as a merge-conflict hotspot. To see
|
||||
what's pending, read `changelog.d/*.toml` directly or run
|
||||
`./scripts/preview-changelog.py`.
|
||||
"""
|
||||
out: list[str] = []
|
||||
unreleased_sections = fragments_as_sections(fragments)
|
||||
if unreleased_sections:
|
||||
_render_entry("[Unreleased]", {"sections": unreleased_sections}, out)
|
||||
for entry in data.get("history", []):
|
||||
_render_entry(f"v{entry['version']}", entry, out)
|
||||
return _KEEP_A_CHANGELOG_HEADER + "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_unreleased_md(fragments: list[dict]) -> str:
|
||||
"""Unreleased-only preview, rendered on demand from fragments.
|
||||
Not written to a checked-in file.
|
||||
"""
|
||||
sections = fragments_as_sections(fragments)
|
||||
if not sections:
|
||||
return "(no unreleased fragments)\n"
|
||||
out: list[str] = []
|
||||
_render_entry("[Unreleased]", {"sections": sections}, out)
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_short_md(data: dict) -> str:
|
||||
"""Last MD_RECENT_VERSIONS released versions only, no Unreleased,
|
||||
no preamble. For Magisk/KSU popup.
|
||||
|
|
@ -156,8 +215,12 @@ def render_short_md(data: dict) -> str:
|
|||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_md(data: dict, fragments: list[dict]) -> None:
|
||||
FULL_MD_PATH.write_text(render_full_md(data, fragments), encoding="utf-8")
|
||||
def write_md(data: dict) -> None:
|
||||
"""Regenerate the two checked-in markdown artifacts. Release-only —
|
||||
`changelog.py` (per-PR) does NOT call this, otherwise every PR would
|
||||
touch CHANGELOG.md and reintroduce conflicts.
|
||||
"""
|
||||
FULL_MD_PATH.write_text(render_full_md(data), encoding="utf-8")
|
||||
SHORT_MD_PATH.write_text(render_short_md(data), encoding="utf-8")
|
||||
|
||||
|
||||
|
|
|
|||
29
scripts/preview-changelog.py
Executable file
29
scripts/preview-changelog.py
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
#
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# ///
|
||||
"""Print the pending (fragments-only) changelog to stdout.
|
||||
|
||||
Between releases `CHANGELOG.md` intentionally contains only released
|
||||
versions — that's what stops PRs from conflicting on it. To see what's
|
||||
accumulated under `changelog.d/` run this script. It writes nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from changelog_lib import load_fragments, render_unreleased_md # type: ignore[import-not-found]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fragments = load_fragments()
|
||||
print(render_unreleased_md(fragments), end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -143,7 +143,7 @@ def main() -> int:
|
|||
# Changelog: rotate fragments into history, then delete them.
|
||||
rotate_fragments_into_history(data, fragments, version)
|
||||
save_json(data)
|
||||
write_md(data, fragments=[])
|
||||
write_md(data)
|
||||
console.print(
|
||||
f" [green]✓[/green] changelog: {len(fragments)} fragment(s) → history[0] as v{version}",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue