feat(site-publish): scope publication with an artifacts selection
Test / contract (pull_request) Successful in 7s
Test / contract (pull_request) Successful in 7s
A repository whose artifacts ship on different cadences has no way to publish one of them. Baseline needs it: every merge to main must put the catalogue live in under five minutes, while `dist/` is content-addressed and may only be written by a tag release. Today the action iterates cfg["artifacts"] unconditionally, so the only lever is deleting the distributions artifact from site.yaml — which changes the stored publication contract and drives the route-retirement path. The new `artifacts:` input names the subset this run builds and publishes. Selection scopes the build, the immutable preflight, the CORS reconcile, the S3 sync, and credential resolution. It deliberately does not scope manifest rendering or the immutable-path history: those stay whole, so a scoped run can never retire another artifact's route or delete its bucket contents. An undeclared name fails before the first bucket is touched; `enabled: false` refuses a selection because decommissioning is whole-site. Default is unchanged: no input publishes every declared artifact. 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
9287e4861a
commit
3dfee64335
+133
-1
@@ -24,7 +24,12 @@ sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import deploy
|
||||
import build
|
||||
import utils
|
||||
from utils import ConfigError, normalize_site_config, validate_artifact_inputs
|
||||
from utils import (
|
||||
ConfigError,
|
||||
normalize_site_config,
|
||||
parse_site_yaml,
|
||||
validate_artifact_inputs,
|
||||
)
|
||||
|
||||
|
||||
def fixture(name):
|
||||
@@ -404,6 +409,133 @@ class BuildTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class ArtifactSelectionTests(unittest.TestCase):
|
||||
"""The `artifacts` input scopes publication, never the route contract."""
|
||||
|
||||
def parse(self, selection):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "site.yaml").write_text(
|
||||
(ROOT / "tests" / "fixtures" / "split-site.yaml").read_text()
|
||||
)
|
||||
environment = {"SITE_REPO": "fritzlab/baseline"}
|
||||
if selection is not None:
|
||||
environment["SITE_ARTIFACTS"] = selection
|
||||
with patch.dict(os.environ, environment, clear=False), \
|
||||
redirect_stdout(io.StringIO()):
|
||||
if selection is None:
|
||||
os.environ.pop("SITE_ARTIFACTS", None)
|
||||
return parse_site_yaml(root)
|
||||
|
||||
def test_absent_selection_publishes_every_declared_artifact(self):
|
||||
self.assertEqual(["distributions", "portal"], self.parse(None)["selected"])
|
||||
self.assertEqual(["distributions", "portal"], self.parse(" ")["selected"])
|
||||
|
||||
def test_selection_accepts_comma_and_whitespace_separated_names(self):
|
||||
self.assertEqual(["portal"], self.parse("portal")["selected"])
|
||||
self.assertEqual(
|
||||
["distributions", "portal"], self.parse("portal, distributions")["selected"]
|
||||
)
|
||||
self.assertEqual(
|
||||
["distributions", "portal"], self.parse("portal distributions")["selected"]
|
||||
)
|
||||
|
||||
def test_undeclared_selection_fails_before_any_publication(self):
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr), self.assertRaises(SystemExit):
|
||||
self.parse("catalogue")
|
||||
self.assertIn("undeclared artifact", stderr.getvalue())
|
||||
self.assertIn("catalogue", stderr.getvalue())
|
||||
|
||||
def test_build_skips_unselected_artifacts(self):
|
||||
raw = fixture("split-site.yaml")
|
||||
for artifact in raw["artifacts"]:
|
||||
artifact["tidy"] = False
|
||||
cfg = normalize_site_config(raw, "baseline.fritzlab.net")
|
||||
cfg["selected"] = ["portal"]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "portal" / "build").mkdir(parents=True)
|
||||
(root / "portal" / "build" / "index.html").write_text("portal")
|
||||
built = []
|
||||
with patch.object(build, "build_artifact",
|
||||
side_effect=lambda _root, artifact: built.append(artifact["name"])), \
|
||||
patch.dict(os.environ, {"SITE_DIR": str(root)}, clear=False), \
|
||||
patch.object(build, "parse_site_yaml", return_value=cfg), \
|
||||
redirect_stdout(io.StringIO()):
|
||||
build.cmd_build()
|
||||
self.assertEqual(["portal"], built)
|
||||
|
||||
def deploy_selection(self, selection, environment):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
cfg["selected"] = selection
|
||||
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)
|
||||
for artifact in cfg["artifacts"]:
|
||||
if artifact["name"] not in set(selection):
|
||||
continue
|
||||
html = root / artifact["build_dir"]
|
||||
html.mkdir(parents=True)
|
||||
(html / "index.html").write_text(artifact["name"])
|
||||
for rule in artifact["cache_rules"]:
|
||||
if rule["path"]:
|
||||
(html / rule["path"]).mkdir(parents=True, exist_ok=True)
|
||||
(html / rule["path"] / "keep.json").write_text("{}")
|
||||
synced, immutables, cors = [], [], []
|
||||
with patch.dict(os.environ, environment, clear=False), \
|
||||
patch.object(deploy, "clone_apps", return_value=apps_dir), \
|
||||
patch.object(deploy, "commit_and_push"), \
|
||||
patch.object(deploy, "publish_route_immutables",
|
||||
side_effect=lambda artifact, *_a, **_k: immutables.append(artifact["name"])), \
|
||||
patch.object(deploy, "reconcile_artifact_cors",
|
||||
side_effect=lambda artifacts, *_a: cors.extend(item["name"] for item in artifacts)), \
|
||||
patch.object(deploy, "s3_sync",
|
||||
side_effect=lambda artifact, *_a, **_k: synced.append(artifact["name"])), \
|
||||
redirect_stdout(io.StringIO()):
|
||||
deploy.deploy_static("baseline.fritzlab.net", root, ROOT, "token", cfg)
|
||||
files = {path.relative_to(app_dir).as_posix(): path.read_text()
|
||||
for path in sorted(app_dir.rglob("*.yaml"))}
|
||||
return {"synced": synced, "immutables": immutables, "cors": cors, "files": files}
|
||||
|
||||
def test_selected_publication_leaves_the_whole_route_contract_rendered(self):
|
||||
# Only the portal credentials exist: a scoped run must not demand the
|
||||
# secrets of an artifact it is not publishing.
|
||||
environment = {"PORTAL_S3_ACCESS_KEY": "portal-key",
|
||||
"PORTAL_S3_SECRET_KEY": "portal-secret"}
|
||||
for name in ("DIST_S3_ACCESS_KEY", "DIST_S3_SECRET_KEY"):
|
||||
os.environ.pop(name, None)
|
||||
result = self.deploy_selection(["portal"], environment)
|
||||
self.assertEqual(["portal"], result["synced"])
|
||||
self.assertEqual(["portal"], result["immutables"])
|
||||
self.assertEqual(["portal"], result["cors"])
|
||||
self.assertIn("manifests/ingress-distributions.yaml", result["files"])
|
||||
self.assertIn("manifests/service-distributions.yaml", result["files"])
|
||||
history = yaml.safe_load(result["files"]["site-publish-history.yaml"])
|
||||
self.assertEqual({"baseline-dist", "baseline-portal"}, set(history["buckets"]))
|
||||
self.assertEqual(
|
||||
["dist/releases"], history["buckets"]["baseline-dist"]["immutable_paths"]
|
||||
)
|
||||
|
||||
def test_disabled_site_refuses_a_partial_selection(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
cfg["enabled"] = False
|
||||
cfg["selected"] = ["portal"]
|
||||
with patch.object(deploy, "parse_site_yaml", return_value=cfg), \
|
||||
patch.dict(os.environ, {
|
||||
"SITE_REPO": "fritzlab/baseline.fritzlab.net", "SITE_DIR": ".",
|
||||
"ACTION_DIR": str(ROOT), "CI_BOT_TOKEN": "token",
|
||||
}, clear=False), \
|
||||
patch.object(deploy, "decommission") as decommission, \
|
||||
redirect_stderr(io.StringIO()), redirect_stdout(io.StringIO()), \
|
||||
self.assertRaises(SystemExit):
|
||||
deploy.cmd_deploy()
|
||||
decommission.assert_not_called()
|
||||
|
||||
|
||||
class PublishingTests(unittest.TestCase):
|
||||
def test_cors_policy_is_reconciled_as_read_only_browser_access(self):
|
||||
captured = {}
|
||||
|
||||
Reference in New Issue
Block a user