A release sends the Cardputer Zero package to M5Stack's app store

installers.yml gains a cardputer-zero job: build-deb.sh in a Debian 13
container on an arm64 runner, the same system as the Cardputer. The
release attaches the .deb with the other downloads and lists it in
SHA256SUMS, and its notes say where Cardputer users get MeshTerm.

github-release.yml gains a store job after the release. It runs
scripts/cardputer-zero/store-publish.py, which calls czdev publish with
two corrections: the manifest's email is johnputer@meshterm.net, not
the GitHub profile's, and the branch name stays the same between
czdev's two calls (for a fork, the second call named a branch that was
never pushed, and GitHub refused the PR). It also sends czdev's git
over HTTPS with the token, and it publishes nothing when the store has
the version or a PR for it is open, so a rerun opens no second PR.
AppBuilder is pinned to the commit whose functions it corrects.

The job needs the CARDPUTER_STORE_TOKEN secret (public_repo scope). The
release skill now checks the store PR and counts fifteen assets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-07 04:32:35 -04:00
1 parent cc626160d4
commit c5e7ce5a62
5 files changed
+288 -5

No files matched your search

+22 -4
View File
@@ -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 <run-id> --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'
+49
View File
@@ -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
+36
View File
@@ -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
+1 -1
View File
@@ -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`) |
+180
View File
@@ -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())