Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/register-keyper-releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ on:
paths:
- appcast.xml
- scripts/sync_keyper_releases.py
- scripts/register_keyper_beta.py
- .github/workflows/register-keyper-releases.yml
push:
branches:
- main
paths:
- appcast.xml
workflow_dispatch:
release:
types:
- published

permissions:
contents: read
Expand All @@ -22,6 +26,7 @@ concurrency:

jobs:
validate-and-register:
if: github.event_name != 'release'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand All @@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions scripts/register_keyper_beta.py
Original file line number Diff line number Diff line change
@@ -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())
46 changes: 46 additions & 0 deletions scripts/test_register_keyper_beta.py
Original file line number Diff line number Diff line change
@@ -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()
Loading