Test / contract (pull_request) Successful in 7s
A repository whose artifacts ship on different cadences has no way to publish one of them. Baseline needs it: every merge to main must put the catalogue live in under five minutes, while `dist/` is content-addressed and may only be written by a tag release. Today the action iterates cfg["artifacts"] unconditionally, so the only lever is deleting the distributions artifact from site.yaml — which changes the stored publication contract and drives the route-retirement path. The new `artifacts:` input names the subset this run builds and publishes. Selection scopes the build, the immutable preflight, the CORS reconcile, the S3 sync, and credential resolution. It deliberately does not scope manifest rendering or the immutable-path history: those stay whole, so a scoped run can never retire another artifact's route or delete its bucket contents. An undeclared name fails before the first bucket is touched; `enabled: false` refuses a selection because decommissioning is whole-site. Default is unchanged: no input publishes every declared artifact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQqc4qFmdpAWaYfy2Aypb
295 lines
13 KiB
Markdown
295 lines
13 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
|
|
cors_origins: ['*']
|
|
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 across every artifact is validated and published before
|
|
any route's 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.
|
|
Generated Ingress annotations and `site-publish-history.yaml` retain every
|
|
seen bucket's access, prior route, and cumulative bucket-relative immutable key
|
|
prefixes, including while an artifact is absent. Removed or renamed rules stay
|
|
recorded. When a move places a retired prefix inside the new sync scope, its
|
|
immutable subtrees are excluded; a current-file collision fails publication.
|
|
The bucket-keyed history rejects a protected-to-public transition even when the
|
|
artifact is renamed; publishing that artifact publicly requires a new bucket.
|
|
Legacy single-surface is public for this downgrade check. Removing or renaming
|
|
an immutable rule preserves its prior URLs; current mutable content at one of
|
|
those paths is rejected instead of replacing it.
|
|
|
|
Artifact input directories must be pairwise disjoint after filesystem
|
|
resolution. Publication stops before build or upload if one contains another or
|
|
escapes the repository. Symlinked roots, components, and descendants are also rejected, preventing
|
|
protected input from entering a public artifact through dereference. Split
|
|
storage endpoints are pinned to Garage, and each website
|
|
authority is derived from its bucket; a site cannot expose an arbitrary backend.
|
|
|
|
`cors_origins` is reconciled as a bucket policy on every split publication. Values are either `*`
|
|
or HTTPS origins; browser access is limited to `GET` and `HEAD`. Omitting the field removes stale
|
|
CORS from that bucket. Protected artifacts cannot allow wildcard CORS. All immutable objects and
|
|
all bucket CORS policies complete before mutable channels change; if any policy write fails, the
|
|
policies already attempted are restored to their pre-publication values.
|
|
|
|
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 |
|
|
| `artifacts` | no | every declared artifact | Space- or comma-separated subset of `site.yaml` artifacts to build and publish |
|
|
|
|
Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`,
|
|
`GARAGE_S3_SECRET_KEY`, `GARAGE_ADMIN_TOKEN`.
|
|
|
|
### Publishing a subset of artifacts
|
|
|
|
A repository whose branches ship on different cadences names the ones this run
|
|
owns:
|
|
|
|
```yaml
|
|
- uses: https://code.fritzlab.net/action/site-publish@<sha>
|
|
with:
|
|
token: ${{ secrets.CI_BOT_TOKEN }}
|
|
artifacts: catalogue
|
|
```
|
|
|
|
Selection scopes the build and the S3 publication only. Ingresses, Services,
|
|
Certificates and the immutable-path history are always rendered from the whole
|
|
`site.yaml`, so a scoped run cannot retire another artifact's route or delete
|
|
its bucket contents. Credentials are resolved for the selected artifacts alone,
|
|
so a workflow need not carry secrets for artifacts it does not publish. An
|
|
undeclared name fails before the first bucket is touched, and `enabled: false`
|
|
refuses a selection because decommissioning is whole-site.
|
|
|
|
## 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`.
|