# action/site-publish 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 applications use `action/image-build`, `action/image-push`, and `action/image-deploy`. `type: docker` fails validation. ## Delivery contracts There are two explicit internal modes: - `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. Never mix the modes. A partial v2 declaration fails closed. ### 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] ``` 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 `.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 content_dir: html # aliases: [www.my-site.vino.network] # tidy: true # enabled: true # excludes: [welcome/welcome.pdf] # middlewares: [authentik-forwardauth] ``` 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 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 }} ``` ## Inputs | Input | Required | Default | Description | |---|---|---|---| | `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 | 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. ## Generated topology ```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 ``` Manual changes to generated Apps resources are overwritten. `new-site.sh` continues to scaffold legacy one-surface sites.