# 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 `** — remove a site's manifests from apps repo. Bucket purge is manual. ## Architecture ``` push to websites/ → 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`.