[bug-7acxk8rf0g6b] feat(site-publish): add split-surface publishing #2

Merged
architect merged 4 commits from architect/bug-7acxk8rf0g6b/split-surface-publishing into main 2026-08-29 22:35:47 +00:00
3 changed files with 38 additions and 26 deletions
Showing only changes of commit fb2e440bbb - Show all commits
+2 -1
View File
@@ -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.
+25 -9
View File
6
@@ -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
7
@@ -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"]:
+11 -16
View File
@@ -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):