mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-05-23 20:50:50 +00:00
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.
156 lines
5.1 KiB
Python
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": []})))
|