Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ zepp-devkit @ git+https://github.com/raetha/zepp-devkit@<commit or tag>
zepp-devkit dev-variant <src> <dst> --app-id N --build N [--sha S] [--ignore P ...] [--stamp FILE ...]
zepp-devkit preview <project> <out> --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 <component|CHANGELOG.md> <version> [--html]
zepp-devkit notes <component|CHANGELOG.md> <version> [--install FILE] [--test-build N [--intro TEXT]]
zepp-devkit extract-zpk <dist> <out.zpk> [--device-source N] [--device-zip out.zip]
Expand All @@ -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 `<name>-v<version>`; a component whose draft file exists is never released; every other component's version needs a changelog section, or `release-plan` fails.
Tags are `<name>-v<version>`, 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

Expand Down
30 changes: 30 additions & 0 deletions tests/test_devkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions zepp_devkit/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""zepp-devkit: shared tooling for Zepp OS projects. Run from the project's folder.

zepp-devkit dev-variant <src> <dst> --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 <component|CHANGELOG.md> <version> [--html]
zepp-devkit notes <component|CHANGELOG.md> <version> [--install FILE] [--test-build N [--intro TEXT]]
zepp-devkit extract-zpk <dist> <out.zpk> [--device-source N] [--device-zip out.zip]
Expand Down Expand Up @@ -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=<json> 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")
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 27 additions & 9 deletions zepp_devkit/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<name>-v<version>`; a component whose draft file exists is never released.
`<name>-v<version>`, 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"
Expand All @@ -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
Expand All @@ -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", [])},
)


Expand Down
18 changes: 12 additions & 6 deletions zepp_devkit/release.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Releases from changelogs: which components need one, and their notes.

A component is released when its version has no `<name>-v<version>` 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 (`<name>-v<version>` 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
Expand All @@ -13,16 +15,20 @@
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 = [], [], []
for c in components(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})
Expand Down
Loading