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 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):

./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: 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-siteaction/site-publish.
S
Description
fritzlab composite action: site-publish
Readme
755 KiB
Languages
Python 93.8%
Shell 4%
Jinja 2.2%