1 Commits
Author SHA1 Message Date
Evelyn Chen 3bd3370cd9 feat(site-publish): add split-surface publishing
Test / contract (pull_request) Successful in 6s
Authored-By: OpenAI (GPT-5) <noreply@openai.com>
2026-08-29 21:41:55 +00:00
5 changed files with 82 additions and 16 deletions
+10 -2
View File
@@ -92,8 +92,10 @@ routes:
``` ```
The caller supplies each declared credential name as an environment variable The caller supplies each declared credential name as an environment variable
on the action step. Credential values are passed to `aws` only through its on the action step. Names must be matched `<NAME>_S3_ACCESS_KEY` and
environment and never appear in a logged command or process argument. `<NAME>_S3_SECRET_KEY` pairs; arbitrary environment variables cannot become
publication credentials. Values pass to `aws` only through its environment and
never appear in a logged command or process argument.
```yaml ```yaml
- uses: https://code.fritzlab.net/action/site-publish@v1 - uses: https://code.fritzlab.net/action/site-publish@v1
@@ -127,6 +129,12 @@ writes identical even though Garage v2.2.0 has no conditional destination
write. An identical retry converges; a changed object, missing digest metadata, write. An identical retry converges; a changed object, missing digest metadata,
wrong address, or nested policy under that immutable prefix fails publication. wrong address, or nested policy under that immutable prefix fails publication.
Artifact input directories must be pairwise disjoint after filesystem
resolution. Publication stops before build or upload if one contains another or
escapes the repository, preventing protected input from entering a public
artifact. Split storage endpoints are pinned to Garage, and each website
authority is derived from its bucket; a site cannot expose an arbitrary backend.
Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net` Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net`
ExternalName Service annotated to disable pass-host-header and a separate Ingress. Route ExternalName Service annotated to disable pass-host-header and a separate Ingress. Route
Ingresses share the hostname's certificate Secret. The access middleware and Ingresses share the hostname's certificate Secret. The access middleware and
+3 -1
View File
@@ -5,7 +5,7 @@ import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from utils import EXCLUDE_FILES, env, parse_site_yaml, run from utils import EXCLUDE_FILES, env, parse_site_yaml, run, validate_artifact_inputs
def build_artifact(site_dir, artifact): def build_artifact(site_dir, artifact):
@@ -64,5 +64,7 @@ def cmd_build():
print("Site disabled — skipping build") print("Site disabled — skipping build")
return return
validate_artifact_inputs(site_dir, cfg)
for artifact in cfg["artifacts"]: for artifact in cfg["artifacts"]:
build_artifact(site_dir, artifact) build_artifact(site_dir, artifact)
+2
View File
@@ -23,6 +23,7 @@ from utils import (
parse_site_yaml, parse_site_yaml,
render_templates, render_templates,
run, run,
validate_artifact_inputs,
) )
GARAGE_ADMIN_ENDPOINT = os.environ.get( GARAGE_ADMIN_ENDPOINT = os.environ.get(
@@ -350,4 +351,5 @@ def cmd_deploy():
decommission(site_name, token, [artifact["bucket"] for artifact in cfg["artifacts"]]) decommission(site_name, token, [artifact["bucket"] for artifact in cfg["artifacts"]])
return return
validate_artifact_inputs(site_dir, cfg)
deploy_static(site_name, site_dir, action_dir, token, cfg) deploy_static(site_name, site_dir, action_dir, token, cfg)
+32 -6
View File
@@ -26,6 +26,7 @@ EXCLUDE_FILES = {
VALID_TYPES = {"static", "hugo", "mkdocs"} VALID_TYPES = {"static", "hugo", "mkdocs"}
NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
ENV_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") ENV_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
ACCESS_KEY_ENV_RE = re.compile(r"^([A-Z][A-Z0-9_]*)_S3_ACCESS_KEY$")
BUCKET_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$") BUCKET_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$")
MIDDLEWARE_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?$") MIDDLEWARE_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?$")
@@ -238,7 +239,7 @@ def _artifact(item, index):
if not isinstance(name, str) or not NAME_RE.fullmatch(name): if not isinstance(name, str) or not NAME_RE.fullmatch(name):
raise ConfigError(f"{label}.name must be a DNS label") raise ConfigError(f"{label}.name must be a DNS label")
publish = _mapping(item.get("publish"), f"{label}.publish") publish = _mapping(item.get("publish"), f"{label}.publish")
_known_keys(publish, {"bucket", "endpoint", "website_authority", "credentials"}, f"{label}.publish") _known_keys(publish, {"bucket", "credentials"}, f"{label}.publish")
bucket = publish.get("bucket") bucket = publish.get("bucket")
if not isinstance(bucket, str) or not BUCKET_RE.fullmatch(bucket): if not isinstance(bucket, str) or not BUCKET_RE.fullmatch(bucket):
raise ConfigError(f"{label}.publish.bucket is not a valid bucket name") raise ConfigError(f"{label}.publish.bucket is not a valid bucket name")
@@ -250,6 +251,15 @@ def _artifact(item, index):
if not isinstance(value, str) or not ENV_RE.fullmatch(value): if not isinstance(value, str) or not ENV_RE.fullmatch(value):
raise ConfigError(f"{label}.publish.credentials.{key} must name an environment variable") raise ConfigError(f"{label}.publish.credentials.{key} must name an environment variable")
normalized_credentials[key] = value normalized_credentials[key] = value
access_match = ACCESS_KEY_ENV_RE.fullmatch(normalized_credentials["access_key_env"])
expected_secret = (
f"{access_match.group(1)}_S3_SECRET_KEY" if access_match else None
)
if normalized_credentials["secret_key_env"] != expected_secret:
raise ConfigError(
f"{label}.publish.credentials must be a matched "
"<NAME>_S3_ACCESS_KEY and <NAME>_S3_SECRET_KEY pair"
)
cache = _mapping(item.get("cache"), f"{label}.cache") cache = _mapping(item.get("cache"), f"{label}.cache")
_known_keys(cache, {"rules"}, f"{label}.cache") _known_keys(cache, {"rules"}, f"{label}.cache")
rules = _list(cache.get("rules"), f"{label}.cache.rules") rules = _list(cache.get("rules"), f"{label}.cache.rules")
@@ -278,10 +288,7 @@ def _artifact(item, index):
for path in immutable_paths: for path in immutable_paths:
if any(other.startswith(f"{path}/") for other in paths): if any(other.startswith(f"{path}/") for other in paths):
raise ConfigError(f"{label}.cache.rules cannot nest another policy under immutable /{path}") raise ConfigError(f"{label}.cache.rules cannot nest another policy under immutable /{path}")
authority = _hostname( authority = f"{bucket}.{DEFAULT_WEBSITE_SUFFIX}"
publish.get("website_authority", f"{bucket}.{DEFAULT_WEBSITE_SUFFIX}"),
f"{label}.publish.website_authority",
)
if not isinstance(item.get("tidy", True), bool): if not isinstance(item.get("tidy", True), bool):
raise ConfigError(f"{label}.tidy must be a boolean") raise ConfigError(f"{label}.tidy must be a boolean")
return { return {
@@ -292,7 +299,7 @@ def _artifact(item, index):
"excludes": _strings(item.get("excludes") or [], f"{label}.excludes"), "excludes": _strings(item.get("excludes") or [], f"{label}.excludes"),
"build_dir": f".site-publish/{name}/html", "build_dir": f".site-publish/{name}/html",
"bucket": bucket, "bucket": bucket,
"s3_endpoint": _endpoint(publish.get("endpoint", DEFAULT_S3_ENDPOINT), f"{label}.publish.endpoint"), "s3_endpoint": DEFAULT_S3_ENDPOINT,
"website_authority": authority, "website_authority": authority,
"credentials": normalized_credentials, "credentials": normalized_credentials,
"cache_rules": cache_rules, "cache_rules": cache_rules,
@@ -418,6 +425,25 @@ def normalize_site_config(raw, site_name):
return cfg return cfg
def validate_artifact_inputs(site_dir, cfg):
"""Reject source containment before a public or protected build starts."""
root = Path(site_dir).resolve()
sources = []
for artifact in cfg["artifacts"]:
source = (root / artifact["content_dir"]).resolve()
if source != root and root not in source.parents:
raise ConfigError(
f"artifact {artifact['name']} content_dir resolves outside the repository"
)
sources.append((artifact["name"], source))
for index, (name, source) in enumerate(sources):
for other_name, other_source in sources[index + 1:]:
if source == other_source or source in other_source.parents or other_source in source.parents:
raise ConfigError(
f"artifact build inputs overlap after resolution: {name} and {other_name}"
)
def parse_site_yaml(site_dir, site_name=None): def parse_site_yaml(site_dir, site_name=None):
path = Path(site_dir) / "site.yaml" path = Path(site_dir) / "site.yaml"
if not path.exists(): if not path.exists():
+35 -7
View File
@@ -24,7 +24,7 @@ sys.path.insert(0, str(ROOT / "scripts"))
import deploy import deploy
import build import build
import utils import utils
from utils import ConfigError, normalize_site_config from utils import ConfigError, normalize_site_config, validate_artifact_inputs
def fixture(name): def fixture(name):
@@ -151,12 +151,11 @@ class ConfigContractTests(unittest.TestCase):
) )
def test_publication_credentials_cannot_be_reused(self): def test_publication_credentials_cannot_be_reused(self):
self.assert_invalid( def mutate(raw):
lambda raw: raw["artifacts"][0]["publish"]["credentials"].__setitem__( raw["artifacts"][0]["publish"]["credentials"] = copy.deepcopy(
"access_key_env", "DIST_S3_ACCESS_KEY" raw["artifacts"][1]["publish"]["credentials"]
), )
"publication credential DIST_S3_ACCESS_KEY is reused", self.assert_invalid(mutate, "publication credential DIST_S3_ACCESS_KEY is reused")
)
def test_cache_directive_contradiction_is_rejected(self): def test_cache_directive_contradiction_is_rejected(self):
self.assert_invalid( self.assert_invalid(
@@ -191,6 +190,35 @@ class ConfigContractTests(unittest.TestCase):
"unknown fields: storage_bucket", "unknown fields: storage_bucket",
) )
def test_backend_authority_and_endpoint_are_derived(self):
for field, value in (
("endpoint", "https://attacker.example"),
("website_authority", "internal-api.default.svc.k8s.sjc001.fritzlab.net"),
):
with self.subTest(field=field):
self.assert_invalid(
lambda raw, field=field, value=value: raw["artifacts"][0]["publish"].__setitem__(field, value),
f"unknown fields: {field}",
)
def test_publication_credentials_use_dedicated_matched_names(self):
self.assert_invalid(
lambda raw: raw["artifacts"][0]["publish"]["credentials"].__setitem__(
"access_key_env", "CI_BOT_TOKEN"
),
"must be a matched <NAME>_S3_ACCESS_KEY",
)
def test_resolved_build_inputs_must_be_pairwise_disjoint(self):
raw = copy.deepcopy(self.raw)
raw["artifacts"][1]["content_dir"] = ""
cfg = normalize_site_config(raw, "baseline.fritzlab.net")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "portal" / "build").mkdir(parents=True)
with self.assertRaisesRegex(ConfigError, "build inputs overlap after resolution"):
validate_artifact_inputs(root, cfg)
class GenerationTests(unittest.TestCase): class GenerationTests(unittest.TestCase):
def render(self, raw): def render(self, raw):