fix(site-publish): refuse to record a contract a scoped run did not publish
Test / contract (pull_request) Successful in 7s

Review found the hole in the first commit's claim. render_site_manifests
advances the stored route contract for every route in site.yaml, and
`access` there is a replacement, not a union the way immutable_paths is.
So a catalogue-only publish could write `protected` for the distributions
bucket that nothing published — and validate_route_migrations then
refuses to put that bucket back public. Unpublished intent became an
irreversible fact.

Reproduced from the repo's own fixture: after a whole publish the record
reads public; after a catalogue-only publish with the route flipped it
reads protected, with nothing written to baseline-dist, and reverting
fails with "artifact distributions cannot become public while reusing
protected bucket baseline-dist".

A scoped run now refuses before the first bucket is touched when an
unselected artifact's path, access or artifact name differs from what is
recorded, naming both contracts. It also refuses an unselected artifact
with no published history, which is the same defect at time zero. Publish
the artifact in the same run.

Three tests: the reviewer's flip scenario (and the same change published
in the same run, which proceeds), the no-history case, and one proving
deploy_static reaches the guard before publish_route_immutables,
reconcile_artifact_cors or s3_sync. Disabling the call site alone turns
the last one red.

The README sentence is narrowed to what the code actually guarantees, and
gains the CORS consequence: a scoped run holds no credential for the
other bucket, so a cors_origins change lands with that artifact's next
publish rather than on the merge that edits site.yaml.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjQqc4qFmdpAWaYfy2Aypb
This commit is contained in:
Evelyn Chen
2026-09-06 01:56:43 +00:00
co-authored by Claude Fable 5.1
parent 3dfee64335
commit b14f6a856b
3 changed files with 138 additions and 3 deletions
+38
View File
@@ -535,6 +535,43 @@ def validate_route_migrations(cfg, previous_contracts):
)
def validate_scoped_history(cfg, previous_contracts):
"""A scoped run may not record a route contract it did not publish.
render_site_manifests advances the stored contract for every route in
site.yaml, and `access` is overwritten rather than unioned the way
immutable_paths is. Without this, a catalogue-only publish could write a
protected access for the distributions bucket that nothing published, and
validate_route_migrations would then refuse to put that bucket back —
unpublished intent turned into an irreversible fact.
"""
chosen = set(cfg["selected"])
if len(chosen) == len(cfg["artifacts"]):
return
artifact_by_name = {artifact["name"]: artifact for artifact in cfg["artifacts"]}
for route in cfg["routes"]:
if route["artifact"] in chosen:
continue
artifact = artifact_by_name[route["artifact"]]
previous = previous_contracts.get(artifact["bucket"])
if previous is None:
raise RuntimeError(
f"a scoped run cannot introduce artifact {route['artifact']}; "
f"publish it in the same run"
)
current = {
"path": route["path"],
"access": "public" if route["access"] == "legacy" else route["access"],
"artifact": route["artifact"],
}
recorded = {key: previous[key] for key in current}
if recorded != current:
raise RuntimeError(
f"a scoped run may not change unselected artifact {route['artifact']}'s route "
f"contract ({recorded} -> {current}); publish it in the same run"
)
def deploy_static(site_name, site_dir, action_dir, token, cfg):
artifact_by_name = {artifact["name"]: artifact for artifact in cfg["artifacts"]}
credential_env_names = {
@@ -552,6 +589,7 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
manifests_dir = app_dir / "manifests"
previous_contracts = previous_route_contracts(app_dir)
validate_route_migrations(cfg, previous_contracts)
validate_scoped_history(cfg, previous_contracts)
# 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.