feat: coordinate static site deployments through Gandalf
All checks were successful
Test / test (push) Successful in 8s

This commit is contained in:
Eric Wendland 2026-07-21 23:26:34 +02:00
commit 411010ef5a
4 changed files with 257 additions and 17 deletions

View file

@ -45,11 +45,14 @@ jobs:
Only run deployments for trusted branches. A contributor able to change a
workflow that receives production secrets can attempt to expose those secrets.
`purge-endpoint` is optional. The Gandalf-managed gateway reuses the dedicated
Storage Zone password to authorize purging only Pull Zones linked to that
allowlisted zone. It avoids exposing the account-wide Bunny API key to project
repositories. A configured purge is attempted after every non-dry-run sync,
including a retry where Storage already matches the local build.
`purge-endpoint` is the backward-compatible name of the optional Gandalf
deployment gateway input. Before uploading, the action acquires an expiring
per-zone lease. After publishing the manifest, Gandalf verifies its SHA-256
digest, records the revision and deployment outcome, and purges the mapped Pull
Zones only when the manifest changed from the previous successful deployment.
Interrupted uploads are aborted best-effort; abandoned leases expire after 30
minutes. The dedicated Storage Zone password authorizes this workflow without
exposing an account-wide API key to the project.
## Command line

View file

@ -14,7 +14,7 @@ inputs:
required: false
default: storage.bunnycdn.com
purge-endpoint:
description: Optional trusted static-site purge gateway base URL.
description: Optional trusted Gandalf deployment gateway base URL.
required: false
default: ""
dry-run:
@ -36,6 +36,7 @@ runs:
BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }}
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
STATIC_SITE_REVISION: ${{ github.sha }}
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
STATIC_SITE_WORKERS: ${{ inputs.workers }}
run: |

View file

@ -84,6 +84,10 @@ def manifest_for(files: dict[str, LocalFile]) -> dict[str, Any]:
}
def manifest_bytes(manifest: dict[str, Any]) -> bytes:
return (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
def parse_manifest(raw: bytes | None) -> dict[str, dict[str, Any]]:
if raw is None:
return {}
@ -182,7 +186,7 @@ class BunnyStorage:
)
def upload_manifest(self, manifest: dict[str, Any]) -> None:
body = (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
body = manifest_bytes(manifest)
self._request(
"PUT",
MANIFEST_NAME,
@ -297,12 +301,139 @@ def purge_bunny_gateway(endpoint: str, zone: str, password: str) -> None:
raise DeployError(f"Bunny purge gateway failed: {exc.reason}") from exc
def gateway_origin(endpoint: str) -> str:
parsed = urllib.parse.urlsplit(endpoint)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.port is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise DeployError("BUNNY_PURGE_ENDPOINT must be an HTTPS origin without a path")
return f"https://{parsed.hostname}"
def deployment_gateway_request(
endpoint: str,
zone: str,
password: str,
method: str,
path: str,
payload: dict[str, Any],
) -> dict[str, Any]:
url = (
f"{gateway_origin(endpoint)}/v1/static-sites/"
f"{urllib.parse.quote(zone, safe='')}/{path.lstrip('/')}"
)
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {password}",
"Accept": "application/json",
"Content-Type": "application/json",
},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
detail = exc.read()
try:
message = str(json.loads(detail).get("error") or "")
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
message = ""
suffix = f": {message}" if message else ""
raise DeployError(
f"Bunny deployment gateway {method} failed: HTTP {exc.code}{suffix}"
) from exc
except urllib.error.URLError as exc:
raise DeployError(f"Bunny deployment gateway failed: {exc.reason}") from exc
def start_bunny_deployment(
endpoint: str,
zone: str,
password: str,
idempotency_key: str,
revision: str | None,
) -> dict[str, Any]:
result = deployment_gateway_request(
endpoint,
zone,
password,
"POST",
"deployments",
{"idempotency_key": idempotency_key, "revision": revision},
)
deployment = result.get("deployment")
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
raise DeployError("Bunny deployment gateway returned an invalid lease")
return deployment
def finalize_bunny_deployment(
endpoint: str,
zone: str,
password: str,
deployment_id: str,
idempotency_key: str,
revision: str | None,
manifest_sha256: str,
) -> dict[str, Any]:
result = deployment_gateway_request(
endpoint,
zone,
password,
"POST",
f"deployments/{urllib.parse.quote(deployment_id, safe='')}/finalize",
{
"idempotency_key": idempotency_key,
"revision": revision,
"manifest_sha256": manifest_sha256,
},
)
deployment = result.get("deployment")
if not isinstance(deployment, dict):
raise DeployError("Bunny deployment gateway returned an invalid finalization")
return deployment
def abort_bunny_deployment(
endpoint: str,
zone: str,
password: str,
deployment_id: str,
idempotency_key: str,
) -> None:
deployment_gateway_request(
endpoint,
zone,
password,
"DELETE",
f"deployments/{urllib.parse.quote(deployment_id, safe='')}",
{"idempotency_key": idempotency_key},
)
def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
if args.purge_endpoint and args.pull_zone_id:
raise DeployError(
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
)
files = collect_files(args.directory)
manifest = manifest_for(files)
manifest_sha256 = hashlib.sha256(manifest_bytes(manifest)).hexdigest()
revision = args.revision.strip() or None
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
f"{args.zone}\0{revision or ''}\0{manifest_sha256}".encode()
).hexdigest()
storage = BunnyStorage(args.zone, args.password, args.endpoint)
remote = parse_manifest(storage.get_manifest())
upload, delete = bunny_plan(files, remote)
@ -313,18 +444,52 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"upload": upload,
"delete": delete,
"purged": False,
"deployment": None,
}
if args.dry_run:
return result
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
if args.purge_endpoint:
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
result["purged"] = True
lease = start_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
idempotency_key,
revision,
)
try:
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
deployment = finalize_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
str(lease["deployment_id"]),
idempotency_key,
revision,
manifest_sha256,
)
except Exception:
try:
abort_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
str(lease["deployment_id"]),
idempotency_key,
)
except DeployError:
pass
raise
result["deployment"] = deployment
result["purged"] = bool(deployment.get("purged"))
elif args.pull_zone_id:
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
result["purged"] = True
elif result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
return result
@ -385,6 +550,19 @@ def parser() -> argparse.ArgumentParser:
bunny.add_argument(
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
)
bunny.add_argument(
"--revision",
default=(
os.environ.get("STATIC_SITE_REVISION")
or os.environ.get("GITHUB_SHA")
or os.environ.get("FORGEJO_SHA")
or os.environ.get("CI_COMMIT_SHA", "")
),
)
bunny.add_argument(
"--idempotency-key",
default=os.environ.get("STATIC_SITE_IDEMPOTENCY_KEY", ""),
)
bunny.add_argument(
"--workers",
type=positive_int,

View file

@ -203,10 +203,14 @@ class StaticSiteTests(unittest.TestCase):
request.get_header("Authorization"), "Bearer storage-password"
)
@mock.patch.object(static_site, "purge_bunny_gateway")
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage")
def test_unchanged_deployment_retries_gateway_purge(
self, storage_class: mock.Mock, purge_gateway: mock.Mock
def test_unchanged_deployment_is_finalized_without_forcing_a_purge(
self,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@ -215,6 +219,12 @@ class StaticSiteTests(unittest.TestCase):
storage_class.return_value.get_manifest.return_value = json.dumps(
manifest_for(files)
).encode()
start_deployment.return_value = {"deployment_id": "deployment-id"}
finalize_deployment.return_value = {
"deployment_id": "deployment-id",
"status": "unchanged",
"purged": False,
}
args = static_site.argparse.Namespace(
directory=root,
zone="example-org",
@ -224,13 +234,61 @@ class StaticSiteTests(unittest.TestCase):
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
)
result = static_site.deploy_bunny(args)
self.assertFalse(result["changed"])
self.assertTrue(result["purged"])
purge_gateway.assert_called_once_with(
"https://purge.example", "example-org", "storage-password"
self.assertFalse(result["purged"])
start_deployment.assert_called_once()
finalize_deployment.assert_called_once()
storage_class.return_value.upload_manifest.assert_not_called()
@mock.patch.object(static_site, "abort_bunny_deployment")
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "apply_bunny_plan")
@mock.patch.object(static_site, "BunnyStorage")
def test_failed_upload_releases_the_deployment_lease(
self,
storage_class: mock.Mock,
apply_plan: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
abort_deployment: mock.Mock,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
storage_class.return_value.get_manifest.return_value = None
start_deployment.return_value = {"deployment_id": "deployment-id"}
apply_plan.side_effect = static_site.DeployError("upload failed")
args = static_site.argparse.Namespace(
directory=root,
zone="example-org",
password="storage-password",
endpoint="storage.bunnycdn.com",
purge_endpoint="https://purge.example",
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
)
with self.assertRaisesRegex(static_site.DeployError, "upload failed"):
static_site.deploy_bunny(args)
finalize_deployment.assert_not_called()
abort_deployment.assert_called_once_with(
"https://purge.example",
"example-org",
"storage-password",
"deployment-id",
"test-run-123",
)