feat(site-publish): scope publication with an artifacts selection #7

Merged
dfritz merged 3 commits from artifacts-input into main 2026-09-06 21:31:51 +00:00
3 changed files with 138 additions and 3 deletions
Showing only changes of commit b14f6a856b - Show all commits
+18 -3
View File
@@ -255,9 +255,24 @@ Selection scopes the build and the S3 publication only. Ingresses, Services,
Certificates and the immutable-path history are always rendered from the whole
`site.yaml`, so a scoped run cannot retire another artifact's route or delete
its bucket contents. Credentials are resolved for the selected artifacts alone,
so a workflow need not carry secrets for artifacts it does not publish. An
undeclared name fails before the first bucket is touched, and `enabled: false`
refuses a selection because decommissioning is whole-site.
so a workflow need not carry secrets for artifacts it does not publish.
What a scoped run may **not** do is move an artifact it is not publishing. The
generated route contract in `site-publish-history.yaml` is written from the
whole `site.yaml`, and `access` there is a replacement rather than a union — so
recording a change nothing published would turn intent into a fact that
`validate_route_migrations` then refuses to undo. A run whose `site.yaml`
changes an unselected artifact's route path, access or artifact name is refused
before the first bucket is touched, naming both contracts: publish that artifact
in the same run. For the same reason a scoped run cannot introduce an artifact
that has no published history yet.
Two more refusals, both before any bucket changes: an undeclared name, and
`enabled: false` with a selection, because decommissioning is whole-site.
One consequence to know: CORS reconciliation is scoped too, since a scoped run
holds no credential for the other bucket. A `cors_origins:` change lands with
that artifact's next publish, not on the merge that edits `site.yaml`.
## Tools
+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.
+82
View File
@@ -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