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
15 changes: 14 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Generate and Deploy Sparkle Appcast
- name: Generate, Deploy, and Verify Sparkle Appcast
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down Expand Up @@ -554,6 +554,19 @@ jobs:
git commit -m "Update appcast for ${TAG}" || echo "No changes to commit"
git push origin gh-pages

cd "$GITHUB_WORKSPACE"
python3 scripts/verify_appcast_publication.py \
--url "https://typewhisper.github.io/typewhisper-mac/appcast.xml" \
--version "$DISPLAY_VERSION" \
--build-version "$BUILD_NUMBER" \
--channel "$RELEASE_CHANNEL" \
--download-url "$DOWNLOAD_URL" \
--signature "$ED_SIGNATURE" \
--length "$ED_LENGTH" \
--maximum-cache-age-seconds 600 \
--timeout-seconds 900 \
--poll-interval-seconds 15

trigger-website:
if: "!contains(needs.prepare.outputs.tag, '-')"
needs: [prepare, build]
Expand Down
1 change: 1 addition & 0 deletions docs/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@
- RC and daily tags must not update Homebrew or trigger stable website messaging
- Verify DMG, ZIP, and the `release-candidate` appcast entry with `minimumSystemVersion` set to `14.0`
- Verify Homebrew and the stable appcast update only at the final `1.6.0`
- Confirm the release workflow's appcast publication gate observes the new version on the canonical GitHub Pages URL within 900 seconds
17 changes: 17 additions & 0 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ These surfaces remain part of `1.x`, but they are positioned as advanced or auto
- Stable releases update Homebrew and the stable appcast entry
- RC and daily releases update only their own Sparkle channels

### Appcast publication gate

- The canonical feed is `https://typewhisper.github.io/typewhisper-mac/appcast.xml`, published from the `gh-pages` branch.
- GitHub Pages currently serves the feed with `Cache-Control: max-age=600`; clients may therefore see the previous appcast during that cache window.
- After pushing `appcast.xml`, the release workflow polls the canonical URL without cache-busting query parameters for up to 900 seconds.
- The release job succeeds only when the public feed advertises a cache lifetime of at most 600 seconds and contains the exact release version, build version, Sparkle channel, ZIP download URL, EdDSA signature, and archive length.
- A timeout leaves the GitHub release visible but marks the release workflow as failed, so the appcast publication must be investigated before the release is considered complete.

Inspect the effective public headers and feed with:

```bash
curl -fsS -D /tmp/typewhisper-appcast-headers.txt \
-o /tmp/typewhisper-appcast.xml \
https://typewhisper.github.io/typewhisper-mac/appcast.xml
cat /tmp/typewhisper-appcast-headers.txt
```

## Support and Diagnostics

- Support requests should always include the JSON diagnostics export from the Error Log.
Expand Down
1 change: 1 addition & 0 deletions scripts/pr-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ done < <(git ls-files 'scripts/*.sh')
log "running Python script tests"
python3 scripts/test_assemble_community_plugin_registry.py
python3 scripts/test_plugin_registry_metadata.py
python3 scripts/test_verify_appcast_publication.py

log "checking release instrumentation helper"
run_if_exists scripts/check_release_binary_instrumentation.sh --self-test
Expand Down
174 changes: 174 additions & 0 deletions scripts/test_verify_appcast_publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3

import unittest
from unittest.mock import patch

from verify_appcast_publication import (
PublicationExpectation,
cache_max_age,
verify_appcast,
verify_response,
wait_for_publication,
)


def appcast_item(
*,
version: str,
build_version: str,
channel: str | None,
download_url: str,
signature: str = "test-signature",
length: str = "1234",
) -> bytes:
channel_element = f"<sparkle:channel>{channel}</sparkle:channel>" if channel else ""
return f"""<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<item>
<sparkle:version>{build_version}</sparkle:version>
<sparkle:shortVersionString>{version}</sparkle:shortVersionString>
{channel_element}
<enclosure url="{download_url}" sparkle:edSignature="{signature}" length="{length}" />
</item>
</channel>
</rss>
""".encode()


class CacheControlTests(unittest.TestCase):
def test_reads_max_age(self) -> None:
self.assertEqual(cache_max_age("public, max-age=600"), 600)
self.assertEqual(cache_max_age('max-age="60", must-revalidate'), 60)

def test_returns_none_without_max_age(self) -> None:
self.assertIsNone(cache_max_age("no-cache"))


class AppcastVerificationTests(unittest.TestCase):
def setUp(self) -> None:
self.download_url = (
"https://github.com/TypeWhisper/typewhisper-mac/releases/download/"
"v1.6.0-daily.20260714/TypeWhisper-v1.6.0-daily.20260714.zip"
)
self.expected = PublicationExpectation(
version="1.6.0-daily.20260714",
build_version="961",
channel="daily",
download_url=self.download_url,
signature="test-signature",
length="1234",
)

def test_accepts_matching_preview_release(self) -> None:
body = appcast_item(
version=self.expected.version,
build_version=self.expected.build_version,
channel=self.expected.channel,
download_url=self.download_url,
)

verified, detail = verify_appcast(body, self.expected)

self.assertTrue(verified, detail)

def test_accepts_stable_release_without_channel_element(self) -> None:
expected = PublicationExpectation(
version="1.6.0",
build_version="1000",
channel="stable",
download_url="https://example.com/TypeWhisper-v1.6.0.zip",
)
body = appcast_item(
version=expected.version,
build_version=expected.build_version,
channel=None,
download_url=expected.download_url,
)

verified, detail = verify_appcast(body, expected)

self.assertTrue(verified, detail)

def test_rejects_stale_public_feed(self) -> None:
body = appcast_item(
version="1.6.0-daily.20260713",
build_version="960",
channel="daily",
download_url="https://example.com/old.zip",
)

verified, detail = verify_appcast(body, self.expected)

self.assertFalse(verified)
self.assertIn("not public yet", detail)

def test_rejects_wrong_release_metadata(self) -> None:
body = appcast_item(
version=self.expected.version,
build_version="960",
channel="release-candidate",
download_url="https://example.com/wrong.zip",
signature="wrong-signature",
length="999",
)

verified, detail = verify_appcast(body, self.expected)

self.assertFalse(verified)
self.assertIn("build version", detail)
self.assertIn("channel", detail)
self.assertIn("download URL", detail)
self.assertIn("signature", detail)
self.assertIn("archive length", detail)

def test_enforces_documented_cache_bound(self) -> None:
body = appcast_item(
version=self.expected.version,
build_version=self.expected.build_version,
channel=self.expected.channel,
download_url=self.download_url,
)

verified, detail = verify_response(
body,
{"Cache-Control": "max-age=601"},
self.expected,
maximum_cache_age_seconds=600,
)

self.assertFalse(verified)
self.assertIn("exceeding", detail)

@patch("verify_appcast_publication.time.sleep")
@patch("verify_appcast_publication.fetch")
def test_waits_for_stale_feed_to_publish(self, mock_fetch, mock_sleep) -> None:
stale_body = appcast_item(
version="1.6.0-daily.20260713",
build_version="960",
channel="daily",
download_url="https://example.com/old.zip",
)
current_body = appcast_item(
version=self.expected.version,
build_version=self.expected.build_version,
channel=self.expected.channel,
download_url=self.download_url,
)
headers = {"Cache-Control": "max-age=600", "X-Cache": "HIT"}
mock_fetch.side_effect = [(stale_body, headers), (current_body, headers)]

wait_for_publication(
"https://example.com/appcast.xml",
self.expected,
maximum_cache_age_seconds=600,
timeout_seconds=1,
poll_interval_seconds=0.01,
)

self.assertEqual(mock_fetch.call_count, 2)
mock_sleep.assert_called_once()


if __name__ == "__main__":
unittest.main()
Loading