diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml new file mode 100644 index 0000000..45d6409 --- /dev/null +++ b/.github/workflows/app-release.yml @@ -0,0 +1,53 @@ +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 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. +# +# 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: + runner: + type: string + required: false + default: ubuntu-latest + +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 the 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 + # refs/tags/: a bare `v1` would prefer a branch named v1. + ref: refs/tags/v1 + path: .crosspad-ci + persist-credentials: false + - name: Tag and publish the version + env: + GITHUB_TOKEN: ${{ github.token }} + GIT_CONFIG_GLOBAL: /dev/null + EVENT_NAME: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} + run: > + python3 -I .crosspad-ci/scripts/app_release.py release + --event "$EVENT_NAME" --before "$BEFORE" 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..3a661cc --- /dev/null +++ b/scripts/app_release.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""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). 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 --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 +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +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(rf"v{NUM}\.{NUM}\.{NUM}") +SHA = re.compile(r"[0-9a-f]{40,64}") + + +class Refused(Exception): + pass + + +def _load_object(text: str, what: str) -> dict: + try: + doc = json.loads(text) + except ValueError as 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)") + app_id = str(doc.get("id", "")) + if not APP_ID.fullmatch(app_id): + # 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 + + +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(_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}" + + +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 _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: + 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): + 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(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) + if problem: + raise Refused(problem) + tags = git("tag", "-l", "v*").split() + tag = f"v{version}" + 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(f"::notice::{why}" if why.startswith("no vX.Y.Z") else why) + _output(released="false") + return 0 + 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, + "draft": False, "prerelease": True}) + print(f"released {tag} at {sha}") + _output(released="true", app_id=app_id, version=version, sha=sha) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", 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(ns.event, ns.before) + except (Refused, OSError, ValueError, subprocess.CalledProcessError) as e: + print("::error::" + str(e).replace("\r", "").replace("\n", "%0A")) + 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..16719d3 --- /dev/null +++ b/scripts/test_app_release.py @@ -0,0 +1,264 @@ +import json +import subprocess +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") + + +@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) + + +@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 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.""" + 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 + + +@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))) + + 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 == ["released=true", "app_id=mixer", "version=0.12.1", f"sha={shas[1]}"] + + +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_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"})) + 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