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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
Expand Down Expand Up @@ -49,3 +51,19 @@ jobs:
run: |
test -f "$(zepp-devkit path)/preview/zeus_preview.sh"
zepp-devkit path

# The one check the main ruleset requires: passes only when every check job above did, so
# jobs can be added or renamed without touching the ruleset.
ci:
name: CI passed
if: always()
needs: [test]
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Every check job passed
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
echo "$RESULTS"
if echo "$RESULTS" | grep -qE '"(failure|cancelled)"'; then exit 1; fi
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0

- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ All notable changes to the Zepp devkit. The format is based on

## [Unreleased]

## [0.2.0] - 2026-11-03

### 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`).

## [0.1.0] - 2026-11-01

### Added
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

Shared tooling for the owner's Zepp OS projects (raetha/zepp-kestrel, raetha/zepp-watch-faces, more later): GitHub Actions and scripts for building, test builds, Zepp previews, releases and the emulator. Read `README.md` and `docs/plan.md` first.

- **Branches:** work on `dev`; never push to `main`. A release is one squash-merged pull request from `dev` into `main`, opened when the owner asks: it sets `pyproject.toml`'s and `zepp_devkit/__init__.py`'s version and dates the changelog section. After the merge, CI tags `main` as `vX.Y.Z`, publishes the GitHub release and resets `dev` to `main` (`release.yml`). Projects pin a commit with the version in a comment, and Dependabot proposes updates there.
- **Repository settings:** a ruleset protects `main` (pull requests, squash only); merged branches are deleted automatically; a classic rule keeps `dev` from being deleted and allows force pushes: after a release squash merge, CI resets `dev` onto `main` (`sync-dev` in `release.yml`) when nothing new landed on `dev` meanwhile; otherwise reset it by hand once that work is released (`git checkout dev && git reset --hard origin/main && git push --force-with-lease`).
- **Branches:** Claude runs `dev`: small, settled changes go straight to it, anything else through a `try/<topic>` branch and pull request that Claude squash-merges once CI is green; `dev` must stay releasable. Never push to `main`. A release is one squash-merged pull request from `dev` into `main`, opened when the owner asks: it sets `pyproject.toml`'s and `zepp_devkit/__init__.py`'s version and dates the changelog section. After the merge, CI tags `main` as `vX.Y.Z`, publishes the GitHub release and resets `dev` to `main` (`release.yml`). Projects pin a commit with the version in a comment, and Dependabot proposes updates there.
- **Repository settings:** a ruleset protects `main` (pull requests, squash only, the `CI passed` check from `ci.yml`'s gate job, CodeQL code scanning results); merged branches are deleted automatically; a classic rule keeps `dev` from being deleted and allows force pushes: after a release squash merge, CI resets `dev` onto `main` (`sync-dev` in `release.yml`) when nothing new landed on `dev` meanwhile; otherwise reset it by hand once that work is released (`git checkout dev && git reset --hard origin/main && git push --force-with-lease`).
- Record each change under `## [Unreleased]` in `CHANGELOG.md`, written for the projects using it. Never bump a version or date the changelog without being asked.
- **Behaviour changes reach every project.** Keep inputs backwards compatible within a major version; a breaking change is a new major version, with the migration in the changelog. Test with the fake Zeus (`test/`), and then in a project's CI before releasing.
- **Security:** the Zepp login is in each project's secrets, never here. Never print it, the Zepp app's log, or `zeus bridge` output. Actions pinned to commits; `contents: read` unless a job needs more.
- **Security:** the Zepp login is in each project's secrets, never here. Never print it, the Zepp app's log, or `zeus bridge` output. Actions pinned to commits; `contents: read` unless a job needs more; `persist-credentials: false` on every checkout except in jobs that `git push`.
- Project-specific things (page layouts and wording, app IDs, which watches, the HA component) stay in each project, passed in as inputs.
- Licence: MIT.
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "zepp-devkit"
version = "0.1.0"
version = "0.2.0"
description = "Shared build, test-build and publishing tooling for Zepp OS projects"
license = "MIT"
requires-python = ">=3.10"
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
2 changes: 1 addition & 1 deletion zepp_devkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Shared tooling for Zepp OS projects. The command line is `zepp-devkit` (cli.py)."""
__version__ = "0.1.0"
__version__ = "0.2.0"
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