static-site-deploy/README.md
Eric Wendland 4490557f13
All checks were successful
Test / test (push) Successful in 6s
feat(static-sites): let project sites declare Bunny edge rules
Project sites can pass a TOML file of edge rules (redirects, status
codes, blocking, response headers, cache times) to the deploy action.
The gateway validates them when a deployment starts and reconciles its
own rules on the site's Pull Zones when it finalizes, deleting dropped
rules and purging when a rule changed. Actions that could reach beyond
the site, such as origin changes, are not available.

The purge retry is limited to recorded purge failures, so a deployment
whose rules failed to apply cannot be completed by a purge alone.
2026-10-02 17:14:42 +02:00

5.7 KiB

Static Site Deploy

A small, dependency-free Forgejo Action that synchronizes a completed static website build to a dedicated Bunny Storage Zone.

The runner needs Bash and Python 3.11 or newer. The action itself installs no packages and executes no code from the Gandalf repository.

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:

---
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
          purge-endpoint: https://gandalf.tionis.dev
        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.

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.

Edge rules

Bunny Storage has no _headers or _redirects file and picks a file's Content-Type from its extension. With the gateway, a site declares Bunny edge rules in a TOML file kept in its repository, outside the build directory:

        with:
          directory: dist
          storage-zone: example-org
          purge-endpoint: https://gandalf.tionis.dev
          edge-rules: bunny.toml
[[edge_rules]]
name = "markdown"
url = "*.md"
response_headers = { "Content-Type" = "text/markdown; charset=utf-8" }

[[edge_rules]]
name = "old-blog"
url = ["*/blog/*", "*/news/*"]
redirect = { to = "https://example.org{{path}}", status = 301 }

[[edge_rules]]
name = "retired"
url = "*/alt/*"
status_code = 410

[[edge_rules]]
name = "hashed-assets"
url = "*/_astro/*"
cache_seconds = 31536000
browser_cache_seconds = 31536000

Each rule has a unique name (lowercase letters, digits, -), at least one trigger and at least one action. Rules run in file order; there are at most 25.

Triggers take one pattern or a list of up to 10, any of which may match:

Key Matches
url The full request URL, e.g. *.md or */docs/* (* also spans /).
method GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS.
status The response status code, e.g. [404].
country Two-letter country codes such as "DE".
request_header { name = "X-Debug", values = ["1"] }.

With several triggers, match decides: "all" (default), "any" or "none".

Actions:

Key Effect
redirect { to = "https://…", status = 301 }; status is 301, 302 (default), 307 or 308. to may use Bunny variables such as {{path}} (path with query string).
status_code Replace the response status, 200-599.
block true: refuse the request.
response_headers Header names to values. Framing and hop-by-hop headers such as Content-Length are rejected.
cache_seconds Edge cache time in seconds.
browser_cache_seconds Browser cache time in seconds.
ignore_query_string true: cache regardless of the query string.
force_download true: serve as a download.

redirect, status_code and block exclude each other. Bunny stops at the first matching redirect or block; headers from all matching rules apply. Actions that could reach beyond the site, such as changing the origin or switching off security features, are not available.

The gateway checks the rules when the deployment starts, so a mistake fails before anything is uploaded, and its error names the rule. A dry run only checks the TOML structure. On finalization the gateway applies the rules to the site's Pull Zones, removes rules dropped from the file, and purges the cache when a rule changed. A deployment through the gateway without edge-rules removes the rules of earlier deployments; rules set up by other means are never touched.

Command line

The script can also be used directly with Python 3.11 or newer:

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

Changed files are transferred with 16 workers by default while preserving the asset-before-HTML and manifest-last publication order. Set BUNNY_TRANSFER_WORKERS or pass --workers to tune the limit.

Set BUNNY_STORAGE_ENDPOINT when the Storage Zone is outside Frankfurt. Pass edge rules with --edge-rules bunny.toml (or STATIC_SITE_EDGE_RULES); --dry-run checks only the file's TOML structure.

Development

python3 -m unittest discover -s tests

The canonical source lives in the private Gandalf infrastructure repository and is published to this repository as a Git subtree.