diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f9ee42ff..4a4c3925 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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: |
@@ -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]
diff --git a/docs/release-checklist.md b/docs/release-checklist.md
index 0bf81ec8..72f74e88 100644
--- a/docs/release-checklist.md
+++ b/docs/release-checklist.md
@@ -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
diff --git a/docs/release-readiness.md b/docs/release-readiness.md
index 3c9992c7..67ea7536 100644
--- a/docs/release-readiness.md
+++ b/docs/release-readiness.md
@@ -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.
diff --git a/scripts/pr-preflight.sh b/scripts/pr-preflight.sh
index 782e961e..1847e15a 100755
--- a/scripts/pr-preflight.sh
+++ b/scripts/pr-preflight.sh
@@ -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
diff --git a/scripts/test_verify_appcast_publication.py b/scripts/test_verify_appcast_publication.py
new file mode 100755
index 00000000..4f5c4142
--- /dev/null
+++ b/scripts/test_verify_appcast_publication.py
@@ -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"{channel}" if channel else ""
+ return f"""
+
+
+ -
+ {build_version}
+ {version}
+ {channel_element}
+
+
+
+
+""".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()
diff --git a/scripts/verify_appcast_publication.py b/scripts/verify_appcast_publication.py
new file mode 100755
index 00000000..9cc90fad
--- /dev/null
+++ b/scripts/verify_appcast_publication.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+import argparse
+import re
+import sys
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from typing import Mapping
+
+
+SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
+CACHE_MAX_AGE_RE = re.compile(r"(?:^|,)\s*max-age\s*=\s*\"?(\d+)\"?", re.IGNORECASE)
+
+
+def qname(tag: str) -> str:
+ return f"{{{SPARKLE_NS}}}{tag}"
+
+
+@dataclass(frozen=True)
+class PublicationExpectation:
+ version: str
+ build_version: str
+ channel: str
+ download_url: str
+ signature: str | None = None
+ length: str | None = None
+
+
+def cache_max_age(cache_control: str) -> int | None:
+ match = CACHE_MAX_AGE_RE.search(cache_control)
+ return int(match.group(1)) if match else None
+
+
+def item_channel(item: ET.Element) -> str:
+ channel = item.findtext(qname("channel"))
+ return channel.strip() if channel else "stable"
+
+
+def verify_appcast(body: bytes, expected: PublicationExpectation) -> tuple[bool, str]:
+ try:
+ root = ET.fromstring(body)
+ except ET.ParseError as error:
+ return False, f"response is not valid XML: {error}"
+
+ channel = root.find("channel")
+ if channel is None:
+ return False, "appcast has no channel element"
+
+ published_versions: list[str] = []
+ version_candidates: list[ET.Element] = []
+ for item in channel.findall("item"):
+ version = item.findtext(qname("shortVersionString"), default="").strip()
+ if version:
+ published_versions.append(version)
+ if version == expected.version:
+ version_candidates.append(item)
+
+ if not version_candidates:
+ visible = ", ".join(published_versions) if published_versions else "none"
+ return False, f"version {expected.version} is not public yet; visible versions: {visible}"
+
+ mismatches: list[str] = []
+ for item in version_candidates:
+ build_version = item.findtext(qname("version"), default="").strip()
+ channel_name = item_channel(item)
+ enclosure = item.find("enclosure")
+ download_url = enclosure.get("url", "") if enclosure is not None else ""
+ signature = enclosure.get(qname("edSignature"), "") if enclosure is not None else ""
+ length = enclosure.get("length", "") if enclosure is not None else ""
+
+ item_mismatches: list[str] = []
+ if build_version != expected.build_version:
+ item_mismatches.append(
+ f"build version is {build_version or 'missing'}, expected {expected.build_version}"
+ )
+ if channel_name != expected.channel:
+ item_mismatches.append(
+ f"channel is {channel_name or 'missing'}, expected {expected.channel}"
+ )
+ if download_url != expected.download_url:
+ item_mismatches.append(
+ f"download URL is {download_url or 'missing'}, expected {expected.download_url}"
+ )
+ if expected.signature is not None and signature != expected.signature:
+ item_mismatches.append("Sparkle signature does not match the published ZIP")
+ if expected.length is not None and length != expected.length:
+ item_mismatches.append(
+ f"archive length is {length or 'missing'}, expected {expected.length}"
+ )
+
+ if not item_mismatches:
+ return True, (
+ f"version {expected.version} build {expected.build_version} "
+ f"is public on the {expected.channel} channel"
+ )
+ mismatches.extend(item_mismatches)
+
+ return False, "; ".join(mismatches)
+
+
+def header_value(headers: Mapping[str, str], name: str) -> str:
+ expected_name = name.lower()
+ for key, value in headers.items():
+ if key.lower() == expected_name:
+ return value
+ return ""
+
+
+def verify_response(
+ body: bytes,
+ headers: Mapping[str, str],
+ expected: PublicationExpectation,
+ maximum_cache_age_seconds: int,
+) -> tuple[bool, str]:
+ cache_control = header_value(headers, "cache-control")
+ max_age = cache_max_age(cache_control)
+ if max_age is None:
+ return False, f"response does not advertise max-age: {cache_control or 'header missing'}"
+ if max_age > maximum_cache_age_seconds:
+ return False, (
+ f"response max-age is {max_age} seconds, exceeding the documented "
+ f"{maximum_cache_age_seconds}-second limit"
+ )
+ return verify_appcast(body, expected)
+
+
+def fetch(url: str) -> tuple[bytes, dict[str, str]]:
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/xml",
+ "User-Agent": "TypeWhisper-Appcast-Publication-Verification/1.0",
+ },
+ )
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return response.read(), {key.lower(): value for key, value in response.headers.items()}
+
+
+def format_headers(headers: Mapping[str, str]) -> str:
+ names = ("cache-control", "age", "x-cache", "etag", "last-modified", "date")
+ values = [
+ f"{name}={value}"
+ for name in names
+ if (value := header_value(headers, name))
+ ]
+ return ", ".join(values) if values else "no cache headers"
+
+
+def wait_for_publication(
+ url: str,
+ expected: PublicationExpectation,
+ maximum_cache_age_seconds: int,
+ timeout_seconds: float,
+ poll_interval_seconds: float,
+) -> None:
+ deadline = time.monotonic() + timeout_seconds
+ attempt = 0
+
+ while True:
+ attempt += 1
+ headers: dict[str, str] = {}
+ try:
+ body, headers = fetch(url)
+ verified, last_detail = verify_response(
+ body,
+ headers,
+ expected,
+ maximum_cache_age_seconds,
+ )
+ except (OSError, urllib.error.URLError) as error:
+ verified = False
+ last_detail = f"request failed: {error}"
+
+ print(
+ f"Attempt {attempt}: {last_detail} ({format_headers(headers)})",
+ flush=True,
+ )
+ if verified:
+ return
+
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise RuntimeError(
+ f"appcast publication was not verified within {timeout_seconds:g} seconds: "
+ f"{last_detail}"
+ )
+ time.sleep(min(poll_interval_seconds, remaining))
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Wait until the canonical public Sparkle appcast contains a release."
+ )
+ parser.add_argument("--url", required=True)
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--build-version", required=True)
+ parser.add_argument(
+ "--channel",
+ required=True,
+ choices=["stable", "release-candidate", "daily"],
+ )
+ parser.add_argument("--download-url", required=True)
+ parser.add_argument("--signature")
+ parser.add_argument("--length")
+ parser.add_argument("--maximum-cache-age-seconds", type=int, default=600)
+ parser.add_argument("--timeout-seconds", type=float, default=900)
+ parser.add_argument("--poll-interval-seconds", type=float, default=15)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ if args.maximum_cache_age_seconds < 0:
+ raise SystemExit("--maximum-cache-age-seconds must not be negative")
+ if args.timeout_seconds < 0:
+ raise SystemExit("--timeout-seconds must not be negative")
+ if args.poll_interval_seconds <= 0:
+ raise SystemExit("--poll-interval-seconds must be positive")
+
+ expected = PublicationExpectation(
+ version=args.version,
+ build_version=args.build_version,
+ channel=args.channel,
+ download_url=args.download_url,
+ signature=args.signature,
+ length=args.length,
+ )
+ try:
+ wait_for_publication(
+ args.url,
+ expected,
+ args.maximum_cache_age_seconds,
+ args.timeout_seconds,
+ args.poll_interval_seconds,
+ )
+ except RuntimeError as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())