feat(site-publish): add split-surface publishing
Test / contract (pull_request) Successful in 6s

Authored-By: OpenAI (GPT-5) <noreply@openai.com>
This commit is contained in:
Evelyn Chen
2026-08-29 21:41:55 +00:00
parent f1f780f5a3
commit 3bd3370cd9
15 changed files with 1453 additions and 191 deletions
+140 -22
View File
@@ -1,9 +1,11 @@
# 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 one or more **static-content** artifacts
to one hostname. Each split surface owns its build input, Garage bucket,
publication credential environment variables, cache rules, Service, Ingress,
route prefix, and access middleware. The hostname shares one Certificate.
`static`, `hugo`, and `mkdocs` builds are supported; a prebuilt Docusaurus
output is a `static` artifact.
> **Containerized web apps (Dockerfile-based) are NOT handled here.** Use the
> standard image-producer chain instead:
@@ -16,9 +18,10 @@ Service with cert-manager TLS.
> for the canonical example. site-publish errors out explicitly if
> `site.yaml` has `type: docker`.
## Convention
## Single-surface compatibility
Bucket name = repo name = canonical domain. Sibling hostnames (e.g. `www.`,
Existing `site.yaml` files remain the `single-surface-v1` compatibility
contract. 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;
@@ -32,6 +35,121 @@ Scaffold a new site (handles repo creation + Garage bucket):
./new-site.sh --name my-site.vino.network --domain my-site.vino.network --type static
```
The compatibility path still writes `build/html`, uploads with
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and renders `service.yaml` plus
`ingress.yaml`. Its only behavior change is the required root-cause repair:
the website Service now targets the data-only `garage-s3` Service.
## Split-surface contract
Use `artifacts` and `routes` together. This example expresses an authenticated
prebuilt portal at `/` and public bundles at `/dist`; it is illustrative and
the schema has no Baseline-specific field.
```yaml
domain: baseline.fritzlab.net
artifacts:
- name: distributions
type: static
content_dir: dist
publish:
bucket: baseline-dist
credentials:
access_key_env: DIST_S3_ACCESS_KEY
secret_key_env: DIST_S3_SECRET_KEY
cache:
rules:
- path: /
cache_control: public, max-age=0, must-revalidate
- path: releases
cache_control: public, max-age=31536000, immutable
- path: channels
cache_control: public, max-age=0, must-revalidate
- name: portal
type: static
content_dir: portal/build
publish:
bucket: baseline-portal
credentials:
access_key_env: PORTAL_S3_ACCESS_KEY
secret_key_env: PORTAL_S3_SECRET_KEY
cache:
rules:
- path: /
cache_control: private, no-store
routes:
- name: distributions
path: /dist
artifact: distributions
access:
mode: public
- name: portal
path: /
artifact: portal
access:
mode: protected
middleware: authentik-forwardauth
```
The caller supplies each declared credential name as an environment variable
on the action step. Names must be matched `<NAME>_S3_ACCESS_KEY` and
`<NAME>_S3_SECRET_KEY` pairs; arbitrary environment variables cannot become
publication credentials. Values pass to `aws` only through its environment and
never appear in a logged command or process argument.
```yaml
- uses: https://code.fritzlab.net/action/site-publish@v1
with:
token: ${{ secrets.CI_BOT_TOKEN }}
env:
DIST_S3_ACCESS_KEY: ${{ secrets.DIST_S3_ACCESS_KEY }}
DIST_S3_SECRET_KEY: ${{ secrets.DIST_S3_SECRET_KEY }}
PORTAL_S3_ACCESS_KEY: ${{ secrets.PORTAL_S3_ACCESS_KEY }}
PORTAL_S3_SECRET_KEY: ${{ secrets.PORTAL_S3_SECRET_KEY }}
```
Routes are normalized and rendered longest-prefix first. Split mode requires
one `/` catch-all so unmatched paths have an explicit access policy. If any
route is protected, that catch-all must also be protected. Every artifact must
belong to exactly one route and bucket; protected and public routes cannot
reuse a bucket. A protected route requires an existing file-provider access
middleware. Public routes cannot declare one.
Every cache policy requires a `/` default. More-specific cache paths override
it, are reapplied in deterministic prefix order, and must exist in the built
artifact. Contradictory directives (`public` plus `private`, `immutable` plus
revalidation, or `no-store` plus a positive max-age) are rejected. Protected
artifacts require `private` or `no-store` and cannot emit `public`.
Metadata restamping transfers each artifact once even on a no-op publication;
that is the cost of making policy changes effective on unchanged Garage objects.
An immutable cache path is excluded from sync and deletion. Every object key in
that path must contain exactly one full publication SHA-256, calculated over its
cache policy, content type, and bytes. That content address makes concurrent
writes identical even though Garage v2.2.0 has no conditional destination
write. An identical retry converges; a changed object, missing digest metadata,
wrong address, or nested policy under that immutable prefix fails publication.
Artifact input directories must be pairwise disjoint after filesystem
resolution. Publication stops before build or upload if one contains another or
escapes the repository, preventing protected input from entering a public
artifact. Split storage endpoints are pinned to Garage, and each website
authority is derived from its bucket; a site cannot expose an arbitrary backend.
Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net`
ExternalName Service annotated to disable pass-host-header and a separate Ingress. Route
Ingresses share the hostname's certificate Secret. The access middleware and
Garage bucket/key must already exist; the publisher doesn't create identity
providers or credentials.
### Migrating a site
Leave an existing single-surface file unchanged until a real second surface
exists. Then build every artifact before this action, move the old fields into
an artifact, declare a route for every artifact, give each bucket a separately
scoped key, and set the route access/cache contract. Run the repository tests
and inspect generated Apps changes. Removing a route removes its generated
Service and Ingress on the next render; bucket deletion remains manual.
Or do it manually. `site.yaml`:
```yaml
@@ -75,11 +193,11 @@ 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:
DNS: subdomains of `vino.network` are covered by the wildcard CNAME to the
public gateway. For other zones, add an explicit CNAME:
```
my-site.fritzlab.net 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net.
my-site.fritzlab.net 300 IN CNAME gateway.sjc001.fritzlab.net.
```
## Inputs
@@ -87,10 +205,10 @@ my-site.fritzlab.net 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net.
| 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) |
| `s3-access-key` | legacy only | | Garage access key id for single-surface sites |
| `s3-secret-key` | legacy only | | Garage secret key for single-surface sites |
| `s3-endpoint` | no | `http://garage-s3.storage.svc:3900` | Legacy Garage S3 endpoint |
| `garage-admin-token` | legacy aliases only | | 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 |
@@ -107,12 +225,11 @@ Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`,
```
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
→ reads and validates all of site.yaml before publication
independently builds each static / Hugo / MkDocs artifact
syncs each artifact to its route-owned Garage bucket and prefix
→ reapplies the artifact's default and longest-prefix cache headers
→ renders one Service + Ingress per route and one shared Certificate
→ commits + pushes apps repo only if diff is non-empty
→ ArgoCD syncs → site live with TLS
```
@@ -120,9 +237,10 @@ push to websites/<repo>
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.
No nginx pods, no per-site Docker images. Compatibility sites pass the public
host to the shared data-only Garage website Service. Split routes disable host
passing on their Service so Garage receives that artifact's bucket-specific
website authority.
## History