Test / contract (pull_request) Successful in 7s
Authored-By: OpenAI (GPT-5) <noreply@openai.com>
255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
# action/site-publish
|
|
|
|
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:
|
|
> [`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`.
|
|
|
|
## Single-surface compatibility
|
|
|
|
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;
|
|
edit `site.yaml` instead.
|
|
|
|
## 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
|
|
```
|
|
|
|
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.
|
|
Every immutable target is validated and published before mutable objects change.
|
|
Mutable default and override partitions receive their final cache policy before
|
|
the matching prefix-scoped stale deletion, so publication never exposes a
|
|
provisional cache policy or a pointer to a missing immutable target.
|
|
|
|
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
|
|
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).
|
|
```
|
|
|
|
`.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 the
|
|
public gateway. For other zones, add an explicit CNAME:
|
|
|
|
```
|
|
my-site.fritzlab.net 300 IN CNAME gateway.sjc001.fritzlab.net.
|
|
```
|
|
|
|
## Inputs
|
|
|
|
| Input | Required | Default | Description |
|
|
|---|---|---|---|
|
|
| `token` | yes | | Gitea token for apps repo push |
|
|
| `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 |
|
|
|
|
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 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
|
|
```
|
|
|
|
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. 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
|
|
|
|
- 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`.
|