Files
site-publish/README.md
T
Evelyn Chen 7aca96e320
Test / test (pull_request) Successful in 5s
feat: publish isolated site artifacts
2026-08-29 21:28:28 +00:00

198 lines
8.1 KiB
Markdown

# action/site-publish
Composite Gitea Action that publishes static content to the fritzlab cluster.
The legacy form supports one `static`, `hugo`, or `mkdocs` output. The
multi-artifact form publishes caller-built outputs to separate Garage buckets
and gives each URL path its own cache, CORS, credential, and middleware
boundary. Traefik fronts the buckets and cert-manager owns TLS.
> **Containerized web apps (Dockerfile-based) are NOT handled here.** Use the
> standard image-producer chain instead:
> [`action/image-build`](https://code.fritzlab.net/action/image-build) +
> [`action/image-push`](https://code.fritzlab.net/action/image-push) +
> [`action/image-deploy`](https://code.fritzlab.net/action/image-deploy).
> Hand-author the apps-repo manifests once (Deployment, Service, Ingress,
> Certificate, kustomization with `images:` block) and let `image-deploy`
> pin the tag on every push. See `sjc001/websites/rainsounds.vino.network/`
> for the canonical example. site-publish errors out explicitly if
> `site.yaml` has `type: docker`.
## Convention
Bucket name = repo name = canonical domain. Sibling hostnames (e.g. `www.`,
`ipv6.`) are declared as `aliases:` in `site.yaml` — the action registers each
as a Garage `globalAlias` on the bucket and adds it to the Ingress + Certificate
on every deploy. Manual edits to manifests in the apps repo are clobbered;
edit `site.yaml` instead.
New sites with more than one security or caching boundary use the
multi-artifact form below. An artifact is storage and release metadata. A
route is edge behavior. Keeping them separate prevents a public path from
inheriting the authenticated catalogue's cache or credentials.
## Usage
Scaffold a new site (handles repo creation + Garage bucket):
```sh
./new-site.sh --name my-site.vino.network --domain my-site.vino.network --type static
```
Or do it manually. `site.yaml`:
```yaml
domain: my-site.vino.network
type: static # static | hugo | mkdocs
# content_dir: html # subdirectory containing content (default: repo root)
# aliases: # additional hostnames (each gets a globalAlias on the bucket)
# - www.my-site.vino.network
# tidy: true # set false to skip HTML tidy
# enabled: true # set false to decommission
# excludes: # paths/patterns to skip during sync (relative to bucket root).
# - welcome/welcome.pdf
# # These are passed verbatim to `aws s3 sync --exclude`,
# # so they're both un-uploaded AND un-deleted. Use this
# # for large assets managed out-of-band via aws-cli
# # (e.g. media files updated more often than the site code).
# middlewares: # extra Traefik FILE-PROVIDER middleware names appended to the
# - authentik-forwardauth # Ingress annotation (after https-redirect,retry-upstream).
# # The middleware must already exist in the traefik-dynamic
# # ConfigMap. Use authentik-forwardauth to auth-gate a site
# # (also requires an Authentik proxy provider + app for the host).
```
### Multiple artifacts on one host
Build each output before invoking the action, then declare the materialized
directories and their routes:
```yaml
domain: baseline.fritzlab.net
artifacts:
catalogue:
source: apps/catalogue/build
bucket: baseline-catalogue
credential: catalogue
cache:
default: private, no-store
dist:
source: dist
bucket: baseline-dist
credential: dist
cache:
default: public, max-age=0, must-revalidate, no-transform
rules:
- match: releases/*
value: public, max-age=31536000, immutable, no-transform
cors_origins: ["*"]
routes:
- name: catalogue
path: /
artifact: catalogue
access: protected
- name: dist
path: /dist
artifact: dist
access: public
```
`source` is a repository-relative directory and cannot be the repository
root. Every artifact needs an explicit, unique bucket and at least one route.
Routes use Kubernetes `Prefix` matching. The route path becomes part of the
published object key: `dist/baseline.css` is served at `/dist/baseline.css`.
`access` is required. Protected routes receive the organization Authentik
middleware automatically and their artifacts must use `private` or `no-store`
cache metadata. Public and protected routes cannot share an artifact. Cache
rules use aws-cli include patterns in order after the default metadata pass;
use immutable caching only for content-addressed or version-pinned paths.
The `default` credential profile uses the existing `AWS_ACCESS_KEY_ID` and
`AWS_SECRET_ACCESS_KEY` inputs. A named profile such as `catalogue` reads
`SITE_PUBLISH_CATALOGUE_S3_ACCESS_KEY_ID` and
`SITE_PUBLISH_CATALOGUE_S3_SECRET_ACCESS_KEY` from the caller environment.
Use a different Garage key for every security boundary. The action never logs
credential values and clones the Apps repository without credentials in the
remote URL.
Each artifact Service resolves through Garage's bucket virtual host and sets
Traefik `passHostHeader` to false. The upstream therefore receives the bucket
host while the browser retains the public host. This lets one DNS host address
isolated Garage buckets without shared proxy configuration or CRDs.
`.gitea/workflows/publish.yaml`:
```yaml
name: Publish
on:
push:
branches: [main]
jobs:
publish:
runs-on: fritzlab
steps:
- uses: actions/checkout@v4
- uses: https://code.fritzlab.net/action/site-publish@v1
with:
token: ${{ secrets.CI_BOT_TOKEN }}
s3-access-key: ${{ secrets.GARAGE_S3_ACCESS_KEY }}
s3-secret-key: ${{ secrets.GARAGE_S3_SECRET_KEY }}
garage-admin-token: ${{ secrets.GARAGE_ADMIN_TOKEN }}
```
DNS: subdomains of `vino.network` are covered by the wildcard CNAME to
`traefik.edge.svc…`. For other zones, add an explicit CNAME:
```
my-site.fritzlab.net 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net.
```
## Inputs
| Input | Required | Default | Description |
|---|---|---|---|
| `token` | yes | | Gitea token for apps repo push |
| `s3-access-key` | only for the `default` profile | | Garage access key id |
| `s3-secret-key` | only for the `default` profile | | Garage secret key |
| `s3-endpoint` | no | `http://garage-s3.storage.svc:3900` | Garage S3 endpoint |
| `garage-admin-token` | only if site has `aliases` | | Garage admin API token (`admin-token` from `garage-rpc-secret` in `storage` ns) |
| `garage-admin-endpoint` | no | `http://garage.storage.svc:3903` | Garage admin API endpoint |
| `username` | no | `ci-bot` | Gitea username |
Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`,
`GARAGE_S3_SECRET_KEY`, `GARAGE_ADMIN_TOKEN`.
## Tools
- **`new-site.sh`** — create a new site: Gitea repo, Garage bucket, web hosting enabled.
- **`scripts/publish.py decommission <site>`** — remove a site's manifests from apps repo. Bucket purge is manual.
## Architecture
```
push to websites/<repo>
→ CI runs site-publish action
→ reads site.yaml and either builds legacy content or snapshots caller-built artifacts
→ aws s3 sync → one Garage bucket per artifact, with isolated credentials and metadata
→ admin API: ensures every alias from site.yaml is a globalAlias on the bucket
→ renders manifests in fritzlab/apps from templates: ExternalName Services →
Garage bucket virtual hosts, path-scoped Traefik Ingresses,
cert-manager Certificate (canonical + aliases as SANs), kustomization
→ commits + pushes apps repo only if diff is non-empty
→ ArgoCD syncs → site live with TLS
```
The Ingress + Certificate are re-rendered on every deploy from `site.yaml`.
There is no "first-deploy vs. update" branching — every deploy is idempotent.
No nginx pods, no per-site Docker images. Garage matches `Host:` header to
bucket name (or any of its globalAliases), so every site shares a single
ExternalName target.
## History
- 2026-05-06: removed `type: docker` support. The single docker site
(`rainsounds.vino.network`) migrated to the `image-*` chain. site-publish
is now scoped strictly to static-content sites.
- 2026-05-06: renamed from `fritzlab/publish-site``action/site-publish`.