diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 447a7b7..702fee2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -210,6 +210,12 @@ release identity, then switches `current` atomically under a file lock. Public `/release.json`, `/`, `/login` and `/flutter_bootstrap.js` must match the artifact. A failed verification restores the previous target. Old directories are retained; there is no automatic data or artifact deletion. Caddy revalidates web content. +If Cloudflare fronts the app, configure an app-host-only cache rule for +`http.host eq "app.papyrus-reader.com"`: bypass the edge cache and set Browser TTL +to **Respect origin TTL**. The default browser TTL can otherwise replace Caddy's +`no-cache` header with a four-hour lifetime for JavaScript. The public deployment +probe checks revalidation headers and exact bytes for metadata, both entrypoint +routes, the Flutter bootstrap and `main.dart.js`; failure rolls activation back. The deployment user cannot restart or change the API, sync service or database. Delivery rejects a lower build number than the active web release; an equal diff --git a/tools/install_web.py b/tools/install_web.py index 7f69dd2..284969e 100644 --- a/tools/install_web.py +++ b/tools/install_web.py @@ -72,16 +72,21 @@ def verify_public(url: str, metadata: dict, release: Path) -> None: ("/", "index.html"), ("/login", "index.html"), ("/flutter_bootstrap.js", "flutter_bootstrap.js"), + ("/main.dart.js", "main.dart.js"), ): request = urllib.request.Request( - f"{url}{route}?deployment={metadata['revision']}", + f"{url}{route}", headers={ - "Cache-Control": "no-cache", "User-Agent": "PapyrusReleaseVerifier/1.0 (+https://github.com/PapyrusReader/client)", }, ) with urllib.request.urlopen(request, timeout=10) as response: + directives = {value.strip().lower() for value in response.headers.get("Cache-Control", "").split(",")} + + if not directives.intersection({"no-cache", "no-store"}): + raise ValueError(f"Public web content must revalidate before reuse: {route}") + if response.read() != (release / filename).read_bytes(): raise ValueError(f"Public web content does not match the release: {route}") diff --git a/tools/tests/test_delivery.py b/tools/tests/test_delivery.py index b3694dc..fe8a072 100644 --- a/tools/tests/test_delivery.py +++ b/tools/tests/test_delivery.py @@ -1,6 +1,7 @@ """Regression tests for release identity, Play retries and transactional web delivery.""" import hashlib +import io import json import os import sys @@ -173,6 +174,54 @@ def test_activate_retry_and_preserve_previous(self): self.assertEqual(os.readlink(self.root / "previous"), "releases/initial") self.assertEqual((self.root / "previous/index.html").read_text(), "previous") + def public_response(self, request, timeout): + route = request.full_url.split("?", 1)[0].removeprefix("https://app.example.com") + filename = "index.html" if route in ("/", "/login") else route.removeprefix("/") + response = io.BytesIO((self.root / "current" / filename).read_bytes()) + response.headers = {"Cache-Control": "no-cache"} + return response + + def test_public_probe_checks_entrypoints_and_compiled_app(self): + self.install() + + with patch.object(web.urllib.request, "urlopen", side_effect=self.public_response) as request: + web.verify_public("https://app.example.com", self.metadata, self.root / "current") + + routes = [call.args[0].full_url for call in request.call_args_list] + self.assertEqual(len(routes), 5) + self.assertIn("https://app.example.com/main.dart.js", routes) + + def test_cdn_cache_override_rolls_back_release(self): + def response(request, timeout): + result = self.public_response(request, timeout) + + if request.full_url.endswith("/flutter_bootstrap.js"): + result.headers["Cache-Control"] = "max-age=14400" + + return result + + with patch.object(web.urllib.request, "urlopen", side_effect=response), patch.object(web.time, "sleep"): + with self.assertRaisesRegex(ValueError, "must revalidate"): + self.install(lambda metadata, release: web.verify_public("https://app.example.com", metadata, release)) + + self.assertEqual(os.readlink(self.root / "current"), "releases/initial") + + def test_stale_compiled_app_rolls_back_release(self): + def response(request, timeout): + result = self.public_response(request, timeout) + + if request.full_url.endswith("/main.dart.js"): + result = io.BytesIO(b"previous compiled app") + result.headers = {"Cache-Control": "no-cache"} + + return result + + with patch.object(web.urllib.request, "urlopen", side_effect=response), patch.object(web.time, "sleep"): + with self.assertRaisesRegex(ValueError, "does not match"): + self.install(lambda metadata, release: web.verify_public("https://app.example.com", metadata, release)) + + self.assertEqual(os.readlink(self.root / "current"), "releases/initial") + def test_older_build_is_rejected_without_activation(self): self.install() self.metadata = assets.identity("v0.0.1+1", "b" * 40)