feat(delivery): publish multiple surfaces
Test / test (pull_request) Failing after 5s

Authored-By: OpenAI (GPT-5) <noreply@openai.com>
This commit is contained in:
Dave Kowalski
2026-08-29 21:21:47 +00:00
parent f1f780f5a3
commit 099a48f2b5
13 changed files with 826 additions and 309 deletions
+119 -88
View File
@@ -1,61 +1,117 @@
# action/site-publish
Composite Gitea Action that publishes a **static-content** website to the
fritzlab k8s cluster. Supports `static`, `hugo`, and `mkdocs`. Content goes
to a Garage S3 bucket; Traefik fronts the bucket via an `ExternalName`
Service with cert-manager TLS.
Composite Gitea Action that publishes static, Hugo, and MkDocs output to Garage
and writes the matching Argo CD, Kubernetes, Traefik, and cert-manager resources
to `fritzlab/apps`.
> **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`.
Containerized applications use `action/image-build`, `action/image-push`, and
`action/image-deploy`. `type: docker` fails validation.
## Convention
## Delivery contracts
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.
There are two explicit internal modes:
## Usage
- `single-surface-v1` is selected only when `schema`, `artifacts`, and `routes` are all
absent. It preserves the original one-repository/one-bucket/one-`/` route
behavior, existing action inputs, aliases, middleware, and revalidated cache.
- `v2` is selected by `schema: v2`. It requires named artifacts and routes and
validates the entire access/storage graph before any upload or manifest write.
Scaffold a new site (handles repo creation + Garage bucket):
Never mix the modes. A partial v2 declaration fails closed.
```sh
./new-site.sh --name my-site.vino.network --domain my-site.vino.network --type static
### Multi-surface schema
This example publishes immutable releases and revalidated channel pointers
anonymously while protecting the portal catch-all with Authentik:
```yaml
schema: v2
domain: baseline.fritzlab.net
type: static
content_dir: output
artifacts:
releases:
source: dist/releases
bucket: baseline-releases
credentials:
access_key_env: BASELINE_RELEASES_S3_ACCESS_KEY
secret_key_env: BASELINE_RELEASES_S3_SECRET_KEY
cache: immutable-release
channels:
source: dist/channels
bucket: baseline-channels
credentials:
access_key_env: BASELINE_CHANNELS_S3_ACCESS_KEY
secret_key_env: BASELINE_CHANNELS_S3_SECRET_KEY
cache: revalidated-channel
portal:
source: portal
bucket: baseline-portal
credentials:
access_key_env: BASELINE_PORTAL_S3_ACCESS_KEY
secret_key_env: BASELINE_PORTAL_S3_SECRET_KEY
cache: private
routes:
- path: /dist/releases
artifact: releases
access: public
- path: /dist/channels
artifact: channels
access: public
- path: /
artifact: portal
access: authenticated
middlewares: [authentik-forwardauth]
```
Or do it manually. `site.yaml`:
Artifact `source` is relative to `build/html` after the build phase. Credential
fields name inherited environment variables; secret values never belong in
`site.yaml`. Callers expose those variables to the composite action through the
workflow `env` contract.
Cache policies are intentionally closed:
| Policy | Header | Publication behavior |
|---|---|---|
| `immutable-release` | `public, max-age=31536000, immutable` | write once; identical SHA-256 retry skips; changed key fails |
| `revalidated-channel` | `public, max-age=0, must-revalidate` | replace and delete stale keys |
| `private` | `private, no-store` | replace and delete stale keys |
Validation rejects unknown keys or references, unsafe paths, duplicate route
prefixes, public `/`, authenticated routes without middleware, private/public
cache mismatches, unrouted artifacts, an artifact routed twice, and bucket or
publication credential reuse between artifacts. V2 also requires an explicit
`/` access policy. Routes render deterministically by
longest prefix.
Each route receives a distinct ExternalName Service and Ingress. The Service
targets `<bucket>.web.sjc001.fritzlab.net` with Traefik host forwarding disabled,
so Garage selects that route's bucket. Objects are stored under their route
prefix, avoiding a path-rewrite middleware. Legacy Services directly target the
live `garage-s3.storage.svc.k8s.sjc001.fritzlab.net:80` endpoint. All routes share
one Certificate.
## Legacy usage
Existing callers remain valid:
```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).
type: static
content_dir: html
# aliases: [www.my-site.vino.network]
# tidy: true
# enabled: true
# excludes: [welcome/welcome.pdf]
# middlewares: [authentik-forwardauth]
```
`.gitea/workflows/publish.yaml`:
Legacy bucket name is the repository name. Aliases are reconciled as Garage
global aliases and require `garage-admin-token`; reconciliation errors stop the
deploy. Disabling a site removes generated Apps manifests but never deletes its
bucket or objects.
```yaml
name: Publish
@@ -75,58 +131,33 @@ jobs:
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` | yes | | Garage `ci-deploy-key` access key id |
| `s3-secret-key` | yes | | Garage `ci-deploy-key` secret key |
| `s3-endpoint` | no | `http://garage.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 |
| `token` | yes | | Gitea token used for the Apps repository |
| `s3-access-key` | single-surface-v1 | | Legacy Garage access key |
| `s3-secret-key` | single-surface-v1 | | Legacy Garage secret key |
| `s3-endpoint` | no | `http://garage-s3.storage.svc:3900` | Garage S3 API |
| `garage-admin-token` | with legacy aliases | | Garage admin token |
| `garage-admin-endpoint` | no | `http://garage.storage.svc:3903` | Garage admin API |
| `username` | no | `ci-bot` | Gitea username |
Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`,
`GARAGE_S3_SECRET_KEY`, `GARAGE_ADMIN_TOKEN`.
Git HTTPS authentication is supplied to Git through a short-lived inherited
file descriptor. The token isn't placed in argv, command output, or a clone URL.
## Tools
## Generated topology
- **`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, builds content (static copy / hugo / mkdocs), runs tidy
→ aws s3 sync → Garage bucket named after the repo
→ admin API: ensures every alias from site.yaml is a globalAlias on the bucket
→ renders manifests in fritzlab/apps from templates: ExternalName Service →
garage.storage.svc, Traefik Ingress (canonical + aliases), cert-manager
Certificate (canonical + aliases as SANs), kustomization
→ commits + pushes apps repo only if diff is non-empty
→ ArgoCD syncs → site live with TLS
```text
push
-> build/html
-> validate complete site.yaml graph
-> publish each artifact with its cache/write policy and credential pair
-> one Certificate
-> N bucket-specific Services + longest-prefix Ingresses
-> commit changed manifests to fritzlab/apps
-> Argo CD reconciliation
```
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`.
Manual changes to generated Apps resources are overwritten. `new-site.sh`
continues to scaffold legacy one-surface sites.