diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7431cc2..452d602 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -10,8 +10,9 @@ point of keeping an `[Unreleased]` section. The one part that takes real work is the entry, because the changelog is prose someone reads, not a list of commit subjects. Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml), -which calls `installers.yml`, builds all five platforms, attaches them with a combined -`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the +which calls `installers.yml`, builds all five platforms and the Cardputer Zero's `.deb`, +attaches them with a combined `SHA256SUMS`, sends the `.deb` to M5Stack's app store (the +`store` job), and takes the release notes **from that version's changelog section**. So the changelog is not documentation of the release; it *is* the release notes. An empty section publishes "No changelog entry for X.Y.Z." @@ -171,6 +172,22 @@ the release itself is fine (it runs after publishing); the usual cause is the `SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun --failed` finishes it. +`store` also runs after publishing, so its failure leaves the release fine too. It opens a +pull request on `CardputerZero/packages` (from JP's fork), and the store merges an update +by itself once its own check passes — no video after the first release. Confirm the PR +exists and its check passed: + +``` +gh pr list -R CardputerZero/packages --author jpmartineau --state all --limit 3 +``` + +If `store` failed, the usual causes are the `CARDPUTER_STORE_TOKEN` secret (missing, or the +token revoked) or a change in czdev: the job pins AppBuilder to one commit because +`scripts/cardputer-zero/store-publish.py` corrects two of its functions, and the script +refuses a czdev without them. A rerun is safe — the script publishes nothing when the store +already has the version or a PR for it is open. The manifest's email must be +`johnputer@meshterm.net`; the script sets it, so check it once on the PR's files. + The notes open with an install block the workflow writes from the tag — the same commands as the README, but pinned to *this* release's own assets — and the changelog follows under `## What changed`. **Check that second half actually came out of the changelog**, because a @@ -184,8 +201,9 @@ If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than changelog being empty. Fix the workflow, then repair the published notes in place with `gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move. -Fourteen assets is the right count — five version-stamped binaries, five version-less -copies of them, `SHA256SUMS`, and the three licence files: +Fifteen assets is the right count — five version-stamped binaries, five version-less +copies of them, the Cardputer Zero's `meshterm_X.Y.Z-1_arm64.deb`, `SHA256SUMS`, and the +three licence files: ``` gh release view vX.Y.Z --json assets --jq '.assets | length' diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 341b082..c48aa0b 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -187,6 +187,9 @@ jobs: .\meshterm-@VERSION@-windows-x64.exe ``` + **Cardputer Zero** — install MeshTerm from M5Stack's app store on the device. The + same package is attached below as `meshterm_@VERSION@-1_arm64.deb`. + Every binary below is listed in `SHA256SUMS`. The version-less copies — `meshterm-macos-arm64` and its four siblings — are the same binaries under a name that never changes, for anything that wants whichever release is newest without @@ -223,6 +226,52 @@ jobs: $PRERELEASE echo "https://github.com/${{ github.repository }}/releases/tag/$TAG" + # The Cardputer Zero's package goes to M5Stack's app store. `czdev publish` puts it in a + # pull request on CardputerZero/packages, and the store merges an update when its checks + # pass. scripts/cardputer-zero/store-publish.py runs czdev with two corrections and stops + # if the store already has this version or a pull request for it is open. Thus a second + # run of a release opens no second pull request. + store: + name: send the Cardputer package to the app store + needs: publish + if: ${{ !inputs.prerelease }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + # A token of jpmartineau with the public_repo scope: it uploads to the fork of + # CardputerZero/packages and opens the pull request as its author. + CARDPUTER_STORE_TOKEN: ${{ secrets.CARDPUTER_STORE_TOKEN }} + # The czdev that store-publish.py corrects. Change the two together. + APPBUILDER_COMMIT: 64edc2292628b436d3e624b150faeed2d60c31af + steps: + - name: Check for the token + run: | + if [ -z "$CARDPUTER_STORE_TOKEN" ]; then + echo "::error::CARDPUTER_STORE_TOKEN is not set; scripts/cardputer-zero/store-publish.py says what it needs." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - uses: actions/download-artifact@v4 + with: + name: meshterm-cardputer-zero + path: deb + + - name: Get czdev + run: | + git init -q appbuilder + git -C appbuilder fetch -q --depth 1 \ + https://github.com/m5stack/CardputerZero-AppBuilder.git "$APPBUILDER_COMMIT" + git -C appbuilder checkout -q FETCH_HEAD + + - name: Publish + run: | + python3 scripts/cardputer-zero/store-publish.py --czdev appbuilder \ + --deb deb/meshterm_*_arm64.deb + website: name: put the version on meshterm.net needs: publish diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index e4a29b8..d1fed20 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -198,3 +198,39 @@ jobs: path: | meshterm-* if-no-files-found: error + + # The Debian package for the Cardputer Zero's app store. It holds compiled code for arm64 + # and Python 3.13, so it is built in Debian 13 on an arm64 runner: the same system as the + # Cardputer. The release attaches it with the binaries above, and its `store` job sends it + # to M5Stack's app store. + cardputer-zero: + name: cardputer-zero-deb + runs-on: ubuntu-24.04-arm + container: debian:trixie + timeout-minutes: 30 + steps: + # gcc and the headers compile spidev, which has no arm64 wheel on PyPI. + - name: Install the build tools + run: | + apt-get update + apt-get install -y --no-install-recommends \ + python3 python3-venv python3-dev gcc libc6-dev ca-certificates + + - uses: actions/checkout@v4 + + - name: Build + run: sh scripts/cardputer-zero/build-deb.sh + + - name: Checksum it + working-directory: dist + run: | + for deb in meshterm_*_arm64.deb; do sha256sum "$deb" > "$deb.sha256"; done + cat ./*.sha256 + + - uses: actions/upload-artifact@v4 + with: + name: meshterm-cardputer-zero + path: | + dist/meshterm_*_arm64.deb + dist/meshterm_*_arm64.deb.sha256 + if-no-files-found: error diff --git a/scripts/README.md b/scripts/README.md index 169c4a3..6d09e13 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,7 @@ These are tools for the repository. None of them is in the wheel. | Script | What it is for | |---|---| | `basemap-doctor.py` | Finds why a user's map has no basemap: certificates, DNS, proxy, or colour depth | -| `cardputer-zero/` | `build-deb.sh` builds the Debian package for M5Stack's app store. Run it on the Cardputer Zero or on another arm64 Debian 13 system. `store/` holds the listing for the store (`app-builder.json`, which `czdev publish` reads), its icon, and its screenshots. `screenshot.py` saves the display of a Cardputer as a 320×170 PNG over SSH, as the store wants a screenshot. `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. `meshterm.png` is the package's icon: the website's logo, at the launcher's 100×100 size. `meshterm-dev.png` is its negative, for the checkout's entry, so that the two entries look different. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH | +| `cardputer-zero/` | `build-deb.sh` builds the Debian package for M5Stack's app store. Run it on the Cardputer Zero or on another arm64 Debian 13 system. `store/` holds the listing for the store (`app-builder.json`, which `czdev publish` reads), its icon, and its screenshots. `screenshot.py` saves the display of a Cardputer as a 320×170 PNG over SSH, as the store wants a screenshot. `store-publish.py` sends a package to the store with M5Stack's `czdev`, with two corrections. The release workflow runs it after each release. `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. `meshterm.png` is the package's icon: the website's logo, at the launcher's 100×100 size. `meshterm-dev.png` is its negative, for the checkout's entry, so that the two entries look different. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH | | `picocalc-lyra/` | Setup of the Calculinux device: console font, palette, Wi-Fi. `xiao-radio/` is the kit for the XIAO radio (firmware build, flashing, UART setup) | | `uconsole/` | The uConsole's always-on SPI bridge service. MeshTerm can also control that radio directly, with no script (refer to `docs/devices/uconsole.md`) | diff --git a/scripts/cardputer-zero/store-publish.py b/scripts/cardputer-zero/store-publish.py new file mode 100755 index 0000000..2d96d84 --- /dev/null +++ b/scripts/cardputer-zero/store-publish.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +r"""Publish the Cardputer Zero package to M5Stack's app store, with ``czdev`` and two corrections. + +``czdev publish`` (M5Stack's CardputerZero-AppBuilder) uploads the ``.deb`` to a release in +the fork of ``CardputerZero/packages``, pushes a branch with the store listing, and opens a +pull request. The store merges an update by itself when its checks pass. This script runs +``czdev`` in its own process with two corrections, and it sets up ``git`` for it: + +* The manifest records the email of the uploader. ``czdev`` takes the public email of the + GitHub profile. MeshTerm uses only ``johnputer@meshterm.net`` (JP, 2026-10-07), so the + script gives ``czdev`` that address. +* For a fork, ``czdev`` makes the branch name from the clock two times: before the upload + and after it. The upload takes seconds, so the pull request asks for a branch that was + never pushed, and GitHub refuses it (HTTP 422). The script makes the second call return + the first name. +* ``czdev`` pushes to ``git@github.com:``. A CI runner or a Cardputer has no SSH key for + GitHub, so the script sends ``git`` over HTTPS with the token, and gives it a name and an + email for the commit. These settings are in the environment of this process only. + +The corrections replace two functions of ``czdev``. Thus the release workflow pins +AppBuilder to one commit. If a later ``czdev`` does not have these functions, the script +stops with an error and does not publish. + +The script publishes nothing when the store already has this version, or when a pull +request for it is open. Thus a second run of a release does not open a second pull request. + + python scripts/cardputer-zero/store-publish.py --czdev ~/CardputerZero-AppBuilder \ + --deb dist/meshterm_0.10.4-1_arm64.deb + +The token comes from ``$CARDPUTER_STORE_TOKEN`` (the release workflow), or from the +credentials of ``czdev login`` (a Cardputer). It must have the ``public_repo`` scope. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +from pathlib import Path + +#: The only email of MeshTerm. +EMAIL = "johnputer@meshterm.net" +NAME = "Jean-Pierre Martineau" +#: The directory with ``app-builder.json``, the icon, and the screenshots. +STORE = Path(__file__).resolve().parent / "store" +TARGET = "CardputerZero/packages" +PUBLISHED_INDEX = "https://cardputerzero.github.io/packages/dists/stable/main/binary-arm64/Packages" +TOKEN_ENV = "CARDPUTER_STORE_TOKEN" + + +def _api(path: str, token: str) -> object: + """GET one GitHub API path, as JSON.""" + request = urllib.request.Request( + f"https://api.github.com{path}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - a fixed https host + return json.load(response) + + +def _deb_field(deb: Path, field: str) -> str: + """One field of the control file of ``deb``.""" + done = subprocess.run(["dpkg-deb", "-f", str(deb), field], capture_output=True, text=True) + return done.stdout.strip() + + +def _published_versions(package: str) -> set[str]: + """The versions of ``package`` in the store's index. Empty if the index cannot be read.""" + try: + with urllib.request.urlopen(PUBLISHED_INDEX, timeout=30) as response: # noqa: S310 + text = response.read().decode() + except OSError: + return set() + versions: set[str] = set() + for stanza in text.split("\n\n"): + fields = dict(line.split(": ", 1) for line in stanza.splitlines() if ": " in line) + if fields.get("Package") == package and "Version" in fields: + versions.add(fields["Version"]) + return versions + + +def _open_pull_request(title: str, login: str, token: str) -> str | None: + """The URL of an open pull request of ``login`` with ``title``, or ``None``.""" + pulls = _api(f"/repos/{TARGET}/pulls?state=open&per_page=100", token) + for pull in pulls: + if pull["user"]["login"] == login and pull["title"] == title: + return pull["html_url"] + return None + + +def _set_up_git(login: str, token: str) -> None: + """Send ``czdev``'s git over HTTPS with ``token``, as MeshTerm's author.""" + os.environ["CZDEV_GIT_TOKEN"] = token + os.environ["CZDEV_GIT_LOGIN"] = login + helper = ( + '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" ' + '"$CZDEV_GIT_LOGIN" "$CZDEV_GIT_TOKEN"; }; f' + ) + settings = [ + ("url.https://github.com/.insteadOf", "git@github.com:"), + ("credential.https://github.com.helper", helper), + ] + os.environ["GIT_CONFIG_COUNT"] = str(len(settings)) + for index, (key, value) in enumerate(settings): + os.environ[f"GIT_CONFIG_KEY_{index}"] = key + os.environ[f"GIT_CONFIG_VALUE_{index}"] = value + os.environ["GIT_TERMINAL_PROMPT"] = "0" + for role in ("AUTHOR", "COMMITTER"): + os.environ[f"GIT_{role}_NAME"] = NAME + os.environ[f"GIT_{role}_EMAIL"] = EMAIL + + +def _correct(publish: object, client_class: type) -> None: + """Make the two corrections in ``czdev`` (refer to the module docstring).""" + get_user = client_class.get_user + + def get_user_with_meshterm_email(self: object): # noqa: ANN202 - czdev's own User + user = get_user(self) + user.email = EMAIL + return user + + client_class.get_user = get_user_with_meshterm_email + names: dict[tuple[str, str], str] = {} + branch_name = publish.branch_name + publish.branch_name = lambda meta: names.setdefault( + (meta["package"], meta["version"]), branch_name(meta) + ) + + +def main() -> int: + """Check, correct, and run ``czdev publish``.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--czdev", required=True, type=Path, help="a CardputerZero-AppBuilder") + parser.add_argument("--deb", required=True, type=Path, help="the package to publish") + args = parser.parse_args() + deb = args.deb.resolve() + + sys.path.insert(0, str(args.czdev.resolve() / "scripts")) + from czdev import auth, publish # noqa: PLC0415 - from the AppBuilder given on the command line + from czdev.github_client import GitHubClient # noqa: PLC0415 + + for owner, name in ((publish, "branch_name"), (GitHubClient, "get_user"), (auth, "load_token")): + if not callable(getattr(owner, name, None)): + sys.stderr.write( + f"store-publish: czdev has no {name} -- this czdev is not the pinned one\n" + ) + return 1 + + token = os.environ.get(TOKEN_ENV) or auth.load_token() + if os.environ.get(TOKEN_ENV): + auth.load_token = lambda: token + + package, version = _deb_field(deb, "Package"), _deb_field(deb, "Version") + if not package or not version: + sys.stderr.write(f"store-publish: {deb} is not a Debian package\n") + return 1 + if version in _published_versions(package): + print(f"the store already has {package} {version}: nothing to publish") + return 0 + login = _api("/user", token)["login"] + title = f"publish: {package} {version}" + already = _open_pull_request(title, login, token) + if already: + print(f"a pull request for {package} {version} is already open: {already}") + return 0 + + _correct(publish, GitHubClient) + _set_up_git(login, token) + # czdev reads app-builder.json from the working directory. + os.chdir(STORE) + publish.run(deb=str(deb)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())