fix(site-publish): refuse to record a contract a scoped run did not publish
Test / contract (pull_request) Successful in 7s
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:
co-authored by
Claude Fable 5.1
parent
3dfee64335
commit
b14f6a856b
@@ -475,6 +475,22 @@ class ArtifactSelectionTests(unittest.TestCase):
|
||||
apps_dir = root / "apps"
|
||||
app_dir = apps_dir / "sjc001" / "websites" / "baseline.fritzlab.net"
|
||||
app_dir.mkdir(parents=True)
|
||||
# A scoped run refuses an artifact it has no published history for, so
|
||||
# the fixture starts from the whole publication this one narrows.
|
||||
(app_dir / "site-publish-history.yaml").write_text(yaml.safe_dump({
|
||||
"version": 1,
|
||||
"buckets": {
|
||||
artifact["bucket"]: {
|
||||
"path": route["path"],
|
||||
"access": "public" if route["access"] == "legacy" else route["access"],
|
||||
"artifact": route["artifact"],
|
||||
"immutable_paths": deploy.immutable_key_prefixes(artifact, route),
|
||||
}
|
||||
for route in cfg["routes"]
|
||||
for artifact in [next(item for item in cfg["artifacts"]
|
||||
if item["name"] == route["artifact"])]
|
||||
},
|
||||
}, sort_keys=True))
|
||||
for artifact in cfg["artifacts"]:
|
||||
if artifact["name"] not in set(selection):
|
||||
continue
|
||||
@@ -520,6 +536,72 @@ class ArtifactSelectionTests(unittest.TestCase):
|
||||
["dist/releases"], history["buckets"]["baseline-dist"]["immutable_paths"]
|
||||
)
|
||||
|
||||
def test_scoped_run_refuses_to_record_a_contract_it_did_not_publish(self):
|
||||
"""The reviewer's scenario: distributions goes protected in site.yaml while
|
||||
a catalogue-only run publishes. Recording that access would make the
|
||||
bucket unreturnable through validate_route_migrations."""
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
published = {
|
||||
"baseline-portal": {
|
||||
"path": "/", "access": "protected", "artifact": "portal", "immutable_paths": [],
|
||||
},
|
||||
"baseline-dist": {
|
||||
"path": "/dist", "access": "public", "artifact": "distributions",
|
||||
"immutable_paths": ["dist/releases"],
|
||||
},
|
||||
}
|
||||
# A whole publish agrees with what is recorded, scoped or not.
|
||||
deploy.validate_scoped_history(cfg, published)
|
||||
cfg["selected"] = ["portal"]
|
||||
deploy.validate_scoped_history(cfg, published)
|
||||
|
||||
moved = copy.deepcopy(cfg)
|
||||
route = next(item for item in moved["routes"] if item["artifact"] == "distributions")
|
||||
route["access"] = "protected"
|
||||
route["access_middleware"] = "authentik-forwardauth"
|
||||
with self.assertRaises(RuntimeError) as refused:
|
||||
deploy.validate_scoped_history(moved, published)
|
||||
self.assertIn("may not change unselected artifact distributions", str(refused.exception))
|
||||
# The same change published in the same run is allowed to proceed.
|
||||
moved["selected"] = ["distributions", "portal"]
|
||||
deploy.validate_scoped_history(moved, published)
|
||||
|
||||
def test_scoped_run_refuses_an_artifact_with_no_published_history(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
cfg["selected"] = ["portal"]
|
||||
with self.assertRaises(RuntimeError) as refused:
|
||||
deploy.validate_scoped_history(cfg, {})
|
||||
self.assertIn("cannot introduce artifact distributions", str(refused.exception))
|
||||
|
||||
def test_scoped_deploy_runs_the_history_guard_before_touching_a_bucket(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
cfg["selected"] = ["portal"]
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
root = Path(tmp.name)
|
||||
apps_dir = root / "apps"
|
||||
app_dir = apps_dir / "sjc001" / "websites" / "baseline.fritzlab.net"
|
||||
app_dir.mkdir(parents=True)
|
||||
html = root / next(
|
||||
item for item in cfg["artifacts"] if item["name"] == "portal"
|
||||
)["build_dir"]
|
||||
html.mkdir(parents=True)
|
||||
(html / "index.html").write_text("portal")
|
||||
with patch.dict(os.environ, {
|
||||
"PORTAL_S3_ACCESS_KEY": "portal-key", "PORTAL_S3_SECRET_KEY": "portal-secret",
|
||||
}, clear=False), \
|
||||
patch.object(deploy, "clone_apps", return_value=apps_dir), \
|
||||
patch.object(deploy, "commit_and_push"), \
|
||||
patch.object(deploy, "publish_route_immutables") as immutables, \
|
||||
patch.object(deploy, "reconcile_artifact_cors") as cors, \
|
||||
patch.object(deploy, "s3_sync") as sync, \
|
||||
redirect_stdout(io.StringIO()), \
|
||||
self.assertRaises(RuntimeError):
|
||||
deploy.deploy_static("baseline.fritzlab.net", root, ROOT, "token", cfg)
|
||||
immutables.assert_not_called()
|
||||
cors.assert_not_called()
|
||||
sync.assert_not_called()
|
||||
|
||||
def test_disabled_site_refuses_a_partial_selection(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
cfg["enabled"] = False
|
||||
|
||||
Reference in New Issue
Block a user