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
13 changes: 8 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Release

# On main (one squash-merged pull request per release): when pyproject.toml's version has no
# `v<version>` tag yet, tag it and publish a GitHub release with that version's changelog
# section. Projects then pin the tag (zepp-devkit @ git+...@vX.Y.Z). Then reset dev to main
# `<version>` tag yet (or `v<version>`, as releases up to 0.2.0 were tagged), tag it and publish
# a GitHub release with that version's changelog section. Projects then pin the tag
# (zepp-devkit @ git+...@X.Y.Z). Then reset dev to main
# (sync-dev), as zepp-kestrel and zepp-watch-faces do.

on:
Expand Down Expand Up @@ -34,11 +35,13 @@ jobs:
set -e
pip install -q .
version=$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
echo "v$version is already released"; exit 0
# no leading "v" (releases up to 0.2.0 have one)
if git rev-parse -q --verify "refs/tags/$version" >/dev/null ||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
echo "$version is already released"; exit 0
fi
zepp-devkit notes CHANGELOG.md "$version" > notes.md
gh release create "v$version" --target "$GITHUB_SHA" --title "zepp-devkit $version" --notes-file notes.md
gh release create "$version" --target "$GITHUB_SHA" --title "zepp-devkit $version" --notes-file notes.md

# After a release pull request is merged, dev holds the same files as main but different
# commits; reset it to main so the next cycle starts clean. Skipped if dev has new work.
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
All notable changes to the Zepp devkit. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/). Projects pin a commit of a release
(`uses: raetha/zepp-devkit/preview@<sha> # vX.Y.Z`), and Dependabot proposes updates.
(`uses: raetha/zepp-devkit/preview@<sha> # X.Y.Z`), and Dependabot proposes updates.

## [Unreleased]

### Changed

- Release tags no longer start with "v": a component's default tag is `<name>-<version>` (was `<name>-v<version>`), and this package's own releases are tagged `X.Y.Z` (pin `@0.3.0`, not `@v0.3.0`). A version that already has the older `v` tag counts as released, so nothing is released twice; a project with its own `tag` format gets the same check for that format with a "v" before the version.

## [0.2.0] - 2026-11-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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:** 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.
- **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 dates the changelog section. `dev` holds the expected next version (`pyproject.toml` and `zepp_devkit/__init__.py`): with the first change after a release, set it to what that change calls for under semver, and raise it when a later change calls for more; say what and why in a line. After the merge, CI tags `main` as `X.Y.Z` (no leading "v"; releases up to 0.2.0 have one), 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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Python code can import it too (`from zepp_devkit.changelog import section, to_ht
]}
```

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`.
Tags are `<name>-<version>`, or the component's `tag` format; a version that has the older tag with a "v" before the version (`<name>-v<version>`) counts as released too. 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 Expand Up @@ -105,7 +105,7 @@ Projects that would rather not install it can use the actions, pinned to a commi

### Updating, and trying devkit changes in a project

Projects pin a release tag (`@v0.1.0`). To update, change the pin in `tools/requirements.txt` (Dependabot may propose it). To try a devkit change before it's released, push it to this repository's `dev` and, on a branch of the project, pin that commit (`@<sha>`); the project's pull request then runs its CI against it. Projects don't follow this repository's `dev` branch directly: a moving reference would change their builds without a commit of their own, and a broken devkit `dev` would break every project at once.
Projects pin a release tag (`@0.3.0`; releases up to 0.2.0 are tagged `v0.2.0`). To update, change the pin in `tools/requirements.txt` (Dependabot may propose it). To try a devkit change before it's released, push it to this repository's `dev` and, on a branch of the project, pin that commit (`@<sha>`); the project's pull request then runs its CI against it. Projects don't follow this repository's `dev` branch directly: a moving reference would change their builds without a commit of their own, and a broken devkit `dev` would break every project at once.

## The Zepp login

Expand Down
2 changes: 1 addition & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ How projects use it: one pin in `tools/requirements.txt` (`zepp-devkit @ git+htt

Later candidates: Kestrel's model sweep (`tools/compat/`, run the app on every watch model's emulator image) and the faces' screenshot tooling, once a second project needs them.

Each step: build it here with tests, release (`vX.Y.Z`), switch Kestrel (from this session's repo access) and give the owner a prompt for the faces, update both projects' docs to point here, then delete their copies.
Each step: build it here with tests, release (`X.Y.Z`), switch Kestrel (from this session's repo access) and give the owner a prompt for the faces, update both projects' docs to point here, then delete their copies.
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.2.0"
version = "0.3.0"
description = "Shared build, test-build and publishing tooling for Zepp OS projects"
license = "MIT"
requires-python = ">=3.10"
Expand Down
12 changes: 7 additions & 5 deletions tests/test_devkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ def test_release_plan_components_and_notes(tmp_path, monkeypatch):
releases, problems, lines = release.plan(tmp_path)
# twisted's 2.0.0 has no changelog section: that's a problem; sextant is already tagged; ha is a draft
assert [r["component"] for r in releases] == ["watch", "twisted"]
assert releases[0] == {"component": "watch", "version": "1.1.0", "tag": "watch-v1.1.0", "title": "Kestrel watch app"}
assert releases[0] == {"component": "watch", "version": "1.1.0", "tag": "watch-1.1.0", "title": "Kestrel watch app"}
assert problems == [f"{tmp_path / 'faces/twisted/CHANGELOG.md'} needs a '## [2.0.0]' section (the version is 2.0.0)"]
assert "ha: draft, not released" in lines

out = tmp_path / "out"
monkeypatch.setenv("GITHUB_OUTPUT", str(out))
release.write_github_output(releases)
assert json.loads(out.read_text().split("=", 1)[1])[0]["tag"] == "watch-v1.1.0"
assert json.loads(out.read_text().split("=", 1)[1])[0]["tag"] == "watch-1.1.0"

cl = tmp_path / "watch" / "CHANGELOG.md"
assert release.notes(cl, "1.0.0", "**Install** it.") == "- First.\n\n---\n\n**Install** it."
Expand All @@ -132,19 +132,21 @@ def test_release_plan_shared_version_and_dev_mode(tmp_path, monkeypatch):
(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}"},
"changelog": "CHANGELOG.md", "tag": "{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"
assert c.tag == "0.2.0" and c.legacy_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"
assert problems == [] and releases[0]["tag"] == "0.2.0"
git(tmp_path, "tag", "v0.2.0") # released under the older tag form: not released again
assert release.plan(tmp_path)[0] == []

manifest.write_text(json.dumps({"name": "Kestrel", "version": "0.1.9"}))
assert release.plan(tmp_path, dev=True)[1] == [
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.2.0"
__version__ = "0.3.0"
2 changes: 1 addition & 1 deletion zepp_devkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def main(argv=None):

install = ""
if a.install:
fields = {"version": a.version, "name": a.source, "tag": f"{a.source}-v{a.version}"}
fields = {"version": a.version, "name": a.source, "tag": f"{a.source}-{a.version}"}
install = Path(a.install).read_text().format(**fields)
print(notes(_changelog_path(a.source), a.version, install, a.test_build, a.intro))
elif a.cmd == "extract-zpk":
Expand Down
15 changes: 10 additions & 5 deletions zepp_devkit/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
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>`, or the component's "tag" format (e.g. "v{version}"); a component whose
draft file exists is never released.
`<name>-<version>`, or the component's "tag" format (e.g. "{version}"). A version also counts
as released when it has the older tag with a "v" before the version (`<name>-v<version>`,
`v<version>`), so projects that tagged that way don't release a version twice. 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),
Expand All @@ -22,7 +24,7 @@
{"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}"}
"changelog": "CHANGELOG.md", "draft": "DRAFT", "tag": "{version}", "title": "{app_name} {version}"}
]}
"""
import fnmatch
Expand All @@ -43,9 +45,11 @@ class Component:
title: str
tag: str = ""
others: dict = field(default_factory=dict) # same_version: path -> the version it holds
legacy_tag: str = "" # the tag with a "v" before the version, as earlier releases were tagged

def __post_init__(self):
self.tag = self.tag or f"{self.name}-v{self.version}"
self.tag = self.tag or f"{self.name}-{self.version}"
self.legacy_tag = self.legacy_tag or f"{self.name}-v{self.version}"


def _version(root, path):
Expand All @@ -69,7 +73,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}"),
tag=fmt("tag", "{name}-{version}"),
legacy_tag=str(spec.get("tag", "{name}-{version}")).replace("{version}", "v{version}").format(**fields),
others={p: _version(root, p)[0] for p in spec.get("same_version", [])},
)

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

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,
A component is released when its version has no tag yet (`<name>-<version>` unless it sets
its own format, or the older `<name>-v<version>` form). 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`.
Expand Down Expand Up @@ -29,7 +29,7 @@ def plan(root=".", dev=False):
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
new = c.tag not in tags and c.legacy_tag not in tags
if new:
releases.append({"component": c.name, "version": c.version, "tag": c.tag, "title": c.title})
lines.append(f"{c.name}: version {c.version} -> {'RELEASE ' + c.tag if new else 'already released'}")
Expand Down
Loading