Three classes of drift cleaned up across 85 .md files:
- Em-dashes: replaced ` — ` with the contextually correct separator
(colon for `term: explanation`, period to split clauses across
sentences/source lines, parentheses for asides, semicolon for
tight-linked continuations). Two intentional exceptions remain
in `docs/contributing/documentation.md` (the rule literal) and
inside a fenced ```sh block in `docs/contributing/actions/debugging/ci.md`.
- SPOT self-labels: rewrote "This is the SPOT for X" to describe
the subject directly ("This directory holds X" / "This file
describes X"). Cross-references to other artefacts' canonical
role remain unchanged.
- Heading-emoji position: moved leading-emoji headings (`# 🧩 Title`)
to trailing position (`# Title 🧩`) per the headline rule that
emojis MUST come after the title text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
016 - web-app-hugo (Static Site Generator)
User Story
As an operator, I want a web-app-hugo role that builds and serves
Hugo static sites (one per canonical domain
configured for the role) so that I can host fast, dependency-free
HTML/CSS/JS websites (blogs, marketing pages, documentation portals)
on infinito-nexus with the same lifecycle, deploy, and observability
guarantees as every other web-app-* role.
Background
Hugo is a single-binary static site generator written in Go. It reads
Markdown content plus a theme and emits a fully static public/
directory in seconds. There is no runtime application server: once the
site is built, any HTTP server (nginx, caddy, S3, …) can serve it.
The infinito-nexus stack already has long-running CMS roles
(web-app-wordpress, web-app-discourse, …) and one no-database
static-asset role (web-app-littlejs) that clones an upstream Git
repository, builds a custom Docker image around it, and serves the
result via nginx. web-app-hugo adopts the same shape and adds Hugo's
build step:
- Content comes from an external Git repository (markdown + theme)
cloned by
sys-stk-fullviadocker_git_repository_*(same path used byweb-app-littlejs). - Build + Serve are wrapped in a single multi-stage Dockerfile:
the
builderstage runshugo --minifyagainst the cloned source; theservestage is a pinnednginx:<version>-alpinethat COPYs the builder's/public/into/usr/share/nginx/html. There is no separate long-running build container;compose buildre-bakes the static output whenever the cloned source changes, andcompose uprestarts nginx with the new image.
This keeps the runtime container small (just nginx + static files) and
makes "the source ref changed → rebuild" automatic via Docker's layer
cache: compose build is a no-op when services/repository/'s commit
hash is unchanged.
Naming
- Role directory:
roles/web-app-hugo/. - Application ID:
web-app-hugo. - Compose service name:
{{ application_id | get_entity_name }}(resolves tohugo); container name:infinito-hugo. Same shape asweb-app-littlejs's single-service container. - Internal hostname inside the compose default network:
hugo.
Scope
V1 (this requirement) implements single canonical domain per
deploy, the most common shape for a Hugo site, matching every
existing simple web-app-* role in the tree. Multi-canonical-domain
support (one role deploy serving N independent Hugo sites with
distinct --baseURLs) is out of scope for V1; if needed, a
follow-up requirement adds it as a separate iteration. The role MUST
still validate the server.domains.canonical list at deploy time and
fail explicitly if more than one canonical domain is configured.
Dependencies
- Reuses the existing role-meta layout conventions defined in requirement 008.
- Reuses the standard compose includes
(
sys-svc-compose/templates/base.yml.j2,sys-svc-container/templates/...); see roles/web-app-yourls/templates/compose.yml.j2 as the structural reference. - Pulls Hugo from upstream via the
package-cacheprofile when active (requirement 012). When the profile is inactive, the role MUST still deploy by going to upstream directly. - Does NOT require a database, OIDC, or LDAP; the served output is fully static.
Acceptance Criteria
Role layout
roles/web-app-hugo/follows the standard layout for a DB-less, single-service web-app:meta/(main.yml,info.yml,services.yml,server.yml),tasks/,templates/,vars/,files/,README.md,Administration.md.schema.yml/users.ymlare not required (matchesweb-app-littlejs/web-app-mini-qr).meta/main.ymldeclares author, license, and tags consistent with siblingweb-app-*roles.- The role registers itself with the dashboard and reverse-proxy
stack via the same
sys-stk-fullinclude hook used byweb-app-littlejs(no bespoke registration path).
Image & versions
- Hugo image is pinned to a specific extended-Hugo tag
(
hugomods/hugo:exts-0.148.0) inmeta/services.yml, surfaced throughvars/main.ymlvialookup('config', …).:latestis forbidden. - The serving image is pinned (
nginx:1.28.0-alpine) inmeta/services.yml, surfaced throughvars/main.yml. - Both image references go through
lookup('config', application_id, 'services.hugo.{image,version,builder_image,builder_version}'), the same path used by every otherweb-app-*role, so they appear in the global image-version drift report.
Configuration surface
server.domains.canonicalforweb-app-hugoMUST contain exactly one entry. If more than one entry, the play MUST fail with a clear "multi-canonical not supported in V1" message. (Asserted intasks/01_core.yml.)- Inventory configuration under
applications.web-app-hugo.services.hugo: -source_repository(HTTPS Git URL, OPTIONAL; defaulthttps://github.com/gohugoio/hugoDocs.git; see Default site below) -source_version(branch, tag, or commit; OPTIONAL; default value is the pinned tag from Default site) -hugo_environment(OPTIONAL; defaultproduction) -hugo_minify(OPTIONAL boolean; defaulttrue) - Hugo--baseURLis derived fromlookup('tls', application_id, 'url.base'); the role's TLS URL is single-source-of-truth for the canonical scheme+host and an inventory override would silently fight that lookup. - Theme handling: theme is whatever the upstream content repo
ships in
themes/orgo.mod(Hugo modules). The role does NOT clone a separate theme repo in V1; keeping theme + content bundled mirrors howweb-app-littlejsconsumes its upstream and avoids per-site theme version drift. A separate theme override is deferred to the multi-site follow-up. - Defaults live in
meta/services.yml(read vialookup('config', application_id, …)fromvars/main.yml), not in a role-level schema validator; same convention as every otherweb-app-*role.
Default site
- When the operator enables
web-app-hugowithout overridingservices.hugo.source_repository, the role clones the official Hugo documentation source from github.com/gohugoio/hugoDocs and builds it. Validated end-to-end: Playwrighthugo serves rendered HTML with a non-empty <title>passes with the docs theme's homepage title. - The default
source_versionis a pinned tag (NOTmaster/HEAD). Initial pin:v0.148.0(commit36e3d7b7b521a165bdbf8f63cd417720f37832c6, tagged on github.com/gohugoio/hugoDocs). Declared inmeta/services.yml. - The role README documents how to override the default with a custom content repo + ref.
Build pipeline
tasks/01_core.ymlincludessys-stk-fulland setsdocker_git_repository_address/docker_git_repository_versionto the inventory-resolvedsource_repository/source_version. The clone path is the standardservices/repository/under the role's container directory.files/Dockerfileis a multi-stage build: - Stagebuilder(hugomods/hugo:exts-0.148.0) installs Node.js/npm, runsnpm installifpackage.jsonis present (hugoDocs uses Hugo's JS pipeline withimport 'alpinejs'), and runshugo --minify -e ${HUGO_ENVIRONMENT} -b ${HUGO_BASE_URL} -d /publicover the cloned content. - Stageserve(nginx:1.28.0-alpine)COPY --from=builder /public/ /usr/share/nginx/html/.- The build is idempotent: on a second
deploy-reuse-kept-appswith no inventory change, the git-pull task reportschanged: false(before == after == 36e3d7b7…) and nocompose build/compose uphandler fires. - Build failures (Hugo non-zero exit) fail
compose buildand therefore the play. Because the new image is never produced,compose upkeeps serving the previous image, so content never flips to a half-built state. Observed during this iteration: an npm-modules build error (Could not resolve "alpinejs") failed the play with no image swap.
Serving
- The serving container exposes a single
${DOCKER_BIND_HOST}:8008:80mapping; port allocated viacli meta ports suggest --scope local --category http. - The healthcheck uses
sys-svc-container/templates/healthcheck/wget.yml.j2(BusyBox wget; the pinnednginx:1.28.0-alpineships no curl). Container reportsUp (healthy)after first deploy. - CSP (
content-security-policy: default-src 'self'; …),server: openresty/…(no version leak from the role's nginx, reverse-proxy strips server header), and HSTS (strict-transport-security: max-age=15768000) are emitted by the front proxy. Validated bycurl -sk -I https://hugo.infinito.example/.
Idempotency & deploy
make deploy-fresh-purged-apps APPS=web-app-hugo FULL_CYCLE=truesucceeds end-to-end (failed=0in PLAY RECAP).- Running
make deploy-reuse-kept-apps APPS=web-app-hugoa second time reports zero changes for the Hugo build step: git pullchanged: false, no compose-build / compose-up handler fires.
Tests
roles/web-app-hugo/files/playwright.spec.jsexercises: - front-page reachability of the canonical domain (HTTP < 400, canonical-domain check, HSTS present), - non-empty<title>+ Hugo content sentinel (rejects nginx default index / directory listing), - CSP header present and non-empty. All three pass under the deploy-timetest-e2e-playwrightrunner (3 passed).make testpasses (162 tests, 1 skipped).
Documentation
roles/web-app-hugo/README.mddocuments purpose, the default content source (hugoDocs), and how to overridesource_repository/source_versionfrom the inventory.roles/web-app-hugo/Administration.mddocuments day-2 ops: forcing a rebuild (docker compose build --no-cache), bumping the source ref, debugging a failed Hugo build (where to find thecompose buildlog viamake exec).
Out of Scope
- Authoring UI (Hugo has none; content lives in Git).
- Server-side comments, search-as-a-service, or any dynamic backend.
- OIDC / LDAP integration; the served content is public-by-design. A future requirement MAY add an oauth2-proxy wrapper for staging sites; this requirement does NOT.
- Multi-tenant theme marketplace. Themes are bundled with the upstream content repository in V1.
- Multi-canonical-domain support. V1 supports one canonical domain per role deploy; running multiple Hugo sites concurrently is a follow-up requirement.
- Canonical-domain redirect Playwright test. Aliases-to-canonical redirect is tested centrally by the proxy stack; the role's spec does not duplicate it.
Validation Apps
A single fresh-purged deploy with one canonical domain and no
source.repo override is the minimum validation set: the role falls
back to the bundled default (gohugoio/hugoDocs, see Default
site) and renders the official Hugo documentation site.
APPS="web-app-hugo" make deploy-fresh-purged-apps
Expected outcome: the canonical domain serves the rendered Hugo docs homepage over HTTPS within the standard deploy timeout. The Playwright suite asserts the front page is reachable and contains the title emitted by the docs theme.
Prerequisites
Before starting any implementation work, you MUST read AGENTS.md and follow all instructions in it.
Primary structural reference: web-app-littlejs. It is the
no-database, single-canonical-domain web-app in the tree that already
demonstrates the exact pattern this requirement adopts: clone an
upstream Git repo via sys-stk-full + docker_git_repository_*,
pass the cloned tree into a custom files/Dockerfile as build
context, build a custom image, and serve via nginx. Deviating from
that role's conventions requires explicit justification in the PR
description.
Commit Policy
- The agent MUST NOT create any git commit during implementation.
No partial commits, no checkpoint commits, no per-step commits.
The working tree evolves in place until both of the following hold:
- every Acceptance Criterion in this document is checked off
(
- [x]); make testis green with no skipped suites.
- every Acceptance Criterion in this document is checked off
(
- At that point, the agent lands the whole change set as a single
commit (or a tight, related sequence) and then instructs the
operator to run
git-sign-pushoutside the sandbox (per CLAUDE.md). The agent MUST NOT push.