diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index dc3e046..056ac0c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,9 +2,13 @@ name: Publish plugin # Builds dist/.plugin and publishes it to the Releases tab. # -# Triggers: -# push a v* tag -> release named after that tag -# manual dispatch -> release named v +# 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 @@ -12,6 +16,8 @@ name: Publish plugin on: push: + branches: + - main tags: - "v*" workflow_dispatch: @@ -54,8 +60,11 @@ jobs: 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" diff --git a/README.md b/README.md index 8bff6be..f6e51a1 100644 --- a/README.md +++ b/README.md @@ -100,21 +100,25 @@ standard `ANDROID_SERIAL` env var for device selection. ## Publishing a release `.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's -Releases tab. `dist/` is gitignored, so the artifact is always built by CI rather than -committed. +Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built +by CI rather than committed. -```bash -# tag a version and push it -> release named after the tag -git tag v1.0.1 && git push origin v1.0.1 -``` +The `version` field in `plugin.json` is the single source of truth: -The workflow also accepts a manual run (Actions → Publish plugin → Run workflow), which -derives the tag from `version` in `plugin.json`. Either way it runs `--check`, builds with -`--release`, and gates the upload on `verify_bundle.py`, so a bundle that fails verification -is never published. +| 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 | -Re-running against an existing tag reuses that release and replaces the attachment instead of -piling up duplicates. +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 diff --git a/tools/release.py b/tools/release.py index 98df88f..3ea3278 100755 --- a/tools/release.py +++ b/tools/release.py @@ -67,6 +67,7 @@ def find_release(api: str, tag: str, token: str) -> Optional[dict]: 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, @@ -83,6 +84,22 @@ def create_release(api: str, tag: str, token: str, commitish: str, notes: str) - 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) @@ -144,16 +161,21 @@ def main(argv: Optional[list] = None) -> int: 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: - notes = ( - f"Automated build of version {version}.\n\n" - "Install by opening the attached `.plugin` file in exteraGram." - ) - release = create_release(api, args.tag, token, args.commitish, notes) + release = create_release(api, args.tag, token, args.commitish, notes_text) print(f"created release {args.tag}") else: - print(f"reusing existing release {args.tag}") + # 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)