diff --git a/CHANGELOG.md b/CHANGELOG.md index ca99ac8..9ccb8b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ All notable changes to the Zepp devkit. The format is based on ## [Unreleased] +### Added + +- `release-plan --dev`: on a `dev` branch that holds the expected next version, an `[Unreleased]` changelog section is enough; without `--dev` (on `main` and pull requests into it) the version still needs its own section. +- Components that ship together under one version: `same_version` in `zepp-devkit.json` lists other version files (Zeus `app.json` or Home Assistant `manifest.json`) that must hold the component's version, and `tag` sets the tag format (for example `v{version}`, one tag and GitHub release for the whole project). + ### Internal - CI hardening: a single `CI passed` check gates releases, and no job keeps the workflow token on disk unless it pushes with git (`persist-credentials: false`). diff --git a/README.md b/README.md index 97a2275..690242a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ zepp-devkit @ git+https://github.com/raetha/zepp-devkit@ zepp-devkit dev-variant --app-id N --build N [--sha S] [--ignore P ...] [--stamp FILE ...] zepp-devkit preview --watches "Amazfit Balance 3,Amazfit Balance 3 Ti" [--label L] [--color C] [--github] zepp-devkit preview-check --build-result R [--failed TEXT] [--repositories TEXT] -zepp-devkit release-plan [--github] +zepp-devkit release-plan [--dev] [--github] zepp-devkit changelog [--html] zepp-devkit notes [--install FILE] [--test-build N [--intro TEXT]] zepp-devkit extract-zpk [--device-source N] [--device-zip out.zip] @@ -46,12 +46,13 @@ Python code can import it too (`from zepp_devkit.changelog import section, to_ht ```json {"components": [ - {"name": "watch", "app": "watch/app.json", "changelog": "watch/CHANGELOG.md", "draft": "watch/DRAFT", "title": "{app_name} watch app"}, + {"name": "kestrel", "app": "watch/app.json", "same_version": ["custom_components/kestrel/manifest.json"], + "changelog": "CHANGELOG.md", "draft": "DRAFT", "tag": "v{version}", "title": "{app_name} {version}"}, {"each": "faces/*/app.json", "skip": ["*-dev"], "changelog": "{dir}/CHANGELOG.md", "draft": "{dir}/DRAFT", "title": "{app_name} {version}"} ]} ``` -Tags are `-v`; a component whose draft file exists is never released; every other component's version needs a changelog section, or `release-plan` fails. +Tags are `-v`, or the component's `tag` format. Parts that ship together under one version (a watch app and its Home Assistant integration) are one component: `same_version` lists the other version files, which must match. A component whose draft file exists is never released. Every other component's version needs a changelog section, or `release-plan` fails; with `--dev` (for a `dev` branch that holds the expected next version) an `[Unreleased]` section is enough, so run it without `--dev` on `main` and on pull requests into `main`. ### In GitHub Actions diff --git a/tests/test_devkit.py b/tests/test_devkit.py index 0edffcc..f95119a 100644 --- a/tests/test_devkit.py +++ b/tests/test_devkit.py @@ -124,6 +124,36 @@ def test_release_plan_components_and_notes(tmp_path, monkeypatch): assert cli.main(["release-plan"]) == 1 # twisted's missing section fails it +def test_release_plan_shared_version_and_dev_mode(tmp_path, monkeypatch): + app_json(tmp_path / "watch", name="Kestrel", version="0.2.0") + manifest = tmp_path / "custom_components" / "kestrel" / "manifest.json" + manifest.parent.mkdir(parents=True) + manifest.write_text(json.dumps({"name": "Kestrel", "version": "0.2.0"})) + (tmp_path / "CHANGELOG.md").write_text("## [Unreleased]\n\n- x\n\n## [0.1.0] - 2026-11-01\n\n- y\n") + (tmp_path / project.CONFIG).write_text(json.dumps({"components": [ + {"name": "kestrel", "app": "watch/app.json", "same_version": ["custom_components/kestrel/manifest.json"], + "changelog": "CHANGELOG.md", "tag": "v{version}"}, + ]})) + git(tmp_path, "init", "-q") + git(tmp_path, "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "x") + + c = project.component("kestrel", tmp_path) + assert c.tag == "v0.2.0" and c.title == "Kestrel 0.2.0" + # dev holds the expected next version: [Unreleased] is enough there, not for a release + assert release.plan(tmp_path, dev=True)[1] == [] + assert release.plan(tmp_path)[1] == [f"{tmp_path / 'CHANGELOG.md'} needs a '## [0.2.0]' section (the version is 0.2.0)"] + (tmp_path / "CHANGELOG.md").write_text("## [0.2.0] - 2026-11-03\n\n- x\n") + releases, problems, _ = release.plan(tmp_path) + assert problems == [] and releases[0]["tag"] == "v0.2.0" + + manifest.write_text(json.dumps({"name": "Kestrel", "version": "0.1.9"})) + assert release.plan(tmp_path, dev=True)[1] == [ + "custom_components/kestrel/manifest.json has version 0.1.9, but kestrel's version is 0.2.0: they must match" + ] + monkeypatch.chdir(tmp_path) + assert cli.main(["release-plan", "--dev"]) == 1 + + def fake_zab(path): """A .zab with two packages: round 480 (Balance 3) and square.""" def zpk(sources): diff --git a/zepp_devkit/cli.py b/zepp_devkit/cli.py index c62c198..d584b6c 100644 --- a/zepp_devkit/cli.py +++ b/zepp_devkit/cli.py @@ -1,7 +1,7 @@ """zepp-devkit: shared tooling for Zepp OS projects. Run from the project's folder. zepp-devkit dev-variant --app-id N --build N [--sha S] [--ignore P ...] [--stamp FILE ...] - zepp-devkit release-plan [--github] which components need a release (zepp-devkit.json) + zepp-devkit release-plan [--dev] [--github] which components need a release (zepp-devkit.json) zepp-devkit changelog [--html] zepp-devkit notes [--install FILE] [--test-build N [--intro TEXT]] zepp-devkit extract-zpk [--device-source N] [--device-zip out.zip] @@ -52,6 +52,7 @@ def main(argv=None): p = sub.add_parser("release-plan", help="which components need a release") p.add_argument("--github", action="store_true", help="also write releases= to $GITHUB_OUTPUT") + p.add_argument("--dev", action="store_true", help="a branch holding the expected next version: [Unreleased] is enough") p = sub.add_parser("changelog", help="print one version's section") p.add_argument("source") @@ -112,7 +113,7 @@ def main(argv=None): elif a.cmd == "release-plan": from .release import plan, write_github_output - releases, problems, lines = plan() + releases, problems, lines = plan(dev=a.dev) print("\n".join(lines)) if problems: print("\n".join(problems), file=sys.stderr) diff --git a/zepp_devkit/project.py b/zepp_devkit/project.py index 8dff426..5ddf3ac 100644 --- a/zepp_devkit/project.py +++ b/zepp_devkit/project.py @@ -12,11 +12,22 @@ A component's version comes from a Zeus `app.json` (app.version.name, name app.appName) or a Home Assistant `manifest.json` (version, name). With "each", every matching app.json is a component named after its folder ({dir} is that folder, {name} its name). Tags are -`-v`; a component whose draft file exists is never released. +`-v`, or the component's "tag" format (e.g. "v{version}"); a component whose +draft file exists is never released. + +Parts that ship together under one version are one component: its "app" or "manifest" gives +the version, and "same_version" lists the other version files (app.json or manifest.json), +which must hold the same version: + + {"components": [ + {"name": "kestrel", "app": "watch/app.json", + "same_version": ["custom_components/kestrel/manifest.json"], + "changelog": "CHANGELOG.md", "draft": "DRAFT", "tag": "v{version}", "title": "{app_name} {version}"} + ]} """ import fnmatch import json -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path CONFIG = "zepp-devkit.json" @@ -30,18 +41,23 @@ class Component: changelog: Path draft: bool title: str + tag: str = "" + others: dict = field(default_factory=dict) # same_version: path -> the version it holds - @property - def tag(self): - return f"{self.name}-v{self.version}" + def __post_init__(self): + self.tag = self.tag or f"{self.name}-v{self.version}" -def _read(root, spec, path, name=None, folder=None): +def _version(root, path): + """-> (version, app name) from a Zeus app.json or a Home Assistant manifest.json.""" data = json.loads((root / path).read_text()) if "app" in data: # Zeus app.json - version, app_name = data["app"]["version"]["name"], data["app"]["appName"] - else: # Home Assistant manifest.json - version, app_name = data["version"], data["name"] + return data["app"]["version"]["name"], data["app"]["appName"] + return data["version"], data["name"] # Home Assistant manifest.json + + +def _read(root, spec, path, name=None, folder=None): + version, app_name = _version(root, path) name = name or spec["name"] fields = {"name": name, "dir": folder or "", "version": version, "app_name": app_name} fmt = lambda key, default="": str(spec.get(key, default)).format(**fields) # noqa: E731 @@ -53,6 +69,8 @@ def _read(root, spec, path, name=None, folder=None): changelog=root / fmt("changelog"), draft=bool(draft) and (root / draft).exists(), title=fmt("title", "{app_name} {version}"), + tag=fmt("tag", "{name}-v{version}"), + others={p: _version(root, p)[0] for p in spec.get("same_version", [])}, ) diff --git a/zepp_devkit/release.py b/zepp_devkit/release.py index 475995f..7e64f46 100644 --- a/zepp_devkit/release.py +++ b/zepp_devkit/release.py @@ -1,8 +1,10 @@ """Releases from changelogs: which components need one, and their notes. -A component is released when its version has no `-v` git tag. Every -non-draft component's current version must have a changelog section, so a version bump -without notes fails CI (also on pull requests). +A component is released when its version has no tag yet (`-v` unless it sets +its own format). Every non-draft component's current version must have a changelog section, +so a version bump without notes fails CI. With `dev`, for a branch that holds the expected +next version, an `[Unreleased]` section is enough: the release pull request names the +version's section, and CI on it (and on `main`) runs without `dev`. """ import json import os @@ -13,7 +15,7 @@ from .project import components -def plan(root="."): +def plan(root=".", dev=False): """-> (releases, problems, lines): releases as dicts for CI, problems that must fail it.""" tags = set(subprocess.run(["git", "tag", "-l"], capture_output=True, text=True, cwd=root).stdout.split()) releases, problems, lines = [], [], [] @@ -21,8 +23,12 @@ def plan(root="."): if c.draft: lines.append(f"{c.name}: draft, not released") continue - if section(c.changelog, c.version) is None: - problems.append(f"{c.changelog} needs a '## [{c.version}]' section (the version is {c.version})") + for path, version in c.others.items(): + if version != c.version: + problems.append(f"{path} has version {version}, but {c.name}'s version is {c.version}: they must match") + if section(c.changelog, c.version) is None and not (dev and section(c.changelog, "Unreleased") is not None): + wanted = f"'## [{c.version}]'" + (" or '## [Unreleased]'" if dev else "") + problems.append(f"{c.changelog} needs a {wanted} section (the version is {c.version})") new = c.tag not in tags if new: releases.append({"component": c.name, "version": c.version, "tag": c.tag, "title": c.title})