Review found the hole in the first commit's claim. render_site_manifests advances the stored route contract for every route in site.yaml, and `access` there is a replacement, not a union the way immutable_paths is. So a catalogue-only publish could write `protected` for the distributions bucket that nothing published — and validate_route_migrations then refuses to put that bucket back public. Unpublished intent became an irreversible fact. Reproduced from the repo's own fixture: after a whole publish the record reads public; after a catalogue-only publish with the route flipped it reads protected, with nothing written to baseline-dist, and reverting fails with "artifact distributions cannot become public while reusing protected bucket baseline-dist". A scoped run now refuses before the first bucket is touched when an unselected artifact's path, access or artifact name differs from what is recorded, naming both contracts. It also refuses an unselected artifact with no published history, which is the same defect at time zero. Publish the artifact in the same run. Three tests: the reviewer's flip scenario (and the same change published in the same run, which proceeds), the no-history case, and one proving deploy_static reaches the guard before publish_route_immutables, reconcile_artifact_cors or s3_sync. Disabling the call site alone turns the last one red. The README sentence is narrowed to what the code actually guarantees, and gains the CORS consequence: a scoped run holds no credential for the other bucket, so a cors_origins change lands with that artifact's next publish rather than on the merge that edits site.yaml. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQqc4qFmdpAWaYfy2Aypb
14 KiB
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+action/image-push+action/image-deploy. Hand-author the apps-repo manifests once (Deployment, Service, Ingress, Certificate, kustomization withimages:block) and letimage-deploypin the tag on every push. Seesjc001/websites/rainsounds.vino.network/for the canonical example. site-publish errors out explicitly ifsite.yamlhastype: 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):
./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.
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.
- 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:
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:
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:
- 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.
What a scoped run may not do is move an artifact it is not publishing. The
generated route contract in site-publish-history.yaml is written from the
whole site.yaml, and access there is a replacement rather than a union — so
recording a change nothing published would turn intent into a fact that
validate_route_migrations then refuses to undo. A run whose site.yaml
changes an unselected artifact's route path, access or artifact name is refused
before the first bucket is touched, naming both contracts: publish that artifact
in the same run. For the same reason a scoped run cannot introduce an artifact
that has no published history yet.
Two more refusals, both before any bucket changes: an undeclared name, and
enabled: false with a selection, because decommissioning is whole-site.
One consequence to know: CORS reconciliation is scoped too, since a scoped run
holds no credential for the other bucket. A cors_origins: change lands with
that artifact's next publish, not on the merge that edits site.yaml.
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: dockersupport. The single docker site (rainsounds.vino.network) migrated to theimage-*chain. site-publish is now scoped strictly to static-content sites. - 2026-05-06: renamed from
fritzlab/publish-site→action/site-publish.