commit 38ed25fb763d5340f30678d54b77b028adfc3804 Author: Eric Wendland Date: Wed Jul 15 16:18:50 2026 +0200 Add public Forgejo static site deploy action diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..9d2de22 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,14 @@ +--- +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v6 + - run: python3 -m pip install pytest + - run: python3 -m pytest -q diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11d56e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eric Wendland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c044c1 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Static Site Deploy + +A small, dependency-free Forgejo Action that synchronizes a completed static +website build to a dedicated Bunny Storage Zone. + +The deployer requires `index.html`, rejects symbolic links, compares files with +a checksum manifest, uploads changed assets before HTML, deletes stale files +only after successful uploads, and publishes the new manifest last. + +## Forgejo Actions + +Store the website-specific Storage Zone password as the repository secret +`BUNNY_STORAGE_PASSWORD`, then pin the action to an immutable commit from the +public action repository: + +```yaml +--- +name: Deploy website + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v6 + - run: npm ci + - run: npm run build + - name: Deploy to Bunny + uses: https://forge.tionis.dev/actions/static-site-deploy@ + with: + directory: dist + storage-zone: example-org + env: + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} +``` + +Only run deployments for trusted branches. A contributor able to change a +workflow that receives production secrets can attempt to expose those secrets. + +## Command line + +The script can also be used directly with Python 3.11 or newer: + +```bash +export BUNNY_STORAGE_ZONE=example-org +export BUNNY_STORAGE_PASSWORD='zone-specific password' +python3 static_site.py bunny dist --dry-run +python3 static_site.py bunny dist +``` + +Set `BUNNY_STORAGE_ENDPOINT` when the Storage Zone is outside Frankfurt. + +## Development + +```bash +python3 -m pytest tests +``` + +The canonical source lives in the private Gandalf infrastructure repository +and is published to this repository as a Git subtree. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..794bb64 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +"""Static website deployment tool published as a Forgejo Action.""" diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ea64df8 --- /dev/null +++ b/action.yml @@ -0,0 +1,40 @@ +--- +name: Deploy static site to Bunny +description: Safely synchronize a completed static website build to Bunny Storage. + +inputs: + directory: + description: Directory containing the completed static build and index.html. + required: true + storage-zone: + description: Bunny Storage Zone name. + required: true + storage-endpoint: + description: Regional Bunny Storage API hostname. + required: false + default: storage.bunnycdn.com + dry-run: + description: Report changes without uploading or deleting files. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Synchronize static build + shell: bash + env: + STATIC_SITE_DIRECTORY: ${{ inputs.directory }} + BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }} + BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }} + STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + args=(bunny "$STATIC_SITE_DIRECTORY") + if [[ "$STATIC_SITE_DRY_RUN" == "true" ]]; then + args+=(--dry-run) + elif [[ "$STATIC_SITE_DRY_RUN" != "false" ]]; then + echo "dry-run must be true or false" >&2 + exit 2 + fi + python3 "$FORGEJO_ACTION_PATH/static_site.py" "${args[@]}" diff --git a/static_site.py b/static_site.py new file mode 100755 index 0000000..ae8c368 --- /dev/null +++ b/static_site.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Deploy a built static website to Bunny Storage or an SSH endpoint.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import mimetypes +import os +import subprocess +import sys +import tarfile +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +MANIFEST_NAME = ".static-site-manifest.json" +MANIFEST_VERSION = 1 + + +class DeployError(RuntimeError): + """A static-site deployment could not be completed safely.""" + + +@dataclass(frozen=True) +class LocalFile: + path: str + source: Path + size: int + sha256: str + + +def collect_files(root: Path) -> dict[str, LocalFile]: + root = root.resolve() + if not root.is_dir(): + raise DeployError(f"build directory does not exist: {root}") + + files: dict[str, LocalFile] = {} + for source in sorted(root.rglob("*")): + if source.is_symlink(): + raise DeployError(f"symbolic links are not supported: {source.relative_to(root)}") + if not source.is_file(): + continue + relative = source.relative_to(root).as_posix() + if relative == MANIFEST_NAME: + raise DeployError(f"{MANIFEST_NAME} is reserved by the deployer") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + files[relative] = LocalFile(relative, source, source.stat().st_size, digest) + if "index.html" not in files: + raise DeployError("build directory must contain index.html") + return files + + +def manifest_for(files: dict[str, LocalFile]) -> dict[str, Any]: + return { + "version": MANIFEST_VERSION, + "files": { + path: {"sha256": item.sha256, "size": item.size} + for path, item in sorted(files.items()) + }, + } + + +def parse_manifest(raw: bytes | None) -> dict[str, dict[str, Any]]: + if raw is None: + return {} + try: + value = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DeployError(f"remote {MANIFEST_NAME} is not valid JSON") from exc + if not isinstance(value, dict) or value.get("version") != MANIFEST_VERSION: + raise DeployError(f"remote {MANIFEST_NAME} has an unsupported version") + files = value.get("files") + if not isinstance(files, dict) or not all(isinstance(key, str) for key in files): + raise DeployError(f"remote {MANIFEST_NAME} has an invalid files map") + for path, metadata in files.items(): + parts = Path(path).parts + if ( + not path + or path.startswith("/") + or ".." in parts + or path == MANIFEST_NAME + or not isinstance(metadata, dict) + ): + raise DeployError(f"remote {MANIFEST_NAME} contains an unsafe path") + return files + + +class BunnyStorage: + def __init__(self, zone: str, password: str, endpoint: str) -> None: + if not zone or not password: + raise DeployError("BUNNY_STORAGE_ZONE and BUNNY_STORAGE_PASSWORD are required") + endpoint = endpoint.removeprefix("https://").rstrip("/") + if not endpoint or "/" in endpoint: + raise DeployError("Bunny storage endpoint must be a hostname") + self.zone = zone + self.password = password + self.base_url = f"https://{endpoint}/{urllib.parse.quote(zone, safe='')}" + + def _request( + self, + method: str, + path: str, + *, + body: bytes | None = None, + headers: dict[str, str] | None = None, + missing_ok: bool = False, + ) -> bytes | None: + url_path = urllib.parse.quote(path, safe="/") + request_headers = {"AccessKey": self.password, **(headers or {})} + request = urllib.request.Request( + f"{self.base_url}/{url_path}", + data=body, + headers=request_headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except urllib.error.HTTPError as exc: + if missing_ok and exc.code == 404: + return None + detail = exc.read().decode("utf-8", errors="replace") + raise DeployError( + f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}" + ) from exc + except urllib.error.URLError as exc: + raise DeployError(f"Bunny Storage {method} {path} failed: {exc.reason}") from exc + + def get_manifest(self) -> bytes | None: + return self._request("GET", MANIFEST_NAME, missing_ok=True) + + def upload(self, item: LocalFile) -> None: + content_type = mimetypes.guess_type(item.path)[0] or "application/octet-stream" + self._request( + "PUT", + item.path, + body=item.source.read_bytes(), + headers={ + "Checksum": item.sha256.upper(), + "Content-Type": content_type, + }, + ) + + def upload_manifest(self, manifest: dict[str, Any]) -> None: + body = (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode() + self._request( + "PUT", + MANIFEST_NAME, + body=body, + headers={ + "Checksum": hashlib.sha256(body).hexdigest().upper(), + "Content-Type": "application/json", + }, + ) + + def delete(self, path: str) -> None: + self._request("DELETE", path, missing_ok=True) + + +def bunny_plan( + local: dict[str, LocalFile], remote: dict[str, dict[str, Any]] +) -> tuple[list[str], list[str]]: + upload = [ + path + for path, item in local.items() + if not isinstance(remote.get(path), dict) + or remote[path].get("sha256") != item.sha256 + or remote[path].get("size") != item.size + ] + delete = sorted(set(remote) - set(local)) + # Publish assets before documents that may start referring to them. + upload.sort(key=lambda path: (path.lower().endswith(('.html', '.htm')), path)) + return upload, delete + + +def purge_bunny_pull_zone(pull_zone_id: str, api_key: str) -> None: + if not api_key: + raise DeployError("BUNNY_API_KEY is required when BUNNY_PULL_ZONE_ID is set") + request = urllib.request.Request( + f"https://api.bunny.net/pullzone/{urllib.parse.quote(pull_zone_id, safe='')}/purgeCache", + headers={"AccessKey": api_key}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=60): + return + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise DeployError(f"Bunny cache purge failed: HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise DeployError(f"Bunny cache purge failed: {exc.reason}") from exc + + +def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]: + files = collect_files(args.directory) + storage = BunnyStorage(args.zone, args.password, args.endpoint) + remote = parse_manifest(storage.get_manifest()) + upload, delete = bunny_plan(files, remote) + result = { + "backend": "bunny", + "changed": bool(upload or delete), + "dry_run": args.dry_run, + "upload": upload, + "delete": delete, + "purged": False, + } + if args.dry_run or not result["changed"]: + return result + + for path in upload: + storage.upload(files[path]) + for path in delete: + storage.delete(path) + storage.upload_manifest(manifest_for(files)) + if args.pull_zone_id: + purge_bunny_pull_zone(args.pull_zone_id, args.api_key) + result["purged"] = True + return result + + +def deploy_ssh(args: argparse.Namespace) -> dict[str, Any]: + files = collect_files(args.directory) + if args.dry_run: + return { + "backend": "ssh", + "changed": True, + "dry_run": True, + "files": len(files), + "target": args.target, + "site": args.site, + } + + command = ["ssh", "-p", str(args.port)] + if args.identity: + command.extend(["-i", str(args.identity)]) + command.extend([args.target, args.site]) + process = subprocess.Popen(command, stdin=subprocess.PIPE) + assert process.stdin is not None + try: + with tarfile.open(fileobj=process.stdin, mode="w|gz") as archive: + for path, item in sorted(files.items()): + archive.add(item.source, arcname=path, recursive=False) + except BrokenPipeError: + pass + return_code = process.wait() + if return_code: + raise DeployError(f"SSH deployment failed with exit code {return_code}") + return { + "backend": "ssh", + "changed": True, + "dry_run": False, + "files": len(files), + "target": args.target, + "site": args.site, + } + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description="Deploy a completed static-site build to Bunny or an SSH endpoint." + ) + subparsers = result.add_subparsers(dest="backend", required=True) + + bunny = subparsers.add_parser("bunny", help="Sync a build to Bunny Storage.") + bunny.add_argument("directory", type=Path) + bunny.add_argument("--zone", default=os.environ.get("BUNNY_STORAGE_ZONE", "")) + bunny.add_argument( + "--endpoint", + default=os.environ.get("BUNNY_STORAGE_ENDPOINT", "storage.bunnycdn.com"), + help="Regional storage hostname (default: BUNNY_STORAGE_ENDPOINT or Frankfurt).", + ) + bunny.add_argument( + "--pull-zone-id", default=os.environ.get("BUNNY_PULL_ZONE_ID", "") + ) + bunny.add_argument("--dry-run", action="store_true") + bunny.set_defaults( + handler=deploy_bunny, + password=os.environ.get("BUNNY_STORAGE_PASSWORD", ""), + api_key=os.environ.get("BUNNY_API_KEY", ""), + ) + + ssh = subparsers.add_parser("ssh", help="Atomically deploy through static-deploy SSH.") + ssh.add_argument("directory", type=Path) + ssh.add_argument("--target", required=True, help="SSH target, e.g. static-deploy@citadel.") + ssh.add_argument("--site", required=True, help="Configured caddy_static_sites name.") + ssh.add_argument("--port", type=int, default=22) + ssh.add_argument("--identity", type=Path) + ssh.add_argument("--dry-run", action="store_true") + ssh.set_defaults(handler=deploy_ssh) + return result + + +def main() -> int: + args = parser().parse_args() + try: + report = args.handler(args) + except DeployError as exc: + print(f"static-site: {exc}", file=sys.stderr) + return 1 + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_static_site.py b/tests/test_static_site.py new file mode 100644 index 0000000..094b799 --- /dev/null +++ b/tests/test_static_site.py @@ -0,0 +1,70 @@ +import importlib.util +from pathlib import Path +import sys + +import pytest + +MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py" +SPEC = importlib.util.spec_from_file_location("static_site_deploy", MODULE_PATH) +assert SPEC and SPEC.loader +static_site = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = static_site +SPEC.loader.exec_module(static_site) + +DeployError = static_site.DeployError +bunny_plan = static_site.bunny_plan +collect_files = static_site.collect_files +manifest_for = static_site.manifest_for +parse_manifest = static_site.parse_manifest + + +def write_site(root: Path) -> None: + (root / "assets").mkdir() + (root / "index.html").write_text("

Hello

") + (root / "assets" / "app.js").write_text("console.log('hello')") + + +def test_collect_files_requires_index(tmp_path): + with pytest.raises(DeployError, match="index.html"): + collect_files(tmp_path) + + +def test_collect_files_rejects_symlinks(tmp_path): + write_site(tmp_path) + (tmp_path / "linked").symlink_to(tmp_path / "index.html") + with pytest.raises(DeployError, match="symbolic links"): + collect_files(tmp_path) + + +def test_manifest_round_trip(tmp_path): + write_site(tmp_path) + files = collect_files(tmp_path) + manifest = manifest_for(files) + parsed = parse_manifest(__import__("json").dumps(manifest).encode()) + assert parsed["index.html"]["sha256"] == files["index.html"].sha256 + + +def test_manifest_rejects_unsafe_remote_path(): + raw = b'{"version": 1, "files": {"../other-site/index.html": {}}}' + with pytest.raises(DeployError, match="unsafe path"): + parse_manifest(raw) + + +def test_bunny_plan_uploads_assets_before_html_and_removes_stale(tmp_path): + write_site(tmp_path) + files = collect_files(tmp_path) + remote = { + "index.html": {"sha256": "old", "size": 1}, + "assets/app.js": {"sha256": "old", "size": 1}, + "old.css": {"sha256": "old", "size": 1}, + } + upload, delete = bunny_plan(files, remote) + assert upload == ["assets/app.js", "index.html"] + assert delete == ["old.css"] + + +def test_bunny_plan_skips_unchanged_files(tmp_path): + write_site(tmp_path) + files = collect_files(tmp_path) + remote = manifest_for(files)["files"] + assert bunny_plan(files, remote) == ([], [])