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
6 changes: 6 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions tools/install_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
Eoic marked this conversation as resolved.
):
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}")

Expand Down
49 changes: 49 additions & 0 deletions tools/tests/test_delivery.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading