Add public Forgejo static site deploy action
Some checks failed
Test / test (push) Failing after 15s
Some checks failed
Test / test (push) Failing after 15s
This commit is contained in:
commit
38ed25fb76
7 changed files with 521 additions and 0 deletions
14
.forgejo/workflows/test.yml
Normal file
14
.forgejo/workflows/test.yml
Normal 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
21
LICENSE
Normal 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
64
README.md
Normal 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
1
__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Static website deployment tool published as a Forgejo Action."""
|
||||
40
action.yml
Normal file
40
action.yml
Normal 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
311
static_site.py
Executable 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
70
tests/test_static_site.py
Normal 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) == ([], [])
|
||||
Loading…
Reference in a new issue