Files
MeshTerm/scripts/cardputer-zero/store-publish.py
T
Jean-Pierre MartineauandClaude Opus 5.5 7209d89911 The store job brings JP's fork of the store up to date before it publishes
czdev starts its branch from the fork's default branch, and the store's
check compares the PR branch with the store's main directly. After
M5Stack removed every app on 2026-10-12, the fork was 10 commits behind,
so the 0.11.0 PR (#176) carried all 40 removed apps as new files, and
the check failed on the first of them. store-publish.py now calls
GitHub's merge-upstream for the fork first, and publishes nothing if
that fails. PR #176 was repaired by hand with "Update branch".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:57:20 -04:00

233 lines
10 KiB
Python
Executable File

#!/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. It also sets up ``git`` for it, and it
brings the fork up to date first:
* 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.
* ``czdev`` starts its branch from the default branch of the fork, and the store's check
compares the pull request's branch with the store's ``main`` directly. Thus, if the fork
is behind, each file that the store removed since then looks like a new file of the pull
request, and the check fails on it. This occurred on 2026-10-12, when M5Stack removed
all apps from the store to start the reviews again. The script brings the fork's
default branch up to the store's ``main`` before ``czdev`` starts. If it cannot, it
publishes nothing.
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.
It also publishes nothing while a pull request for an earlier version is open, because
the two would add the same files and conflict. A release in that time goes out as usual,
and its store job only shows a warning. After the merge, run that store job again, and it
publishes the version.
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.error
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, body: dict[str, str] | None = None) -> object:
"""Call one GitHub API path, as JSON: a GET, or a POST of ``body``."""
request = urllib.request.Request(
f"https://api.github.com{path}",
data=None if body is None else json.dumps(body).encode(),
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_requests(package: str, login: str, token: str) -> dict[str, str]:
"""The open pull requests of ``login`` for ``package``, any version: title to URL."""
pulls = _api(f"/repos/{TARGET}/pulls?state=open&per_page=100", token)
prefix = f"publish: {package} "
return {
pull["title"]: pull["html_url"]
for pull in pulls
if pull["user"]["login"] == login and pull["title"].startswith(prefix)
}
def _sync_fork(login: str, token: str) -> str | None:
"""Bring the fork's default branch up to the store's ``main``. Return a problem, or ``None``.
GitHub's merge-upstream does a fast-forward when the fork has no commits of its own. If
it has some and the merge fails, the script must not publish (refer to the module
docstring). With no fork, there is nothing to do: ``czdev`` makes one from the store's
current ``main``.
"""
try:
fork = _api(f"/repos/{login}/{TARGET.split('/')[1]}", token)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise
if (fork.get("parent") or {}).get("full_name") != TARGET:
return f"{fork['full_name']} is not a fork of {TARGET}"
branch = fork["default_branch"]
try:
_api(f"/repos/{fork['full_name']}/merge-upstream", token, {"branch": branch})
except urllib.error.HTTPError as error:
return f"cannot bring {fork['full_name']} {branch} up to {TARGET} (HTTP {error.code})"
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}"
pending = _open_pull_requests(package, login, token)
if title in pending:
print(f"a pull request for {package} {version} is already open: {pending[title]}")
return 0
if pending:
# A second pull request would add the same files as the open one, and the two
# would conflict. Thus this version waits until the open one is merged. Then a
# rerun of the release's store job publishes it.
mark = "::warning::" if os.environ.get("GITHUB_ACTIONS") else ""
for other, url in pending.items():
print(f"{mark}{other} is still open ({url}); {version} waits for its merge")
return 0
problem = _sync_fork(login, token)
if problem:
sys.stderr.write(f"store-publish: {problem}\n")
return 1
_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())