diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..056ac0c --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,85 @@ +name: Publish plugin + +# Builds dist/.plugin and publishes it to the Releases tab. +# +# The version in plugin.json is the single source of truth: +# new version -> a new v tag and release are created +# same version -> the existing release is rebuilt, replacing its attachment +# +# So a normal push to main always produces a downloadable build, and bumping +# the version in plugin.json is what cuts a new release. Pushing a v* tag by +# hand still works and uses that tag verbatim. +# +# dist/ is gitignored, so the artifact is always built here rather than +# committed. The build uses --release because --dev embeds absolute paths from +# whichever machine produced it. + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Validate plugin metadata and sources + run: python3 tools/bundle.py --check + + - name: Build release bundle + id: build + run: | + set -eu + python3 tools/bundle.py --release + plugin_id=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") + version=$(python3 -c "import json; print(json.load(open('plugin.json'))['version'])") + artifact="dist/${plugin_id}.plugin" + test -s "$artifact" + echo "artifact=${artifact}" >> "$GITHUB_OUTPUT" + echo "asset_name=${plugin_id}.plugin" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "built ${artifact} ($(wc -c < "$artifact") bytes)" + + - name: Verify the built bundle + # Executes the bundle against faked SDK modules and asserts the + # plugin's observable behaviour. A build that fails this is not + # publishable, so this gates the upload. + run: python3 tools/verify_bundle.py "${{ steps.build.outputs.artifact }}" + + - name: Resolve release tag + id: tag + run: | + set -eu + if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + # Hand-pushed tag: honour it exactly as written. + tag="${GITHUB_REF_NAME}" + else + # Branch push or manual run: the version in plugin.json decides + # whether this is a new release or a rebuild of the current one. + tag="v${{ steps.build.outputs.version }}" + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "publishing as ${tag}" + + - name: Publish release and upload plugin + env: + # Org-level secret holding a user token with write:repository scope. + # Not using a `||` fallback to GITEA_TOKEN: Gitea's expression engine + # supports only always(), so the operator would not evaluate. + GITEA_TOKEN: ${{ secrets.API_TOKEN }} + run: | + python3 tools/release.py \ + --api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \ + --tag "${{ steps.tag.outputs.tag }}" \ + --artifact "${{ steps.build.outputs.artifact }}" \ + --asset-name "${{ steps.build.outputs.asset_name }}" \ + --commitish "${{ github.sha }}" diff --git a/README.md b/README.md index 1418811..aee6dda 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ tools/ bundle.py compiles plugin.json + src/ into dist/.plugin devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690) verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed + release.py publishes a built bundle to a Gitea release (used by CI) +.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases scripts/ bash automation for the ADB debug loop (see below) docs/sdk-notes.md condensed SDK API surface gathered from the official docs ``` @@ -91,6 +93,41 @@ standard `ANDROID_SERIAL` env var for device selection. - `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/` for editor autocomplete. +## Publishing a release + +`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's +Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built +by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and +version straight out of `plugin.json`, so it keeps working after you rename the plugin. + +The `version` field in `plugin.json` is the single source of truth: + +| Push to `main` with… | Result | +| --- | --- | +| a new `version` | creates tag `v` and a new release | +| the same `version` | rebuilds the existing release, replacing its attachment | + +So every push leaves a downloadable build attached to the current version, and bumping +`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that +tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow). + +Every path runs `--check`, builds with `--release`, and gates the upload on +`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild +replaces the attachment rather than piling up duplicates, and refreshes the release notes to +name the commit it came from. The tag itself is never moved: it keeps pointing at the commit +that first introduced that version. + +Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user +token with the `write:repository` scope (which covers releases). Set it once under +**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a +repo-level secret of the same name takes precedence if one repo ever needs different +credentials. + +If you would rather not manage a token, Gitea also injects a per-job `secrets.GITEA_TOKEN` +that can create releases in its own repo — swap the `env:` line in the workflow and drop the +org secret. It is scoped to the single job and expires with it, so it is the safer default; +`API_TOKEN` is what you want when publishing needs to reach beyond this repo. + ## Writing your own plugin 1. Edit `plugin.json` (id, name, description, etc.). diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 0000000..3ea3278 --- /dev/null +++ b/tools/release.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Publishes a built .plugin file to a Gitea release. + +Creates the release for the given tag if it does not exist, replaces any +same-named asset already attached, then uploads the artifact. Re-running is +safe: the release is reused and the asset is replaced rather than duplicated. + +Stdlib only, so it runs on any runner image without a pip install step. + +Usage: + tools/release.py --api --tag v1.0.0 --artifact dist/x.plugin + +The token is read from GITEA_TOKEN (or GITHUB_TOKEN as a fallback). +""" +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path +from typing import Optional, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def fail(message: str) -> "None": + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def request( + method: str, + url: str, + token: str, + body: Optional[bytes] = None, + content_type: Optional[str] = None, +) -> Tuple[int, bytes]: + """Performs one API call, returning (status, body) instead of raising.""" + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"token {token}") + req.add_header("Accept", "application/json") + if content_type: + req.add_header("Content-Type", content_type) + try: + with urllib.request.urlopen(req) as response: + return response.status, response.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + except urllib.error.URLError as exc: + fail(f"{method} {url} failed: {exc.reason}") + raise # unreachable, keeps type checkers quiet + + +def find_release(api: str, tag: str, token: str) -> Optional[dict]: + status, payload = request("GET", f"{api}/releases/tags/{tag}", token) + if status == 404: + return None + if status != 200: + fail(f"looking up release {tag} returned HTTP {status}: {payload[:400]!r}") + return json.loads(payload) + + +def create_release(api: str, tag: str, token: str, commitish: str, notes: str) -> dict: + """Creates the release, which also creates the tag if it does not exist.""" + body = json.dumps( + { + "tag_name": tag, + "target_commitish": commitish, + "name": tag, + "body": notes, + "draft": False, + "prerelease": False, + } + ).encode() + status, payload = request("POST", f"{api}/releases", token, body, "application/json") + if status != 201: + fail(f"creating release {tag} returned HTTP {status}: {payload[:400]!r}") + return json.loads(payload) + + +def update_release(api: str, release_id: int, token: str, commitish: str, notes: str) -> dict: + """Refreshes an existing release so a rebuild reports the commit it came from. + + target_commitish is deliberately not changed: the tag already points at the + commit that first introduced this version, and moving it would silently + rewrite history for anyone who already fetched it. + """ + body = json.dumps({"body": notes}).encode() + status, payload = request( + "PATCH", f"{api}/releases/{release_id}", token, body, "application/json" + ) + if status != 200: + fail(f"updating release {release_id} returned HTTP {status}: {payload[:400]!r}") + return json.loads(payload) + + +def delete_existing_assets(api: str, release_id: int, name: str, token: str) -> int: + """Removes same-named assets, since Gitea keeps duplicates side by side.""" + status, payload = request("GET", f"{api}/releases/{release_id}/assets", token) + if status != 200: + fail(f"listing assets returned HTTP {status}: {payload[:400]!r}") + removed = 0 + for asset in json.loads(payload) or []: + if asset.get("name") != name: + continue + status, payload = request( + "DELETE", f"{api}/releases/{release_id}/assets/{asset['id']}", token + ) + if status not in (204, 404): + fail(f"deleting asset {asset['id']} returned HTTP {status}: {payload[:400]!r}") + removed += 1 + return removed + + +def upload_asset(api: str, release_id: int, path: Path, name: str, token: str) -> dict: + """Uploads the artifact as multipart/form-data.""" + boundary = uuid.uuid4().hex + mime = mimetypes.guess_type(name)[0] or "application/octet-stream" + payload = b"".join( + [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(), + f"Content-Type: {mime}\r\n\r\n".encode(), + path.read_bytes(), + f"\r\n--{boundary}--\r\n".encode(), + ] + ) + url = f"{api}/releases/{release_id}/assets?name={urllib.parse.quote(name)}" + status, response = request( + "POST", url, token, payload, f"multipart/form-data; boundary={boundary}" + ) + if status != 201: + fail(f"uploading {name} returned HTTP {status}: {response[:400]!r}") + return json.loads(response) + + +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--api", required=True, help="repo API base, e.g. https://host/api/v1/repos/owner/repo") + parser.add_argument("--tag", required=True, help="release tag") + parser.add_argument("--artifact", required=True, help="file to attach") + parser.add_argument("--commitish", default="", help="target commit for a new tag") + parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name") + args = parser.parse_args(argv) + + token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" + if not token: + fail("GITEA_TOKEN is not set") + + artifact = Path(args.artifact) + if not artifact.is_file() or artifact.stat().st_size == 0: + fail(f"artifact {artifact} is missing or empty") + asset_name = args.asset_name or artifact.name + + api = args.api.rstrip("/") + version = json.loads((REPO_ROOT / "plugin.json").read_text(encoding="utf-8"))["version"] + + notes = [f"Automated build of version {version}."] + if args.commitish: + notes.append(f"Built from `{args.commitish[:12]}`.") + notes.append("Install by opening the attached `.plugin` file in exteraGram.") + notes_text = "\n\n".join(notes) + + release = find_release(api, args.tag, token) + if release is None: + release = create_release(api, args.tag, token, args.commitish, notes_text) + print(f"created release {args.tag}") + else: + # Version unchanged since the last push: keep the same tag and refresh + # it in place so the attachment always matches the newest commit. + release = update_release(api, int(release["id"]), token, args.commitish, notes_text) + print(f"rebuilding existing release {args.tag}") + + release_id = int(release["id"]) + removed = delete_existing_assets(api, release_id, asset_name, token) + if removed: + print(f"removed {removed} previous copy/copies of {asset_name}") + + asset = upload_asset(api, release_id, artifact, asset_name, token) + print(f"uploaded {asset_name} ({artifact.stat().st_size} bytes) -> {asset.get('browser_download_url', '')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())