vpnhide/scripts/changelog_lib.py
okhsunrog ad2590a453 refactor(scripts): unreleased section + rename scripts
Rework the changelog and release flow to remove the aspirational
top-level version that made it unclear whether new entries were
landing in an already-released section.

Schema change: `changelog.json` now has an explicit `unreleased`
object instead of hoisting the upcoming version to the top level. The
old `{version, sections, history}` layout becomes
`{unreleased, history}`, with the previously-released version moved
into `history[0]`.

New entries always go into `unreleased` via `changelog.py`. Releasing
is a single atomic operation (`release.py X.Y.Z`) that promotes
`unreleased` into `history[0]` with the target version number,
propagates the version to every source file, and regenerates the
markdown artifacts.

Script renames:
- `_changelog.py` → `changelog_lib.py` (no more underscore-prefixed
  module that's imported by two siblings)
- `changelog-add.py` → `changelog.py`
- `update-version.py` → `release.py` (does more than just version
  propagation — the name now reflects the full release action)

CHANGELOG.md rendering follows Keep a Changelog: a `## [Unreleased]`
block appears on top only when there are unreleased entries; the
update-json/changelog.md shown in Magisk/KSU popups still skips
Unreleased (only released versions make sense there).

Docs (docs/changelog.md, docs/releasing.md, CONTRIBUTING.md, CLAUDE.md)
updated with the new commands and the clarified model.

CLAUDE.md additionally gains a "read these before doing any work"
section that lists the contributor docs — so future sessions load the
workflow rules into context instead of skipping them as optional.
2026-04-17 14:34:47 +03:00

156 lines
5.1 KiB
Python

"""Shared helpers for changelog manipulation.
Used by `changelog.py` (append entry to unreleased) and `release.py`
(rotate unreleased into history + bump version files).
Source of truth: `lsposed/app/src/main/assets/changelog.json` (bilingual,
full history).
JSON schema:
```
{
"unreleased": {
"sections": [
{ "type": "fixed", "items": [{"en": "...", "ru": "..."}] }
]
},
"history": [
{ "version": "0.6.1", "sections": [...] },
{ "version": "0.6.0", "sections": [...] }
]
}
```
`unreleased` holds entries accumulated during development — it is
always present (possibly empty). `history[0]` is the most recent
released version.
Two generated markdown artifacts (en only, overwritten on every script
run — never edited by hand):
* `CHANGELOG.md` at the repo root — full history, Keep a Changelog
convention with an optional `## [Unreleased]` block on top. The
canonical human-facing changelog; CI extracts a single tag's section
from here for the GitHub release body.
* `update-json/changelog.md` — the last MD_RECENT_VERSIONS released
versions only (no Unreleased block). Served at a stable URL
referenced from module update-json files; Magisk/KSU fetches it and
displays it in the update popup.
"""
from __future__ import annotations
import json
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
JSON_PATH = REPO_ROOT / "lsposed/app/src/main/assets/changelog.json"
FULL_MD_PATH = REPO_ROOT / "CHANGELOG.md"
SHORT_MD_PATH = REPO_ROOT / "update-json/changelog.md"
VALID_TYPES = ("added", "changed", "fixed", "removed", "deprecated", "security")
MD_RECENT_VERSIONS = 5
_KEEP_A_CHANGELOG_HEADER = """# Changelog
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).
"""
def load_json() -> dict:
return json.loads(JSON_PATH.read_text(encoding="utf-8"))
def save_json(data: dict) -> None:
JSON_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _section_items(entry: dict) -> list[tuple[str, list[dict]]]:
"""Return [(type, items), ...] for an entry in canonical order,
skipping types with no items.
"""
sections_by_type = {s["type"]: s for s in entry.get("sections", [])}
out: list[tuple[str, list[dict]]] = []
for type_ in VALID_TYPES:
section = sections_by_type.get(type_)
if section and section.get("items"):
out.append((type_, section["items"]))
return out
def _render_entry(heading: str, entry: dict, out: list[str]) -> None:
out.append(f"## {heading}")
out.append("")
for type_, items in _section_items(entry):
out.append(f"### {type_.title()}")
for item in items:
out.append(f"- {item['en']}")
out.append("")
def render_full_md(data: dict) -> str:
"""Full history (with optional Unreleased block on top), Keep a
Changelog header.
"""
out: list[str] = []
unreleased = data.get("unreleased", {"sections": []})
if _section_items(unreleased):
_render_entry("[Unreleased]", unreleased, 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_short_md(data: dict) -> str:
"""Last MD_RECENT_VERSIONS released versions only, no Unreleased,
no preamble. For Magisk/KSU popup.
"""
out: list[str] = []
for entry in data.get("history", [])[:MD_RECENT_VERSIONS]:
_render_entry(f"v{entry['version']}", entry, out)
return "\n".join(out).rstrip() + "\n"
def write_md(data: dict) -> None:
FULL_MD_PATH.write_text(render_full_md(data), encoding="utf-8")
SHORT_MD_PATH.write_text(render_short_md(data), encoding="utf-8")
def append_unreleased(data: dict, type_: str, en: str, ru: str) -> None:
"""Add an entry to the unreleased section."""
if type_ not in VALID_TYPES:
raise ValueError(f"invalid type {type_!r}; valid: {', '.join(VALID_TYPES)}")
unreleased = data.setdefault("unreleased", {"sections": []})
sections = unreleased.setdefault("sections", [])
section = next((s for s in sections if s["type"] == type_), None)
if section is None:
section = {"type": type_, "items": []}
sections.append(section)
section["items"].append({"en": en, "ru": ru})
def rotate_unreleased(data: dict, version: str) -> dict:
"""Promote `unreleased` into `history[0]` with the given version,
then reset `unreleased` to empty. Returns the newly-released entry.
"""
unreleased = data.get("unreleased", {"sections": []})
released = {
"version": version,
"sections": unreleased.get("sections", []),
}
history = data.setdefault("history", [])
history.insert(0, released)
data["unreleased"] = {"sections": []}
return released
def unreleased_has_entries(data: dict) -> bool:
return bool(_section_items(data.get("unreleased", {"sections": []})))