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