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())