diff --git a/.github/workflows/register-keyper-releases.yml b/.github/workflows/register-keyper-releases.yml index f27c907..b82ca3a 100644 --- a/.github/workflows/register-keyper-releases.yml +++ b/.github/workflows/register-keyper-releases.yml @@ -5,6 +5,7 @@ on: paths: - appcast.xml - scripts/sync_keyper_releases.py + - scripts/register_keyper_beta.py - .github/workflows/register-keyper-releases.yml push: branches: @@ -12,6 +13,9 @@ on: paths: - appcast.xml workflow_dispatch: + release: + types: + - published permissions: contents: read @@ -22,6 +26,7 @@ concurrency: jobs: validate-and-register: + if: github.event_name != 'release' runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -35,3 +40,14 @@ jobs: KEYPER_RELEASE_TOKEN: ${{ secrets.KEYPER_RELEASE_TOKEN }} KEYPER_RELEASE_ENDPOINT: ${{ vars.KEYPER_RELEASE_ENDPOINT }} run: python3 scripts/sync_keyper_releases.py --appcast appcast.xml + + register-v4-beta: + if: github.event_name == 'release' && github.event.release.prerelease && github.event.release.tag_name == 'beta' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Register published beta with Keyper + env: + KEYPER_RELEASE_TOKEN: ${{ secrets.KEYPER_RELEASE_TOKEN }} + KEYPER_RELEASE_ENDPOINT: ${{ vars.KEYPER_RELEASE_ENDPOINT }} + run: python3 scripts/register_keyper_beta.py diff --git a/README.md b/README.md index d8ea4e4..8809e6b 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ ExtraDock Updates + +# ExtraDock V4 beta registration + +The V4 `beta` GitHub pre-release is distributed without an appcast entry. +Publishing it triggers `register-keyper-releases.yml`, which registers the +release title's version (for example `B4.4.0` → `4.4.0`) with Keyper as a +prerelease. This lets installed beta builds pass license version checks while +the stable update target remains the latest stable appcast version. The mutable +`beta` download URL is deliberately omitted from Keyper's release history. + +The release must contain `extraDock.dmg`, and the workflow requires the existing +`KEYPER_RELEASE_TOKEN` secret for the V4 stable track. diff --git a/scripts/register_keyper_beta.py b/scripts/register_keyper_beta.py new file mode 100644 index 0000000..4b477c4 --- /dev/null +++ b/scripts/register_keyper_beta.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Register the published, mutable ExtraDock V4 beta with Keyper.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime + + +DEFAULT_ENDPOINT = "https://keyper.appitstudio.com/api/releases" +VERSION_RE = re.compile(r"B(4\.\d+\.\d+)$") + + +def beta_payload(event: dict) -> dict[str, object]: + release = event["release"] + if event["repository"]["full_name"] != "AppitStudio/extra-dock-updates": + raise ValueError("Unexpected release repository") + if release["tag_name"] != "beta" or release["prerelease"] is not True: + raise ValueError("Expected the published ExtraDock V4 beta release") + match = VERSION_RE.fullmatch(release["name"]) + if match is None: + raise ValueError("Beta release title must be B followed by a V4 version") + if not any(asset.get("name") == "extraDock.dmg" for asset in release["assets"]): + raise ValueError("Published beta release has no extraDock.dmg asset") + published_at = release["published_at"] + if not isinstance(published_at, str) or datetime.fromisoformat( + published_at.replace("Z", "+00:00") + ).tzinfo is None: + raise ValueError("Published beta release has no timestamp with timezone") + + # The beta tag is replaced on every upload. Keep its mutable download URL + # out of the immutable Keyper history while allowing version checks. + return { + "version": match.group(1), + "released_at": published_at, + "is_prerelease": True, + } + + +def register(endpoint: str, token: str, payload: dict[str, object]) -> int: + request = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + method="POST", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "appit-updates-release-sync/1", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.status + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"Keyper rejected beta with HTTP {error.code}: {body}") from error + + +def main() -> int: + try: + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + payload = beta_payload(event) + token = os.environ.get("KEYPER_RELEASE_TOKEN", "").strip() + if not token: + raise ValueError("KEYPER_RELEASE_TOKEN is required") + endpoint = os.environ.get("KEYPER_RELEASE_ENDPOINT", "").strip() or DEFAULT_ENDPOINT + if not endpoint.startswith("https://"): + raise ValueError("KEYPER_RELEASE_ENDPOINT must use HTTPS") + status = register(endpoint, token, payload) + print(f"Registered ExtraDock V4 beta {payload['version']} (HTTP {status})") + except (KeyError, OSError, ValueError, RuntimeError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_register_keyper_beta.py b/scripts/test_register_keyper_beta.py new file mode 100644 index 0000000..6167489 --- /dev/null +++ b/scripts/test_register_keyper_beta.py @@ -0,0 +1,46 @@ +import unittest + +from register_keyper_beta import beta_payload + + +def event(): + return { + "repository": {"full_name": "AppitStudio/extra-dock-updates"}, + "release": { + "tag_name": "beta", + "prerelease": True, + "name": "B4.4.0", + "published_at": "2026-09-27T10:41:08Z", + "assets": [{"name": "extraDock.dmg"}], + }, + } + + +class BetaRegistrationTest(unittest.TestCase): + def test_published_beta_registers_without_mutable_download_url(self): + self.assertEqual( + beta_payload(event()), + { + "version": "4.4.0", + "released_at": "2026-09-27T10:41:08Z", + "is_prerelease": True, + }, + ) + + def test_rejects_wrong_release_or_missing_asset(self): + for change in ( + {"prerelease": False}, + {"tag_name": "prod"}, + {"name": "B4.4.0;echo unsafe"}, + {"name": "B5.0.0"}, + {"assets": []}, + ): + with self.subTest(change=change): + payload = event() + payload["release"].update(change) + with self.assertRaises(ValueError): + beta_payload(payload) + + +if __name__ == "__main__": + unittest.main()