Template
Build, verify and publish dist/<id>.plugin to the Releases tab on every push to main. Nothing is plugin-specific: the id and version are read from plugin.json, so the workflow keeps working once this template is renamed into a real plugin. The version in plugin.json decides what happens: a new version creates tag v<version> and a new release, while an unchanged version rebuilds the existing release and replaces its attachment. The tag is never moved, so it keeps pointing at the commit that first introduced that version, but the release notes are refreshed to name the commit the build came from. The upload is gated on verify_bundle.py, so a bundle that fails offline verification is never published. dist/ stays gitignored and the artifact is always built by CI rather than committed.
This commit is contained in:
1 parent
ae63f8b9a3
commit
cfa6dd9e1e
3 files changed
+313
No files matched your search
@@ -0,0 +1,85 @@
|
||||
name: Publish plugin
|
||||
|
||||
# Builds dist/<id>.plugin and publishes it to the Releases tab.
|
||||
#
|
||||
# The version in plugin.json is the single source of truth:
|
||||
# new version -> a new v<version> 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 }}"
|
||||
@@ -40,6 +40,8 @@ tools/
|
||||
bundle.py compiles plugin.json + src/ into dist/<id>.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<version>` 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.).
|
||||
|
||||
Executable
+191
@@ -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 <repo-api-url> --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())
|
||||
Reference in new issue
Block a user