From fb2e440bbb7df928d567a078b15ebaab9cf599c1 Mon Sep 17 00:00:00 2001 From: Evelyn Chen Date: Sat, 29 Aug 2026 22:23:51 +0000 Subject: [PATCH] fix(site-publish): preflight all immutable routes Authored-By: OpenAI (GPT-5) --- README.md | 3 ++- scripts/deploy.py | 34 +++++++++++++++++++++++++--------- tests/test_contract.py | 27 +++++++++++---------------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6cdc5a4..e01dd87 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ cache policy, content type, and bytes. That content address makes concurrent writes identical even though Garage v2.2.0 has no conditional destination write. An identical retry converges; a changed object, missing digest metadata, wrong address, or nested policy under that immutable prefix fails publication. -Every immutable target is validated and published before mutable objects change. +Every immutable target across every artifact is validated and published before +any route's mutable objects change. Mutable default and override partitions receive their final cache policy before the matching prefix-scoped stale deletion, so publication never exposes a provisional cache policy or a pointer to a missing immutable target. diff --git a/scripts/deploy.py b/scripts/deploy.py index d7354c9..025189d 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -149,9 +149,8 @@ def publish_immutable_rule(artifact, route, rule, html_dir, aws_env): ) -def s3_sync(artifact, route, site_dir, credential_env_names=None): - endpoint = artifact["s3_endpoint"] - html_dir = site_dir / artifact["build_dir"] +def publication_aws_env(artifact, credential_env_names=None): + """Build the route-scoped AWS environment without leaking other credentials.""" access_key = env(artifact["credentials"]["access_key_env"]) secret_key = env(artifact["credentials"]["secret_key_env"]) aws_env = os.environ.copy() @@ -165,6 +164,22 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None): "AWS_SECRET_ACCESS_KEY": secret_key, "AWS_DEFAULT_REGION": os.environ.get("AWS_DEFAULT_REGION", "sjc001"), }) + return aws_env + + +def publish_route_immutables(artifact, route, site_dir, credential_env_names=None): + """Publish one route's immutable partitions during the global preflight.""" + html_dir = site_dir / artifact["build_dir"] + aws_env = publication_aws_env(artifact, credential_env_names) + for rule in artifact["cache_rules"]: + if _is_immutable(rule): + publish_immutable_rule(artifact, route, rule, html_dir, aws_env) + + +def s3_sync(artifact, route, site_dir, credential_env_names=None): + endpoint = artifact["s3_endpoint"] + html_dir = site_dir / artifact["build_dir"] + aws_env = publication_aws_env(artifact, credential_env_names) bucket = artifact["bucket"] object_prefix = route["path"].strip("/") destination = f"s3://{bucket}/{object_prefix + '/' if object_prefix else ''}" @@ -176,12 +191,6 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None): exclude_args = [arg for pattern in artifact["excludes"] for arg in ("--exclude", pattern)] if artifact["excludes"]: print(f"Excluding patterns: {artifact['excludes']}") - # Validate and publish every append-only target before a mutable channel can - # point at it. Partial immutable success is safe; partial mutable success is - # not. - for rule in artifact["cache_rules"]: - if _is_immutable(rule): - publish_immutable_rule(artifact, route, rule, html_dir, aws_env) print(f"Syncing artifact {artifact['name']} → {destination} via {endpoint}") # Upload with the final cache policy before cleanup. Sync and deletion are # scoped to the same current route prefix and cache partition. A route move @@ -296,6 +305,13 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg): validate_publication_environment(cfg) for artifact in cfg["artifacts"]: validate_artifact_output(site_dir, artifact) + # Complete immutable work across the whole publication before any route's + # mutable pointers can change. Partial immutable success is safe; mixing a + # new route with an old route after a later immutable failure is not. + for route in cfg["routes"]: + publish_route_immutables( + artifact_by_name[route["artifact"]], route, site_dir, credential_env_names, + ) for route in cfg["routes"]: s3_sync(artifact_by_name[route["artifact"]], route, site_dir, credential_env_names) if cfg["compatibility"]: diff --git a/tests/test_contract.py b/tests/test_contract.py index e9be6e9..3452d9d 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -395,6 +395,7 @@ class PublishingTests(unittest.TestCase): }, clear=False), patch.object(deploy, "run", side_effect=capture), \ patch.object(deploy, "publish_immutable_rule", side_effect=publish_immutable) as immutable_publish, \ redirect_stdout(output): + deploy.publish_route_immutables(artifact, route, root) deploy.s3_sync(artifact, route, root) self.assertTrue(all(secret not in " ".join(command) for command, _ in commands)) @@ -417,28 +418,22 @@ class PublishingTests(unittest.TestCase): immutable_publish.call_args.args[2]["cache_control"], ) - def test_immutable_failure_stops_before_mutable_publication(self): + def test_later_route_immutable_failure_stops_all_mutable_publication(self): cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") - artifact = next(item for item in cfg["artifacts"] if item["name"] == "distributions") - route = next(item for item in cfg["routes"] if item["artifact"] == "distributions") with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - html = root / artifact["build_dir"] - (html / "releases").mkdir(parents=True) - (html / "channels").mkdir() - (html / "releases" / "1.0.js").write_text("release") - (html / "channels" / "stable.json").write_text("channel") - - with patch.dict(os.environ, { - "DIST_S3_ACCESS_KEY": "dist-key", "DIST_S3_SECRET_KEY": "dist-secret" - }, clear=False), patch.object( - deploy, "publish_immutable_rule", side_effect=RuntimeError("immutable failed") - ), patch.object(deploy, "run") as mutable_run, self.assertRaisesRegex( + with patch.object(deploy, "validate_publication_environment"), \ + patch.object(deploy, "validate_artifact_output"), patch.object( + deploy, "publish_route_immutables", + side_effect=[None, RuntimeError("immutable failed")], + ) as immutable_publish, patch.object(deploy, "s3_sync") as mutable_sync, \ + self.assertRaisesRegex( RuntimeError, "immutable failed" ): - deploy.s3_sync(artifact, route, root) + deploy.deploy_static("baseline", root, root, "token", cfg) - mutable_run.assert_not_called() + self.assertEqual(2, immutable_publish.call_count) + mutable_sync.assert_not_called() def test_absent_artifact_is_detected_before_publish(self):