From 70f7a860ad56c822826c765e680b177be1c26664 Mon Sep 17 00:00:00 2001 From: Raetha Date: Mon, 2 Nov 2026 22:57:29 -0500 Subject: [PATCH 1/3] CI gate check, persist-credentials: false; Claude runs dev (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns with the updated repo-workflow skill: a single `CI passed` gate job (the one status check the `main` ruleset needs to require), `persist-credentials: false` on every checkout except in jobs that push with git, and the docs saying Claude runs `dev` (owner gates `dev` → `main`); zip delivery removed where it was still described. --- _Generated by [Claude Code](https://claude.ai/code/session_01Y5FKGuxtPN9CKcP5GhjfJY)_ Co-authored-by: Claude --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .github/workflows/release.yml | 1 + CHANGELOG.md | 4 ++++ CLAUDE.md | 6 +++--- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9a53b8..ad78537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a54590..85d0f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4415085..ca99ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to the Zepp devkit. The format is based on ## [Unreleased] +### 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 diff --git a/CLAUDE.md b/CLAUDE.md index 4037c71..4e17141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 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. From be814833dba96caf54ab761ad936e3971facb92f Mon Sep 17 00:00:00 2001 From: Raetha Date: Tue, 3 Nov 2026 00:29:00 -0500 Subject: [PATCH 2/3] Release plan: shared-version components, tag format, --dev mode (#4) - `same_version` in `zepp-devkit.json`: one component covers parts that ship under one version (Kestrel's watch app and HA integration); the other version files must match. - `tag` format per component (Kestrel: `v{version}`, one tag and release per version). - `release-plan --dev`: `[Unreleased]` is enough on a `dev` branch holding the expected next version; without it the version needs its own section (main and PRs into main). Backwards compatible (defaults unchanged), so a minor release: 0.2.0. --- _Generated by [Claude Code](https://claude.ai/code/session_01Y5FKGuxtPN9CKcP5GhjfJY)_ Co-authored-by: Claude --- CHANGELOG.md | 5 +++++ README.md | 7 ++++--- tests/test_devkit.py | 30 ++++++++++++++++++++++++++++++ zepp_devkit/cli.py | 5 +++-- zepp_devkit/project.py | 36 +++++++++++++++++++++++++++--------- zepp_devkit/release.py | 18 ++++++++++++------ 6 files changed, 81 insertions(+), 20 deletions(-) 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}) From 19d74fbccd1548edd085f338e625c5ebfad21cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:29:41 -0500 Subject: [PATCH 3/3] Release 0.2.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y5FKGuxtPN9CKcP5GhjfJY --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- zepp_devkit/__init__.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccb8b7..c0bc510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 135e406..aa1b349 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/zepp_devkit/__init__.py b/zepp_devkit/__init__.py index 6118741..fb6f485 100644 --- a/zepp_devkit/__init__.py +++ b/zepp_devkit/__init__.py @@ -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"