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
+9
-2
@@ -5,7 +5,14 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from utils import EXCLUDE_FILES, env, parse_site_yaml, run, validate_artifact_inputs
|
||||
from utils import (
|
||||
EXCLUDE_FILES,
|
||||
env,
|
||||
parse_site_yaml,
|
||||
run,
|
||||
selected_artifacts,
|
||||
validate_artifact_inputs,
|
||||
)
|
||||
|
||||
|
||||
def build_artifact(site_dir, artifact):
|
||||
@@ -66,5 +73,5 @@ def cmd_build():
|
||||
|
||||
validate_artifact_inputs(site_dir, cfg)
|
||||
|
||||
for artifact in cfg["artifacts"]:
|
||||
for artifact in selected_artifacts(cfg):
|
||||
build_artifact(site_dir, artifact)
|
||||
|
||||
+14
-6
@@ -25,6 +25,8 @@ from utils import (
|
||||
parse_site_yaml,
|
||||
render_templates,
|
||||
run,
|
||||
selected_artifacts,
|
||||
selected_routes,
|
||||
validate_artifact_inputs,
|
||||
)
|
||||
|
||||
@@ -47,8 +49,8 @@ def validate_artifact_output(site_dir, artifact):
|
||||
|
||||
|
||||
def validate_publication_environment(cfg):
|
||||
"""Resolve every declared credential before the first bucket is changed."""
|
||||
for artifact in cfg["artifacts"]:
|
||||
"""Resolve every credential this run needs before the first bucket is changed."""
|
||||
for artifact in selected_artifacts(cfg):
|
||||
env(artifact["credentials"]["access_key_env"])
|
||||
env(artifact["credentials"]["secret_key_env"])
|
||||
if cfg["compatibility"] and cfg["aliases"] and not os.environ.get("GARAGE_ADMIN_TOKEN"):
|
||||
@@ -538,8 +540,12 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
|
||||
credential_env_names = {
|
||||
name for artifact in cfg["artifacts"] for name in artifact["credentials"].values()
|
||||
}
|
||||
# Publication is scoped to the selected artifacts; the rendered route
|
||||
# contract is not. Manifests and immutable-path history stay whole, so a
|
||||
# partial publish can never retire another artifact's route or bucket.
|
||||
publishing = selected_routes(cfg)
|
||||
validate_publication_environment(cfg)
|
||||
for artifact in cfg["artifacts"]:
|
||||
for artifact in selected_artifacts(cfg):
|
||||
validate_artifact_output(site_dir, artifact)
|
||||
apps_dir = clone_apps(token)
|
||||
app_dir = apps_dir / "sjc001" / "websites" / site_name
|
||||
@@ -549,15 +555,15 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
|
||||
# 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"]:
|
||||
for route in publishing:
|
||||
publish_route_immutables(
|
||||
artifact_by_name[route["artifact"]], route, site_dir, credential_env_names,
|
||||
)
|
||||
# Reconcile every browser-read policy before publishing mutable content.
|
||||
# A CORS failure therefore cannot leave a new channel pointing at a release
|
||||
# whose cross-origin assets browsers cannot consume.
|
||||
reconcile_artifact_cors(cfg["artifacts"], credential_env_names)
|
||||
for route in cfg["routes"]:
|
||||
reconcile_artifact_cors(selected_artifacts(cfg), credential_env_names)
|
||||
for route in publishing:
|
||||
s3_sync(
|
||||
artifact_by_name[route["artifact"]], route, site_dir, credential_env_names,
|
||||
previous_contracts.get(artifact_by_name[route["artifact"]]["bucket"]),
|
||||
@@ -596,6 +602,8 @@ def cmd_deploy():
|
||||
cfg = parse_site_yaml(site_dir)
|
||||
|
||||
if not cfg["enabled"]:
|
||||
if len(cfg["selected"]) != len(cfg["artifacts"]):
|
||||
die("a disabled site decommissions whole; drop the artifacts selection")
|
||||
print("Site disabled — running decommission...")
|
||||
decommission(site_name, token, [artifact["bucket"] for artifact in cfg["artifacts"]])
|
||||
return
|
||||
|
||||
@@ -274,6 +274,7 @@ def _legacy_config(raw, site_name):
|
||||
"aliases": _aliases(raw.get("aliases"), raw["domain"]),
|
||||
"enabled": raw.get("enabled", True),
|
||||
"artifacts": [artifact],
|
||||
"selected": ["site"],
|
||||
"routes": [{
|
||||
"name": "site", "path": "/", "artifact": "site", "access": "legacy",
|
||||
"access_middleware": None,
|
||||
@@ -457,6 +458,33 @@ def _validate_multi(cfg):
|
||||
raise ConfigError("a public '/' catch-all would expose unmatched protected content")
|
||||
|
||||
|
||||
def _artifact_selection(cfg, requested):
|
||||
"""Resolve the subset of declared artifacts this run publishes."""
|
||||
declared = [artifact["name"] for artifact in cfg["artifacts"]]
|
||||
if not requested or not requested.strip():
|
||||
return declared
|
||||
names = [part for part in re.split(r"[,\s]+", requested.strip()) if part]
|
||||
unknown = sorted({name for name in names if name not in declared})
|
||||
if unknown:
|
||||
raise ConfigError(
|
||||
"artifacts selects undeclared artifact(s) " + ", ".join(unknown)
|
||||
+ "; site.yaml declares " + ", ".join(declared)
|
||||
)
|
||||
return [name for name in declared if name in set(names)]
|
||||
|
||||
|
||||
def selected_artifacts(cfg):
|
||||
"""Artifacts this run builds and publishes, in declaration order."""
|
||||
chosen = set(cfg["selected"])
|
||||
return [artifact for artifact in cfg["artifacts"] if artifact["name"] in chosen]
|
||||
|
||||
|
||||
def selected_routes(cfg):
|
||||
"""Routes whose artifact this run publishes, in route order."""
|
||||
chosen = set(cfg["selected"])
|
||||
return [route for route in cfg["routes"] if route["artifact"] in chosen]
|
||||
|
||||
|
||||
def normalize_site_config(raw, site_name):
|
||||
raw = _mapping(raw, "site.yaml")
|
||||
domain = _hostname(raw.get("domain"), "domain")
|
||||
@@ -482,6 +510,7 @@ def normalize_site_config(raw, site_name):
|
||||
"routes": sorted(routes, key=lambda item: (-len(item["path"]), item["path"], item["name"])),
|
||||
}
|
||||
_validate_multi(cfg)
|
||||
cfg["selected"] = [artifact["name"] for artifact in cfg["artifacts"]]
|
||||
for route in cfg["routes"]:
|
||||
if len(f"{k8s_name(site_name)}-{route['name']}") > 63:
|
||||
raise ConfigError(f"route {route['name']} makes the generated Service name exceed 63 characters")
|
||||
@@ -534,12 +563,14 @@ def parse_site_yaml(site_dir, site_name=None):
|
||||
try:
|
||||
with open(path) as stream:
|
||||
cfg = normalize_site_config(yaml.safe_load(stream), site_name)
|
||||
cfg["selected"] = _artifact_selection(cfg, os.environ.get("SITE_ARTIFACTS"))
|
||||
except (ConfigError, yaml.YAMLError) as exc:
|
||||
die(str(exc))
|
||||
print("Site config:")
|
||||
print(f" domain: {cfg['domain']}")
|
||||
print(f" contract: {cfg['compatibility'] or 'split-surface-v2'}")
|
||||
print(f" artifacts: {[item['name'] for item in cfg['artifacts']]}")
|
||||
print(f" publishing: {cfg['selected']}")
|
||||
print(f" routes: {[(item['path'], item['access']) for item in cfg['routes']]}")
|
||||
return cfg
|
||||
|
||||
|
||||
Reference in New Issue
Block a user