From 53e10a6b599cd5c736c33647b490b0aa6165fb07 Mon Sep 17 00:00:00 2001 From: Mateusz Czarnecki Date: Sat, 10 Oct 2026 23:25:27 +0200 Subject: [PATCH 1/3] app-release: reusable workflow + script (tag, prerelease, dispatch) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/app-release.yml | 72 ++++++++++++ .github/workflows/test.yml | 15 +++ scripts/app_release.py | 189 ++++++++++++++++++++++++++++++ scripts/test_app_release.py | 166 ++++++++++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 .github/workflows/app-release.yml create mode 100644 .github/workflows/test.yml create mode 100644 scripts/app_release.py create mode 100644 scripts/test_app_release.py diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml new file mode 100644 index 0000000..0622f50 --- /dev/null +++ b/.github/workflows/app-release.yml @@ -0,0 +1,72 @@ +name: App release (reusable) + +# Called by every enrolled app repo's .github/workflows/app-release.yml on a +# push that touches crosspad-app.json. Tags vX.Y.Z, publishes a GitHub +# prerelease (the announcer posts it as a nightly), then dispatches +# `app-released` to crosspad-apps (registry) and platform-idf (bump PR). +# Callers pin @v1. Spec: platform-idf 2026-10-10-release-automation-design.md. +# +# This repo is public: the workflow holds no secrets. The caller passes the bot +# key explicitly (no `secrets: inherit`). +on: + workflow_call: + inputs: + bot-app-id: + type: string + required: true + runner: + type: string + required: false + default: ubuntu-latest + secrets: + bot-private-key: + required: true + +permissions: + contents: write + +jobs: + release: + # Only the repo's default branch, only on a push (or a manual run of the + # caller). A pull request, a fork or any other ref never reaches a step + # that holds a token. + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ${{ inputs.runner }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: CrossPad/.github + ref: v1 + path: .crosspad-ci + persist-credentials: false + - id: rel + name: Tag and publish the version + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 .crosspad-ci/scripts/app_release.py release + - id: token + if: steps.rel.outputs.released == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ inputs.bot-app-id }} + private-key: ${{ secrets.bot-private-key }} + owner: CrossPad + repositories: platform-idf,crosspad-apps + permission-contents: write + - name: Tell crosspad-apps and platform-idf + if: steps.rel.outputs.released == 'true' + env: + BOT_TOKEN: ${{ steps.token.outputs.token }} + APP_ID: ${{ steps.rel.outputs.app_id }} + APP_VERSION: ${{ steps.rel.outputs.version }} + APP_SHA: ${{ steps.rel.outputs.sha }} + run: > + python3 .crosspad-ci/scripts/app_release.py dispatch + --app-id "$APP_ID" --version "$APP_VERSION" --sha "$APP_SHA" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4f79be9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: Test +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - run: python3 -m pip install --quiet pytest && python3 -m pytest scripts -q diff --git a/scripts/app_release.py b/scripts/app_release.py new file mode 100644 index 0000000..73416b4 --- /dev/null +++ b/scripts/app_release.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Tag and publish an app version, then tell crosspad-apps and platform-idf. + +Run by .github/workflows/app-release.yml (reusable) inside the app repo's +checkout. Standard library and git only: it runs on the crosspad-light org +runner (no gh). Spec: platform-idf docs/superpowers/specs/2026-10-10-release- +automation-design.md, "CrossPad/.github". + + app_release.py release # GITHUB_TOKEN; outputs released, app_id, version, sha + app_release.py dispatch --app-id ID --version V --sha SHA # BOT_TOKEN +""" +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +VERSION = re.compile(r"\d+\.\d+\.\d+") +APP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +RELEASE_TAG = re.compile(r"v(\d+)\.(\d+)\.(\d+)") +DISPATCH_TARGETS = ("CrossPad/crosspad-apps", "CrossPad/platform-idf") + + +class Refused(Exception): + pass + + +def read_manifest(text: str) -> tuple[str, str, str]: + try: + doc = json.loads(text) + except ValueError as e: + raise Refused(f"crosspad-app.json is not valid JSON: {e}") from e + version = str(doc.get("version", "")) + if not VERSION.fullmatch(version): + raise Refused(f"crosspad-app.json version {version!r}: must be X.Y.Z (the app manager's release rule)") + app_id = str(doc.get("id", "")) + if not APP_ID.fullmatch(app_id): + # The id is passed on to the next steps and to the dispatch payload. + raise Refused(f"crosspad-app.json id {app_id!r}: letters, digits, '.', '_' and '-' only") + return app_id, doc.get("name", app_id), version + + +def check_library(version: str, library_text: str | None) -> str | None: + if library_text is None: + return None + lib = str(json.loads(library_text).get("version", "")) + return None if lib == version else f"library.json version {lib} differs from crosspad-app.json {version}" + + +def newest_release(tags) -> tuple[int, int, int] | None: + vs = [tuple(int(x) for x in m.groups()) for m in map(RELEASE_TAG.fullmatch, tags) if m] + return max(vs) if vs else None + + +def decide(version: str, tags, tag_is_ancestor: bool) -> tuple[str, str]: + tag = f"v{version}" + if tag in tags: + if tag_is_ancestor: + return "noop", f"{tag} already released" + raise Refused(f"{tag} exists and is not an ancestor of this commit") + newest = newest_release(tags) + mine = tuple(int(x) for x in version.split(".")) + if newest is not None and mine <= newest: + raise Refused(f"{version} is not above the newest release v{'.'.join(map(str, newest))}") + return "release", tag + + +def introducing_commit(history, version: str) -> str: + """history: [(sha, version-or-None)], newest first, first-parent.""" + pick = None + for sha, v in history: + if v != version: + break + pick = sha + if pick is None: + raise Refused(f"HEAD's crosspad-app.json is not version {version}") + return pick + + +def notes_for(changelog, version: str) -> str: + pat = re.compile(rf"{re.escape(version)}(?=[:\s(])") + return "\n".join(f"- {e}" for e in changelog if pat.match(e)) + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def _version_at(sha: str) -> str | None: + try: + return str(json.loads(git("show", f"{sha}:crosspad-app.json")).get("version")) + except (subprocess.CalledProcessError, ValueError): + return None + + +def api(method: str, path: str, token: str, body=None): + req = urllib.request.Request(f"https://api.github.com{path}", method=method, + data=None if body is None else json.dumps(body).encode(), + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "crosspad-app-release"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + raise Refused(f"{method} {path}: HTTP {e.code}: {e.read().decode(errors='replace')[:300]}") from e + except urllib.error.URLError as e: + raise Refused(f"{method} {path}: {e.reason}") from e + + +def _output(**kv) -> None: + path = os.environ.get("GITHUB_OUTPUT") + if path: + with open(path, "a", encoding="utf-8") as f: + for k, v in kv.items(): + f.write(f"{k}={v}\n") + + +def cmd_release() -> int: + app_id, name, version = read_manifest(open("crosspad-app.json", encoding="utf-8").read()) + lib = open("library.json", encoding="utf-8").read() if os.path.exists("library.json") else None + problem = check_library(version, lib) + if problem: + raise Refused(problem) + tags = git("tag", "-l", "v*").split() + tag = f"v{version}" + ancestor = tag in tags and subprocess.run( + ["git", "merge-base", "--is-ancestor", f"refs/tags/{tag}", "HEAD"]).returncode == 0 + action, why = decide(version, tags, ancestor) + if action == "noop": + print(why) + _output(released="false") + return 0 + shas = git("log", "--first-parent", "--format=%H", "--", "crosspad-app.json").split() + history = [] + for sha in shas: + v = _version_at(sha) + history.append((sha, v)) + if v != version: + break + sha = introducing_commit(history, version) + changelog = json.loads(git("show", f"{sha}:crosspad-app.json")).get("changelog", []) + notes = notes_for(changelog, version) or f"{name} {version}" + api("POST", f"/repos/{os.environ['GITHUB_REPOSITORY']}/releases", os.environ["GITHUB_TOKEN"], + {"tag_name": tag, "target_commitish": sha, "name": f"{name} {version}", "body": notes, + "draft": False, "prerelease": True}) + print(f"released {tag} at {sha}") + _output(released="true", app_id=app_id, version=version, sha=sha) + return 0 + + +def cmd_dispatch(app_id: str, version: str, sha: str) -> int: + payload = {"repo": os.environ["GITHUB_REPOSITORY"], "app_id": app_id, "version": version, + "sha": sha, "tag": f"v{version}"} + failed = [] + for target in DISPATCH_TARGETS: + try: + api("POST", f"/repos/{target}/dispatches", os.environ["BOT_TOKEN"], + {"event_type": "app-released", "client_payload": payload}) + print(f"dispatched app-released to {target}") + except Refused as e: # one target down must not starve the other + failed.append(f"{target}: {e}") + if failed: + raise Refused("; ".join(failed)) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("release") + d = sub.add_parser("dispatch") + d.add_argument("--app-id", required=True) + d.add_argument("--version", required=True) + d.add_argument("--sha", required=True) + ns = ap.parse_args(argv) + try: + return cmd_release() if ns.cmd == "release" else cmd_dispatch(ns.app_id, ns.version, ns.sha) + except Refused as e: + print(f"::error::{e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_app_release.py b/scripts/test_app_release.py new file mode 100644 index 0000000..69cbb44 --- /dev/null +++ b/scripts/test_app_release.py @@ -0,0 +1,166 @@ +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import app_release as ar + +MANIFEST = json.dumps({"name": "Mixer", "id": "mixer", "version": "0.12.1", + "changelog": ["0.12.1: fixes the desk", "0.12.1 (merged 0.11.2): also this", + "0.12.0: older", "0.12.10: a different version"]}) + + +def test_read_manifest(): + assert ar.read_manifest(MANIFEST) == ("mixer", "Mixer", "0.12.1") + + +def test_read_manifest_refuses_a_prerelease_version(): + with pytest.raises(ar.Refused, match="X.Y.Z"): + ar.read_manifest(json.dumps({"id": "m", "name": "M", "version": "0.12.1-rc1"})) + + +@pytest.mark.parametrize("bad_id", ["", "a\nb", "a b", "x$(id)", '"; rm', "../x", ".hidden"]) +def test_read_manifest_refuses_an_id_that_is_unsafe_to_pass_on(bad_id): + with pytest.raises(ar.Refused, match="id"): + ar.read_manifest(json.dumps({"id": bad_id, "name": "M", "version": "1.0.0"})) + + +def test_read_manifest_refuses_a_missing_id_or_broken_json(): + with pytest.raises(ar.Refused, match="id"): + ar.read_manifest(json.dumps({"name": "M", "version": "1.0.0"})) + with pytest.raises(ar.Refused, match="JSON"): + ar.read_manifest("{not json") + + +def test_library_json_must_agree(): + assert ar.check_library("0.12.1", None) is None + assert ar.check_library("0.12.1", json.dumps({"version": "0.12.1"})) is None + assert "0.12.0" in ar.check_library("0.12.1", json.dumps({"version": "0.12.0"})) + + +def test_newest_release_ignores_non_release_tags(): + assert ar.newest_release(["v0.11.0", "v0.12.0", "v0.12.1-rc1", "nightly"]) == (0, 12, 0) + assert ar.newest_release([]) is None + + +def test_introducing_commit_is_the_oldest_of_the_newest_run(): + history = [("h3", "0.12.1"), ("h2", "0.12.1"), ("h1", "0.12.0"), ("h0", "0.12.1")] + assert ar.introducing_commit(history, "0.12.1") == "h2" + + +def test_introducing_commit_refuses_when_head_is_another_version(): + with pytest.raises(ar.Refused, match="not version"): + ar.introducing_commit([("h1", "0.12.0")], "0.12.1") + + +def test_notes_take_only_this_versions_entries(): + notes = ar.notes_for(json.loads(MANIFEST)["changelog"], "0.12.1") + assert notes == "- 0.12.1: fixes the desk\n- 0.12.1 (merged 0.11.2): also this" + + +def test_existing_tag_on_ancestor_is_a_noop(): + d = ar.decide(version="0.12.1", tags=["v0.12.0", "v0.12.1"], tag_is_ancestor=True) + assert d == ("noop", "v0.12.1 already released") + + +def test_existing_tag_elsewhere_fails(): + with pytest.raises(ar.Refused, match="not an ancestor"): + ar.decide(version="0.12.1", tags=["v0.12.1"], tag_is_ancestor=False) + + +def test_version_must_be_above_the_newest_tag(): + with pytest.raises(ar.Refused, match="not above"): + ar.decide(version="0.11.9", tags=["v0.12.0"], tag_is_ancestor=False) + + +def test_new_version_releases(): + assert ar.decide(version="0.12.1", tags=["v0.12.0"], tag_is_ancestor=False) == ("release", "v0.12.1") + + +def test_dispatch_sends_app_released_to_both_targets(monkeypatch): + calls = [] + monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") + monkeypatch.setenv("BOT_TOKEN", "t") + monkeypatch.setattr(ar, "api", lambda method, path, token, body=None: calls.append((method, path, token, body))) + assert ar.cmd_dispatch("mixer", "0.12.1", "abc") == 0 + assert [c[1] for c in calls] == ["/repos/CrossPad/crosspad-apps/dispatches", + "/repos/CrossPad/platform-idf/dispatches"] + assert all(c[0] == "POST" and c[2] == "t" for c in calls) + assert calls[0][3] == {"event_type": "app-released", + "client_payload": {"repo": "CrossPad/crosspad-mixer", "app_id": "mixer", + "version": "0.12.1", "sha": "abc", "tag": "v0.12.1"}} + + +def test_dispatch_tries_the_second_target_when_the_first_fails(monkeypatch): + calls = [] + + def api(method, path, token, body=None): + calls.append(path) + if "crosspad-apps" in path: + raise ar.Refused("boom") + + monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") + monkeypatch.setenv("BOT_TOKEN", "t") + monkeypatch.setattr(ar, "api", api) + with pytest.raises(ar.Refused, match="crosspad-apps"): + ar.cmd_dispatch("mixer", "0.12.1", "abc") + assert calls == ["/repos/CrossPad/crosspad-apps/dispatches", "/repos/CrossPad/platform-idf/dispatches"] + + +def _repo(tmp_path, monkeypatch, versions): + """A repo with one crosspad-app.json commit per entry of versions, oldest first.""" + import subprocess + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t"} + for k, v in env.items(): + monkeypatch.setenv(k, v) + monkeypatch.chdir(tmp_path) + subprocess.run(["git", "init", "-q", "-b", "main"], check=True) + shas = [] + for i, v in enumerate(versions): + doc = {"id": "mixer", "name": "Mixer", "version": v, "changelog": [f"{v}: change {i}"]} + Path("crosspad-app.json").write_text(json.dumps(doc)) + Path("other.txt").write_text(str(i)) + subprocess.run(["git", "add", "-A"], check=True) + subprocess.run(["git", "commit", "-q", "-m", f"c{i}"], check=True) + shas.append(subprocess.run(["git", "rev-parse", "HEAD"], check=True, capture_output=True, + text=True).stdout.strip()) + return shas + + +def test_release_publishes_a_prerelease_at_the_introducing_commit(tmp_path, monkeypatch, capsys): + shas = _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1", "0.12.1"]) + out = tmp_path / "out.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") + monkeypatch.setenv("GITHUB_TOKEN", "t") + calls = [] + monkeypatch.setattr(ar, "api", lambda method, path, token, body=None: calls.append((method, path, token, body))) + assert ar.main(["release"]) == 0 + (method, path, token, body), = calls + assert (method, path, token) == ("POST", "/repos/CrossPad/crosspad-mixer/releases", "t") + assert body["tag_name"] == "v0.12.1" and body["target_commitish"] == shas[1] + assert body["prerelease"] is True and body["draft"] is False + assert body["body"] == "- 0.12.1: change 1" + assert out.read_text().splitlines() == ["released=true", "app_id=mixer", "version=0.12.1", f"sha={shas[1]}"] + + +def test_release_is_a_noop_when_the_tag_is_already_below_head(tmp_path, monkeypatch): + import subprocess + _repo(tmp_path, monkeypatch, ["0.12.1", "0.12.1"]) + subprocess.run(["git", "tag", "v0.12.1", "HEAD~1"], check=True) + out = tmp_path / "out.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + monkeypatch.setattr(ar, "api", lambda *a, **k: pytest.fail("must not call the API")) + assert ar.main(["release"]) == 0 + assert out.read_text() == "released=false\n" + + +def test_release_refuses_when_library_json_disagrees(tmp_path, monkeypatch, capsys): + _repo(tmp_path, monkeypatch, ["0.12.1"]) + (tmp_path / "library.json").write_text(json.dumps({"version": "0.12.0"})) + monkeypatch.setattr(ar, "api", lambda *a, **k: pytest.fail("must not call the API")) + assert ar.main(["release"]) == 1 + assert "library.json" in capsys.readouterr().out From 3bb9600dfdd9cfa7ffad051e3244fb16b8abc449 Mon Sep 17 00:00:00 2001 From: Mateusz Czarnecki Date: Sun, 11 Oct 2026 00:39:58 +0200 Subject: [PATCH 2/3] app-release: no bot key in app repos, no-backfill guard, stricter parsing The announcer relays app-released after release.published, so the reusable workflow only tags and publishes with GITHUB_TOKEN: the bot-token and dispatch steps, bot-app-id and bot-private-key are gone. The scripts checkout uses refs/tags/v1 (a branch named v1 can no longer shadow it). While a repo has no vX.Y.Z tag only a push that bumps the version publishes; workflow_dispatch and new-branch pushes are no-ops. Versions refuse leading zeros and non-ASCII digits; bad JSON, non-object manifests and non-string changelogs end in a clean ::error:: (exit 1). The script runs with python3 -I and GIT_CONFIG_GLOBAL=/dev/null. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/app-release.yml | 53 +++------ scripts/app_release.py | 138 ++++++++++++++-------- scripts/test_app_release.py | 186 +++++++++++++++++++++++------- 3 files changed, 247 insertions(+), 130 deletions(-) diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 0622f50..45d6409 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -1,26 +1,22 @@ name: App release (reusable) # Called by every enrolled app repo's .github/workflows/app-release.yml on a -# push that touches crosspad-app.json. Tags vX.Y.Z, publishes a GitHub -# prerelease (the announcer posts it as a nightly), then dispatches -# `app-released` to crosspad-apps (registry) and platform-idf (bump PR). -# Callers pin @v1. Spec: platform-idf 2026-10-10-release-automation-design.md. +# push that touches crosspad-app.json. Tags vX.Y.Z and publishes it as a GitHub +# prerelease with the job's GITHUB_TOKEN, and nothing more: app repos hold no +# bot key. The announcer Worker sees `release.published`, posts the Nightly +# ping and relays `app-released` to crosspad-apps (registry) and platform-idf +# (bump PR). Callers pin @v1. Spec: platform-idf +# 2026-10-10-release-automation-design.md. # -# This repo is public: the workflow holds no secrets. The caller passes the bot -# key explicitly (no `secrets: inherit`). +# No backfill: while a repo has no vX.Y.Z tag, only a push that bumps the +# version publishes (see scripts/app_release.py). on: workflow_call: inputs: - bot-app-id: - type: string - required: true runner: type: string required: false default: ubuntu-latest - secrets: - bot-private-key: - required: true permissions: contents: write @@ -28,8 +24,7 @@ permissions: jobs: release: # Only the repo's default branch, only on a push (or a manual run of the - # caller). A pull request, a fork or any other ref never reaches a step - # that holds a token. + # caller). A pull request, a fork or any other ref never reaches the token. if: >- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) @@ -43,30 +38,16 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: CrossPad/.github - ref: v1 + # refs/tags/: a bare `v1` would prefer a branch named v1. + ref: refs/tags/v1 path: .crosspad-ci persist-credentials: false - - id: rel - name: Tag and publish the version + - name: Tag and publish the version env: GITHUB_TOKEN: ${{ github.token }} - run: python3 .crosspad-ci/scripts/app_release.py release - - id: token - if: steps.rel.outputs.released == 'true' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ inputs.bot-app-id }} - private-key: ${{ secrets.bot-private-key }} - owner: CrossPad - repositories: platform-idf,crosspad-apps - permission-contents: write - - name: Tell crosspad-apps and platform-idf - if: steps.rel.outputs.released == 'true' - env: - BOT_TOKEN: ${{ steps.token.outputs.token }} - APP_ID: ${{ steps.rel.outputs.app_id }} - APP_VERSION: ${{ steps.rel.outputs.version }} - APP_SHA: ${{ steps.rel.outputs.sha }} + GIT_CONFIG_GLOBAL: /dev/null + EVENT_NAME: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} run: > - python3 .crosspad-ci/scripts/app_release.py dispatch - --app-id "$APP_ID" --version "$APP_VERSION" --sha "$APP_SHA" + python3 -I .crosspad-ci/scripts/app_release.py release + --event "$EVENT_NAME" --before "$BEFORE" diff --git a/scripts/app_release.py b/scripts/app_release.py index 73416b4..6719f24 100644 --- a/scripts/app_release.py +++ b/scripts/app_release.py @@ -1,13 +1,18 @@ #!/usr/bin/env python3 -"""Tag and publish an app version, then tell crosspad-apps and platform-idf. +"""Tag an app version and publish it as a GitHub prerelease. Run by .github/workflows/app-release.yml (reusable) inside the app repo's checkout. Standard library and git only: it runs on the crosspad-light org -runner (no gh). Spec: platform-idf docs/superpowers/specs/2026-10-10-release- -automation-design.md, "CrossPad/.github". +runner (no gh). It holds GITHUB_TOKEN and nothing else: app repos have no bot +key. The announcer Worker relays `app-released` to crosspad-apps and +platform-idf after `release.published`. Spec: platform-idf docs/superpowers/ +specs/2026-10-10-release-automation-design.md, "CrossPad/.github". - app_release.py release # GITHUB_TOKEN; outputs released, app_id, version, sha - app_release.py dispatch --app-id ID --version V --sha SHA # BOT_TOKEN + app_release.py release --event NAME --before SHA + outputs released, app_id, version, sha to $GITHUB_OUTPUT + +No backfill: while the repo has no vX.Y.Z tag, only a push that itself bumps the +version publishes (the existing versions get no tags, no burst of pings). """ import argparse import json @@ -18,21 +23,29 @@ import urllib.error import urllib.request -VERSION = re.compile(r"\d+\.\d+\.\d+") +NUM = r"(0|[1-9][0-9]*)" # ASCII digits, no leading zeros +VERSION = re.compile(rf"{NUM}\.{NUM}\.{NUM}") APP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") -RELEASE_TAG = re.compile(r"v(\d+)\.(\d+)\.(\d+)") -DISPATCH_TARGETS = ("CrossPad/crosspad-apps", "CrossPad/platform-idf") +RELEASE_TAG = re.compile(rf"v{NUM}\.{NUM}\.{NUM}") +SHA = re.compile(r"[0-9a-f]{40,64}") class Refused(Exception): pass -def read_manifest(text: str) -> tuple[str, str, str]: +def _load_object(text: str, what: str) -> dict: try: doc = json.loads(text) except ValueError as e: - raise Refused(f"crosspad-app.json is not valid JSON: {e}") from e + raise Refused(f"{what} is not valid JSON: {e}") from e + if not isinstance(doc, dict): + raise Refused(f"{what} must be a JSON object") + return doc + + +def read_manifest(text: str) -> tuple[str, str, str]: + doc = _load_object(text, "crosspad-app.json") version = str(doc.get("version", "")) if not VERSION.fullmatch(version): raise Refused(f"crosspad-app.json version {version!r}: must be X.Y.Z (the app manager's release rule)") @@ -40,13 +53,20 @@ def read_manifest(text: str) -> tuple[str, str, str]: if not APP_ID.fullmatch(app_id): # The id is passed on to the next steps and to the dispatch payload. raise Refused(f"crosspad-app.json id {app_id!r}: letters, digits, '.', '_' and '-' only") - return app_id, doc.get("name", app_id), version + return app_id, str(doc.get("name") or app_id), version + + +def changelog_of(doc: dict) -> list[str]: + entries = doc.get("changelog", []) + if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries): + raise Refused("crosspad-app.json changelog must be a list of strings") + return entries def check_library(version: str, library_text: str | None) -> str | None: if library_text is None: return None - lib = str(json.loads(library_text).get("version", "")) + lib = str(_load_object(library_text, "library.json").get("version", "")) return None if lib == version else f"library.json version {lib} differs from crosspad-app.json {version}" @@ -89,11 +109,48 @@ def git(*args: str) -> str: return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout +def _is_ancestor(commit: str, of: str) -> bool | None: + """None when git cannot tell (an unknown object).""" + rc = subprocess.run(["git", "merge-base", "--is-ancestor", commit, of], capture_output=True).returncode + return {0: True, 1: False}.get(rc) + + +def first_release_skip(event: str, before: str, introduced_before) -> str | None: + """Why the first release of a repo with no release tag must not happen, or None. + + Only a push that bumps the version may publish: the version's introducing + commit must not be an ancestor of the push's `before`. introduced_before is + called only when needed and returns True, False or None (git cannot tell). + """ + if event != "push": + return f"no vX.Y.Z tag yet and the event is {event or 'unknown'}, not a push: nothing released" + if not before or set(before) == {"0"}: + return "no vX.Y.Z tag yet and this push creates the branch: nothing released" + was = introduced_before() + if was is None: + return "no vX.Y.Z tag yet and cannot tell whether this push bumped the version: nothing released" + if was: + return "no vX.Y.Z tag yet and the version was not bumped in this push: nothing released" + return None + + +def _introducing_sha(version: str) -> str: + shas = git("log", "--first-parent", "--format=%H", "--", "crosspad-app.json").split() + history = [] + for sha in shas: + v = _version_at(sha) + history.append((sha, v)) + if v != version: + break + return introducing_commit(history, version) + + def _version_at(sha: str) -> str | None: try: - return str(json.loads(git("show", f"{sha}:crosspad-app.json")).get("version")) + doc = json.loads(git("show", f"{sha}:crosspad-app.json")) except (subprocess.CalledProcessError, ValueError): return None + return str(doc.get("version")) if isinstance(doc, dict) else None def api(method: str, path: str, token: str, body=None): @@ -120,7 +177,9 @@ def _output(**kv) -> None: f.write(f"{k}={v}\n") -def cmd_release() -> int: +def cmd_release(event: str, before: str) -> int: + if before and not SHA.fullmatch(before): + raise Refused(f"--before {before!r}: not a commit sha") app_id, name, version = read_manifest(open("crosspad-app.json", encoding="utf-8").read()) lib = open("library.json", encoding="utf-8").read() if os.path.exists("library.json") else None problem = check_library(version, lib) @@ -128,22 +187,19 @@ def cmd_release() -> int: raise Refused(problem) tags = git("tag", "-l", "v*").split() tag = f"v{version}" - ancestor = tag in tags and subprocess.run( - ["git", "merge-base", "--is-ancestor", f"refs/tags/{tag}", "HEAD"]).returncode == 0 + ancestor = tag in tags and _is_ancestor(f"refs/tags/{tag}", "HEAD") is True action, why = decide(version, tags, ancestor) + if action == "release" and newest_release(tags) is None: + action, why = "noop", first_release_skip( + event, before, lambda: _is_ancestor(_introducing_sha(version), before)) or "" + if not why: + action = "release" if action == "noop": - print(why) + print(f"::notice::{why}" if why.startswith("no vX.Y.Z") else why) _output(released="false") return 0 - shas = git("log", "--first-parent", "--format=%H", "--", "crosspad-app.json").split() - history = [] - for sha in shas: - v = _version_at(sha) - history.append((sha, v)) - if v != version: - break - sha = introducing_commit(history, version) - changelog = json.loads(git("show", f"{sha}:crosspad-app.json")).get("changelog", []) + sha = _introducing_sha(version) + changelog = changelog_of(_load_object(git("show", f"{sha}:crosspad-app.json"), "crosspad-app.json")) notes = notes_for(changelog, version) or f"{name} {version}" api("POST", f"/repos/{os.environ['GITHUB_REPOSITORY']}/releases", os.environ["GITHUB_TOKEN"], {"tag_name": tag, "target_commitish": sha, "name": f"{name} {version}", "body": notes, @@ -153,35 +209,17 @@ def cmd_release() -> int: return 0 -def cmd_dispatch(app_id: str, version: str, sha: str) -> int: - payload = {"repo": os.environ["GITHUB_REPOSITORY"], "app_id": app_id, "version": version, - "sha": sha, "tag": f"v{version}"} - failed = [] - for target in DISPATCH_TARGETS: - try: - api("POST", f"/repos/{target}/dispatches", os.environ["BOT_TOKEN"], - {"event_type": "app-released", "client_payload": payload}) - print(f"dispatched app-released to {target}") - except Refused as e: # one target down must not starve the other - failed.append(f"{target}: {e}") - if failed: - raise Refused("; ".join(failed)) - return 0 - - def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) - sub.add_parser("release") - d = sub.add_parser("dispatch") - d.add_argument("--app-id", required=True) - d.add_argument("--version", required=True) - d.add_argument("--sha", required=True) + r = sub.add_parser("release") + r.add_argument("--event", required=True, help="github.event_name of the caller") + r.add_argument("--before", required=True, help="github.event.before ('' outside a push)") ns = ap.parse_args(argv) try: - return cmd_release() if ns.cmd == "release" else cmd_dispatch(ns.app_id, ns.version, ns.sha) - except Refused as e: - print(f"::error::{e}") + return cmd_release(ns.event, ns.before) + except (Refused, OSError, ValueError, subprocess.CalledProcessError) as e: + print("::error::" + str(e).replace("\r", "").replace("\n", "%0A")) return 1 diff --git a/scripts/test_app_release.py b/scripts/test_app_release.py index 69cbb44..16719d3 100644 --- a/scripts/test_app_release.py +++ b/scripts/test_app_release.py @@ -1,4 +1,5 @@ import json +import subprocess import sys from pathlib import Path @@ -79,39 +80,72 @@ def test_new_version_releases(): assert ar.decide(version="0.12.1", tags=["v0.12.0"], tag_is_ancestor=False) == ("release", "v0.12.1") -def test_dispatch_sends_app_released_to_both_targets(monkeypatch): - calls = [] - monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") - monkeypatch.setenv("BOT_TOKEN", "t") - monkeypatch.setattr(ar, "api", lambda method, path, token, body=None: calls.append((method, path, token, body))) - assert ar.cmd_dispatch("mixer", "0.12.1", "abc") == 0 - assert [c[1] for c in calls] == ["/repos/CrossPad/crosspad-apps/dispatches", - "/repos/CrossPad/platform-idf/dispatches"] - assert all(c[0] == "POST" and c[2] == "t" for c in calls) - assert calls[0][3] == {"event_type": "app-released", - "client_payload": {"repo": "CrossPad/crosspad-mixer", "app_id": "mixer", - "version": "0.12.1", "sha": "abc", "tag": "v0.12.1"}} +@pytest.mark.parametrize("text", ["[]", "42", '"x"', "null"]) +def test_read_manifest_refuses_a_manifest_that_is_not_an_object(text): + with pytest.raises(ar.Refused, match="object"): + ar.read_manifest(text) -def test_dispatch_tries_the_second_target_when_the_first_fails(monkeypatch): - calls = [] +@pytest.mark.parametrize("version", ["01.2.3", "1.02.3", "1.2.03", "\u0661.2.3", "1.2", "v1.2.3"]) +def test_read_manifest_refuses_leading_zeros_and_non_ascii_digits(version): + with pytest.raises(ar.Refused, match="X.Y.Z"): + ar.read_manifest(json.dumps({"id": "m", "name": "M", "version": version})) - def api(method, path, token, body=None): - calls.append(path) - if "crosspad-apps" in path: - raise ar.Refused("boom") - monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") - monkeypatch.setenv("BOT_TOKEN", "t") - monkeypatch.setattr(ar, "api", api) - with pytest.raises(ar.Refused, match="crosspad-apps"): - ar.cmd_dispatch("mixer", "0.12.1", "abc") - assert calls == ["/repos/CrossPad/crosspad-apps/dispatches", "/repos/CrossPad/platform-idf/dispatches"] +def test_release_tags_with_leading_zeros_or_non_ascii_digits_are_ignored(): + assert ar.newest_release(["v01.2.3", "v\u0661.2.3", "v1.2.3"]) == (1, 2, 3) + assert ar.newest_release(["v01.2.3"]) is None + + +@pytest.mark.parametrize("text", ["{not json", "[]", "null"]) +def test_check_library_refuses_a_broken_library_json(text): + with pytest.raises(ar.Refused, match="library.json"): + ar.check_library("1.0.0", text) + + +def test_changelog_must_be_a_list_of_strings(): + assert ar.changelog_of({"changelog": ["1.0.0: a"]}) == ["1.0.0: a"] + assert ar.changelog_of({}) == [] + for bad in ("1.0.0: a", [1, 2], ["1.0.0: a", None], {"a": "b"}): + with pytest.raises(ar.Refused, match="changelog"): + ar.changelog_of({"changelog": bad}) + + +ZEROS = "0" * 40 + + +def test_first_release_must_come_from_a_push_that_bumps_the_version(): + bumped = lambda: False # noqa: E731 - the introducing commit is not in `before` + assert ar.first_release_skip("push", "a" * 40, bumped) is None + + +def test_first_release_skipped_when_the_version_was_already_there(): + reason = ar.first_release_skip("push", "a" * 40, lambda: True) + assert "not bumped" in reason + + +def test_first_release_skipped_on_workflow_dispatch(): + reason = ar.first_release_skip("workflow_dispatch", "", lambda: pytest.fail("not needed")) + assert "workflow_dispatch" in reason + + +@pytest.mark.parametrize("before", ["", ZEROS]) +def test_first_release_skipped_for_a_new_branch(before): + reason = ar.first_release_skip("push", before, lambda: pytest.fail("not needed")) + assert "creates the branch" in reason + + +def test_first_release_skipped_when_before_is_unknown_to_git(): + assert "cannot tell" in ar.first_release_skip("push", "a" * 40, lambda: None) + + +def test_before_must_be_a_hex_sha(capsys): + assert ar.main(["release", "--event", "push", "--before=--upload-pack=x"]) == 1 + assert "::error::--before" in capsys.readouterr().out def _repo(tmp_path, monkeypatch, versions): """A repo with one crosspad-app.json commit per entry of versions, oldest first.""" - import subprocess env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} for k, v in env.items(): @@ -130,37 +164,101 @@ def _repo(tmp_path, monkeypatch, versions): return shas -def test_release_publishes_a_prerelease_at_the_introducing_commit(tmp_path, monkeypatch, capsys): - shas = _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1", "0.12.1"]) - out = tmp_path / "out.txt" +@pytest.fixture +def run(tmp_path, monkeypatch): + """Run `release`; returns (exit code, API calls, GITHUB_OUTPUT lines).""" + out = tmp_path.parent / (tmp_path.name + "-out.txt") monkeypatch.setenv("GITHUB_OUTPUT", str(out)) monkeypatch.setenv("GITHUB_REPOSITORY", "CrossPad/crosspad-mixer") monkeypatch.setenv("GITHUB_TOKEN", "t") calls = [] monkeypatch.setattr(ar, "api", lambda method, path, token, body=None: calls.append((method, path, token, body))) - assert ar.main(["release"]) == 0 + + def go(event, before): + rc = ar.main(["release", "--event", event, "--before", before]) + return rc, calls, (out.read_text().splitlines() if out.exists() else []) + return go + + +def test_first_release_on_a_push_that_bumps_publishes_a_prerelease(tmp_path, monkeypatch, run): + shas = _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1", "0.12.1"]) + rc, calls, out = run("push", shas[0]) + assert rc == 0 (method, path, token, body), = calls assert (method, path, token) == ("POST", "/repos/CrossPad/crosspad-mixer/releases", "t") assert body["tag_name"] == "v0.12.1" and body["target_commitish"] == shas[1] assert body["prerelease"] is True and body["draft"] is False assert body["body"] == "- 0.12.1: change 1" - assert out.read_text().splitlines() == ["released=true", "app_id=mixer", "version=0.12.1", f"sha={shas[1]}"] + assert out == ["released=true", "app_id=mixer", "version=0.12.1", f"sha={shas[1]}"] -def test_release_is_a_noop_when_the_tag_is_already_below_head(tmp_path, monkeypatch): - import subprocess - _repo(tmp_path, monkeypatch, ["0.12.1", "0.12.1"]) - subprocess.run(["git", "tag", "v0.12.1", "HEAD~1"], check=True) - out = tmp_path / "out.txt" - monkeypatch.setenv("GITHUB_OUTPUT", str(out)) - monkeypatch.setattr(ar, "api", lambda *a, **k: pytest.fail("must not call the API")) - assert ar.main(["release"]) == 0 - assert out.read_text() == "released=false\n" +def test_no_tags_and_no_bump_in_this_push_is_not_backfilled(tmp_path, monkeypatch, run, capsys): + shas = _repo(tmp_path, monkeypatch, ["0.12.1", "0.12.1"]) + rc, calls, out = run("push", shas[0]) # the version was already there before this push + assert (rc, calls, out) == (0, [], ["released=false"]) + assert "::notice::" in capsys.readouterr().out + + +def test_no_tags_workflow_dispatch_is_a_noop(tmp_path, monkeypatch, run, capsys): + _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1"]) + rc, calls, out = run("workflow_dispatch", "") + assert (rc, calls, out) == (0, [], ["released=false"]) + assert "::notice::" in capsys.readouterr().out + + +def test_no_tags_new_branch_push_is_a_noop(tmp_path, monkeypatch, run): + _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1"]) + assert run("push", ZEROS) == (0, [], ["released=false"]) -def test_release_refuses_when_library_json_disagrees(tmp_path, monkeypatch, capsys): - _repo(tmp_path, monkeypatch, ["0.12.1"]) +def test_no_tags_and_a_before_git_does_not_have_is_a_noop(tmp_path, monkeypatch, run): + _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1"]) + assert run("push", "b" * 40) == (0, [], ["released=false"]) + + +def test_once_a_release_tag_exists_a_dispatch_may_catch_up(tmp_path, monkeypatch, run): + shas = _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1"]) + subprocess.run(["git", "tag", "v0.12.0", shas[0]], check=True) + rc, calls, out = run("workflow_dispatch", "") + assert rc == 0 and calls[0][3]["tag_name"] == "v0.12.1" and out[0] == "released=true" + + +def test_release_is_a_noop_when_the_tag_is_already_below_head(tmp_path, monkeypatch, run): + shas = _repo(tmp_path, monkeypatch, ["0.12.1", "0.12.1"]) + subprocess.run(["git", "tag", "v0.12.1", shas[0]], check=True) + assert run("push", shas[0]) == (0, [], ["released=false"]) + + +def test_release_refuses_when_library_json_disagrees(tmp_path, monkeypatch, run, capsys): + shas = _repo(tmp_path, monkeypatch, ["0.12.0", "0.12.1"]) (tmp_path / "library.json").write_text(json.dumps({"version": "0.12.0"})) - monkeypatch.setattr(ar, "api", lambda *a, **k: pytest.fail("must not call the API")) - assert ar.main(["release"]) == 1 - assert "library.json" in capsys.readouterr().out + rc, calls, _ = run("push", shas[0]) + assert (rc, calls) == (1, []) + assert "::error::" in capsys.readouterr().out + + +@pytest.mark.parametrize("manifest", ["{not json", "[]"]) +def test_a_bad_manifest_is_a_clean_error_not_a_traceback(tmp_path, monkeypatch, run, capsys, manifest): + shas = _repo(tmp_path, monkeypatch, ["0.12.0"]) + (tmp_path / "crosspad-app.json").write_text(manifest) + rc, calls, _ = run("push", shas[0]) + assert (rc, calls) == (1, []) + assert capsys.readouterr().out.startswith("::error::") + + +def test_a_missing_manifest_is_a_clean_error(tmp_path, monkeypatch, run, capsys): + monkeypatch.chdir(tmp_path) + rc, calls, _ = run("push", "a" * 40) + assert (rc, calls) == (1, []) + assert capsys.readouterr().out.startswith("::error::") + + +def test_a_committed_manifest_with_a_bad_changelog_is_a_clean_error(tmp_path, monkeypatch, run, capsys): + shas = _repo(tmp_path, monkeypatch, ["0.11.0"]) + (tmp_path / "crosspad-app.json").write_text( + json.dumps({"id": "mixer", "version": "0.12.0", "changelog": [1, 2]})) + subprocess.run(["git", "commit", "-qam", "bad"], check=True) + rc, calls, _ = run("push", shas[0]) + assert (rc, calls) == (1, []) + out = capsys.readouterr().out + assert out.startswith("::error::") and "changelog" in out From 730fec06da3152772415267c5df27ac5fbc21058 Mon Sep 17 00:00:00 2001 From: Mateusz Czarnecki Date: Sun, 11 Oct 2026 00:40:11 +0200 Subject: [PATCH 3/3] app-release: refresh a stale comment Co-Authored-By: Claude Sonnet 5.5 --- scripts/app_release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/app_release.py b/scripts/app_release.py index 6719f24..3a661cc 100644 --- a/scripts/app_release.py +++ b/scripts/app_release.py @@ -51,7 +51,7 @@ def read_manifest(text: str) -> tuple[str, str, str]: raise Refused(f"crosspad-app.json version {version!r}: must be X.Y.Z (the app manager's release rule)") app_id = str(doc.get("id", "")) if not APP_ID.fullmatch(app_id): - # The id is passed on to the next steps and to the dispatch payload. + # The id goes to $GITHUB_OUTPUT (and on to the announcer), so no newlines or odd characters. raise Refused(f"crosspad-app.json id {app_id!r}: letters, digits, '.', '_' and '-' only") return app_id, str(doc.get("name") or app_id), version