8.1 KiB
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+action/image-push+action/image-deploy. Hand-author the apps-repo manifests once (Deployment, Service, Ingress, Certificate, kustomization withimages:block) and letimage-deploypin the tag on every push. Seesjc001/websites/rainsounds.vino.network/for the canonical example. site-publish errors out explicitly ifsite.yamlhastype: 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):
./new-site.sh --name my-site.vino.network --domain my-site.vino.network --type static
Or do it manually. site.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:
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:
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: dockersupport. The single docker site (rainsounds.vino.network) migrated to theimage-*chain. site-publish is now scoped strictly to static-content sites. - 2026-05-06: renamed from
fritzlab/publish-site→action/site-publish.