Add public Forgejo static site deploy action
Some checks failed
Test / test (push) Failing after 15s

This commit is contained in:
Eric Wendland 2026-07-15 16:18:50 +02:00
commit 38ed25fb76
7 changed files with 521 additions and 0 deletions

View file

@ -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

21
LICENSE Normal file
View file

@ -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.

64
README.md Normal file
View file

@ -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@<commit-sha>
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.

1
__init__.py Normal file
View file

@ -0,0 +1 @@
"""Static website deployment tool published as a Forgejo Action."""

40
action.yml Normal file
View file

@ -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[@]}"

311
static_site.py Executable file
View file

@ -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())

70
tests/test_static_site.py Normal file
View file

@ -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("<h1>Hello</h1>")
(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) == ([], [])